Papure/src/lib/components/CanvasView.svelte
agent 3d29255d0c Add background grid settings
Appearance now has a toggle for the background grid and a choice of
subdivisions (none, 2×2 up to 16×16) that split each page-sized cell
into fainter lines. Subdivisions are left out when zoomed so far out
that they would be under 8 px apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaoZtJQZwLXkEgWs8twCia
2026-09-27 21:31:12 +00:00

907 lines
28 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, touchIntent } from '$lib/ink/palm';
import { flingDir, pushSample, type Sample } from '$lib/ink/fling';
import { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
import { pageCssVars } from '$lib/editor/pageStyle';
import { downloadCanvas } from './download';
import PageSizeDialog from './PageSizeDialog.svelte';
import PageView from './PageView.svelte';
import Toolbar from './Toolbar.svelte';
let { doc }: { doc: CanvasDoc } = $props();
let stage: HTMLDivElement;
const cssVars = pageCssVars();
// Background grid: one cell per active page, aligned to its edges (A4 at the origin if none),
// optionally split into fainter subdivisions.
const gridStyle = $derived.by(() => {
const g = settings.data.grid;
if (!g.show) return '';
const r = (doc.activeId && doc.rects.get(doc.activeId)) || { x: 0, y: 0, ...A4 };
const s = viewport.scale;
const w = r.width * s;
const h = r.height * s;
const lines = (color: string) => [
`linear-gradient(to right, ${color} 1px, transparent 1px)`,
`linear-gradient(to bottom, ${color} 1px, transparent 1px)`
];
const images = lines('var(--grid-line)');
const sizes = [`${w}px ${h}px`, `${w}px ${h}px`];
// Leave out subdivisions once they'd be too dense to read.
const n = g.divisions;
if (n > 1 && Math.min(w, h) / n >= 8) {
images.push(...lines('var(--grid-line-minor)'));
sizes.push(`${w / n}px ${h / n}px`, `${w / n}px ${h / n}px`);
}
return `background-image: ${images.join(', ')}; background-size: ${sizes.join(', ')}; background-position: ${viewport.x + r.x * s}px ${viewport.y + r.y * s}px`;
});
// ---- 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);
clearTimeout(offerTimer);
};
});
// 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) : []);
// A finger fling past the active page's free edge highlights the plus on that side.
let offer = $state<Slot | null>(null);
let offerTimer: ReturnType<typeof setTimeout> | undefined;
function setOffer(slot: Slot | null) {
clearTimeout(offerTimer);
offer = slot;
if (slot) offerTimer = setTimeout(() => (offer = null), 3000);
}
function offerAfterFling(samples: Sample[]) {
const dir = flingDir(samples);
const id = doc.activeId;
const r = id && doc.rects.get(id);
const slot = dir && ghostSlots.find((s) => s.anchor === id && s.dir === dir);
if (!r || !slot) return;
// Only once that edge has come into view, i.e. the fling went past the end.
const a = viewport.toScreen(r.x, r.y);
const b = viewport.toScreen(r.x + r.width, r.y + r.height);
const edge = { left: a.x, right: b.x, up: a.y, down: b.y }[slot.dir];
const size = slot.dir === 'left' || slot.dir === 'right' ? viewport.width : viewport.height;
if (edge >= 0 && edge <= size) setOffer(slot);
}
// 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);
}
/**
* Hit area of the add-page button: 48 px square touching the middle of the anchor's
* free edge. The visible 32 px circle sits centred in it, 8 px off the page.
*/
function plusRect(slot: Slot): Rect | null {
const a = doc.rectOf(slot.anchor);
if (!a) return null;
const d = 48 / viewport.scale;
const cx = a.x + (a.width - d) / 2;
const cy = a.y + (a.height - d) / 2;
if (slot.dir === 'right') return { x: a.x + a.width, y: cy, width: d, height: d };
if (slot.dir === 'left') return { x: a.x - d, y: cy, width: d, height: d };
if (slot.dir === 'down') return { x: cx, y: a.y + a.height, width: d, height: d };
return { x: cx, y: a.y - d, width: d, height: d };
}
/** 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);
}
/** Page whose custom-size dialog is open. */
let sizing = $state<string | null>(null);
const customSize = (id: string) => (sizing = id);
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; pointerType: string; lastX: number; lastY: number; startX: number; startY: number; moved: boolean; tap: boolean; trail: Sample[] }
| { kind: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot }
| { 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();
if (offer) setOffer(null);
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;
// Taps on an add-page button are palm-checked like navigation: still rejected for
// pen-down, palm-sized contacts and right after pen use, but not for the 5 s pen session.
const plus = target.closest<HTMLElement>('.ghost');
if (e.pointerType === 'touch') {
const decision = palm.evaluate(e, touchIntent(fingerInks, !!plus));
if (!decision.accept) return; // palm: ignore entirely
touches.set(e.pointerId, p);
if (touches.size >= 2) {
cancelGesture();
gesture = { kind: 'pinch' };
pinchLast = pinchState();
return;
}
}
if (plus && e.button === 0) {
e.preventDefault();
const slot = { anchor: plus.dataset.anchor!, dir: plus.dataset.dir as Dir };
gesture = { kind: 'add', pointerId: e.pointerId, pointerType: e.pointerType, startX: p.x, startY: p.y, slot };
capture(e.pointerId);
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('.slot'));
if (wantsPan) {
if (onPage && !inEditor) doc.activeId = onPage;
gesture = {
kind: 'pan',
pointerId: e.pointerId,
pointerType: e.pointerType,
lastX: p.x,
lastY: p.y,
startX: p.x,
startY: p.y,
moved: false,
tap: inEditor || !!target.closest('.slot'),
trail: [{ ...p, t: e.timeStamp }]
};
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;
pushSample(gesture.trail, { ...p, t: e.timeStamp });
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, gesture.kind === 'add' ? 'navigate' : 'draw').accept) {
cancelGesture();
return;
}
if (gesture.kind === 'add') {
// Dragging off the button is not a tap.
if (Math.hypot(p.x - gesture.startX, p.y - gesture.startY) > 12) gesture = null;
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 === 'pan') {
if (e.type === 'pointerup' && g.moved && g.pointerType === 'touch') {
pushSample(g.trail, { ...local(e), t: e.timeStamp });
offerAfterFling(g.trail);
}
} else if (g.kind === 'add') {
if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e, 'navigate').accept)) addPage(g.slot.anchor, g.slot.dir);
} else if (g.kind === 'draw') {
live = null;
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
} else if (g.kind === 'erase') {
// Like ink, an interrupted (pointercancel) erase is dropped.
if (e.type === 'pointerup') 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 || sizing) 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}
style={gridStyle}
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 = plusRect(slot)}
{#if r}
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
<button
class="ghost"
class:offer={offer?.anchor === slot.anchor && offer.dir === slot.dir}
data-anchor={slot.anchor}
data-dir={slot.dir}
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
title="Add page ({slot.dir})"
onclick={(e) => e.detail === 0 && addPage(slot.anchor, slot.dir)}
>
<span class="plus"><CirclePlusIcon size="100%" strokeWidth={1.5} /></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>
{#if sizing && doc.pages[sizing]}
{@const id = sizing}
<PageSizeDialog
width={doc.pages[id].width}
height={doc.pages[id].height}
onclose={() => (sizing = null)}
onapply={(w, h) => {
sizing = null;
keepAnchor(id, () => doc.resizePage(id, w, h));
}}
/>
{/if}
<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-color: 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 {
position: absolute;
display: grid;
place-items: center;
background: none;
border: 0;
padding: 0;
color: var(--faint);
cursor: pointer;
transition: color 0.12s;
}
.ghost:hover,
.ghost:focus-visible {
color: var(--accent);
}
.ghost .plus {
display: grid;
width: calc(32px / var(--s));
height: calc(32px / var(--s));
border-radius: 50%;
background: var(--bg-canvas);
}
.ghost.offer {
color: var(--accent);
}
.ghost.offer .plus {
animation: offer 1s ease-in-out infinite;
}
@keyframes offer {
50% {
transform: scale(1.3);
}
}
@media (prefers-reduced-motion: reduce) {
.ghost.offer .plus {
animation: none;
}
}
.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;
}
.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);
}
.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>