web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement snapshot command for history management

+392 -3
+2 -1
TODO.txt
··· 157 157 [ ] Implement SVG export for basic shapes: 158 158 - rect/ellipse/line/arrow/text 159 159 - camera transform baked into output or removed-pick one and document 160 + - show bottom bar, expandable with copyable SVG code 160 161 161 162 Tests: 162 163 [ ] exported SVG parses and contains expected elements ··· 287 288 288 289 [ ] Snapping: 289 290 - snap move to grid 290 - - snap to other shape edges/centers (basic) 291 + - snap to other shape edges/centers 291 292 [ ] Handles: 292 293 - resize handles for rect/ellipse 293 294 - rotate handle
+50 -1
apps/web/src/lib/canvas/Canvas.svelte
··· 4 4 import { createInputAdapter, type InputAdapter } from '$lib/input'; 5 5 import { 6 6 ArrowTool, 7 + EditorState, 7 8 EllipseTool, 8 9 InkfiniteDB, 9 10 LineTool, 10 11 RectTool, 11 12 SelectTool, 13 + SnapshotCommand, 12 14 Store, 13 15 TextTool, 14 16 createPersistenceSink, ··· 18 20 routeAction, 19 21 switchTool, 20 22 type Action, 23 + type CommandKind, 21 24 type LoadedDoc, 22 25 type ToolId, 23 26 type Viewport ··· 75 78 historyViewerOpen = false; 76 79 } 77 80 81 + function applyActionWithHistory(action: Action) { 82 + const before = store.getState(); 83 + const nextState = routeAction(before, action, tools); 84 + if (statesEqual(before, nextState)) { 85 + return; 86 + } 87 + 88 + const kind = getCommandKind(before, nextState); 89 + const commandName = describeAction(action, kind); 90 + const command = new SnapshotCommand(commandName, kind, EditorState.clone(before), EditorState.clone(nextState)); 91 + store.executeCommand(command); 92 + } 93 + 78 94 function handleAction(action: Action) { 79 95 if (action.type === 'key-down') { 80 96 const isPrimary = ··· 92 108 } 93 109 } 94 110 95 - store.setState((state) => routeAction(state, action, tools)); 111 + applyActionWithHistory(action); 112 + } 113 + 114 + function statesEqual(a: EditorState, b: EditorState): boolean { 115 + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 116 + } 117 + 118 + function getCommandKind(before: EditorState, after: EditorState): CommandKind { 119 + if (before.doc !== after.doc) { 120 + return 'doc'; 121 + } 122 + if (before.camera !== after.camera) { 123 + return 'camera'; 124 + } 125 + return 'ui'; 126 + } 127 + 128 + function describeAction(action: Action, kind: CommandKind): string { 129 + switch (action.type) { 130 + case 'pointer-down': 131 + return 'Pointer down'; 132 + case 'pointer-move': 133 + return 'Pointer move'; 134 + case 'pointer-up': 135 + return 'Pointer up'; 136 + case 'wheel': 137 + return 'Wheel'; 138 + case 'key-down': 139 + return 'Key down'; 140 + case 'key-up': 141 + return 'Key up'; 142 + default: 143 + return kind === 'doc' ? 'Edit' : kind === 'camera' ? 'Camera change' : 'UI change'; 144 + } 96 145 } 97 146 98 147 let canvas: HTMLCanvasElement;
+262
apps/web/src/lib/tests/Canvas.history.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { cleanup, render } from "vitest-browser-svelte"; 3 + 4 + const actionHandlers: Array<(action: any) => void> = []; 5 + const coreMocks = vi.hoisted(() => ({ sinkEnqueueSpy: vi.fn(), storeInstances: [] as any[] })); 6 + 7 + vi.mock("../input", () => { 8 + return { 9 + createInputAdapter: vi.fn((config) => { 10 + actionHandlers.push(config.onAction); 11 + return { dispose: vi.fn() }; 12 + }), 13 + }; 14 + }); 15 + 16 + vi.mock("inkfinite-renderer", () => { 17 + return { createRenderer: vi.fn(() => ({ dispose: vi.fn(), markDirty: vi.fn() })) }; 18 + }); 19 + 20 + const createDoc = () => ({ 21 + pages: { "page:1": { id: "page:1", name: "Page 1", shapeIds: [] } }, 22 + shapes: {}, 23 + bindings: {}, 24 + order: { pageIds: ["page:1"], shapeOrder: { "page:1": [] } }, 25 + }); 26 + 27 + vi.mock("inkfinite-core", () => { 28 + const { sinkEnqueueSpy, storeInstances } = coreMocks; 29 + 30 + class BaseTool { 31 + constructor(readonly id: string) {} 32 + onEnter(state: any) { 33 + return state; 34 + } 35 + onExit(state: any) { 36 + return state; 37 + } 38 + onAction(state: any) { 39 + return state; 40 + } 41 + } 42 + 43 + class MockStore { 44 + state: any; 45 + private readonly options?: any; 46 + private readonly subscribers: Array<(state: any) => void> = []; 47 + readonly commands: any[] = []; 48 + private historyState = { undoStack: [] as any[], redoStack: [] as any[] }; 49 + 50 + constructor(initialState?: any, options?: any) { 51 + this.state = initialState 52 + ?? { 53 + doc: createDoc(), 54 + ui: { currentPageId: null, selectionIds: [], toolId: "select" }, 55 + camera: { x: 0, y: 0, zoom: 1 }, 56 + }; 57 + this.options = options; 58 + storeInstances.push(this); 59 + } 60 + 61 + getState() { 62 + return this.state; 63 + } 64 + 65 + setState(updater: (state: any) => any) { 66 + this.state = updater(this.state); 67 + for (const listener of this.subscribers) { 68 + listener(this.state); 69 + } 70 + } 71 + 72 + subscribe(listener: (state: any) => void) { 73 + this.subscribers.push(listener); 74 + listener(this.state); 75 + return () => {}; 76 + } 77 + 78 + executeCommand(command: any) { 79 + this.commands.push(command); 80 + const before = this.state; 81 + const after = command.do(before); 82 + this.state = after; 83 + this.historyState.undoStack.push({ command, timestamp: Date.now() }); 84 + this.historyState.redoStack = []; 85 + this.options?.onHistoryEvent?.({ 86 + op: "do", 87 + commandId: Date.now(), 88 + command, 89 + kind: command.kind, 90 + beforeState: before, 91 + afterState: after, 92 + }); 93 + } 94 + 95 + undo() { 96 + const entry = this.historyState.undoStack.pop(); 97 + if (!entry) return false; 98 + this.historyState.redoStack.push(entry); 99 + return true; 100 + } 101 + 102 + redo() { 103 + const entry = this.historyState.redoStack.pop(); 104 + if (!entry) return false; 105 + this.historyState.undoStack.push(entry); 106 + return true; 107 + } 108 + 109 + getHistory() { 110 + return this.historyState; 111 + } 112 + 113 + canUndo() { 114 + return this.historyState.undoStack.length > 0; 115 + } 116 + 117 + canRedo() { 118 + return this.historyState.redoStack.length > 0; 119 + } 120 + } 121 + 122 + const createWebDocRepo = vi.fn(() => ({ 123 + listBoards: vi.fn(async () => [{ id: "board:1", name: "Board 1", createdAt: 0, updatedAt: 0 }]), 124 + createBoard: vi.fn(async () => "board:new"), 125 + renameBoard: vi.fn(), 126 + deleteBoard: vi.fn(), 127 + loadDoc: vi.fn(async () => createDoc()), 128 + applyDocPatch: vi.fn(), 129 + })); 130 + 131 + const routeAction = vi.fn((state: any, action: any) => { 132 + if (action.type === "pointer-down") { 133 + const shapeId = `shape:${Date.now()}`; 134 + const currentPage = state.doc.pages["page:1"]; 135 + return { 136 + ...state, 137 + doc: { 138 + ...state.doc, 139 + shapes: { 140 + ...state.doc.shapes, 141 + [shapeId]: { 142 + id: shapeId, 143 + type: "rect", 144 + pageId: "page:1", 145 + x: 0, 146 + y: 0, 147 + rot: 0, 148 + props: { w: 10, h: 10, fill: "#000", stroke: "#000", radius: 0 }, 149 + }, 150 + }, 151 + pages: { ...state.doc.pages, "page:1": { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] } }, 152 + }, 153 + }; 154 + } 155 + return state; 156 + }); 157 + 158 + const EditorState = { 159 + create: () => ({ 160 + doc: createDoc(), 161 + ui: { currentPageId: null, selectionIds: [], toolId: "select" }, 162 + camera: { x: 0, y: 0, zoom: 1 }, 163 + }), 164 + clone: (state: any) => structuredClone(state), 165 + }; 166 + 167 + class SnapshotCommand { 168 + constructor( 169 + readonly name: string, 170 + readonly kind: string, 171 + private readonly before: any, 172 + private readonly after: any, 173 + ) {} 174 + do() { 175 + return structuredClone(this.after); 176 + } 177 + undo() { 178 + return structuredClone(this.before); 179 + } 180 + } 181 + 182 + return { 183 + ArrowTool: class extends BaseTool { 184 + constructor() { 185 + super("arrow"); 186 + } 187 + }, 188 + EllipseTool: class extends BaseTool { 189 + constructor() { 190 + super("ellipse"); 191 + } 192 + }, 193 + LineTool: class extends BaseTool { 194 + constructor() { 195 + super("line"); 196 + } 197 + }, 198 + RectTool: class extends BaseTool { 199 + constructor() { 200 + super("rect"); 201 + } 202 + }, 203 + SelectTool: class extends BaseTool { 204 + constructor() { 205 + super("select"); 206 + } 207 + }, 208 + TextTool: class extends BaseTool { 209 + constructor() { 210 + super("text"); 211 + } 212 + }, 213 + Store: MockStore, 214 + EditorState, 215 + SnapshotCommand, 216 + createToolMap: (toolList: any[]) => new Map(toolList.map((tool) => [tool.id, tool])), 217 + routeAction, 218 + switchTool: (state: any, toolId: string) => ({ ...state, ui: { ...state.ui, toolId } }), 219 + createWebDocRepo, 220 + createPersistenceSink: vi.fn(() => ({ enqueueDocPatch: sinkEnqueueSpy, flush: vi.fn() })), 221 + diffDoc: vi.fn(() => ({})), 222 + InkfiniteDB: class {}, 223 + __storeInstances: storeInstances, 224 + __sinkEnqueueSpy: sinkEnqueueSpy, 225 + }; 226 + }); 227 + 228 + import * as InkfiniteCore from "inkfinite-core"; 229 + import Canvas from "../canvas/Canvas.svelte"; 230 + const { sinkEnqueueSpy, storeInstances } = coreMocks; 231 + 232 + describe("Canvas history integration", () => { 233 + beforeEach(() => { 234 + cleanup(); 235 + actionHandlers.length = 0; 236 + storeInstances.length = 0; 237 + sinkEnqueueSpy.mockClear(); 238 + }); 239 + 240 + it("wraps pointer actions in SnapshotCommands and enqueues persistence", async () => { 241 + render(Canvas); 242 + await new Promise((resolve) => setTimeout(resolve, 0)); 243 + const handler = actionHandlers.at(-1); 244 + expect(handler).toBeTypeOf("function"); 245 + 246 + handler?.({ 247 + type: "pointer-down", 248 + screen: { x: 0, y: 0 }, 249 + world: { x: 0, y: 0 }, 250 + button: 0, 251 + buttons: { left: true, middle: false, right: false }, 252 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 253 + timestamp: Date.now(), 254 + }); 255 + 256 + const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; 257 + expect(stores.at(-1)?.commands).toHaveLength(1); 258 + expect(stores.at(-1)?.commands[0].kind).toBe("doc"); 259 + const sinkSpy = (InkfiniteCore as any).__sinkEnqueueSpy as ReturnType<typeof vi.fn>; 260 + expect(sinkSpy).toHaveBeenCalledTimes(1); 261 + }); 262 + });
+1
eslint.config.js
··· 23 23 "unicorn/prefer-ternary": "off", 24 24 "unicorn/no-null": "off", 25 25 "unicorn/no-array-reverse": "off", 26 + "unicorn/prefer-structured-clone": "off", 26 27 "unicorn/prevent-abbreviations": ["error", { 27 28 "replacements": { "i": false, "props": false, "doc": false, "db": false }, 28 29 }],
+33 -1
packages/core/src/history.ts
··· 14 14 beforeState: EditorState; 15 15 afterState: EditorState; 16 16 }; 17 - 18 17 /** 19 18 * Command interface for undo/redo operations 20 19 * ··· 39 38 * @returns New editor state with command undone 40 39 */ 41 40 undo(state: EditorState): EditorState; 41 + } 42 + 43 + /** 44 + * Generic command that stores before/after state snapshots. 45 + */ 46 + export class SnapshotCommand implements Command { 47 + readonly name: string; 48 + readonly kind: CommandKind; 49 + private readonly before: EditorState; 50 + private readonly after: EditorState; 51 + 52 + constructor(name: string, kind: CommandKind, before: EditorState, after: EditorState) { 53 + this.name = name; 54 + this.kind = kind; 55 + this.before = deepCloneState(before); 56 + this.after = deepCloneState(after); 57 + } 58 + 59 + do(_: EditorState): EditorState { 60 + return deepCloneState(this.after); 61 + } 62 + 63 + undo(_: EditorState): EditorState { 64 + return deepCloneState(this.before); 65 + } 42 66 } 43 67 44 68 /** ··· 312 336 return History.create(); 313 337 }, 314 338 }; 339 + 340 + function deepCloneState(state: EditorState): EditorState { 341 + if (typeof structuredClone === "function") { 342 + return structuredClone(state); 343 + } 344 + 345 + return JSON.parse(JSON.stringify(state)) as EditorState; 346 + }
+44
packages/core/tests/history.test.ts
··· 6 6 History, 7 7 SetCameraCommand, 8 8 SetSelectionCommand, 9 + SnapshotCommand, 9 10 UpdateShapeCommand, 10 11 } from "../src/history"; 11 12 import { PageRecord, ShapeRecord } from "../src/model"; ··· 25 26 26 27 expect(History.canUndo(history)).toBe(false); 27 28 expect(History.canRedo(history)).toBe(false); 29 + }); 30 + }); 31 + 32 + describe("SnapshotCommand", () => { 33 + it("clones before/after states so mutations do not leak", () => { 34 + const before = EditorState.create(); 35 + const after = EditorState.clone(before); 36 + const page = PageRecord.create("Snapshot Page"); 37 + after.doc.pages[page.id] = page; 38 + const command = new SnapshotCommand("Snapshot", "doc", before, after); 39 + 40 + const result = command.do(before); 41 + expect(result).toEqual(after); 42 + expect(result).not.toBe(after); 43 + 44 + (result.doc.pages[page.id] as PageRecord).name = "Mutated"; 45 + expect(after.doc.pages[page.id]?.name).toBe("Snapshot Page"); 46 + const undoState = command.undo(after); 47 + expect(undoState).toEqual(before); 48 + expect(undoState).not.toBe(before); 49 + expect(before.doc.pages[page.id]).toBeUndefined(); 50 + }); 51 + 52 + it("works with history execute/undo/redo flow", () => { 53 + const before = EditorState.create(); 54 + const after = EditorState.clone(before); 55 + const page = PageRecord.create("Snapshot Page"); 56 + after.doc.pages[page.id] = page; 57 + const command = new SnapshotCommand("Snapshot", "doc", before, after); 58 + const history = History.create(); 59 + 60 + const [historyAfterDo, stateAfterDo] = History.execute(history, before, command); 61 + expect(stateAfterDo).toEqual(after); 62 + 63 + const undoResult = History.undo(historyAfterDo, stateAfterDo); 64 + expect(undoResult).not.toBeNull(); 65 + const [historyAfterUndo, stateAfterUndo] = undoResult!; 66 + expect(stateAfterUndo).toEqual(before); 67 + 68 + const redoResult = History.redo(historyAfterUndo, stateAfterUndo); 69 + expect(redoResult).not.toBeNull(); 70 + const [, stateAfterRedo] = redoResult!; 71 + expect(stateAfterRedo).toEqual(after); 28 72 }); 29 73 }); 30 74