diff --git a/TODO.txt b/TODO.txt index 1f478ec..6dbc538 100644 --- a/TODO.txt +++ b/TODO.txt @@ -157,6 +157,7 @@ Goal: export drawings as shareable artifacts. [ ] Implement SVG export for basic shapes: - rect/ellipse/line/arrow/text - camera transform baked into output or removed-pick one and document + - show bottom bar, expandable with copyable SVG code Tests: [ ] exported SVG parses and contains expected elements @@ -287,7 +288,7 @@ Goal: the UX crosses the "this is legit" threshold. [ ] Snapping: - snap move to grid - - snap to other shape edges/centers (basic) + - snap to other shape edges/centers [ ] Handles: - resize handles for rect/ellipse - rotate handle diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index d3f3836..3082c4c 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -4,11 +4,13 @@ import { createInputAdapter, type InputAdapter } from '$lib/input'; import { ArrowTool, + EditorState, EllipseTool, InkfiniteDB, LineTool, RectTool, SelectTool, + SnapshotCommand, Store, TextTool, createPersistenceSink, @@ -18,6 +20,7 @@ routeAction, switchTool, type Action, + type CommandKind, type LoadedDoc, type ToolId, type Viewport @@ -75,6 +78,19 @@ historyViewerOpen = false; } + function applyActionWithHistory(action: Action) { + const before = store.getState(); + const nextState = routeAction(before, action, tools); + if (statesEqual(before, nextState)) { + return; + } + + const kind = getCommandKind(before, nextState); + const commandName = describeAction(action, kind); + const command = new SnapshotCommand(commandName, kind, EditorState.clone(before), EditorState.clone(nextState)); + store.executeCommand(command); + } + function handleAction(action: Action) { if (action.type === 'key-down') { const isPrimary = @@ -92,7 +108,40 @@ } } - store.setState((state) => routeAction(state, action, tools)); + applyActionWithHistory(action); + } + + function statesEqual(a: EditorState, b: EditorState): boolean { + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; + } + + function getCommandKind(before: EditorState, after: EditorState): CommandKind { + if (before.doc !== after.doc) { + return 'doc'; + } + if (before.camera !== after.camera) { + return 'camera'; + } + return 'ui'; + } + + function describeAction(action: Action, kind: CommandKind): string { + switch (action.type) { + case 'pointer-down': + return 'Pointer down'; + case 'pointer-move': + return 'Pointer move'; + case 'pointer-up': + return 'Pointer up'; + case 'wheel': + return 'Wheel'; + case 'key-down': + return 'Key down'; + case 'key-up': + return 'Key up'; + default: + return kind === 'doc' ? 'Edit' : kind === 'camera' ? 'Camera change' : 'UI change'; + } } let canvas: HTMLCanvasElement; diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts new file mode 100644 index 0000000..80b1f63 --- /dev/null +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render } from "vitest-browser-svelte"; + +const actionHandlers: Array<(action: any) => void> = []; +const coreMocks = vi.hoisted(() => ({ sinkEnqueueSpy: vi.fn(), storeInstances: [] as any[] })); + +vi.mock("../input", () => { + return { + createInputAdapter: vi.fn((config) => { + actionHandlers.push(config.onAction); + return { dispose: vi.fn() }; + }), + }; +}); + +vi.mock("inkfinite-renderer", () => { + return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; +}); + +const createDoc = () => ({ + pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: [] } }, + shapes: {}, + bindings: {}, + order: { pageIds: ["page:1"], shapeOrder: { "page:1": [] } }, +}); + +vi.mock("inkfinite-core", () => { + const { sinkEnqueueSpy, storeInstances } = coreMocks; + + class BaseTool { + constructor(readonly id: string) {} + onEnter(state: any) { + return state; + } + onExit(state: any) { + return state; + } + onAction(state: any) { + return state; + } + } + + class MockStore { + state: any; + private readonly options?: any; + private readonly subscribers: Array<(state: any) => void> = []; + readonly commands: any[] = []; + private historyState = { undoStack: [] as any[], redoStack: [] as any[] }; + + constructor(initialState?: any, options?: any) { + this.state = initialState + ?? { + doc: createDoc(), + ui: { currentPageId: null, selectionIds: [], toolId: "select" }, + camera: { x: 0, y: 0, zoom: 1 }, + }; + this.options = options; + storeInstances.push(this); + } + + getState() { + return this.state; + } + + setState(updater: (state: any) => any) { + this.state = updater(this.state); + for (const listener of this.subscribers) { + listener(this.state); + } + } + + subscribe(listener: (state: any) => void) { + this.subscribers.push(listener); + listener(this.state); + return () => {}; + } + + executeCommand(command: any) { + this.commands.push(command); + const before = this.state; + const after = command.do(before); + this.state = after; + this.historyState.undoStack.push({ command, timestamp: Date.now() }); + this.historyState.redoStack = []; + this.options?.onHistoryEvent?.({ + op: "do", + commandId: Date.now(), + command, + kind: command.kind, + beforeState: before, + afterState: after, + }); + } + + undo() { + const entry = this.historyState.undoStack.pop(); + if (!entry) return false; + this.historyState.redoStack.push(entry); + return true; + } + + redo() { + const entry = this.historyState.redoStack.pop(); + if (!entry) return false; + this.historyState.undoStack.push(entry); + return true; + } + + getHistory() { + return this.historyState; + } + + canUndo() { + return this.historyState.undoStack.length > 0; + } + + canRedo() { + return this.historyState.redoStack.length > 0; + } + } + + const createWebDocRepo = vi.fn(() => ({ + listBoards: vi.fn(async () => [{ id: "board:1", name: "Board 1", createdAt: 0, updatedAt: 0 }]), + createBoard: vi.fn(async () => "board:new"), + renameBoard: vi.fn(), + deleteBoard: vi.fn(), + loadDoc: vi.fn(async () => createDoc()), + applyDocPatch: vi.fn(), + })); + + const routeAction = vi.fn((state: any, action: any) => { + if (action.type === "pointer-down") { + const shapeId = `shape:${Date.now()}`; + const currentPage = state.doc.pages["page:1"]; + return { + ...state, + doc: { + ...state.doc, + shapes: { + ...state.doc.shapes, + [shapeId]: { + id: shapeId, + type: "rect", + pageId: "page:1", + x: 0, + y: 0, + rot: 0, + props: { w: 10, h: 10, fill: "#000", stroke: "#000", radius: 0 }, + }, + }, + pages: { ...state.doc.pages, "page:1": { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] } }, + }, + }; + } + return state; + }); + + const EditorState = { + create: () => ({ + doc: createDoc(), + ui: { currentPageId: null, selectionIds: [], toolId: "select" }, + camera: { x: 0, y: 0, zoom: 1 }, + }), + clone: (state: any) => structuredClone(state), + }; + + class SnapshotCommand { + constructor( + readonly name: string, + readonly kind: string, + private readonly before: any, + private readonly after: any, + ) {} + do() { + return structuredClone(this.after); + } + undo() { + return structuredClone(this.before); + } + } + + return { + ArrowTool: class extends BaseTool { + constructor() { + super("arrow"); + } + }, + EllipseTool: class extends BaseTool { + constructor() { + super("ellipse"); + } + }, + LineTool: class extends BaseTool { + constructor() { + super("line"); + } + }, + RectTool: class extends BaseTool { + constructor() { + super("rect"); + } + }, + SelectTool: class extends BaseTool { + constructor() { + super("select"); + } + }, + TextTool: class extends BaseTool { + constructor() { + super("text"); + } + }, + Store: MockStore, + EditorState, + SnapshotCommand, + createToolMap: (toolList: any[]) => new Map(toolList.map((tool) => [tool.id, tool])), + routeAction, + switchTool: (state: any, toolId: string) => ({ ...state, ui: { ...state.ui, toolId } }), + createWebDocRepo, + createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: sinkEnqueueSpy, flush: vi.fn() })), + diffDoc: vi.fn(() => ({})), + InkfiniteDB: class {}, + __storeInstances: storeInstances, + __sinkEnqueueSpy: sinkEnqueueSpy, + }; +}); + +import * as InkfiniteCore from "inkfinite-core"; +import Canvas from "../canvas/Canvas.svelte"; +const { sinkEnqueueSpy, storeInstances } = coreMocks; + +describe("Canvas history integration", () => { + beforeEach(() => { + cleanup(); + actionHandlers.length = 0; + storeInstances.length = 0; + sinkEnqueueSpy.mockClear(); + }); + + it("wraps pointer actions in SnapshotCommands and enqueues persistence", async () => { + render(Canvas); + await new Promise((resolve) => setTimeout(resolve, 0)); + const handler = actionHandlers.at(-1); + expect(handler).toBeTypeOf("function"); + + handler?.({ + type: "pointer-down", + screen: { x: 0, y: 0 }, + world: { x: 0, y: 0 }, + button: 0, + buttons: { left: true, middle: false, right: false }, + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, + timestamp: Date.now(), + }); + + const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; + expect(stores.at(-1)?.commands).toHaveLength(1); + expect(stores.at(-1)?.commands[0].kind).toBe("doc"); + const sinkSpy = (InkfiniteCore as any).__sinkEnqueueSpy as ReturnType; + expect(sinkSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/eslint.config.js b/eslint.config.js index 6f51851..b8645f2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -23,6 +23,7 @@ export default defineConfig( "unicorn/prefer-ternary": "off", "unicorn/no-null": "off", "unicorn/no-array-reverse": "off", + "unicorn/prefer-structured-clone": "off", "unicorn/prevent-abbreviations": ["error", { "replacements": { "i": false, "props": false, "doc": false, "db": false }, }], diff --git a/packages/core/src/history.ts b/packages/core/src/history.ts index a1732c8..d97ebfc 100644 --- a/packages/core/src/history.ts +++ b/packages/core/src/history.ts @@ -14,7 +14,6 @@ export type HistoryAppliedEvent = { beforeState: EditorState; afterState: EditorState; }; - /** * Command interface for undo/redo operations * @@ -41,6 +40,31 @@ export interface Command { undo(state: EditorState): EditorState; } +/** + * Generic command that stores before/after state snapshots. + */ +export class SnapshotCommand implements Command { + readonly name: string; + readonly kind: CommandKind; + private readonly before: EditorState; + private readonly after: EditorState; + + constructor(name: string, kind: CommandKind, before: EditorState, after: EditorState) { + this.name = name; + this.kind = kind; + this.before = deepCloneState(before); + this.after = deepCloneState(after); + } + + do(_: EditorState): EditorState { + return deepCloneState(this.after); + } + + undo(_: EditorState): EditorState { + return deepCloneState(this.before); + } +} + /** * Create a shape command */ @@ -312,3 +336,11 @@ export const History = { return History.create(); }, }; + +function deepCloneState(state: EditorState): EditorState { + if (typeof structuredClone === "function") { + return structuredClone(state); + } + + return JSON.parse(JSON.stringify(state)) as EditorState; +} diff --git a/packages/core/tests/history.test.ts b/packages/core/tests/history.test.ts index f7845e7..56b3e01 100644 --- a/packages/core/tests/history.test.ts +++ b/packages/core/tests/history.test.ts @@ -6,6 +6,7 @@ import { History, SetCameraCommand, SetSelectionCommand, + SnapshotCommand, UpdateShapeCommand, } from "../src/history"; import { PageRecord, ShapeRecord } from "../src/model"; @@ -28,6 +29,49 @@ describe("History", () => { }); }); + describe("SnapshotCommand", () => { + it("clones before/after states so mutations do not leak", () => { + const before = EditorState.create(); + const after = EditorState.clone(before); + const page = PageRecord.create("Snapshot Page"); + after.doc.pages[page.id] = page; + const command = new SnapshotCommand("Snapshot", "doc", before, after); + + const result = command.do(before); + expect(result).toEqual(after); + expect(result).not.toBe(after); + + (result.doc.pages[page.id] as PageRecord).name = "Mutated"; + expect(after.doc.pages[page.id]?.name).toBe("Snapshot Page"); + const undoState = command.undo(after); + expect(undoState).toEqual(before); + expect(undoState).not.toBe(before); + expect(before.doc.pages[page.id]).toBeUndefined(); + }); + + it("works with history execute/undo/redo flow", () => { + const before = EditorState.create(); + const after = EditorState.clone(before); + const page = PageRecord.create("Snapshot Page"); + after.doc.pages[page.id] = page; + const command = new SnapshotCommand("Snapshot", "doc", before, after); + const history = History.create(); + + const [historyAfterDo, stateAfterDo] = History.execute(history, before, command); + expect(stateAfterDo).toEqual(after); + + const undoResult = History.undo(historyAfterDo, stateAfterDo); + expect(undoResult).not.toBeNull(); + const [historyAfterUndo, stateAfterUndo] = undoResult!; + expect(stateAfterUndo).toEqual(before); + + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); + expect(redoResult).not.toBeNull(); + const [, stateAfterRedo] = redoResult!; + expect(stateAfterRedo).toEqual(after); + }); + }); + describe("CreateShapeCommand", () => { it("should execute create shape command", () => { const page = PageRecord.create("Test Page", "page:1");