Compare commits
14 commits
build-vers
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 894291df7f | |||
| 5fb11e4928 | |||
| 361e3e5e89 | |||
| 2df91b561d | |||
| 3d29255d0c | |||
| f018012cdd | |||
| 5a501a5c87 | |||
| abb11ad407 | |||
| d2c6e617bb | |||
| 2760819466 | |||
| a813c1c086 | |||
| 4ab0d95cad | |||
| 4cc2f1ae74 | |||
| 2d415d88a3 |
11 changed files with 549 additions and 80 deletions
|
|
@ -23,7 +23,8 @@ The image builds the PWA and serves it with nginx. Put an HTTPS reverse proxy in
|
||||||
## Using it
|
## Using it
|
||||||
|
|
||||||
- **Tools** (bottom bar): Text `T`, Pen `P`, Highlighter `H`, Eraser `E`, Import PDF, Undo/Redo, zoom. The pen's eraser end erases too.
|
- **Tools** (bottom bar): Text `T`, Pen `P`, Highlighter `H`, Eraser `E`, Import PDF, Undo/Redo, zoom. The pen's eraser end erases too.
|
||||||
- **Pages**: the grey "+" tiles next to the active page add a page there; `Alt+Arrow` moves to (or creates) the neighbour. Right-click a page or use its `⋯` button to insert, resize or delete it.
|
- **Pages**: the "+" circles beside the active page add a page there (a quick finger flick toward a free side offers one there; turn it off under Settings → Pen & touch); `Alt+Arrow` moves to (or creates) the neighbour. Right-click a page or use its `⋯` button to insert, resize or delete it.
|
||||||
|
- **Grid**: the faint background grid has one cell per active page; Settings → Appearance can hide it or subdivide it.
|
||||||
- **Importing a PDF** into a non-empty canvas shows every free spot; click one to place the chain. Into a blank canvas it simply replaces the empty page. The Files panel can also import a PDF as a new canvas.
|
- **Importing a PDF** into a non-empty canvas shows every free spot; click one to place the chain. Into a blank canvas it simply replaces the empty page. The Files panel can also import a PDF as a new canvas.
|
||||||
- **View**: wheel/trackpad pans, `Ctrl`+wheel or pinch zooms, `Ctrl+0` fits the page, `Ctrl+9` shows the whole tree, space-drag or middle-drag pans.
|
- **View**: wheel/trackpad pans, `Ctrl`+wheel or pinch zooms, `Ctrl+0` fits the page, `Ctrl+9` shows the whole tree, space-drag or middle-drag pans.
|
||||||
- **Sync**: Settings → Sync. Use a fine-grained token with *Contents: read & write* on the repo. Edits autosave locally; pushes happen every N minutes and on `Ctrl+S` / the sync button. Conflicts: newer wins.
|
- **Sync**: Settings → Sync. Use a fine-grained token with *Contents: read & write* on the repo. Edits autosave locally; pushes happen every N minutes and on `Ctrl+S` / the sync button. Conflicts: newer wins.
|
||||||
|
|
|
||||||
32
spec.md
32
spec.md
|
|
@ -9,10 +9,11 @@ One canvas = one tree = one `.pdf` file.
|
||||||
- A branch can only ever continue in the direction it started (strictly linear — no sub-branches off a branch).
|
- A branch can only ever continue in the direction it started (strictly linear — no sub-branches off a branch).
|
||||||
- A trunk page can have at most one left branch and one right branch.
|
- A trunk page can have at most one left branch and one right branch.
|
||||||
- **PDF import**: creates a chain of pages in whatever direction you pick when placing it (up/down = extends the trunk, left/right = becomes a branch off the trunk page you clicked). Only one trunk exists per canvas — additional imported PDFs must attach as a branch off some trunk page, or extend an existing branch further outward in its established direction.
|
- **PDF import**: creates a chain of pages in whatever direction you pick when placing it (up/down = extends the trunk, left/right = becomes a branch off the trunk page you clicked). Only one trunk exists per canvas — additional imported PDFs must attach as a branch off some trunk page, or extend an existing branch further outward in its established direction.
|
||||||
- Hidden/unopened neighbor pages render as a gray tile with a "+" in a circle; clicking (or a shortcut) instantiates them as real pages.
|
- Each free side of the active page shows a small "+" circle just off its edge; tapping it (or `Alt+Arrow`) adds a blank page there. A quick finger fling toward a free side offers a page there for a few seconds: that side's "+" pulses, or, if it is off screen, an "Add page" button appears at that edge of the screen. The fling never adds a page by itself, and can be turned off in Settings.
|
||||||
|
- The canvas background is a faint grid with one cell per active page, aligned to its edges; Settings can hide it or subdivide it.
|
||||||
- Deleting a page removes it (undo via Ctrl+Z); no permanent trash.
|
- Deleting a page removes it (undo via Ctrl+Z); no permanent trash.
|
||||||
- Default zoom: one page fills the screen. Zoom out reveals the tree.
|
- Default zoom: one page fills the screen. Zoom out reveals the tree.
|
||||||
- Pages default to A4, or auto-match the aspect ratio of an imported PDF page; resizable manually otherwise.
|
- Pages default to A4, or auto-match the aspect ratio of an imported PDF page; resizable manually otherwise (page menu: presets, or a custom size in mm).
|
||||||
|
|
||||||
## 2. Flatten Algorithm (tree → linear page order, e.g. for PDF page order / print)
|
## 2. Flatten Algorithm (tree → linear page order, e.g. for PDF page order / print)
|
||||||
|
|
||||||
|
|
@ -45,7 +46,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le
|
||||||
|
|
||||||
## 5. Sync & Storage
|
## 5. Sync & Storage
|
||||||
|
|
||||||
- Repo-backed (GitHub/GitLab), one file per canvas.
|
- Repo-backed (GitHub for now; GitLab not implemented), one file per canvas.
|
||||||
- PWA: offline-first, caches locally, explicit save/sync pushes to repo.
|
- PWA: offline-first, caches locally, explicit save/sync pushes to repo.
|
||||||
- Multi-device: git pull/push is the sync mechanism (not real-time collab).
|
- Multi-device: git pull/push is the sync mechanism (not real-time collab).
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@ Canvases (PDF files) live in a **file tree** you define, e.g. `school/english/le
|
||||||
- **Frontend**: SvelteKit, deployed on your VPS, installable as PWA.
|
- **Frontend**: SvelteKit, deployed on your VPS, installable as PWA.
|
||||||
- **Ink input**: Pointer Events + pressure (your existing artifact — pending).
|
- **Ink input**: Pointer Events + pressure (your existing artifact — pending).
|
||||||
- **PDF read/write**: pdf.js + pdf-lib.
|
- **PDF read/write**: pdf.js + pdf-lib.
|
||||||
- **Storage**: Git repo via GitHub/GitLab API (or local backend proxy holding the token).
|
- **Storage**: IndexedDB locally; Git repo via the GitHub REST API (token in the browser, no proxy).
|
||||||
|
|
||||||
## 7. Confirmed Product Features
|
## 7. Confirmed Product Features
|
||||||
|
|
||||||
|
|
@ -79,12 +80,23 @@ Source: user-provided `palm-rejection-test.html` test harness. The acceptance po
|
||||||
- A `touch` pointer is rejected if any of: a pen stroke is active or was active within the last ~5s, its contact ellipse (`width`/`height`) exceeds ~35px (palm heel vs. fingertip), it's within 150ms of the last pen activity, or a pen is currently hovering (armed lockout before it even touches down).
|
- A `touch` pointer is rejected if any of: a pen stroke is active or was active within the last ~5s, its contact ellipse (`width`/`height`) exceeds ~35px (palm heel vs. fingertip), it's within 150ms of the last pen activity, or a pen is currently hovering (armed lockout before it even touches down).
|
||||||
- A touch stroke already in progress gets cut off mid-stroke if pen activity appears.
|
- A touch stroke already in progress gets cut off mid-stroke if pen activity appears.
|
||||||
|
|
||||||
**Gaps before this is the real ink engine** (currently a raster test, not vector):
|
**How the app applies it** (`src/lib/ink/palm.ts`):
|
||||||
- `draw()` writes straight to canvas via `ctx.lineTo`/`stroke` with pressure-scaled line width, and throws points away. Needs to become `recordPoint()`: push `{x, y, pressure, tiltX, tiltY, t}` into a `Stroke.points[]` array per stroke; rendering = replaying that array. This is what makes strokes resizable/erasable/undoable and exportable to both the PDF Ink-annotation layer and the JSON attachment layer.
|
- Each touch is evaluated with an intent. `draw` (finger ink) gets the full policy above. `navigate` (pan, pinch, and taps on canvas buttons such as the add-page "+") skips the 5 s pen session and the hover lockout, so fingers can move around and tap between pen strokes; pen-down, wide contacts and the 150 ms window still reject it.
|
||||||
- `tiltX`/`tiltY` exist on the pen's `PointerEvent` but aren't read anywhere yet — needed for tilt support.
|
- Once a pen has been used, fingers only navigate. "Draw with finger" in Settings turns finger ink off entirely.
|
||||||
- No smoothing yet (raw straight segments) — fine as a v1, can layer in curve smoothing later without changing point storage.
|
- Each rule (pen session, wide contact, timing, hover) can be switched off in Settings → Pen & touch.
|
||||||
- No undo/redo, multi-color, eraser yet — needs a `Stroke[]` array per page's ink layer with add/remove, which undo/redo just pops/pushes.
|
- An interrupted pointer (`pointercancel`) drops the stroke or erase in progress instead of committing it.
|
||||||
|
|
||||||
|
**Ink engine (implemented)**:
|
||||||
|
- `StrokeRecorder.recordPoint()` stores `x, y, pressure, tiltX, tiltY, t` per sample in a flat `Stroke.points` array (stride 6); rendering replays it. Tilt is recorded but not yet used for rendering.
|
||||||
|
- Outlines are smoothed with `perfect-freehand`; mouse and finger strokes simulate pressure.
|
||||||
|
- Pen, highlighter (translucent, no thinning) and stroke eraser, with configurable colour palettes; the pen's eraser end erases too.
|
||||||
|
- Undo/redo covers ink, erasing and page-tree changes.
|
||||||
|
- Strokes are written to the PDF both as Ink annotations and as JSON attachments.
|
||||||
|
|
||||||
## 10. Open Items
|
## 10. Open Items
|
||||||
|
|
||||||
- [ ] Nothing blocking — ready to move into implementation planning (component breakdown, page-tree data structures in Svelte, PDF encode/decode module)
|
- [ ] Conflict copies: sync currently lets the newer version win silently; keep the losing version as a separate file.
|
||||||
|
- [ ] Tell the user when a new version of the app has been deployed (the service worker updates, but the open tab keeps old code until reload).
|
||||||
|
- [ ] CI that runs `check`, `test` and `build` on every pull request.
|
||||||
|
- [ ] Use recorded tilt for rendering.
|
||||||
|
- [ ] GitLab sync.
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
--page-line: rgba(0, 0, 0, 0.18);
|
--page-line: rgba(0, 0, 0, 0.18);
|
||||||
--grid-line: rgba(0, 0, 0, 0.05);
|
--grid-line: rgba(0, 0, 0, 0.05);
|
||||||
|
--grid-line-minor: rgba(0, 0, 0, 0.028);
|
||||||
--ghost: rgba(0, 0, 0, 0.045);
|
--ghost: rgba(0, 0, 0, 0.045);
|
||||||
--ghost-border: rgba(0, 0, 0, 0.12);
|
--ghost-border: rgba(0, 0, 0, 0.12);
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
|
|
@ -88,6 +89,7 @@
|
||||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.35);
|
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||||
--page-line: rgba(255, 255, 255, 0.16);
|
--page-line: rgba(255, 255, 255, 0.16);
|
||||||
--grid-line: rgba(255, 255, 255, 0.04);
|
--grid-line: rgba(255, 255, 255, 0.04);
|
||||||
|
--grid-line-minor: rgba(255, 255, 255, 0.022);
|
||||||
--ghost: rgba(255, 255, 255, 0.04);
|
--ghost: rgba(255, 255, 255, 0.04);
|
||||||
--ghost-border: rgba(255, 255, 255, 0.14);
|
--ghost-border: rgba(255, 255, 255, 0.14);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
|
||||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||||
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
|
||||||
|
|
@ -15,10 +16,12 @@
|
||||||
import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout';
|
import { bounds, intersects, rectBeside, type Rect } from '$lib/model/layout';
|
||||||
import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree';
|
import { canInsert, freeSlots, freeSlotsOf, neighbor, type Dir, type Slot } from '$lib/model/tree';
|
||||||
import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types';
|
import { A4, PAGE_PRESETS, type Stroke } from '$lib/model/types';
|
||||||
import { PalmRejector } from '$lib/ink/palm';
|
import { PalmRejector, touchIntent } from '$lib/ink/palm';
|
||||||
|
import { flingDir, pushSample, type Sample } from '$lib/ink/fling';
|
||||||
import { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
|
import { StrokeRecorder, hitStroke } from '$lib/ink/stroke';
|
||||||
import { pageCssVars } from '$lib/editor/pageStyle';
|
import { pageCssVars } from '$lib/editor/pageStyle';
|
||||||
import { downloadCanvas } from './download';
|
import { downloadCanvas } from './download';
|
||||||
|
import PageSizeDialog from './PageSizeDialog.svelte';
|
||||||
import PageView from './PageView.svelte';
|
import PageView from './PageView.svelte';
|
||||||
import Toolbar from './Toolbar.svelte';
|
import Toolbar from './Toolbar.svelte';
|
||||||
|
|
||||||
|
|
@ -27,11 +30,28 @@
|
||||||
let stage: HTMLDivElement;
|
let stage: HTMLDivElement;
|
||||||
const cssVars = pageCssVars();
|
const cssVars = pageCssVars();
|
||||||
|
|
||||||
// Background grid: one cell per active page, aligned to its edges (A4 at the origin if none).
|
// Background grid: one cell per active page, aligned to its edges (A4 at the origin if none),
|
||||||
const grid = $derived.by(() => {
|
// 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 r = (doc.activeId && doc.rects.get(doc.activeId)) || { x: 0, y: 0, ...A4 };
|
||||||
const s = viewport.scale;
|
const s = viewport.scale;
|
||||||
return { w: r.width * s, h: r.height * s, x: viewport.x + r.x * s, y: viewport.y + r.y * s };
|
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 --------------------------------------------------------
|
// ---- autosave --------------------------------------------------------
|
||||||
|
|
@ -54,6 +74,7 @@
|
||||||
return () => {
|
return () => {
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
stage.removeEventListener('wheel', onWheel);
|
stage.removeEventListener('wheel', onWheel);
|
||||||
|
clearTimeout(offerTimer);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -98,6 +119,34 @@
|
||||||
|
|
||||||
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []);
|
const placeSlots = $derived(tools.placing ? freeSlots(doc.tree) : []);
|
||||||
|
|
||||||
|
// A finger fling toward a free side of the active page offers a new page there: the plus
|
||||||
|
// pulses if it's on screen, otherwise a button appears at that edge of the screen.
|
||||||
|
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[]) {
|
||||||
|
if (!settings.data.flingToAdd) return;
|
||||||
|
const dir = flingDir(samples);
|
||||||
|
const id = doc.activeId;
|
||||||
|
const slot = dir && ghostSlots.find((s) => s.anchor === id && s.dir === dir);
|
||||||
|
if (slot) setOffer(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The offered plus is off screen, so the offer shows as an edge-of-screen button instead. */
|
||||||
|
const offerOffscreen = $derived.by(() => {
|
||||||
|
const r = offer && plusRect(offer);
|
||||||
|
if (!r) return false;
|
||||||
|
const a = viewport.toScreen(r.x, r.y);
|
||||||
|
const b = viewport.toScreen(r.x + r.width, r.y + r.height);
|
||||||
|
return b.x < 0 || b.y < 0 || a.x > viewport.width || a.y > viewport.height;
|
||||||
|
});
|
||||||
|
|
||||||
// Show every possible spot when an imported PDF is waiting to be placed.
|
// Show every possible spot when an imported PDF is waiting to be placed.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!tools.placing) return;
|
if (!tools.placing) return;
|
||||||
|
|
@ -117,15 +166,20 @@
|
||||||
return rectBeside(a, slot.dir, s);
|
return rectBeside(a, slot.dir, s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hit area of the add-page bar: a strip hugging the anchor's free edge. */
|
/**
|
||||||
function barRect(slot: Slot): Rect | null {
|
* 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);
|
const a = doc.rectOf(slot.anchor);
|
||||||
if (!a) return null;
|
if (!a) return null;
|
||||||
const t = 20 / viewport.scale;
|
const d = 48 / viewport.scale;
|
||||||
if (slot.dir === 'right') return { x: a.x + a.width, y: a.y, width: t, height: a.height };
|
const cx = a.x + (a.width - d) / 2;
|
||||||
if (slot.dir === 'left') return { x: a.x - t, y: a.y, width: t, height: a.height };
|
const cy = a.y + (a.height - d) / 2;
|
||||||
if (slot.dir === 'down') return { x: a.x, y: a.y + a.height, width: a.width, height: t };
|
if (slot.dir === 'right') return { x: a.x + a.width, y: cy, width: d, height: d };
|
||||||
return { x: a.x, y: a.y - t, width: a.width, height: t };
|
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. */
|
/** Run a tree change without the anchor page jumping on screen. */
|
||||||
|
|
@ -209,15 +263,9 @@
|
||||||
menu.show(x, y, items);
|
menu.show(x, y, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
function customSize(id: string) {
|
/** Page whose custom-size dialog is open. */
|
||||||
const page = doc.pages[id];
|
let sizing = $state<string | null>(null);
|
||||||
const mm = (pt: number) => Math.round((pt / 72) * 25.4);
|
const customSize = (id: string) => (sizing = id);
|
||||||
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 activeMenuPos = $derived.by(() => {
|
||||||
const id = doc.activeId;
|
const id = doc.activeId;
|
||||||
|
|
@ -236,7 +284,7 @@
|
||||||
type Gesture =
|
type Gesture =
|
||||||
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
|
| { kind: 'draw'; pointerId: number; pointerType: string; pageId: string; rec: StrokeRecorder }
|
||||||
| { kind: 'erase'; pointerId: number; pointerType: string; hits: Map<string, Set<string>> }
|
| { 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: '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: 'add'; pointerId: number; pointerType: string; startX: number; startY: number; slot: Slot }
|
||||||
| { kind: 'pinch' };
|
| { kind: 'pinch' };
|
||||||
|
|
||||||
|
|
@ -285,16 +333,18 @@
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
palm.track(e);
|
palm.track(e);
|
||||||
if (menu.open) menu.close();
|
if (menu.open) menu.close();
|
||||||
|
if (offer) setOffer(null);
|
||||||
const target = e.target as Element;
|
const target = e.target as Element;
|
||||||
if (target.closest('.no-stage')) return;
|
if (target.closest('.no-stage')) return;
|
||||||
const p = local(e);
|
const p = local(e);
|
||||||
|
|
||||||
// Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom.
|
// Fingers ink only when enabled and no pen has been used; otherwise they pan/zoom.
|
||||||
const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen;
|
const fingerInks = tools.inking && settings.data.fingerDraw && !palm.penSeen;
|
||||||
// Taps on an add-page bar get the same palm policy as writing.
|
// Taps on an add-page button are palm-checked like navigation: still rejected for
|
||||||
const bar = target.closest<HTMLElement>('.ghost');
|
// pen-down, palm-sized contacts and right after pen use, but not for the 5 s pen session.
|
||||||
|
const plus = target.closest<HTMLElement>('.ghost, .offer-pill');
|
||||||
if (e.pointerType === 'touch') {
|
if (e.pointerType === 'touch') {
|
||||||
const decision = palm.evaluate(e, fingerInks || bar ? 'draw' : 'navigate');
|
const decision = palm.evaluate(e, touchIntent(fingerInks, !!plus));
|
||||||
if (!decision.accept) return; // palm: ignore entirely
|
if (!decision.accept) return; // palm: ignore entirely
|
||||||
touches.set(e.pointerId, p);
|
touches.set(e.pointerId, p);
|
||||||
if (touches.size >= 2) {
|
if (touches.size >= 2) {
|
||||||
|
|
@ -305,9 +355,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bar && e.button === 0) {
|
if (plus && e.button === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const slot = { anchor: bar.dataset.anchor!, dir: bar.dataset.dir as Dir };
|
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 };
|
gesture = { kind: 'add', pointerId: e.pointerId, pointerType: e.pointerType, startX: p.x, startY: p.y, slot };
|
||||||
capture(e.pointerId);
|
capture(e.pointerId);
|
||||||
return;
|
return;
|
||||||
|
|
@ -327,7 +377,18 @@
|
||||||
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.slot'));
|
(e.pointerType === 'mouse' && e.button === 0 && !onPage && !target.closest('.slot'));
|
||||||
if (wantsPan) {
|
if (wantsPan) {
|
||||||
if (onPage && !inEditor) doc.activeId = onPage;
|
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('.slot') };
|
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' || !gesture.tap) {
|
||||||
if (e.pointerType !== 'touch') e.preventDefault();
|
if (e.pointerType !== 'touch') e.preventDefault();
|
||||||
capture(e.pointerId);
|
capture(e.pointerId);
|
||||||
|
|
@ -386,18 +447,19 @@
|
||||||
viewport.panBy(dx, dy);
|
viewport.panBy(dx, dy);
|
||||||
gesture.lastX = p.x;
|
gesture.lastX = p.x;
|
||||||
gesture.lastY = p.y;
|
gesture.lastY = p.y;
|
||||||
|
pushSample(gesture.trail, { ...p, t: e.timeStamp });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A touch stroke is cut off as soon as the palm policy rejects it
|
// A touch stroke is cut off as soon as the palm policy rejects it
|
||||||
// (e.g. the pen just came into range).
|
// (e.g. the pen just came into range).
|
||||||
if (gesture.pointerType === 'touch' && !palm.evaluate(e).accept) {
|
if (gesture.pointerType === 'touch' && !palm.evaluate(e, gesture.kind === 'add' ? 'navigate' : 'draw').accept) {
|
||||||
cancelGesture();
|
cancelGesture();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gesture.kind === 'add') {
|
if (gesture.kind === 'add') {
|
||||||
// Dragging off the bar is not a tap.
|
// Dragging off the button is not a tap.
|
||||||
if (Math.hypot(p.x - gesture.startX, p.y - gesture.startY) > 12) gesture = null;
|
if (Math.hypot(p.x - gesture.startX, p.y - gesture.startY) > 12) gesture = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -429,13 +491,19 @@
|
||||||
if (!gesture || gesture.pointerId !== e.pointerId) return;
|
if (!gesture || gesture.pointerId !== e.pointerId) return;
|
||||||
const g = gesture;
|
const g = gesture;
|
||||||
gesture = null;
|
gesture = null;
|
||||||
if (g.kind === 'add') {
|
if (g.kind === 'pan') {
|
||||||
if (e.type === 'pointerup' && (g.pointerType !== 'touch' || palm.evaluate(e).accept)) addPage(g.slot.anchor, g.slot.dir);
|
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') {
|
} else if (g.kind === 'draw') {
|
||||||
live = null;
|
live = null;
|
||||||
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
|
if (e.type === 'pointerup') doc.addStroke(g.pageId, g.rec.stroke);
|
||||||
} else if (g.kind === 'erase') {
|
} else if (g.kind === 'erase') {
|
||||||
for (const [pageId, ids] of g.hits) doc.setStrokes(pageId, (s) => s.filter((x) => !ids.has(x.id)));
|
// 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();
|
erasing = new Set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -499,7 +567,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (workspace.settingsOpen) return;
|
if (workspace.settingsOpen || sizing) return;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
const k = e.key.toLowerCase();
|
const k = e.key.toLowerCase();
|
||||||
|
|
||||||
|
|
@ -558,7 +626,7 @@
|
||||||
class:panning
|
class:panning
|
||||||
class:erasing={tools.tool === 'eraser'}
|
class:erasing={tools.tool === 'eraser'}
|
||||||
bind:this={stage}
|
bind:this={stage}
|
||||||
style="background-size: {grid.w}px {grid.h}px; background-position: {grid.x}px {grid.y}px"
|
style={gridStyle}
|
||||||
onpointerdown={onPointerDown}
|
onpointerdown={onPointerDown}
|
||||||
onpointermove={onPointerMove}
|
onpointermove={onPointerMove}
|
||||||
onpointerup={onPointerUp}
|
onpointerup={onPointerUp}
|
||||||
|
|
@ -593,17 +661,20 @@
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each ghostSlots as slot (slot.anchor + slot.dir)}
|
{#each ghostSlots as slot (slot.anchor + slot.dir)}
|
||||||
{@const r = barRect(slot)}
|
{@const r = plusRect(slot)}
|
||||||
{#if r}
|
{#if r}
|
||||||
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
|
<!-- Pointer taps go through the palm-checked gesture; onclick only serves the keyboard. -->
|
||||||
<button
|
<button
|
||||||
class="ghost"
|
class="ghost"
|
||||||
|
class:offer={offer?.anchor === slot.anchor && offer.dir === slot.dir}
|
||||||
data-anchor={slot.anchor}
|
data-anchor={slot.anchor}
|
||||||
data-dir={slot.dir}
|
data-dir={slot.dir}
|
||||||
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
|
style="left:{r.x}px; top:{r.y}px; width:{r.width}px; height:{r.height}px"
|
||||||
title="Add page ({slot.dir})"
|
title="Add page ({slot.dir})"
|
||||||
onclick={(e) => e.detail === 0 && addPage(slot.anchor, slot.dir)}
|
onclick={(e) => e.detail === 0 && addPage(slot.anchor, slot.dir)}
|
||||||
></button>
|
>
|
||||||
|
<span class="plus"><CirclePlusIcon size="100%" strokeWidth={1.5} /></span>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|
@ -640,6 +711,20 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if offer && offerOffscreen}
|
||||||
|
<!-- Like the plus buttons, taps go through the palm-checked gesture. -->
|
||||||
|
<button
|
||||||
|
class="offer-pill"
|
||||||
|
data-anchor={offer.anchor}
|
||||||
|
data-dir={offer.dir}
|
||||||
|
data-side={offer.dir}
|
||||||
|
onclick={(e) => e.detail === 0 && offer && addPage(offer.anchor, offer.dir)}
|
||||||
|
>
|
||||||
|
<CirclePlusIcon size={18} strokeWidth={1.75} />
|
||||||
|
Add page
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if eraserAt && tools.tool === 'eraser'}
|
{#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>
|
<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}
|
||||||
|
|
@ -652,6 +737,19 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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} />
|
<Toolbar {doc} onfit={() => fitPage(doc.activeId)} onfitall={fitAll} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -697,9 +795,6 @@
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
background-color: var(--bg-canvas);
|
background-color: var(--bg-canvas);
|
||||||
background-image:
|
|
||||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
|
||||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
|
||||||
}
|
}
|
||||||
.stage :global(.cm-content) {
|
.stage :global(.cm-content) {
|
||||||
user-select: text;
|
user-select: text;
|
||||||
|
|
@ -726,46 +821,76 @@
|
||||||
}
|
}
|
||||||
.ghost {
|
.ghost {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
color: var(--faint);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: color 0.12s;
|
||||||
}
|
}
|
||||||
/* The visible bar: 4 screen px thick, 6 px off the page edge. */
|
.ghost:hover,
|
||||||
.ghost::before {
|
.ghost:focus-visible {
|
||||||
content: '';
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.offer-pill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--ghost-border);
|
display: flex;
|
||||||
border-radius: calc(2px / var(--s));
|
align-items: center;
|
||||||
transition: background 0.12s;
|
gap: 6px;
|
||||||
|
padding: 10px 16px 10px 12px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ghost[data-dir='left']::before,
|
.offer-pill[data-side='right'] {
|
||||||
.ghost[data-dir='right']::before {
|
right: 12px;
|
||||||
top: 0;
|
top: 50%;
|
||||||
bottom: 0;
|
transform: translateY(-50%);
|
||||||
width: calc(4px / var(--s));
|
|
||||||
}
|
}
|
||||||
.ghost[data-dir='up']::before,
|
.offer-pill[data-side='left'] {
|
||||||
.ghost[data-dir='down']::before {
|
left: 12px;
|
||||||
left: 0;
|
top: 50%;
|
||||||
right: 0;
|
transform: translateY(-50%);
|
||||||
height: calc(4px / var(--s));
|
|
||||||
}
|
}
|
||||||
.ghost[data-dir='right']::before {
|
.offer-pill[data-side='up'] {
|
||||||
left: calc(6px / var(--s));
|
top: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
.ghost[data-dir='left']::before {
|
.offer-pill[data-side='down'] {
|
||||||
right: calc(6px / var(--s));
|
/* Clear of the toolbar. */
|
||||||
|
bottom: 72px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
.ghost[data-dir='down']::before {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
top: calc(6px / var(--s));
|
.ghost.offer .plus {
|
||||||
}
|
animation: none;
|
||||||
.ghost[data-dir='up']::before {
|
}
|
||||||
bottom: calc(6px / var(--s));
|
|
||||||
}
|
|
||||||
.ghost:hover::before,
|
|
||||||
.ghost:focus-visible::before {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
}
|
||||||
.slot {
|
.slot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
173
src/lib/components/PageSizeDialog.svelte
Normal file
173
src/lib/components/PageSizeDialog.svelte
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import ArrowLeftRightIcon from '@lucide/svelte/icons/arrow-left-right';
|
||||||
|
import { PAGE_PRESETS } from '$lib/model/types';
|
||||||
|
|
||||||
|
let {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
onapply,
|
||||||
|
onclose
|
||||||
|
}: { width: number; height: number; onapply: (width: number, height: number) => void; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
// Sizes are stored in points; people think in millimetres.
|
||||||
|
const MIN_PT = 50;
|
||||||
|
const MAX_PT = 5000;
|
||||||
|
const toMm = (pt: number) => Math.round((pt / 72) * 25.4);
|
||||||
|
const toPt = (mm: number) => (mm / 25.4) * 72;
|
||||||
|
|
||||||
|
// Start from the page's current size; edits are local until Apply.
|
||||||
|
let w = $state(untrack(() => toMm(width)));
|
||||||
|
let h = $state(untrack(() => toMm(height)));
|
||||||
|
let first: HTMLInputElement;
|
||||||
|
|
||||||
|
const valid = $derived([w, h].every((v) => Number.isFinite(v) && toPt(v) >= MIN_PT && toPt(v) <= MAX_PT));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
first.focus();
|
||||||
|
first.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!valid) return;
|
||||||
|
// Whole millimetres would nudge exact sizes (A4 is 595.28 pt), so keep those as they are.
|
||||||
|
const exact = [{ width, height }, ...PAGE_PRESETS].find((p) => toMm(p.width) === w && toMm(p.height) === h);
|
||||||
|
if (exact) onapply(exact.width, exact.height);
|
||||||
|
else onapply(toPt(w), toPt(h));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={(e) => e.key === 'Escape' && onclose()} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||||
|
<div class="backdrop" onclick={(e) => e.target === e.currentTarget && onclose()}>
|
||||||
|
<form
|
||||||
|
class="dialog"
|
||||||
|
aria-label="Page size"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
apply();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2>Page size</h2>
|
||||||
|
<div class="presets">
|
||||||
|
{#each PAGE_PRESETS as p (p.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip"
|
||||||
|
class:active={toMm(p.width) === w && toMm(p.height) === h}
|
||||||
|
onclick={() => ((w = toMm(p.width)), (h = toMm(p.height)))}>{p.label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="fields">
|
||||||
|
<label>
|
||||||
|
<span>Width</span>
|
||||||
|
<input type="number" inputmode="decimal" min={toMm(MIN_PT)} max={toMm(MAX_PT)} step="1" bind:value={w} bind:this={first} />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="icon-btn" title="Swap width and height" onclick={() => ([w, h] = [h, w])}>
|
||||||
|
<ArrowLeftRightIcon size={16} />
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
<span>Height</span>
|
||||||
|
<input type="number" inputmode="decimal" min={toMm(MIN_PT)} max={toMm(MAX_PT)} step="1" bind:value={h} />
|
||||||
|
</label>
|
||||||
|
<span class="unit">mm</span>
|
||||||
|
</div>
|
||||||
|
{#if !valid}
|
||||||
|
<p class="hint">Sizes must be between {toMm(MIN_PT)} and {toMm(MAX_PT)} mm.</p>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn" onclick={onclose}>Cancel</button>
|
||||||
|
<button type="submit" class="btn primary" disabled={!valid}>Apply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
.presets {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
.chip.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
label span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.fields .icon-btn {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.unit {
|
||||||
|
color: var(--faint);
|
||||||
|
padding-bottom: 7px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -83,12 +83,30 @@
|
||||||
<option value="paper">Paper</option>
|
<option value="paper">Paper</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><div class="name">Background grid</div><div class="desc">Faint lines behind the pages, one cell per active page.</div></div>
|
||||||
|
<input type="checkbox" class="toggle" bind:checked={s.grid.show} onchange={save} />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><div class="name">Grid subdivisions</div><div class="desc">Split each page-sized cell into a finer grid for sketching and lining things up.</div></div>
|
||||||
|
<select bind:value={s.grid.divisions} onchange={save} disabled={!s.grid.show}>
|
||||||
|
<option value={1}>None</option>
|
||||||
|
<option value={2}>2 × 2</option>
|
||||||
|
<option value={4}>4 × 4</option>
|
||||||
|
<option value={8}>8 × 8</option>
|
||||||
|
<option value={16}>16 × 16</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{:else if tab === 'input'}
|
{:else if tab === 'input'}
|
||||||
<h2>Pen & touch</h2>
|
<h2>Pen & touch</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.</div></div>
|
<div><div class="name">Draw with finger</div><div class="desc">When off, a single finger always pans; only pen and mouse draw. Once you use a pen, fingers only pan and zoom.</div></div>
|
||||||
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
|
<input type="checkbox" class="toggle" bind:checked={s.fingerDraw} onchange={save} />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div><div class="name">Swipe to add pages</div><div class="desc">A quick one-finger flick toward a free side of the active page offers to add a page there.</div></div>
|
||||||
|
<input type="checkbox" class="toggle" bind:checked={s.flingToAdd} onchange={save} />
|
||||||
|
</div>
|
||||||
<h3>Palm rejection</h3>
|
<h3>Palm rejection</h3>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.</div></div>
|
<div><div class="name">Pen session lockout</div><div class="desc">Ignore touches while the pen is writing, and finger ink for 5 seconds after it was used.</div></div>
|
||||||
|
|
|
||||||
43
src/lib/ink/fling.test.ts
Normal file
43
src/lib/ink/fling.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { flingDir, pushSample, type Sample } from './fling';
|
||||||
|
|
||||||
|
/** A straight drag from (0,0) by (dx,dy) over `ms`, sampled every 16 ms. */
|
||||||
|
function drag(dx: number, dy: number, ms: number): Sample[] {
|
||||||
|
const out: Sample[] = [];
|
||||||
|
for (let t = 0; t <= ms; t += 16) pushSample(out, { x: (dx * t) / ms, y: (dy * t) / ms, t });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('flingDir', () => {
|
||||||
|
it('maps a flick to the side it reveals', () => {
|
||||||
|
expect(flingDir(drag(-200, 0, 96))).toBe('right');
|
||||||
|
expect(flingDir(drag(200, 0, 96))).toBe('left');
|
||||||
|
expect(flingDir(drag(0, -200, 96))).toBe('down');
|
||||||
|
expect(flingDir(drag(0, 200, 96))).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores slow drags', () => {
|
||||||
|
expect(flingDir(drag(-200, 0, 800))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores diagonal flicks', () => {
|
||||||
|
expect(flingDir(drag(-200, -150, 96))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a drag that stops before lifting', () => {
|
||||||
|
const s = drag(-300, 0, 96);
|
||||||
|
const end = s.at(-1)!;
|
||||||
|
for (let t = 16; t <= 160; t += 16) pushSample(s, { ...end, t: end.t + t });
|
||||||
|
expect(flingDir(s)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs at least two samples', () => {
|
||||||
|
expect(flingDir([])).toBeNull();
|
||||||
|
expect(flingDir([{ x: 0, y: 0, t: 0 }])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the trail short', () => {
|
||||||
|
const s = drag(-2000, 0, 2000);
|
||||||
|
expect(s.at(-1)!.t - s[0].t).toBeLessThanOrEqual(216);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/lib/ink/fling.ts
Normal file
41
src/lib/ink/fling.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Fling detection for one-finger pans: a quick flick that ends the drag.
|
||||||
|
|
||||||
|
import type { Dir } from '$lib/model/tree';
|
||||||
|
|
||||||
|
export interface Sample {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
t: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the last stretch of the drag counts. */
|
||||||
|
const WINDOW_MS = 100;
|
||||||
|
/** px/ms; a slow drag that stops is not a fling. */
|
||||||
|
const MIN_SPEED = 0.6;
|
||||||
|
/** The main axis must dominate by this much. */
|
||||||
|
const MIN_RATIO = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Side of the canvas a fling heads for, or null. Moving the finger left pulls in
|
||||||
|
* what's on the right, so a leftward flick returns 'right'.
|
||||||
|
*/
|
||||||
|
export function flingDir(samples: readonly Sample[]): Dir | null {
|
||||||
|
const last = samples.at(-1);
|
||||||
|
if (!last) return null;
|
||||||
|
const first = samples.find((s) => last.t - s.t <= WINDOW_MS);
|
||||||
|
if (!first || first === last) return null;
|
||||||
|
const dt = last.t - first.t;
|
||||||
|
if (dt <= 0) return null;
|
||||||
|
const dx = last.x - first.x;
|
||||||
|
const dy = last.y - first.y;
|
||||||
|
if (Math.hypot(dx, dy) / dt < MIN_SPEED) return null;
|
||||||
|
if (Math.abs(dx) >= Math.abs(dy) * MIN_RATIO) return dx < 0 ? 'right' : 'left';
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx) * MIN_RATIO) return dy < 0 ? 'down' : 'up';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps a short trail of pointer samples for flingDir. */
|
||||||
|
export function pushSample(samples: Sample[], s: Sample) {
|
||||||
|
samples.push(s);
|
||||||
|
while (samples.length > 2 && s.t - samples[0].t > WINDOW_MS * 2) samples.shift();
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { PalmRejector } from './palm';
|
import { PalmRejector, touchIntent } from './palm';
|
||||||
|
|
||||||
const ev = (type: string, pointerType: string, extra: Partial<PointerEvent> = {}) =>
|
const ev = (type: string, pointerType: string, extra: Partial<PointerEvent> = {}) =>
|
||||||
({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent;
|
({ type, pointerType, pointerId: pointerType === 'pen' ? 1 : 2, width: 10, height: 10, ...extra }) as PointerEvent;
|
||||||
|
|
@ -53,4 +53,37 @@ describe('palm rejection', () => {
|
||||||
it('allows finger ink again once the pen session ends', () => {
|
it('allows finger ink again once the pen session ends', () => {
|
||||||
expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true);
|
expect(afterPen(5100).evaluate(touch(), 'draw').accept).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('add-page button taps', () => {
|
||||||
|
// With a pen in use fingers don't ink, but finger ink may still be on (before any pen).
|
||||||
|
const tap = (palm: PalmRejector, extra: Partial<PointerEvent> = {}, fingerInks = false) =>
|
||||||
|
palm.evaluate(touch(extra), touchIntent(fingerInks, true));
|
||||||
|
|
||||||
|
it('are handled as navigation even when fingers ink', () => {
|
||||||
|
expect(touchIntent(true, true)).toBe('navigate');
|
||||||
|
expect(touchIntent(false, true)).toBe('navigate');
|
||||||
|
expect(touchIntent(true, false)).toBe('draw');
|
||||||
|
expect(touchIntent(false, false)).toBe('navigate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are accepted a couple of seconds after writing', () => {
|
||||||
|
expect(tap(afterPen(2000)).accept).toBe(true);
|
||||||
|
expect(tap(afterPen(2000), {}, true).accept).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are accepted while the pen hovers', () => {
|
||||||
|
expect(tap(afterPen(2000, { leave: false })).accept).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are rejected while the pen is down', () => {
|
||||||
|
const palm = new PalmRejector();
|
||||||
|
palm.track(ev('pointerdown', 'pen'));
|
||||||
|
expect(tap(palm).accept).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('are rejected for a palm or right after the pen', () => {
|
||||||
|
expect(tap(afterPen(2000), { width: 60, height: 40 }).accept).toBe(false);
|
||||||
|
expect(tap(afterPen(50)).accept).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,15 @@ const PALM_CONTACT_PX = 35;
|
||||||
/** What an accepted touch would do: ink (draw/erase) or pan/zoom. */
|
/** What an accepted touch would do: ink (draw/erase) or pan/zoom. */
|
||||||
export type TouchIntent = 'draw' | 'navigate';
|
export type TouchIntent = 'draw' | 'navigate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which policy a touch falls under. It only draws when finger ink is on and it
|
||||||
|
* didn't land on a button; taps on buttons (e.g. add page) navigate, so they keep
|
||||||
|
* working between pen strokes.
|
||||||
|
*/
|
||||||
|
export function touchIntent(fingerInks: boolean, onButton: boolean): TouchIntent {
|
||||||
|
return fingerInks && !onButton ? 'draw' : 'navigate';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Decision {
|
export interface Decision {
|
||||||
accept: boolean;
|
accept: boolean;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import { DEFAULT_PALM, type PalmOptions } from '$lib/ink/palm';
|
||||||
export type ThemePref = 'system' | 'light' | 'dark';
|
export type ThemePref = 'system' | 'light' | 'dark';
|
||||||
export type PageStylePref = 'paper' | 'match';
|
export type PageStylePref = 'paper' | 'match';
|
||||||
|
|
||||||
|
export interface GridSettings {
|
||||||
|
show: boolean;
|
||||||
|
/** Minor lines per page cell side (1 = page-sized cells only). */
|
||||||
|
divisions: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitHubSettings {
|
export interface GitHubSettings {
|
||||||
token: string;
|
token: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
|
@ -18,7 +24,10 @@ export interface GitHubSettings {
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
theme: ThemePref;
|
theme: ThemePref;
|
||||||
pageStyle: PageStylePref;
|
pageStyle: PageStylePref;
|
||||||
|
grid: GridSettings;
|
||||||
fingerDraw: boolean;
|
fingerDraw: boolean;
|
||||||
|
/** A finger fling toward a free side of the active page offers a new page there. */
|
||||||
|
flingToAdd: boolean;
|
||||||
palm: PalmOptions;
|
palm: PalmOptions;
|
||||||
github: GitHubSettings;
|
github: GitHubSettings;
|
||||||
/** Minutes between automatic pushes (0 = manual only). */
|
/** Minutes between automatic pushes (0 = manual only). */
|
||||||
|
|
@ -30,7 +39,9 @@ interface SettingsData {
|
||||||
const DEFAULTS: SettingsData = {
|
const DEFAULTS: SettingsData = {
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
pageStyle: 'match',
|
pageStyle: 'match',
|
||||||
|
grid: { show: true, divisions: 1 },
|
||||||
fingerDraw: true,
|
fingerDraw: true,
|
||||||
|
flingToAdd: true,
|
||||||
palm: DEFAULT_PALM,
|
palm: DEFAULT_PALM,
|
||||||
github: { token: '', owner: '', repo: '', branch: 'main', dir: '' },
|
github: { token: '', owner: '', repo: '', branch: 'main', dir: '' },
|
||||||
pushInterval: 5,
|
pushInterval: 5,
|
||||||
|
|
@ -48,6 +59,7 @@ function load(): SettingsData {
|
||||||
return {
|
return {
|
||||||
...DEFAULTS,
|
...DEFAULTS,
|
||||||
...saved,
|
...saved,
|
||||||
|
grid: { ...DEFAULTS.grid, ...saved.grid },
|
||||||
palm: { ...DEFAULTS.palm, ...saved.palm },
|
palm: { ...DEFAULTS.palm, ...saved.palm },
|
||||||
github: { ...DEFAULTS.github, ...saved.github }
|
github: { ...DEFAULTS.github, ...saved.github }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue