Papure/src/lib/components/CanvasView.svelte
besbara bca1a6a407 Keep finger pan/zoom working after using the pen
After writing with the pen, fingers could no longer navigate:
- every touch was ignored for 5 s after any pen activity (and for as long
  as a hover-capable pen stayed near the screen),
- after that, a one-finger drag on a page drew ink instead of panning,
- a one-finger drag on the background did nothing while an ink tool was on.

Once a pen has been used, fingers now only pan and zoom. The 5 s session
and hover lockouts only block finger ink; touches are still rejected while
the pen is down, for palm-sized contacts and within 150 ms of pen activity.
A finger drag off the page always pans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VsorPV3JeJRoZ1mD1pdvtL
2026-09-26 20:09:53 +00:00

756 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { onMount, untrack } from 'svelte';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import DownloadIcon from '@lucide/svelte/icons/download';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import CheckIcon from '@lucide/svelte/icons/check';
import type { CanvasDoc } from '$lib/state/doc.svelte';
import { viewport } from '$lib/state/viewport.svelte';
import { tools } from '$lib/state/tools.svelte';
import { settings } from '$lib/state/settings.svelte';
import { workspace } from '$lib/state/workspace.svelte';
import { menu, type MenuItem } from '$lib/state/menu.svelte';
import { dirName, displayName } from '$lib/storage/vault.svelte';
import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout';
import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree';
import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types';
import { PalmRejector } from '$lib/ink/palm';
import { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
import { pageCssVars } from '$lib/editor/pageStyle';
import { downloadCanvas } from './download';
import PageView from './PageView.svelte';
import Toolbar from './Toolbar.svelte';
let { doc }: { doc: CanvasDoc } = $props();
let stage: HTMLDivElement;
const cssVars = pageCssVars();
// ---- autosave --------------------------------------------------------
$effect(() => {
if (doc.version > 0) untrack(() => workspace.scheduleSave());
});
// ---- viewport --------------------------------------------------------
onMount(() => {
const ro = new ResizeObserver(() => {
const r = stage.getBoundingClientRect();
const first = viewport.width === 0;
viewport.width = r.width;
viewport.height = r.height;
if (first) fitPage(workspace.focusPage ?? doc.activeId);
});
viewport.width = 0;
ro.observe(stage);
stage.addEventListener('wheel', onWheel, { passive: false });
return () => {
ro.disconnect();
stage.removeEventListener('wheel', onWheel);
};
});
// Search hits etc. ask for a page to be revealed.
$effect(() => {
const id = workspace.focusPage;
if (!id || !viewport.width) return;
untrack(() => {
if (doc.pages[id]) {
doc.activeId = id;
fitPage(id);
}
workspace.focusPage = null;
});
});
function fitPage(id: string | null) {
const r = (id && doc.rectOf(id)) || doc.rectOf(doc.order[0]);
if (r) viewport.fit(r);
}
function fitAll() {
const b = bounds(doc.rects.values());
if (b) viewport.fit(b, 48);
}
const visible = $derived.by(() => {
const w = viewport.world;
// One extra screen of margin around the view so panning doesn't pop.
const area = { x: w.x - w.width, y: w.y - w.height, width: w.width * 3, height: w.height * 3 };
return doc.order.filter((id) => {
const r = doc.rects.get(id);
return r && intersects(r, area);
});
});
// ---- adding pages ----------------------------------------------------
const ghostSlots = $derived.by((): Slot[] => {
if (tools.placing || !doc.activeId) return [];
return freeSlotsOf(doc.tree, doc.activeId);
});
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []);
// Show every possible spot when an imported PDF is waiting to be placed.
$effect(() => {
if (!tools.placing) return;
untrack(() => {
const size = tools.placing!.pages[0];
const rects = [...doc.rects.values(), ...placeSlots.map((s) => slotRect(s, size)).filter((r): r is Rect => !!r)];
const b = bounds(rects);
if (b) viewport.fit(b, 48);
});
});
function slotRect(slot: Slot, size?: { width: number; height: number }): Rect | null {
const a = doc.rectOf(slot.anchor);
if (!a) return null;
const page = doc.pages[slot.anchor];
const s = size ?? (page ? { width: page.width, height: page.height } : A4);
return rectBeside(a, slot.dir, s);
}
/** Run a tree change without the anchor page jumping on screen. */
function keepAnchor(anchor: string, fn: () => void) {
const before = doc.rectOf(anchor);
fn();
const after = doc.rectOf(anchor);
if (before && after) viewport.panBy(-(after.x - before.x) * viewport.scale, -(after.y - before.y) * viewport.scale);
}
function revealPage(id: string) {
const r = doc.rectOf(id);
if (!r) return;
const w = viewport.world;
const inView = r.x >= w.x && r.y >= w.y && r.x + r.width <= w.x + w.width && r.y + r.height <= w.y + w.height;
if (!inView) {
const fits = r.width * viewport.scale <= viewport.width && r.height * viewport.scale <= viewport.height;
if (fits) viewport.center(r);
else viewport.fit(r);
}
}
function addPage(anchor: string, dir: Dir) {
let id: string | null = null;
keepAnchor(anchor, () => (id = doc.addBlankPage(anchor, dir)));
if (id) revealPage(id);
}
function place(slot: Slot) {
const p = tools.placing;
if (!p) return;
let ok = false;
keepAnchor(slot.anchor, () => (ok = doc.insertPages(slot.anchor, slot.dir, p.pages)));
if (ok) {
tools.placing = null;
revealPage(p.pages[0].id);
workspace.toast(`Placed ${p.pages.length} page${p.pages.length === 1 ? '' : 's'} from ${p.name}`);
}
}
function go(dir: Dir) {
const id = doc.activeId;
if (!id) return;
const next = neighbor(doc.tree, id, dir);
if (next) {
doc.activeId = next;
revealPage(next);
} else if (canInsert(doc.tree, id, dir)) addPage(id, dir);
}
// ---- page menu -------------------------------------------------------
function pageMenu(x: number, y: number, id: string) {
const page = doc.pages[id];
if (!page) return;
doc.activeId = id;
const dirs: [Dir, string][] = [
['up', 'Insert page above'],
['down', 'Insert page below'],
['left', 'Insert page left'],
['right', 'Insert page right']
];
const items: MenuItem[] = dirs.map(([dir, label]) => ({
label,
disabled: !canInsert(doc.tree, id, dir),
action: () => addPage(id, dir)
}));
if (!page.origin) {
items.push('separator');
for (const p of PAGE_PRESETS) {
const current = Math.abs(p.width - page.width) < 0.5 && Math.abs(p.height - page.height) < 0.5;
items.push({
label: p.label,
icon: current ? CheckIcon : undefined,
hint: `${Math.round(p.width)}×${Math.round(p.height)}`,
action: () => keepAnchor(id, () => doc.resizePage(id, p.width, p.height))
});
}
items.push({ label: 'Custom size…', action: () => customSize(id) });
}
items.push('separator', { label: 'Delete page', icon: TrashIcon, danger: true, action: () => doc.deletePage(id) });
menu.show(x, y, items);
}
function customSize(id: string) {
const page = doc.pages[id];
const mm = (pt: number) => Math.round((pt / 72) * 25.4);
const answer = prompt('Page size in millimetres (width × height)', `${mm(page.width)} × ${mm(page.height)}`);
const m = answer && /^\s*(\d+(?:\.\d+)?)\s*[x×*,\s]\s*(\d+(?:\.\d+)?)\s*$/i.exec(answer);
if (!m) return;
const pt = (v: string) => Math.min(5000, Math.max(50, (parseFloat(v) / 25.4) * 72));
keepAnchor(id, () => doc.resizePage(id, pt(m[1]), pt(m[2])));
}
const activeMenuPos = $derived.by(() => {
const id = doc.activeId;
const r = id && doc.rects.get(id);
if (!r || tools.placing) return null;
const p = viewport.toScreen(r.x + r.width, r.y);
return { x: p.x + 6, y: p.y };
});
// ---- input -----------------------------------------------------------
const palm = new PalmRejector(settings.data.palm);
$effect(() => {
palm.options = settings.data.palm;
});
type Gesture =
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
| { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> }
| { kind: 'pan'; pointerId: number; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean }
| { kind: 'pinch' };
let gesture: Gesture | null = null;
const touches = new Map<number, { x: number; y: number }>();
let pinchLast: { dist: number; mx: number; my: number } | null = null;
let spaceDown = $state(false);
// Reactive bits for rendering in-progress ink.
let live = $state.raw<{ pageId: string; stroke: Stroke } | null>(null);
let tick = $state(0);
let erasing = $state.raw<Set<string>>(new Set());
let eraserAt = $state<{ x: number; y: number } | null>(null);
function local(e: PointerEvent | MouseEvent) {
const r = stage.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function pageAt(sx: number, sy: number): string | null {
const w = viewport.toWorld(sx, sy);
for (const id of visible) {
const r = doc.rects.get(id)!;
if (w.x >= r.x && w.x <= r.x + r.width && w.y >= r.y && w.y <= r.y + r.height) return id;
}
return null;
}
function pagePoint(pageId: string, sx: number, sy: number) {
const r = doc.rects.get(pageId)!;
const w = viewport.toWorld(sx, sy);
return { x: w.x - r.x, y: w.y - r.y };
}
/** Pointer capture can throw if the pointer is already gone; drawing still works without it. */
function capture(id: number) {
try {
stage.setPointerCapture(id);
} catch {
/* ignore */
}
}
const isEraserButton = (e: PointerEvent) => e.pointerType === 'pen' && (e.button === 5 || (e.buttons & 32) !== 0);
function onPointerDown(e: PointerEvent) {
palm.track(e);
if (menu.open) menu.close();
const target = e.target as Element;
if (target.closest('.no-stage')) return;
const p = local(e);
// Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom.
const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen;
if (e.pointerType === 'touch') {
const decision = palm.evaluate(e, fingerInks ? 'draw' : 'navigate');
if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p);
if (touches.size >= 2) {
cancelGesture();
gesture = { kind: 'pinch' };
pinchLast = pinchState();
return;
}
}
const onPage = pageAt(p.x, p.y);
const inEditor = !!target.closest('.cm-editor');
// Pressing anywhere outside the text leaves the editor (preventDefault
// below would otherwise keep focus in it).
if (!inEditor) (document.activeElement as HTMLElement | null)?.blur?.();
// Panning: middle mouse, space+drag, or background drags.
const wantsPan =
e.button === 1 ||
spaceDown ||
(e.pointerType === 'touch' && (!fingerInks || !onPage)) ||
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.ghost, .slot'));
if (wantsPan) {
if (onPage && !inEditor) doc.activeId = onPage;
gesture = { kind: 'pan', pointerId: e.pointerId, lastX: p.x, lastY: p.y, startX: p.x, startY: p.y, moved: false, tap: inEditor || !!target.closest('.ghost, .slot') };
if (e.pointerType !== 'touch' || !gesture.tap) {
if (e.pointerType !== 'touch') e.preventDefault();
capture(e.pointerId);
}
return;
}
if (onPage) doc.activeId = onPage;
if (e.button !== 0 && e.pointerType === 'mouse') return;
const eraser = tools.tool === 'eraser' || isEraserButton(e);
if ((tools.inking || isEraserButton(e)) && onPage) {
e.preventDefault();
capture(e.pointerId);
if (eraser) {
gesture = { kind: 'erase', pointerId: e.pointerId, pointerType: e.pointerType, hits: new Map() };
erasing = new Set();
eraseAt(e);
} else {
const tool = tools.tool === 'highlighter' ? 'highlighter' : 'pen';
const rec = new StrokeRecorder(tool, tools.color, tools.width, e.pointerType === 'pen');
const pt = pagePoint(onPage, p.x, p.y);
rec.recordPoint(pt.x, pt.y, e);
gesture = { kind: 'draw', pointerId: e.pointerId, pointerType: e.pointerType, pageId: onPage, rec };
live = { pageId: onPage, stroke: rec.stroke };
tick++;
}
}
}
function onPointerMove(e: PointerEvent) {
palm.track(e);
const p = local(e);
if (tools.tool === 'eraser' || isEraserButton(e)) eraserAt = p;
if (e.pointerType === 'touch' && touches.has(e.pointerId)) {
touches.set(e.pointerId, p);
if (gesture?.kind === 'pinch') {
const s = pinchState();
if (s && pinchLast) {
viewport.zoomAt(s.dist / pinchLast.dist, s.mx, s.my);
viewport.panBy(s.mx - pinchLast.mx, s.my - pinchLast.my);
}
pinchLast = s;
return;
}
}
if (!gesture || gesture.kind === 'pinch' || gesture.pointerId !== e.pointerId) return;
if (gesture.kind === 'pan') {
const dx = p.x - gesture.lastX;
const dy = p.y - gesture.lastY;
if (!gesture.moved && Math.hypot(p.x - gesture.startX, p.y - gesture.startY) < 8) return;
if (!gesture.moved && gesture.tap) capture(e.pointerId);
gesture.moved = true;
viewport.panBy(dx, dy);
gesture.lastX = p.x;
gesture.lastY = p.y;
return;
}
// A touch stroke is cut off as soon as the palm policy rejects it
// (e.g. the pen just came into range).
if (gesture.pointerType === 'touch' && !palm.evaluate(e).accept) {
cancelGesture();
return;
}
if (gesture.kind === 'erase') {
eraseAt(e);
return;
}
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
for (const ev of events.length ? events : [e]) {
const q = local(ev);
const pt = pagePoint(gesture.pageId, q.x, q.y);
gesture.rec.recordPoint(pt.x, pt.y, ev);
}
tick++;
}
function onPointerUp(e: PointerEvent) {
palm.track(e);
touches.delete(e.pointerId);
if (gesture?.kind === 'pinch') {
if (touches.size < 2) {
gesture = null;
pinchLast = null;
}
return;
}
if (!gesture || gesture.pointerId !== e.pointerId) return;
const g = gesture;
gesture = null;
if (g.kind === 'draw') {
live = null;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') {
for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id)));
erasing = new Set();
}
}
function cancelGesture() {
if (gesture?.kind === 'draw') live = null;
if (gesture?.kind === 'erase') erasing = new Set();
gesture = null;
}
function eraseAt(e: PointerEvent) {
if (gesture?.kind !== 'erase') return;
const p = local(e);
const pageId = pageAt(p.x, p.y);
if (!pageId) return;
const pt = pagePoint(pageId, p.x, p.y);
const r = tools.eraserRadius / viewport.scale;
let set = gesture.hits.get(pageId);
let changed = false;
for (const s of doc.pages[pageId].strokes) {
if (set?.has(s.id) || !hitStroke(s, pt.x, pt.y, r)) continue;
if (!set) gesture.hits.set(pageId, (set = new Set()));
set.add(s.id);
changed = true;
}
if (changed) erasing = new Set([...gesture.hits.values()].flatMap((x) => [...x]));
}
function pinchState() {
const pts = [...touches.values()];
if (pts.length < 2) return null;
const [a, b] = pts;
return { dist: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2 };
}
function onWheel(e: WheelEvent) {
e.preventDefault();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? viewport.height : 1;
const p = local(e);
if (e.ctrlKey || e.metaKey) {
viewport.zoomAt(Math.exp(-e.deltaY * unit * 0.0025), p.x, p.y);
} else if (e.shiftKey && !e.deltaX) {
viewport.panBy(-e.deltaY * unit, 0);
} else {
viewport.panBy(-e.deltaX * unit, -e.deltaY * unit);
}
}
function onContextMenu(e: MouseEvent) {
if ((e.target as Element).closest('.cm-editor') && !tools.inking) return; // native menu for text
e.preventDefault();
const p = local(e);
const id = pageAt(p.x, p.y);
if (id) pageMenu(e.clientX, e.clientY, id);
}
// ---- keyboard --------------------------------------------------------
function editable(t: EventTarget | null) {
const el = t as HTMLElement | null;
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
}
function onKeyDown(e: KeyboardEvent) {
if (workspace.settingsOpen) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
if (mod && (k === '=' || k === '+')) return void (e.preventDefault(), viewport.zoomAt(1.25));
if (mod && k === '-') return void (e.preventDefault(), viewport.zoomAt(0.8));
if (mod && k === '0') return void (e.preventDefault(), fitPage(doc.activeId));
if (mod && k === '9') return void (e.preventDefault(), fitAll());
if (editable(e.target)) return;
if (mod && k === 'z' && !e.shiftKey) return void (e.preventDefault(), doc.undo());
if (mod && ((k === 'z' && e.shiftKey) || k === 'y')) return void (e.preventDefault(), doc.redo());
if (e.key === 'Escape') {
if (tools.placing) tools.placing = null;
return;
}
if (e.altKey && e.key.startsWith('Arrow')) {
e.preventDefault();
go(e.key.slice(5).toLowerCase() as Dir);
return;
}
if (e.key === ' ' && !e.repeat) {
spaceDown = true;
e.preventDefault();
return;
}
if (mod || e.altKey) return;
const shortcuts: Record<string, typeof tools.tool> = { t: 'text', p: 'pen', h: 'highlighter', e: 'eraser' };
if (shortcuts[k]) tools.tool = shortcuts[k];
}
function onKeyUp(e: KeyboardEvent) {
if (e.key === ' ') spaceDown = false;
}
const panning = $derived(spaceDown);
</script>
<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onblur={() => (spaceDown = false)} />
<div class="view">
<header class="titlebar">
<div class="crumbs">
{#if dirName(doc.path)}<span class="dir">{dirName(doc.path).replaceAll('/', ' / ')} /</span>{/if}
<span class="name">{displayName(doc.path)}</span>
</div>
<button class="icon-btn" title="Download PDF" onclick={() => downloadCanvas(doc.path)}>
<DownloadIcon size={16} strokeWidth={1.75} />
</button>
</header>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="stage"
class:inking={tools.inking}
class:panning
class:erasing={tools.tool === 'eraser'}
bind:this={stage}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
onpointerleave={(e) => {
palm.track(e);
eraserAt = null;
}}
oncontextmenu={onContextMenu}
>
<div
class="world"
style="transform: translate({viewport.x}px, {viewport.y}px) scale({viewport.scale}); --s: {viewport.scale}; {cssVars}"
>
{#each visible as id (id)}
{@const page = doc.pages[id]}
{@const rect = doc.rects.get(id)}
{#if page && rect}
<PageView
{page}
{rect}
active={doc.activeId === id}
invert={settings.dark && settings.data.pageStyle === 'match'}
scale={viewport.scale}
live={live?.pageId === id ? live.stroke : null}
{tick}
{erasing}
onchange={() => doc.touch()}
onactivate={() => (doc.activeId = id)}
/>
{/if}
{/each}
{#each ghostSlots as slot (slot.anchor + slot.dir)}
{@const r = slotRect(slot)}
{#if r}
<button
class="ghost"
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
title="Add page ({slot.dir})"
onclick={() => addPage(slot.anchor, slot.dir)}
>
<span class="plus" style="--icon: {Math.min(r.width, r.height) * 0.14}px">
<CirclePlusIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/if}
{/each}
{#if tools.placing}
{#each placeSlots as slot (slot.anchor + slot.dir)}
{@const first = tools.placing.pages[0]}
{@const r = slotRect(slot, first)}
{#if r}
<button
class="slot"
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
onclick={() => place(slot)}
>
<span class="arrow" data-dir={slot.dir} style="--icon: {Math.min(r.width, r.height) * 0.16}px">
<ArrowUpIcon size="100%" strokeWidth={1.25} />
</span>
</button>
{/if}
{/each}
{/if}
</div>
{#if activeMenuPos && doc.activeId}
<button
class="icon-btn page-menu no-stage"
style="left:{activeMenuPos.x}px; top:{activeMenuPos.y}px"
title="Page options"
onclick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
pageMenu(r.left, r.bottom + 4, doc.activeId!);
}}
>
<EllipsisIcon size={16} />
</button>
{/if}
{#if eraserAt && tools.tool === 'eraser'}
<div class="eraser-ring" style="left:{eraserAt.x}px; top:{eraserAt.y}px; width:{tools.eraserRadius * 2}px; height:{tools.eraserRadius * 2}px"></div>
{/if}
{#if tools.placing}
<div class="banner no-stage">
Choose where to place <b>{tools.placing.name}</b> ({tools.placing.pages.length} page{tools.placing.pages.length === 1 ? '' : 's'})
<button class="btn" onclick={() => (tools.placing = null)}>Cancel</button>
</div>
{/if}
</div>
<Toolbar {doc} onfit={() => fitPage(doc.activeId)} onfitall={fitAll} />
</div>
<style>
.view {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.titlebar {
height: 38px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: var(--bg);
border-bottom: 1px solid var(--border);
padding: 0 8px;
}
.crumbs {
display: flex;
gap: 6px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.crumbs .dir {
color: var(--faint);
}
.titlebar .icon-btn {
position: absolute;
right: 8px;
}
.stage {
position: relative;
flex: 1;
overflow: hidden;
touch-action: none;
user-select: none;
-webkit-user-select: none;
background: var(--bg-canvas);
}
.stage :global(.cm-content) {
user-select: text;
-webkit-user-select: text;
}
.stage.inking {
cursor: crosshair;
}
.stage.erasing {
cursor: none;
}
.stage.inking :global(.page .md),
.stage.panning :global(.page .md) {
pointer-events: none;
}
.stage.panning {
cursor: grab;
}
.world {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
}
.ghost,
.slot {
position: absolute;
display: grid;
place-items: center;
background: var(--ghost);
border: calc(1px / var(--s)) dashed var(--ghost-border);
border-radius: calc(4px / var(--s));
color: var(--faint);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s;
}
.ghost:hover,
.slot:hover {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.slot {
border-style: solid;
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
.plus,
.arrow {
display: grid;
width: var(--icon);
height: var(--icon);
}
.arrow[data-dir='down'] {
transform: rotate(180deg);
}
.arrow[data-dir='left'] {
transform: rotate(-90deg);
}
.arrow[data-dir='right'] {
transform: rotate(90deg);
}
.page-menu {
position: absolute;
width: 26px;
height: 26px;
background: var(--bg);
box-shadow: var(--shadow);
}
.eraser-ring {
position: absolute;
transform: translate(-50%, -50%);
border: 1.5px solid var(--muted);
border-radius: 50%;
pointer-events: none;
}
.banner {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 6px 6px 6px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
white-space: nowrap;
}
</style>