web based infinite canvas

feat: snapping with pointer state management and grid adjustments/guidelines

+164 -9
+64 -1
apps/web/src/lib/canvas/Canvas.svelte
··· 55 }); 56 const cursorStore = new CursorStore(); 57 const snapStore: SnapStore = createSnapStore(); 58 59 function applyLoadedDoc(doc: LoadedDoc) { 60 const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; ··· 107 108 function handleAction(action: Action) { 109 const actionWithSnap = applySnapping(action); 110 if (actionWithSnap.type === 'key-down') { 111 const isPrimary = 112 (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || ··· 126 applyActionWithHistory(actionWithSnap); 127 } 128 129 function statesEqual(a: EditorState, b: EditorState): boolean { 130 return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 131 } ··· 223 return store.getState().camera; 224 } 225 226 - renderer = createRenderer(canvas, store); 227 inputAdapter = createInputAdapter({ 228 canvas, 229 getCamera, ··· 273 .editor { 274 width: 100%; 275 height: 100%; 276 display: flex; 277 flex-direction: column; 278 } 279 280 canvas { 281 flex: 1; 282 display: block; 283 touch-action: none; 284 cursor: default;
··· 55 }); 56 const cursorStore = new CursorStore(); 57 const snapStore: SnapStore = createSnapStore(); 58 + const pointerState = $state({ isPointerDown: false }); 59 + const snapProvider = { get: () => snapStore.get() }; 60 + const cursorProvider = { get: () => cursorStore.getState() }; 61 + const pointerStateProvider = { get: () => pointerState }; 62 + let pendingCommandStart: EditorState | null = null; 63 64 function applyLoadedDoc(doc: LoadedDoc) { 65 const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; ··· 112 113 function handleAction(action: Action) { 114 const actionWithSnap = applySnapping(action); 115 + 116 + if (actionWithSnap.type === 'pointer-down' && actionWithSnap.button === 0) { 117 + pointerState.isPointerDown = true; 118 + pendingCommandStart = EditorState.clone(store.getState()); 119 + const changed = applyImmediateAction(actionWithSnap); 120 + if (!changed) { 121 + pendingCommandStart = null; 122 + } 123 + return; 124 + } 125 + 126 + if (actionWithSnap.type === 'pointer-move' && pointerState.isPointerDown && pendingCommandStart) { 127 + void applyImmediateAction(actionWithSnap); 128 + return; 129 + } 130 + 131 + if (actionWithSnap.type === 'pointer-up' && actionWithSnap.button === 0) { 132 + pointerState.isPointerDown = false; 133 + if (pendingCommandStart) { 134 + const committed = commitPendingCommand(actionWithSnap, pendingCommandStart); 135 + pendingCommandStart = null; 136 + if (committed) { 137 + return; 138 + } 139 + } 140 + } 141 + 142 if (actionWithSnap.type === 'key-down') { 143 const isPrimary = 144 (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || ··· 158 applyActionWithHistory(actionWithSnap); 159 } 160 161 + function applyImmediateAction(action: Action): boolean { 162 + const before = store.getState(); 163 + const nextState = routeAction(before, action, tools); 164 + if (statesEqual(before, nextState)) { 165 + return false; 166 + } 167 + store.setState(() => nextState); 168 + return true; 169 + } 170 + 171 + function commitPendingCommand(action: Action, startState: EditorState): boolean { 172 + const before = store.getState(); 173 + const nextState = routeAction(before, action, tools); 174 + const finalState = statesEqual(before, nextState) ? before : nextState; 175 + if (statesEqual(startState, finalState)) { 176 + return false; 177 + } 178 + const kind = getCommandKind(startState, finalState); 179 + const commandName = describeAction(action, kind); 180 + const command = new SnapshotCommand( 181 + commandName, 182 + kind, 183 + EditorState.clone(startState), 184 + EditorState.clone(finalState) 185 + ); 186 + store.executeCommand(command); 187 + return true; 188 + } 189 + 190 function statesEqual(a: EditorState, b: EditorState): boolean { 191 return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 192 } ··· 284 return store.getState().camera; 285 } 286 287 + renderer = createRenderer(canvas, store, { snapProvider, cursorProvider, pointerStateProvider }); 288 inputAdapter = createInputAdapter({ 289 canvas, 290 getCamera, ··· 334 .editor { 335 width: 100%; 336 height: 100%; 337 + min-height: 0; 338 display: flex; 339 flex-direction: column; 340 } 341 342 canvas { 343 flex: 1; 344 + min-height: 0; 345 display: block; 346 touch-action: none; 347 cursor: default;
+1 -1
apps/web/src/lib/components/StatusBar.svelte
··· 30 let editorSnapshot: EditorState = EditorStateOps.create(); 31 let cursorSnapshot: CursorState = { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 32 let persistenceSnapshot: PersistenceStatus = { backend: 'indexeddb', state: 'saved', pendingWrites: 0 }; 33 - let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: false, gridSize: 10 }); 34 let statusVm = $state(buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot)); 35 let zoomMenuOpen = $state(false); 36 let zoomMenuEl = $state<HTMLDivElement | null>(null);
··· 30 let editorSnapshot: EditorState = EditorStateOps.create(); 31 let cursorSnapshot: CursorState = { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 32 let persistenceSnapshot: PersistenceStatus = { backend: 'indexeddb', state: 'saved', pendingWrites: 0 }; 33 + let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: true, gridSize: 25 }); 34 let statusVm = $state(buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot)); 35 let zoomMenuOpen = $state(false); 36 let zoomMenuEl = $state<HTMLDivElement | null>(null);
+1 -1
apps/web/src/lib/status.ts
··· 177 } 178 179 export function createSnapStore(initial?: Partial<SnapSettings>): SnapStore { 180 - const defaults: SnapSettings = { snapEnabled: false, gridEnabled: false, gridSize: 10 }; 181 let value: SnapSettings = { ...defaults, ...initial }; 182 const listeners = new Set<(snap: SnapSettings) => void>(); 183
··· 177 } 178 179 export function createSnapStore(initial?: Partial<SnapSettings>): SnapStore { 180 + const defaults: SnapSettings = { snapEnabled: false, gridEnabled: true, gridSize: 25 }; 181 let value: SnapSettings = { ...defaults, ...initial }; 182 const listeners = new Set<(snap: SnapSettings) => void>(); 183
+19
apps/web/src/lib/tests/Canvas.history.test.ts
··· 322 timestamp: Date.now(), 323 }); 324 325 const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; 326 expect(stores.at(-1)?.commands).toHaveLength(1); 327 expect(stores.at(-1)?.commands[0].kind).toBe("doc");
··· 322 timestamp: Date.now(), 323 }); 324 325 + handler?.({ 326 + type: "pointer-move", 327 + screen: { x: 10, y: 10 }, 328 + world: { x: 10, y: 10 }, 329 + buttons: { left: true, middle: false, right: false }, 330 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 331 + timestamp: Date.now(), 332 + }); 333 + 334 + handler?.({ 335 + type: "pointer-up", 336 + screen: { x: 10, y: 10 }, 337 + world: { x: 10, y: 10 }, 338 + button: 0, 339 + buttons: { left: false, middle: false, right: false }, 340 + modifiers: { ctrl: false, shift: false, alt: false, meta: false }, 341 + timestamp: Date.now(), 342 + }); 343 + 344 const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; 345 expect(stores.at(-1)?.commands).toHaveLength(1); 346 expect(stores.at(-1)?.commands[0].kind).toBe("doc");
+79 -6
packages/renderer/src/index.ts
··· 1 import type { 2 ArrowShape, 3 Camera, 4 EditorState, 5 EllipseShape, 6 LineShape, ··· 24 markDirty(): void; 25 } 26 27 /** 28 * Create a canvas renderer 29 * ··· 33 * 34 * @param canvas - The HTMLCanvasElement to render to 35 * @param store - The editor state store 36 * @returns Renderer instance with dispose method 37 */ 38 - export function createRenderer(canvas: HTMLCanvasElement, store: Store): Renderer { 39 const maybeContext = canvas.getContext("2d"); 40 if (!maybeContext) { 41 throw new Error("Failed to get 2D context from canvas"); ··· 81 82 const viewport: Viewport = { width: canvas.width / getPixelRatio(), height: canvas.height / getPixelRatio() }; 83 84 - drawScene(context, state, viewport); 85 } 86 87 /** ··· 131 /** 132 * Draw the entire scene 133 */ 134 - function drawScene(context: CanvasRenderingContext2D, state: EditorState, viewport: Viewport) { 135 context.clearRect(0, 0, viewport.width, viewport.height); 136 137 context.save(); 138 139 applyCameraTransform(context, state.camera, viewport); 140 141 - drawGrid(context, state.camera, viewport); 142 143 const shapes = getShapesOnCurrentPage(state); 144 for (const shape of shapes) { ··· 146 } 147 148 drawSelection(context, state, shapes); 149 150 context.restore(); 151 } ··· 170 * Draws a subtle grid that helps with spatial awareness and alignment. 171 * The grid adapts to zoom level to maintain visual clarity. 172 */ 173 - function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport) { 174 - const gridSize = 50; 175 const minorGridColor = "rgba(128, 128, 128, 0.1)"; 176 const majorGridColor = "rgba(128, 128, 128, 0.2)"; 177 ··· 208 context.lineTo(endX, y); 209 context.stroke(); 210 } 211 } 212 213 /**
··· 1 import type { 2 ArrowShape, 3 Camera, 4 + CursorState, 5 EditorState, 6 EllipseShape, 7 LineShape, ··· 25 markDirty(): void; 26 } 27 28 + export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; 29 + 30 + export type RendererOptions = { 31 + snapProvider?: { get(): SnapSettings }; 32 + cursorProvider?: { get(): CursorState }; 33 + pointerStateProvider?: { get(): { isPointerDown: boolean } }; 34 + }; 35 + 36 /** 37 * Create a canvas renderer 38 * ··· 42 * 43 * @param canvas - The HTMLCanvasElement to render to 44 * @param store - The editor state store 45 + * @param gridProvider - Optional provider for grid settings (snap store) 46 * @returns Renderer instance with dispose method 47 */ 48 + export function createRenderer(canvas: HTMLCanvasElement, store: Store, options?: RendererOptions): Renderer { 49 const maybeContext = canvas.getContext("2d"); 50 if (!maybeContext) { 51 throw new Error("Failed to get 2D context from canvas"); ··· 91 92 const viewport: Viewport = { width: canvas.width / getPixelRatio(), height: canvas.height / getPixelRatio() }; 93 94 + const snapSettings = options?.snapProvider?.get(); 95 + const cursorState = options?.cursorProvider?.get(); 96 + const pointerState = options?.pointerStateProvider?.get(); 97 + drawScene(context, state, viewport, snapSettings, cursorState, pointerState); 98 } 99 100 /** ··· 144 /** 145 * Draw the entire scene 146 */ 147 + function drawScene( 148 + context: CanvasRenderingContext2D, 149 + state: EditorState, 150 + viewport: Viewport, 151 + snapSettings?: SnapSettings, 152 + cursorState?: CursorState, 153 + pointerState?: { isPointerDown: boolean }, 154 + ) { 155 context.clearRect(0, 0, viewport.width, viewport.height); 156 157 context.save(); 158 159 applyCameraTransform(context, state.camera, viewport); 160 161 + drawGrid(context, state.camera, viewport, snapSettings); 162 163 const shapes = getShapesOnCurrentPage(state); 164 for (const shape of shapes) { ··· 166 } 167 168 drawSelection(context, state, shapes); 169 + 170 + drawSnapGuides(context, state.camera, viewport, snapSettings, cursorState, pointerState); 171 172 context.restore(); 173 } ··· 192 * Draws a subtle grid that helps with spatial awareness and alignment. 193 * The grid adapts to zoom level to maintain visual clarity. 194 */ 195 + function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport, snapSettings?: SnapSettings) { 196 + if (snapSettings && !snapSettings.gridEnabled) { 197 + return; 198 + } 199 + const gridSize = snapSettings?.gridSize ?? 50; 200 const minorGridColor = "rgba(128, 128, 128, 0.1)"; 201 const majorGridColor = "rgba(128, 128, 128, 0.2)"; 202 ··· 233 context.lineTo(endX, y); 234 context.stroke(); 235 } 236 + } 237 + 238 + function drawSnapGuides( 239 + context: CanvasRenderingContext2D, 240 + camera: Camera, 241 + viewport: Viewport, 242 + snapSettings?: SnapSettings, 243 + cursorState?: CursorState, 244 + pointerState?: { isPointerDown: boolean }, 245 + ) { 246 + if (!snapSettings?.snapEnabled || !cursorState || !pointerState?.isPointerDown) { 247 + return; 248 + } 249 + 250 + const gridSize = snapSettings.gridSize || 1; 251 + const snappedX = Math.round(cursorState.cursorWorld.x / gridSize) * gridSize; 252 + const snappedY = Math.round(cursorState.cursorWorld.y / gridSize) * gridSize; 253 + 254 + const halfWidth = viewport.width / (2 * camera.zoom); 255 + const halfHeight = viewport.height / (2 * camera.zoom); 256 + const minX = camera.x - halfWidth; 257 + const maxX = camera.x + halfWidth; 258 + const minY = camera.y - halfHeight; 259 + const maxY = camera.y + halfHeight; 260 + 261 + context.save(); 262 + const dashLength = 4 / camera.zoom; 263 + context.setLineDash([dashLength, dashLength]); 264 + context.lineWidth = 1 / camera.zoom; 265 + context.strokeStyle = "rgba(59, 130, 246, 0.6)"; 266 + 267 + context.beginPath(); 268 + context.moveTo(minX, snappedY); 269 + context.lineTo(maxX, snappedY); 270 + context.stroke(); 271 + 272 + context.beginPath(); 273 + context.moveTo(snappedX, minY); 274 + context.lineTo(snappedX, maxY); 275 + context.stroke(); 276 + 277 + context.setLineDash([]); 278 + context.fillStyle = "rgba(59, 130, 246, 0.6)"; 279 + context.beginPath(); 280 + context.arc(snappedX, snappedY, 4 / camera.zoom, 0, Math.PI * 2); 281 + context.fill(); 282 + 283 + context.restore(); 284 } 285 286 /**