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 55 }); 56 56 const cursorStore = new CursorStore(); 57 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; 58 63 59 64 function applyLoadedDoc(doc: LoadedDoc) { 60 65 const firstPageId = doc.order.pageIds[0] ?? Object.keys(doc.pages)[0] ?? null; ··· 107 112 108 113 function handleAction(action: Action) { 109 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 + 110 142 if (actionWithSnap.type === 'key-down') { 111 143 const isPrimary = 112 144 (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || ··· 126 158 applyActionWithHistory(actionWithSnap); 127 159 } 128 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 + 129 190 function statesEqual(a: EditorState, b: EditorState): boolean { 130 191 return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; 131 192 } ··· 223 284 return store.getState().camera; 224 285 } 225 286 226 - renderer = createRenderer(canvas, store); 287 + renderer = createRenderer(canvas, store, { snapProvider, cursorProvider, pointerStateProvider }); 227 288 inputAdapter = createInputAdapter({ 228 289 canvas, 229 290 getCamera, ··· 273 334 .editor { 274 335 width: 100%; 275 336 height: 100%; 337 + min-height: 0; 276 338 display: flex; 277 339 flex-direction: column; 278 340 } 279 341 280 342 canvas { 281 343 flex: 1; 344 + min-height: 0; 282 345 display: block; 283 346 touch-action: none; 284 347 cursor: default;
+1 -1
apps/web/src/lib/components/StatusBar.svelte
··· 30 30 let editorSnapshot: EditorState = EditorStateOps.create(); 31 31 let cursorSnapshot: CursorState = { cursorWorld: { x: 0, y: 0 }, lastMoveAt: Date.now() }; 32 32 let persistenceSnapshot: PersistenceStatus = { backend: 'indexeddb', state: 'saved', pendingWrites: 0 }; 33 - let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: false, gridSize: 10 }); 33 + let snapSnapshot = $state<SnapSettings>({ snapEnabled: false, gridEnabled: true, gridSize: 25 }); 34 34 let statusVm = $state(buildStatusBarVM(editorSnapshot, cursorSnapshot, persistenceSnapshot)); 35 35 let zoomMenuOpen = $state(false); 36 36 let zoomMenuEl = $state<HTMLDivElement | null>(null);
+1 -1
apps/web/src/lib/status.ts
··· 177 177 } 178 178 179 179 export function createSnapStore(initial?: Partial<SnapSettings>): SnapStore { 180 - const defaults: SnapSettings = { snapEnabled: false, gridEnabled: false, gridSize: 10 }; 180 + const defaults: SnapSettings = { snapEnabled: false, gridEnabled: true, gridSize: 25 }; 181 181 let value: SnapSettings = { ...defaults, ...initial }; 182 182 const listeners = new Set<(snap: SnapSettings) => void>(); 183 183
+19
apps/web/src/lib/tests/Canvas.history.test.ts
··· 322 322 timestamp: Date.now(), 323 323 }); 324 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 + 325 344 const stores = (InkfiniteCore as any).__storeInstances as Array<{ commands: any[] }>; 326 345 expect(stores.at(-1)?.commands).toHaveLength(1); 327 346 expect(stores.at(-1)?.commands[0].kind).toBe("doc");
+79 -6
packages/renderer/src/index.ts
··· 1 1 import type { 2 2 ArrowShape, 3 3 Camera, 4 + CursorState, 4 5 EditorState, 5 6 EllipseShape, 6 7 LineShape, ··· 24 25 markDirty(): void; 25 26 } 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 + 27 36 /** 28 37 * Create a canvas renderer 29 38 * ··· 33 42 * 34 43 * @param canvas - The HTMLCanvasElement to render to 35 44 * @param store - The editor state store 45 + * @param gridProvider - Optional provider for grid settings (snap store) 36 46 * @returns Renderer instance with dispose method 37 47 */ 38 - export function createRenderer(canvas: HTMLCanvasElement, store: Store): Renderer { 48 + export function createRenderer(canvas: HTMLCanvasElement, store: Store, options?: RendererOptions): Renderer { 39 49 const maybeContext = canvas.getContext("2d"); 40 50 if (!maybeContext) { 41 51 throw new Error("Failed to get 2D context from canvas"); ··· 81 91 82 92 const viewport: Viewport = { width: canvas.width / getPixelRatio(), height: canvas.height / getPixelRatio() }; 83 93 84 - drawScene(context, state, viewport); 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); 85 98 } 86 99 87 100 /** ··· 131 144 /** 132 145 * Draw the entire scene 133 146 */ 134 - function drawScene(context: CanvasRenderingContext2D, state: EditorState, viewport: Viewport) { 147 + function drawScene( 148 + context: CanvasRenderingContext2D, 149 + state: EditorState, 150 + viewport: Viewport, 151 + snapSettings?: SnapSettings, 152 + cursorState?: CursorState, 153 + pointerState?: { isPointerDown: boolean }, 154 + ) { 135 155 context.clearRect(0, 0, viewport.width, viewport.height); 136 156 137 157 context.save(); 138 158 139 159 applyCameraTransform(context, state.camera, viewport); 140 160 141 - drawGrid(context, state.camera, viewport); 161 + drawGrid(context, state.camera, viewport, snapSettings); 142 162 143 163 const shapes = getShapesOnCurrentPage(state); 144 164 for (const shape of shapes) { ··· 146 166 } 147 167 148 168 drawSelection(context, state, shapes); 169 + 170 + drawSnapGuides(context, state.camera, viewport, snapSettings, cursorState, pointerState); 149 171 150 172 context.restore(); 151 173 } ··· 170 192 * Draws a subtle grid that helps with spatial awareness and alignment. 171 193 * The grid adapts to zoom level to maintain visual clarity. 172 194 */ 173 - function drawGrid(context: CanvasRenderingContext2D, camera: Camera, viewport: Viewport) { 174 - const gridSize = 50; 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; 175 200 const minorGridColor = "rgba(128, 128, 128, 0.1)"; 176 201 const majorGridColor = "rgba(128, 128, 128, 0.2)"; 177 202 ··· 208 233 context.lineTo(endX, y); 209 234 context.stroke(); 210 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(); 211 284 } 212 285 213 286 /**