diff --git a/TODO.txt b/TODO.txt --- a/TODO.txt +++ b/TODO.txt @@ -128,23 +128,191 @@ and history-driven syncing. ================================================================================ -14. Milestone N: Desktop packaging (Tauri) *wb-N* +14. Milestone N: Status Bar (Editor HUD) *wb-N* ================================================================================ -Goal: same app works as a desktop app with filesystem access. +Goal: +Add a bottom status bar that surfaces the "always-useful" editor telemetry: +cursor position, zoom, active tool/mode, selection summary, and persistence +state - with a clean core → UI boundary. -Tauri + SvelteKit integration: -[ ] Configure SvelteKit for static/SPA output -[ ] Ensure SSR is disabled for desktop build -[ ] Configure Tauri to load the built assets +Design inspirations: +- Zoom controls commonly expose percentage + "zoom to fit/selection" shortcuts. +Svelte integration detail: +- Use $effect to subscribe/unsubscribe to external sources (runs client-side; + cleanup function runs on re-run/unmount). -File dialogs + FS: -[ ] Implement "Save As…" using Tauri dialog + fs APIs -[ ] Implement "Open…" using Tauri dialog + fs APIs -[ ] Add recent files list (v0: store paths in Tauri local storage) +------------------------------------------------------------------------------ +N1. Define the StatusBar view model (core, pure TS) +------------------------------------------------------------------------------ + +/packages/core/src/ui/statusbar/types.ts +[ ] Define StatusBarVM (single object the UI renders): + - cursorWorld: { x, y } " world coords (always) + - cursorScreen: { x, y }? " optional dev-only + - zoomPct: number " e.g. 100, 67, 250 + - toolId: ToolId " select/rect/pen/... + - mode: string " 'idle'|'dragging'|'panning'|'text-edit' + - selection: + - count: number + - kind?: string " optional: 'rect', 'mixed', etc. + - bounds?: { w, h } " optional v1 + - snap: + - enabled: boolean + - gridSize?: number " if grid enabled + - angleStepDeg?: number " if angle snapping exists + - persistence: + - backend: 'indexeddb' + - state: 'saved'|'saving'|'error' + - lastSavedAt?: number " epoch ms + - pendingWrites?: number " queue depth if you batch writes + - errorMsg?: string + +Notes: +- This VM is intentionally READ-ONLY and derived from existing editor state, + input state, and persistence sink state. (DoD): -- Desktop app opens/saves JSON files on disk and reopens them correctly. +- StatusBarVM compiles and is stable enough to render even before UI exists. + +------------------------------------------------------------------------------ +N2. Provide selectors / derivations for StatusBarVM +------------------------------------------------------------------------------ + +/packages/core/src/ui/statusbar/selectors.ts +[ ] Implement pure functions: + - getZoomPct(state) -> number + - getToolId(state) -> ToolId + - getSelectionSummary(state) -> { count, kind?, bounds? } + - getSnapSummary(state) -> snap summary (default safe values) + +Cursor position source: +[ ] Define a minimal CursorState in core (NOT persisted): + - cursorWorld: Vec2 + - cursorScreen?: Vec2 + - lastMoveAt: number + +[ ] Add updateCursor(world, screen?) action + reducer handler (or direct setter) + that ONLY touches CursorState (no history command, no persistence). + +(DoD): +- You can compute StatusBarVM from (EditorState + CursorState + PersistState). + +------------------------------------------------------------------------------ +N3. Wire cursor updates from pointer movement (apps/web) +------------------------------------------------------------------------------ + +/apps/web/src/lib/pointer.ts +[ ] On pointermove (or mousemove when not captured): + - compute world coords using camera.screenToWorld + - dispatch updateCursor(world, screen) + +Performance: +[ ] Throttle cursor updates: + - v0: requestAnimationFrame coalescing (only publish latest per frame) + - avoid flooding render/history/persistence + +(DoD): +- Cursor world coordinates update smoothly while moving the mouse. + +------------------------------------------------------------------------------ +N4. Add persistence status signals (Dexie + persistence sink integration) +------------------------------------------------------------------------------ + +Goal: +Expose persistence state without touching the history system (Milestone L is +done; persistence is already hooked to history in Milestone M). + +/apps/web/src/lib/status.ts +[ ] Extend your persistence sink (from Milestone M) to expose a small status: + - pendingWrites counter (increment on enqueue, decrement on commit) + - lastSavedAt timestamp (set on successful commit) + - lastError (set on failed commit) +[ ] Use Dexie liveQuery to observe the current board’s updatedAt from IndexedDB + and reflect it in the UI (helps confirm persisted state across tabs). + +(DoD): +- Status bar can show: "Saving…" when pendingWrites > 0, and "Saved" with time + when pendingWrites reaches 0. + +------------------------------------------------------------------------------ +N5. Implement StatusBar.svelte using runes +------------------------------------------------------------------------------ + +/apps/web/src/lib/components/StatusBar.svelte +[ ] Render left → right (suggested): + - Tool + mode + - Cursor: X,Y (world) + - Selection summary + - Snap/grid summary + - Zoom % + - Save state ("Saved 3s ago" / "Saving…" / "Error") + +[ ] Consume state via runes: + - keep a local $state(snapshot) for EditorState + - keep a local $state(cursor) for CursorState + - keep a local $state(persist) for PersistStatus + +Subscriptions: +[ ] Use $effect to subscribe to any external streams and return cleanup + unsubscribe. + +Formatting: +[ ] Cursor formatting: + - v0: integers + - v1: configurable precision (e.g. 0.1 units when zoomed in) + +(DoD): +- Status bar is visible, updates live, and never causes noticeable jank. + +------------------------------------------------------------------------------ +N6. Interactions (small, high-value) +------------------------------------------------------------------------------ + +Zoom control: +[ ] Clicking zoomPct opens a tiny menu: + - 50%, 100%, 200% + - Zoom to fit + - Zoom to selection +(Inspiration: zoom/view options + shortcuts in Figma/FigJam.) + +Snap toggles: +[ ] Add quick toggles (optional v0, recommended v1): + - snap enabled + - grid enabled + +(DoD): +- Zoom is discoverable and controllable from the status bar. + +------------------------------------------------------------------------------ +N7. Tests +------------------------------------------------------------------------------ + +Core unit tests (/packages/core/test/statusbar.test.ts): +[ ] getZoomPct returns expected values from camera zoom +[ ] selection summary is correct (0, 1, many) +[ ] snap summary defaults safe when features disabled + +Web integration tests (optional v0): +[ ] cursor update throttling: 100 pointermoves in a tick results in <= 1 state + publication per frame (if you implement rAF coalescing) + +Persistence tests (web): +[ ] pendingWrites transitions: 0 -> N -> 0 yields state 'saving' then 'saved' +[ ] error sets 'error' state and preserves lastSavedAt + +------------------------------------------------------------------------------ +Definition of Done +------------------------------------------------------------------------------ + +- Status bar shows: + - cursor world position + - zoom percentage + - active tool/mode + - selection count + - persistence state (Saved/Saving/Error + lastSavedAt) +- Cursor updates are throttled and do not spam history or persistence. +- UI subscriptions use $effect with cleanup. ================================================================================ 15. Milestone O: Export (PNG/SVG) *wb-O* @@ -165,9 +333,27 @@ (DoD): - One-click export works in both web and desktop. +================================================================================ +16. Milestone P: Desktop packaging (Tauri) *wb-P* +================================================================================ + +Goal: same app works as a desktop app with filesystem access. + +Tauri + SvelteKit integration: +[ ] Configure SvelteKit for static/SPA output +[ ] Ensure SSR is disabled for desktop build +[ ] Configure Tauri to load the built assets + +File dialogs + FS: +[ ] Implement "Save As…" using Tauri dialog + fs APIs +[ ] Implement "Open…" using Tauri dialog + fs APIs +[ ] Add recent files list (v0: store paths in Tauri local storage) + +(DoD): +- Desktop app opens/saves JSON files on disk and reopens them correctly. ================================================================================ -16. Milestone P: Performance + big docs (pragmatic) *wb-P* +17. Milestone Q: Performance + big docs (pragmatic) *wb-Q* ================================================================================ Goal: the editor stays responsive with many shapes. @@ -175,15 +361,12 @@ [ ] Add spatial index (v0: simple grid buckets): - rebuild index on doc changes - query nearby shapes for hit testing - [ ] Add view culling: - compute viewport bounds in world space - render only shapes whose bounds intersect viewport - [ ] Reduce redraw frequency: - rAF only while dirty - optionally batch multiple store updates into one redraw - [ ] Add microbench harness: - generate 10k shapes doc - measure hit test and render time @@ -192,15 +375,15 @@ - 10k simple shapes pans/zooms smoothly on a typical machine. ================================================================================ -17. Milestone Q: File Browser (web: Dexie inspector, desktop: FS) *wb-Q* +18. Milestone R: File Browser (web: Dexie inspector, desktop: FS) *wb-R* ================================================================================ -Goal: A unified “Open board” experience: +Goal: A unified "Open board" experience: - Web: browse Dexie-backed boards + a useful persistence/migration inspector - Desktop: browse real directories/files (native file browser semantics) -------------------------------------------------------------------------------- -Q1. Shared UX contracts +R1. Shared UX contracts -------------------------------------------------------------------------------- /packages/core/src/persist/DocRepo.ts: @@ -219,7 +402,7 @@ - Svelte UI can render the browser purely from the ViewModel. -------------------------------------------------------------------------------- -Q2. Web: Boards list + Dexie “Inspector” drawer +R2. Web: Boards list + Dexie "Inspector" drawer -------------------------------------------------------------------------------- /apps/web/src/lib/filebrowser/FileBrowser.svelte: @@ -229,7 +412,7 @@ - open / create / rename / delete Inspector drawer (selected board): -[ ] Show “Storage: IndexedDB (Dexie)” +[ ] Show "Storage: IndexedDB (Dexie)" [ ] Show schema info: - declared schema version (your constant) - installed schema version (best-effort display) @@ -239,7 +422,7 @@ - last updatedAt [ ] Show migration info: - list applied migrations from migrations table (id + appliedAt) - - show “pending” migrations if any (based on known list vs applied) + - show "pending" migrations if any (based on known list vs applied) Safe deletes: [ ] deleteBoard must be a single atomic transaction (boards + related tables) @@ -248,10 +431,10 @@ - Web: you can browse boards, open one, and verify migrations + row counts. -------------------------------------------------------------------------------- -Q3. Desktop: real directory + files (Tauri) +R3. Desktop: real directory + files (Tauri) -------------------------------------------------------------------------------- -[ ] Add “Workspace folder” concept: +[ ] Add "Workspace folder" concept: - pick directory - remember last workspace path [ ] Implement directory listing: @@ -268,7 +451,7 @@ - Desktop: pick a folder, browse files, open/save boards from disk. -------------------------------------------------------------------------------- -Q4. Parity behaviors +R4. Parity behaviors -------------------------------------------------------------------------------- [ ] Same shortcuts: @@ -281,7 +464,7 @@ - Web and desktop feel like the same app, with storage differences made explicit. ================================================================================ -18. Milestone R: Quality polish (what makes it feel "real") *wb-R* +19. Milestone S: Quality polish (what makes it feel "real") *wb-S* ================================================================================ Goal: the UX crosses the "this is legit" threshold. diff --git a/packages/core/src/cursor.ts b/packages/core/src/cursor.ts new file mode 100644 --- /dev/null +++ b/packages/core/src/cursor.ts @@ -0,0 +1,63 @@ +import { BehaviorSubject, type Subscription } from "rxjs"; +import { Vec2 } from "./math"; + +/** + * Cursor position + timing in world/screen space. + * + * CursorState is intentionally separate from EditorState so it can be updated + * with high frequency (e.g., on pointer move) without touching history or + * triggering document persistence. + */ +export type CursorState = { cursorWorld: Vec2; cursorScreen?: Vec2; lastMoveAt: number }; + +export const CursorState = { + /** + * Create a cursor state positioned at origin with no screen point. + */ + create(world?: Vec2, screen?: Vec2, timestamp = Date.now()): CursorState { + return { + cursorWorld: Vec2.clone(world ?? { x: 0, y: 0 }), + cursorScreen: screen ? Vec2.clone(screen) : undefined, + lastMoveAt: timestamp, + }; + }, +}; + +export type CursorListener = (state: CursorState) => void; + +/** + * Store that tracks cursor movement separately from the undoable editor state. + */ +export class CursorStore { + private readonly state$: BehaviorSubject; + + constructor(initialState?: CursorState) { + this.state$ = new BehaviorSubject(initialState ?? CursorState.create()); + } + + /** + * Read the latest cursor snapshot. + */ + getState(): CursorState { + return this.state$.value; + } + + /** + * Subscribe to cursor updates. + */ + subscribe(listener: CursorListener): () => void { + const subscription: Subscription = this.state$.subscribe(listener); + return () => subscription.unsubscribe(); + } + + /** + * Update the cursor position without touching editor history/persistence. + */ + updateCursor(world: Vec2, screen?: Vec2, timestamp = Date.now()): void { + this.state$.next({ + cursorWorld: Vec2.clone(world), + cursorScreen: screen ? Vec2.clone(screen) : undefined, + lastMoveAt: timestamp, + }); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export * from "./actions"; export * from "./camera"; +export * from "./cursor"; export * from "./geom"; export * from "./history"; export * from "./math"; @@ -8,3 +9,4 @@ export * from "./persistence/web"; export * from "./reactivity"; export * from "./tools"; +export * from "./ui/statusbar"; diff --git a/packages/core/tests/statusbar.test.ts b/packages/core/tests/statusbar.test.ts new file mode 100644 --- /dev/null +++ b/packages/core/tests/statusbar.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { CursorState as CursorStateOps } from "../src/cursor"; +import { ShapeRecord } from "../src/model"; +import type { ShapeRecord as ShapeRecordType } from "../src/model"; +import { EditorState } from "../src/reactivity"; +import { + buildStatusBarVM, + getSelectionSummary, + getSnapSummary, + getToolId, + getZoomPct, + type PersistenceStatus, +} from "../src/ui/statusbar"; + +describe("Status bar selectors", () => { + describe("getZoomPct", () => { + it("rounds zoom values to percentages", () => { + const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: 1.234 } }; + expect(getZoomPct(state)).toBe(123); + }); + + it("falls back to 100 for invalid zoom", () => { + const state = { ...EditorState.create(), camera: { x: 0, y: 0, zoom: Number.NaN } }; + expect(getZoomPct(state)).toBe(100); + }); + }); + + describe("getToolId", () => { + it("returns the active tool id", () => { + const base = EditorState.create(); + const state: EditorState = { ...base, ui: { ...base.ui, toolId: "rect", currentPageId: null, selectionIds: [] } }; + expect(getToolId(state)).toBe("rect"); + }); + }); + + describe("getSelectionSummary", () => { + it("returns zero summary when nothing is selected", () => { + const state = buildState([], []); + expect(getSelectionSummary(state)).toEqual({ count: 0 }); + }); + + it("describes a single selected shape", () => { + const rect = ShapeRecord.createRect( + "page-1", + 10, + 20, + { w: 40, h: 20, fill: "#000", stroke: "#fff", radius: 0 }, + "shape-rect", + ); + const state = buildState([rect], ["shape-rect"]); + expect(getSelectionSummary(state)).toEqual({ count: 1, kind: "rect", bounds: { w: 40, h: 20 } }); + }); + + it("summarizes multiple selections with combined bounds and mixed kind", () => { + const rect = ShapeRecord.createRect( + "page-1", + 10, + 20, + { w: 40, h: 20, fill: "#000", stroke: "#fff", radius: 0 }, + "shape-rect", + ); + const ellipse = ShapeRecord.createEllipse( + "page-1", + 100, + 50, + { w: 20, h: 20, fill: "#f00", stroke: "#111" }, + "shape-ellipse", + ); + const state = buildState([rect, ellipse], ["shape-rect", "shape-ellipse"]); + + expect(getSelectionSummary(state)).toEqual({ count: 2, kind: "mixed", bounds: { w: 110, h: 50 } }); + }); + + it("marks kind when all selected shapes match", () => { + const rectA = ShapeRecord.createRect( + "page-1", + 0, + 0, + { w: 10, h: 10, fill: "#000", stroke: "#fff", radius: 0 }, + "shape-1", + ); + const rectB = ShapeRecord.createRect( + "page-1", + 20, + 20, + { w: 10, h: 10, fill: "#111", stroke: "#eee", radius: 0 }, + "shape-2", + ); + const state = buildState([rectA, rectB], ["shape-1", "shape-2"]); + + expect(getSelectionSummary(state)).toEqual({ count: 2, kind: "rect", bounds: { w: 30, h: 30 } }); + }); + }); + + describe("getSnapSummary", () => { + it("returns safe defaults when snapping is disabled", () => { + const state = buildState([], []); + expect(getSnapSummary(state)).toEqual({ enabled: false }); + }); + }); + + describe("buildStatusBarVM", () => { + it("composes slices into a status bar view model", () => { + const rect = ShapeRecord.createRect( + "page-1", + 0, + 0, + { w: 50, h: 50, fill: "#000", stroke: "#fff", radius: 0 }, + "shape-rect", + ); + const state = buildState([rect], ["shape-rect"]); + const cursorState = CursorStateOps.create({ x: 5, y: 6 }, { x: 1, y: 2 }, 42); + const persistence: PersistenceStatus = { backend: "indexeddb", state: "saving", pendingWrites: 1 }; + + const vm = buildStatusBarVM(state, cursorState, persistence, "dragging"); + + expect(vm.cursorWorld).toEqual({ x: 5, y: 6 }); + expect(vm.cursorScreen).toEqual({ x: 1, y: 2 }); + expect(vm.zoomPct).toBe(100); + expect(vm.toolId).toBe("select"); + expect(vm.mode).toBe("dragging"); + expect(vm.selection).toEqual({ count: 1, kind: "rect", bounds: { w: 50, h: 50 } }); + expect(vm.snap).toEqual({ enabled: false }); + expect(vm.persistence).toEqual({ backend: "indexeddb", state: "saving", pendingWrites: 1 }); + expect(vm.cursorWorld).not.toBe(cursorState.cursorWorld); + expect(vm.persistence).not.toBe(persistence); + }); + }); +}); + +function buildState(shapes: ShapeRecordType[], selectionIds: string[]) { + const base = EditorState.create(); + const pageId = "page-1"; + const docShapes = Object.fromEntries(shapes.map((shape) => [shape.id, shape])); + const page = { id: pageId, name: "Page 1", shapeIds: shapes.map((shape) => shape.id) }; + + return { + ...base, + doc: { pages: { [pageId]: page }, shapes: docShapes, bindings: {} }, + ui: { ...base.ui, currentPageId: pageId, selectionIds }, + }; +} diff --git a/packages/core/src/ui/statusbar.ts b/packages/core/src/ui/statusbar.ts new file mode 100644 --- /dev/null +++ b/packages/core/src/ui/statusbar.ts @@ -0,0 +1,115 @@ +import type { CursorState } from "../cursor"; +import { shapeBounds } from "../geom"; +import { type Box2, Box2 as Box2Ops, type Vec2, Vec2 as Vec2Ops } from "../math"; +import type { EditorState, ToolId } from "../reactivity"; +import { getSelectedShapes } from "../reactivity"; + +export type SelectionSummary = { count: number; kind?: string; bounds?: { w: number; h: number } }; + +export type SnapSummary = { enabled: boolean; gridSize?: number; angleStepDeg?: number }; + +export type PersistenceStatus = { + backend: "indexeddb"; + state: "saved" | "saving" | "error"; + lastSavedAt?: number; + pendingWrites?: number; + errorMsg?: string; +}; + +export type StatusBarVM = { + cursorWorld: Vec2; + cursorScreen?: Vec2; + zoomPct: number; + toolId: ToolId; + mode: "idle" | "dragging" | "panning" | "text-edit" | string; + selection: SelectionSummary; + snap: SnapSummary; + persistence: PersistenceStatus; +}; + +/** + * Convert the current camera zoom factor into a human-friendly percentage. + */ +export function getZoomPct(state: EditorState): number { + const pct = state.camera.zoom * 100; + if (!Number.isFinite(pct)) { + return 100; + } + return Math.round(pct); +} + +/** + * Get the active tool identifier from UI state. + */ +export function getToolId(state: EditorState): ToolId { + return state.ui.toolId; +} + +/** + * Summarize the current selection for display. + */ +export function getSelectionSummary(state: EditorState): SelectionSummary { + const shapes = getSelectedShapes(state); + const count = shapes.length; + + if (count === 0) { + return { count: 0 }; + } + + const combinedBounds = combineBounds(shapes.map((shape) => shapeBounds(shape))); + + const kind = count === 1 + ? shapes[0].type + : (shapes.every((shape) => shape.type === shapes[0].type) ? shapes[0].type : "mixed"); + + return { + count, + kind, + bounds: combinedBounds ? { w: Box2Ops.width(combinedBounds), h: Box2Ops.height(combinedBounds) } : undefined, + }; +} + +const SNAP_DEFAULT: SnapSummary = { enabled: false }; + +/** + * Provide safe defaults for snap/grid summary until features are enabled. + */ +export function getSnapSummary(_: EditorState): SnapSummary { + return { ...SNAP_DEFAULT }; +} + +/** + * Compose the full StatusBar view model from editor/cursor/persistence state. + */ +export function buildStatusBarVM( + editorState: EditorState, + cursorState: CursorState, + persistence: PersistenceStatus, + mode: StatusBarVM["mode"] = "idle", +): StatusBarVM { + return { + cursorWorld: Vec2Ops.clone(cursorState.cursorWorld), + cursorScreen: cursorState.cursorScreen ? Vec2Ops.clone(cursorState.cursorScreen) : undefined, + zoomPct: getZoomPct(editorState), + toolId: getToolId(editorState), + mode, + selection: getSelectionSummary(editorState), + snap: getSnapSummary(editorState), + persistence: { ...persistence }, + }; +} + +function combineBounds(boxes: Box2[]): Box2 | null { + if (boxes.length === 0) { + return null; + } + let combined = Box2Ops.clone(boxes[0]); + for (let index = 1; index < boxes.length; index++) { + const box = boxes[index]; + combined = { + min: { x: Math.min(combined.min.x, box.min.x), y: Math.min(combined.min.y, box.min.y) }, + max: { x: Math.max(combined.max.x, box.max.x), y: Math.max(combined.max.y, box.max.y) }, + }; + } + return combined; +}