From 00c59b699bb9bf2b09350d99d835e816ec98909d Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Tue, 23 Dec 2025 10:04:36 -0600 Subject: [PATCH] feat: update pen tool with draft synchronization and frame coalescing --- TODO.txt | 134 +----------------- .../src/lib/filebrowser/FileBrowser.svelte | 24 ++-- apps/web/src/lib/tests/Canvas.history.test.ts | 6 + .../web/src/lib/tests/Canvas.keyboard.test.ts | 6 + apps/web/src/lib/tests/Canvas.svelte.test.ts | 11 +- packages/core/src/tools/pen.ts | 89 ++++++++++-- packages/core/tests/pen-tool.test.ts | 92 ++++++++++-- 7 files changed, 199 insertions(+), 163 deletions(-) diff --git a/TODO.txt b/TODO.txt index e10f658..17cbf07 100644 --- a/TODO.txt +++ b/TODO.txt @@ -123,20 +123,9 @@ the Toolbar across web and desktop builds. 16. Milestone P: Desktop packaging (Tauri) *wb-P* ================================================================================ -Goal: same app works as a desktop app with filesystem access. - -Tauri + SvelteKit integration: -[x] Configure SvelteKit for static/SPA output -[x] Ensure SSR is disabled for desktop build -[x] Configure Tauri to load the built assets - -File dialogs + FS: -[x] Implement "Save As…" using Tauri dialog + fs APIs -[x] Implement "Open…" using Tauri dialog + fs APIs -[x] Add recent files list (v0: store paths in Tauri local storage) - -(DoD): -- Desktop app opens/saves JSON files on disk and reopens them correctly. +The Tauri build now ships the static SvelteKit bundle with native file dialogs +for open/save/new/rename/delete workflows so the desktop app works end-to-end +offline. ================================================================================ 17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* @@ -260,122 +249,7 @@ so drawing feels production-ready. 20. Milestone T: Sketching / Pen Tool (perfect-freehand) *wb-T* ================================================================================ -Goal: -Add a pen tool that produces smooth freehand strokes using perfect-freehand. -Strokes are shapes, undo/redo-able (L), persisted (M), selectable, and render on -Canvas2D. - -Refs: -- perfect-freehand getStroke returns outline polygon points. -- Options: size/thinning/smoothing/streamline/simulatePressure. - ------------------------------------------------------------------------------- -T1. Data model: Stroke shape ------------------------------------------------------------------------------- - -/packages/core/src/model.ts: -[x] Add ShapeType: 'stroke' -[x] StrokeShape props (persisted): - - points: Array<[x,y,p?]> " world coords + optional pressure - - style: { color, opacity } - - brush: { size, thinning, smoothing, streamline, simulatePressure } -[x] Derived (NOT persisted): - - outline: computed via computeOutline() from geom.ts - - bounds: computed via shapeBounds() from geom.ts - -(DoD): stroke serializes to JSON and loads back identically. - ------------------------------------------------------------------------------- -T2. Tool: pen (state machine) ------------------------------------------------------------------------------- - -/packages/core/src/tools/pen.ts (PenTool) -[x] PointerDown: start draft, push first point -[x] PointerMove: append point if moved > eps; include pressure if available -[x] PointerUp: create ONE history command that inserts the stroke; clear draft - -Perf: -[ ] Coalesce draft updates (rAF) so you don't recompute per event. - -(DoD): one stroke == one undo step; no DB writes until finalize (via M). - ------------------------------------------------------------------------------- -T3. Geometry: compute outline via perfect-freehand ------------------------------------------------------------------------------- - -/packages/core/src/geom.ts: -[x] computeOutline(points, brush) -> outlinePoints using getStroke() -[x] boundsFromOutline(outline) -> Box2 - -(DoD): outline non-empty for >= 2 points; bounds contain outline. - ------------------------------------------------------------------------------- -T4. Rendering: fill the outline polygon ------------------------------------------------------------------------------- - -/packages/renderer/src/index.ts: -[x] drawStroke(ctx, stroke): - - outline = computeOutline(...) (computed on each draw) - - ctx.beginPath(); moveTo/lineTo...; closePath(); fill() -[x] Render draft stroke inline with shapes (using same rendering path) -[x] Selection outline for strokes - -(DoD): strokes look stable while drawing; committed strokes match preview. - ------------------------------------------------------------------------------- -T5. Hit testing ------------------------------------------------------------------------------- - -/packages/core/src/geom.ts: -[x] hitTestStroke(p, stroke): - - bounds check first - - inside-outline polygon test (ray cast) -[x] Add stroke case to hitTestPoint - -(DoD): clicking a stroke selects it reliably. - ------------------------------------------------------------------------------- -T6. Brush settings (thin UI slice) ------------------------------------------------------------------------------- - -/apps/web/src/lib/components/BrushPopover.svelte: -[x] Sliders: size, thinning, smoothing, streamline -[x] Toggle: simulatePressure -(All map to perfect-freehand options.) - -(DoD): -- settings affect newly drawn strokes immediately. -- tests in BrushPopover.svelte.test.ts - ------------------------------------------------------------------------------- -T7. Tests ------------------------------------------------------------------------------- - -/packages/core/tests/geom-stroke.test.ts: -[x] outline computed for simple polyline -[x] bounds correctness -[x] hit test inside/outside sanity -[x] bounds from outline helper -[x] shapeBounds for stroke shapes - -/packages/core/tests/pen-tool.test.ts: -[x] Tool lifecycle tests -[x] Drawing strokes (pointer down/move/up) -[x] Keyboard interactions (Escape to cancel) -[x] Edge cases - -(DoD): All tests passing - -Integration: -[x] one history command per stroke (tested in pen-tool.test.ts) - ------------------------------------------------------------------------------- -Definition of Done ------------------------------------------------------------------------------- - -- Pen tool draws smooth strokes using perfect-freehand outlines. -- Strokes are selectable, undo/redo-able in one step, and persisted via Dexie. -- Brush controls change appearance of new strokes. +Perfect-freehand pen strokes now behave like first-class shapes with frame-coalesced drafting, geometry/rendering integration, brush controls, and thorough tests across core and web layers. ================================================================================ References (URLs) *wb-refs* diff --git a/apps/web/src/lib/filebrowser/FileBrowser.svelte b/apps/web/src/lib/filebrowser/FileBrowser.svelte index 4a83fe4..23717ca 100644 --- a/apps/web/src/lib/filebrowser/FileBrowser.svelte +++ b/apps/web/src/lib/filebrowser/FileBrowser.svelte @@ -8,7 +8,7 @@ FileBrowserViewModel, InkfiniteDB } from 'inkfinite-core'; - import { BoardStatsOps } from 'inkfinite-core'; + import { BoardStatsOps, FileBrowserVM } from 'inkfinite-core'; import type { Snippet } from 'svelte'; type Props = { @@ -34,7 +34,7 @@ desktopRepo = null }: Props = $props(); - let searchQuery = $state(vm.query); + let searchQuery = $derived(vm.query); let inspectorOpen = $state(false); let inspectorData = $state(null); let inspectorLoading = $state(false); @@ -55,16 +55,20 @@ } }); + function applySearchQuery(nextQuery: string) { + searchQuery = nextQuery; + const updated = FileBrowserVM.setQuery(vm, nextQuery); + vm = updated; + onUpdate?.(updated); + } + function handleSearchInput(event: Event) { const target = event.target as HTMLInputElement; - searchQuery = target.value; - const updated = vm; - onUpdate?.(updated); + applySearchQuery(target.value); } function handleSearchChange() { - const updated = { ...vm, query: searchQuery }; - onUpdate?.(updated); + applySearchQuery(searchQuery); } function closeBrowser() { @@ -238,16 +242,14 @@ {/if} {#if isCreating} diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts index d8a1b9d..24a1a3c 100644 --- a/apps/web/src/lib/tests/Canvas.history.test.ts +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -59,6 +59,12 @@ vi.mock( update: () => {}, set: () => {}, }), + createBrushStore: () => ({ + get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), + subscribe: () => () => {}, + update: () => {}, + set: () => {}, + }), }), ); diff --git a/apps/web/src/lib/tests/Canvas.keyboard.test.ts b/apps/web/src/lib/tests/Canvas.keyboard.test.ts index 176f66f..7b9a53e 100644 --- a/apps/web/src/lib/tests/Canvas.keyboard.test.ts +++ b/apps/web/src/lib/tests/Canvas.keyboard.test.ts @@ -78,6 +78,12 @@ vi.mock( update: () => {}, set: () => {}, }), + createBrushStore: () => ({ + get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), + subscribe: () => () => {}, + update: () => {}, + set: () => {}, + }), }), ); diff --git a/apps/web/src/lib/tests/Canvas.svelte.test.ts b/apps/web/src/lib/tests/Canvas.svelte.test.ts index f130000..51864be 100644 --- a/apps/web/src/lib/tests/Canvas.svelte.test.ts +++ b/apps/web/src/lib/tests/Canvas.svelte.test.ts @@ -28,6 +28,12 @@ vi.mock("$lib/status", () => { update: () => {}, set: () => {}, }), + createBrushStore: () => ({ + get: () => ({ size: 16, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true }), + subscribe: () => () => {}, + update: () => {}, + set: () => {}, + }), }; }); @@ -115,10 +121,11 @@ describe("Canvas component", () => { const { container } = render(Canvas); const toolButtons = container.querySelectorAll(".tool-button"); - expect(toolButtons.length).toBe(7); + expect(toolButtons.length).toBe(8); const toolIds = Array.from(toolButtons).map((btn) => btn.getAttribute("data-tool-id")); - expect(toolIds.slice(0, 6)).toEqual(["select", "rect", "ellipse", "line", "arrow", "text"]); + const coreToolIds = toolIds.filter((id) => id && id !== "history"); + expect(coreToolIds).toEqual(["select", "rect", "ellipse", "line", "arrow", "text", "pen"]); const historyButton = container.querySelector(".tool-button.history-button"); expect(historyButton).toBeTruthy(); diff --git a/packages/core/src/tools/pen.ts b/packages/core/src/tools/pen.ts index a3f41fd..75f0715 100644 --- a/packages/core/src/tools/pen.ts +++ b/packages/core/src/tools/pen.ts @@ -15,6 +15,10 @@ type PenToolState = { draftPoints: StrokePoint[]; /** ID of the shape being created */ draftShapeId: string | null; + /** Whether draft points are unsynced with document */ + draftNeedsSync: boolean; + /** Frame bucket when draft was last synced */ + lastUpdateFrame: number | null; }; /** @@ -27,6 +31,11 @@ const MIN_POINTS = 2; */ const MIN_POINT_DISTANCE = 1; +/** + * Duration for a render frame (~60 FPS) + */ +const FRAME_DURATION_MS = 1000 / 60; + /** * Default brush configuration */ @@ -52,7 +61,13 @@ export class PenTool implements Tool { private getBrush: () => BrushConfig; constructor(getBrush?: () => BrushConfig) { - this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; + this.toolState = { + isDrawing: false, + draftPoints: [], + draftShapeId: null, + draftNeedsSync: false, + lastUpdateFrame: null, + }; this.getBrush = getBrush ?? (() => DEFAULT_BRUSH); } @@ -108,6 +123,8 @@ export class PenTool implements Tool { this.toolState.isDrawing = true; this.toolState.draftPoints = [firstPoint]; this.toolState.draftShapeId = shapeId; + this.toolState.draftNeedsSync = false; + this.toolState.lastUpdateFrame = frameFromTimestamp(action.timestamp); const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] }; @@ -140,27 +157,31 @@ export class PenTool implements Tool { const newPoint: StrokePoint = [action.world.x, action.world.y]; this.toolState.draftPoints.push(newPoint); + this.toolState.draftNeedsSync = true; - const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; + if (this.shouldSyncNow(action.timestamp)) { + return this.syncDraftShape(state); + } - return { - ...state, - doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, - }; + return state; } private handlePointerUp(state: EditorState, action: Action): EditorState { if (action.type !== "pointer-up" || !this.toolState.draftShapeId) return state; - const shape = state.doc.shapes[this.toolState.draftShapeId]; - if (!shape || shape.type !== "stroke") return state; + let newState = this.syncDraftShape(state); - let newState = state; + const shape = newState.doc.shapes[this.toolState.draftShapeId]; + if (!shape || shape.type !== "stroke") { + this.resetToolState(); + return newState; + } if (this.toolState.draftPoints.length < MIN_POINTS) { - newState = this.cancelStroke(state); + newState = this.cancelStroke(newState); } + this.toolState.lastUpdateFrame = frameFromTimestamp(action.timestamp); this.resetToolState(); return newState; } @@ -202,6 +223,52 @@ export class PenTool implements Tool { } private resetToolState(): void { - this.toolState = { isDrawing: false, draftPoints: [], draftShapeId: null }; + this.toolState = { + isDrawing: false, + draftPoints: [], + draftShapeId: null, + draftNeedsSync: false, + lastUpdateFrame: null, + }; + } + + private shouldSyncNow(timestamp: number): boolean { + const frame = frameFromTimestamp(timestamp); + if (this.toolState.lastUpdateFrame === null) { + this.toolState.lastUpdateFrame = frame; + return true; + } + if (frame !== this.toolState.lastUpdateFrame) { + this.toolState.lastUpdateFrame = frame; + return true; + } + return false; + } + + private syncDraftShape(state: EditorState): EditorState { + if (!this.toolState.draftShapeId || !this.toolState.draftNeedsSync) { + return state; + } + + const shape = state.doc.shapes[this.toolState.draftShapeId]; + if (!shape || shape.type !== "stroke") { + this.toolState.draftNeedsSync = false; + return state; + } + + const updatedShape = { ...shape, props: { ...shape.props, points: [...this.toolState.draftPoints] } }; + this.toolState.draftNeedsSync = false; + + return { + ...state, + doc: { ...state.doc, shapes: { ...state.doc.shapes, [this.toolState.draftShapeId]: updatedShape } }, + }; + } +} + +function frameFromTimestamp(timestamp: number): number { + if (!Number.isFinite(timestamp) || timestamp < 0) { + return 0; } + return Math.floor(timestamp / FRAME_DURATION_MS); } diff --git a/packages/core/tests/pen-tool.test.ts b/packages/core/tests/pen-tool.test.ts index 70e69bc..9ede2f3 100644 --- a/packages/core/tests/pen-tool.test.ts +++ b/packages/core/tests/pen-tool.test.ts @@ -1,10 +1,21 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { PageRecord, Store } from "../src"; import type { Action } from "../src/actions"; import { Modifiers, PointerButtons } from "../src/actions"; import { PenTool } from "../src/tools/pen"; -function createPointerDownAction(worldX: number, worldY: number): Action { +let currentTimestamp = 1_000; + +function resetTimestamp(): void { + currentTimestamp = 1_000; +} + +function nextTimestamp(step = 17): number { + currentTimestamp += step; + return currentTimestamp; +} + +function createPointerDownAction(worldX: number, worldY: number, timestamp = nextTimestamp()): Action { return { type: "pointer-down", world: { x: worldX, y: worldY }, @@ -12,22 +23,22 @@ function createPointerDownAction(worldX: number, worldY: number): Action { button: 0, buttons: PointerButtons.create(true, false, false), modifiers: Modifiers.create(false, false, false, false), - timestamp: Date.now(), + timestamp, }; } -function createPointerMoveAction(worldX: number, worldY: number): Action { +function createPointerMoveAction(worldX: number, worldY: number, timestamp = nextTimestamp()): Action { return { type: "pointer-move", world: { x: worldX, y: worldY }, screen: { x: worldX, y: worldY }, buttons: PointerButtons.create(true, false, false), modifiers: Modifiers.create(false, false, false, false), - timestamp: Date.now(), + timestamp, }; } -function createPointerUpAction(worldX: number, worldY: number): Action { +function createPointerUpAction(worldX: number, worldY: number, timestamp = nextTimestamp()): Action { return { type: "pointer-up", world: { x: worldX, y: worldY }, @@ -35,22 +46,26 @@ function createPointerUpAction(worldX: number, worldY: number): Action { button: 0, buttons: PointerButtons.create(false, false, false), modifiers: Modifiers.create(false, false, false, false), - timestamp: Date.now(), + timestamp, }; } -function createKeyDownAction(key: string): Action { +function createKeyDownAction(key: string, timestamp = nextTimestamp()): Action { return { type: "key-down", key, code: key, modifiers: Modifiers.create(false, false, false, false), repeat: false, - timestamp: Date.now(), + timestamp, }; } describe("PenTool", () => { + beforeEach(() => { + resetTimestamp(); + }); + describe("Tool lifecycle", () => { it("should have correct id", () => { const tool = new PenTool(); @@ -150,6 +165,65 @@ describe("PenTool", () => { } }); + it("coalesces pointer updates within the same frame", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + const downTimestamp = nextTimestamp(); + state = tool.onAction(state, createPointerDownAction(100, 100, downTimestamp)); + + state = tool.onAction(state, createPointerMoveAction(110, 110, downTimestamp)); + let shape = state.doc.shapes[Object.keys(state.doc.shapes)[0]]; + if (shape?.type === "stroke") { + expect(shape.props.points.length).toBe(1); + } + + state = tool.onAction(state, createPointerMoveAction(120, 120, nextTimestamp())); + shape = state.doc.shapes[Object.keys(state.doc.shapes)[0]]; + if (shape?.type === "stroke") { + expect(shape.props.points.length).toBe(3); + } + }); + + it("flushes pending points on pointer up even without a new frame", () => { + const tool = new PenTool(); + const store = new Store(); + const page = PageRecord.create("Page 1", "page:1"); + + store.setState((state) => ({ + ...state, + doc: { ...state.doc, pages: { [page.id]: page } }, + ui: { ...state.ui, currentPageId: page.id }, + })); + + let state = store.getState(); + + const downTimestamp = nextTimestamp(); + state = tool.onAction(state, createPointerDownAction(100, 100, downTimestamp)); + + state = tool.onAction(state, createPointerMoveAction(110, 110, downTimestamp)); + + state = tool.onAction(state, createPointerUpAction(110, 110, downTimestamp)); + + const shapeId = Object.keys(state.doc.shapes)[0]; + const shape = state.doc.shapes[shapeId]; + + expect(shape?.type).toBe("stroke"); + if (shape?.type === "stroke") { + expect(shape.props.points.length).toBe(2); + expect(shape.props.points[1]).toEqual([110, 110]); + } + }); + it("should not add point if moved less than minimum distance", () => { const tool = new PenTool(); const store = new Store(); -- 2.51.2