Blank pages added next to an existing page (via the "+" tile, Alt+Arrow or the page menu) now copy that page's width and height, including imported PDF pages, instead of always falling back to A4. The "+" preview tile uses the same size so it matches the page it will create. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsorPV3JeJRoZ1mD1pdvtL
754 lines
23 KiB
Svelte
754 lines
23 KiB
Svelte
<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);
|
||
|
||
if (e.pointerType === 'touch') {
|
||
const decision = palm.evaluate(e);
|
||
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' && (!tools.inking || !settings.data.fingerDraw)) ||
|
||
(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>
|