web based infinite canvas

feat: update toolbar accessibility and color controls, add handle manipulation in select tool

+1150 -224
+6 -72
TODO.txt
··· 14 14 1. Milestone A: Repo skeleton + dev loop *wb-A* 15 15 ================================================================================ 16 16 17 - Goal: a monorepo that can run a blank canvas in web + desktop. 18 - 19 - [ ] Create workspace: 20 - - /apps/web (SvelteKit) 21 - - /apps/desktop (Tauri wrapper) 22 - - /packages/core (pure TS engine) 23 - - /packages/renderer (Canvas2D renderer) 24 - 25 - [ ] Add TypeScript config(s): 26 - - one shared tsconfig.base.json 27 - - package-level tsconfig.json extends base 28 - 29 - [ ] Add lint/format: 30 - - eslint + prettier 31 - - one command: pnpm lint / pnpm fmt 32 - 33 - [ ] Add test runner for core: 34 - - vitest in /packages/core 35 - - one command: pnpm test 36 - 37 - [ ] Add CI: 38 - - install, lint, test (core only) 39 - 40 - (DoD): 41 - - `pnpm dev:web` shows an empty page with a <canvas>. 42 - - `pnpm dev:desktop` launches a Tauri window that shows the same canvas. 43 - - `pnpm test` runs at least 1 passing core test. 17 + Created a project monorepo/workspace. 44 18 45 19 ================================================================================ 46 20 2. Milestone B: Math + coordinate systems *wb-B* ··· 142 116 15. Milestone O: Export (PNG/SVG) *wb-O* 143 117 ================================================================================ 144 118 145 - Goal: export drawings as shareable artifacts. 146 - 147 - [x] Implement exportViewportToPNG(canvas) (screen export) 148 - [x] Implement exportSelectionToPNG (render selection bounds) 149 - [x] Implement SVG export for basic shapes: 150 - - rect/ellipse/line/arrow/text 151 - - camera transform NOT included (exports in world coordinates) 152 - [x] Export controls are in Toolbar (between zoom & history) 153 - 154 - Tests: 155 - [x] exported SVG parses and contains expected elements 156 - 157 - (DoD): 158 - - One-click export works in both web and desktop. ✓ 119 + PNG/SVG export flows now deliver one-click viewport or selection exports from 120 + the Toolbar across web and desktop builds. 159 121 160 122 ================================================================================ 161 123 16. Milestone P: Desktop packaging (Tauri) *wb-P* ··· 291 253 19. Milestone S: Quality polish (what makes it feel "real") *wb-S* 292 254 ================================================================================ 293 255 294 - Goal: the UX crosses the "this is legit" threshold. 295 - 296 - [x] BEM-ify CSS classes 297 - - Dialog, Sheet, Toolbar, and StatusBar now use BEM naming 298 - - Fixed hardcoded white backgrounds in Dialog/Sheet (now use CSS vars) 299 - - All text colors use proper CSS variables for dark mode support 300 - [x] Panning viewport 301 - - Hold space + drag to pan the canvas 302 - - Camera.pan integration in Canvas.svelte 303 - [x] Keyboard affordances: 304 - - Arrow keys nudge selected shapes (1px, 10px with Shift) 305 - - Ctrl/Cmd+D duplicates selected shapes 306 - - Ctrl/Cmd+] brings shapes forward 307 - - Ctrl/Cmd+[ sends shapes backward 308 - [x] Accessibility: 309 - - Tool buttons have ARIA labels and aria-pressed states 310 - - Zoom and Export menus have proper ARIA attributes (haspopup, expanded, role=menu) 311 - - Visible focus states on all interactive elements 312 - - Checkboxes in StatusBar have ARIA labels 313 - - All controls keyboard-navigable with Tab 314 - 315 - [ ] Editable Text 316 - [ ] Snapping refinement 317 - - Guideline positioning 318 - [ ] Handles: 319 - - resize handles for rect/ellipse 320 - - rotate handle 321 - - cursor affordances 322 - 323 - (DoD): 324 - - A user can comfortably draw and edit without surprises. 256 + Comprehensive UX polish adds BEM CSS, space-drag panning, richer keyboard 257 + affordances, improved accessibility and styling, refined snapping, and handles 258 + so drawing feels production-ready. 325 259 326 260 ================================================================================ 327 261 References (URLs) *wb-refs*
+278 -5
apps/web/src/lib/canvas/Canvas.svelte
··· 28 28 createToolMap, 29 29 createWebDocRepo, 30 30 diffDoc, 31 + getShapesOnCurrentPage, 31 32 routeAction, 33 + shapeBounds, 32 34 switchTool, 33 35 type Action, 34 36 type CommandKind, ··· 62 64 }); 63 65 const cursorStore = new CursorStore(); 64 66 const snapStore: SnapStore = createSnapStore(); 65 - const pointerState = $state({ isPointerDown: false }); 67 + const pointerState = $state({ 68 + isPointerDown: false, 69 + snappedWorld: null as { x: number; y: number } | null 70 + }); 71 + const handleState = $state<{ hover: string | null; active: string | null }>({ 72 + hover: null, 73 + active: null 74 + }); 75 + let textEditor = $state<{ shapeId: string; value: string } | null>(null); 76 + let textEditorEl = $state<HTMLTextAreaElement | null>(null); 66 77 const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); 67 78 const snapProvider = { get: () => snapStore.get() }; 68 79 const cursorProvider = { get: () => cursorStore.getState() }; 69 80 const pointerStateProvider = { get: () => pointerState }; 81 + const handleProvider = { get: () => ({ ...handleState }) }; 70 82 let pendingCommandStart: EditorState | null = null; 71 83 72 84 function applyLoadedDoc(doc: LoadedDoc) { ··· 88 100 if (!firstShapeId) { 89 101 return; 90 102 } 91 - const state = store.getState(); 103 + const state = editorSnapshot; 92 104 if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { 93 105 return; 94 106 } ··· 101 113 EditorState.clone(after) 102 114 ); 103 115 store.executeCommand(command); 116 + syncHandleState(); 117 + } 118 + 119 + const handleCursorMap: Record<string, string> = { 120 + n: 'ns-resize', 121 + s: 'ns-resize', 122 + e: 'ew-resize', 123 + w: 'ew-resize', 124 + ne: 'nesw-resize', 125 + sw: 'nesw-resize', 126 + nw: 'nwse-resize', 127 + se: 'nwse-resize', 128 + rotate: 'alias', 129 + 'line-start': 'crosshair', 130 + 'line-end': 'crosshair' 131 + }; 132 + 133 + function refreshCursor() { 134 + if (!canvas) { 135 + return; 136 + } 137 + let cursor = 'default'; 138 + if (textEditor) { 139 + cursor = 'text'; 140 + } else if (panState.isPanning) { 141 + cursor = 'grabbing'; 142 + } else if (panState.spaceHeld) { 143 + cursor = 'grab'; 144 + } else { 145 + const activeHandle = handleState.active; 146 + const hoverHandle = handleState.hover; 147 + const targetHandle = activeHandle ?? hoverHandle; 148 + if (targetHandle) { 149 + cursor = handleCursorMap[targetHandle] ?? 'default'; 150 + } else if (pointerState.isPointerDown) { 151 + cursor = 'grabbing'; 152 + } 153 + } 154 + canvas.style.cursor = cursor; 155 + } 156 + 157 + function setHandleHover(handle: string | null) { 158 + if (handleState.hover === handle) { 159 + return; 160 + } 161 + handleState.hover = handle; 162 + refreshCursor(); 163 + } 164 + 165 + function syncHandleState() { 166 + handleState.active = selectTool.getActiveHandle ? selectTool.getActiveHandle() : null; 167 + refreshCursor(); 168 + } 169 + 170 + function getTextEditorLayout() { 171 + if (!textEditor) { 172 + return null; 173 + } 174 + const state = store.getState(); 175 + const shape = state.doc.shapes[textEditor.shapeId]; 176 + if (!shape || shape.type !== 'text') { 177 + return null; 178 + } 179 + const viewport = getViewport(); 180 + const screenPos = Camera.worldToScreen(state.camera, { x: shape.x, y: shape.y }, viewport); 181 + const widthWorld = shape.props.w ?? 240; 182 + const zoom = state.camera.zoom; 183 + return { 184 + left: screenPos.x, 185 + top: screenPos.y, 186 + width: widthWorld * zoom, 187 + height: shape.props.fontSize * 1.4 * zoom, 188 + fontSize: shape.props.fontSize * zoom 189 + }; 190 + } 191 + 192 + function startTextEditing(shapeId: string) { 193 + const state = store.getState(); 194 + const shape = state.doc.shapes[shapeId]; 195 + if (!shape || shape.type !== 'text') { 196 + return; 197 + } 198 + textEditor = { shapeId, value: shape.props.text }; 199 + refreshCursor(); 200 + queueMicrotask(() => { 201 + textEditorEl?.focus(); 202 + textEditorEl?.select(); 203 + }); 204 + } 205 + 206 + function commitTextEditing() { 207 + if (!textEditor) { 208 + return; 209 + } 210 + const { shapeId, value } = textEditor; 211 + const currentState = store.getState(); 212 + const shape = currentState.doc.shapes[shapeId]; 213 + textEditor = null; 214 + refreshCursor(); 215 + if (!shape || shape.type !== 'text' || shape.props.text === value) { 216 + return; 217 + } 218 + const before = EditorState.clone(currentState); 219 + const updatedShape = { ...shape, props: { ...shape.props, text: value } }; 220 + const newShapes = { ...currentState.doc.shapes, [shapeId]: updatedShape }; 221 + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 222 + const command = new SnapshotCommand('Edit text', 'doc', before, EditorState.clone(after)); 223 + store.executeCommand(command); 224 + } 225 + 226 + function cancelTextEditing() { 227 + textEditor = null; 228 + refreshCursor(); 229 + } 230 + 231 + function handleCanvasDoubleClick(event: MouseEvent) { 232 + if (!canvas) { 233 + return; 234 + } 235 + const rect = canvas.getBoundingClientRect(); 236 + const screen = { x: event.clientX - rect.left, y: event.clientY - rect.top }; 237 + const world = Camera.screenToWorld(store.getState().camera, screen, getViewport()); 238 + const shapeId = findTextShapeAt(world); 239 + if (shapeId) { 240 + startTextEditing(shapeId); 241 + } 242 + } 243 + 244 + function findTextShapeAt(point: { x: number; y: number }): string | null { 245 + const shapes = getShapesOnCurrentPage(store.getState()); 246 + for (let index = shapes.length - 1; index >= 0; index--) { 247 + const shape = shapes[index]; 248 + if (!shape || shape.type !== 'text') { 249 + continue; 250 + } 251 + const bounds = shapeBounds(shape); 252 + if ( 253 + point.x >= bounds.min.x && 254 + point.x <= bounds.max.x && 255 + point.y >= bounds.min.y && 256 + point.y <= bounds.max.y 257 + ) { 258 + return shape.id; 259 + } 260 + } 261 + return null; 262 + } 263 + 264 + function handleTextEditorInput(event: Event) { 265 + if (!textEditor) { 266 + return; 267 + } 268 + const target = event.currentTarget as HTMLTextAreaElement; 269 + textEditor = { ...textEditor, value: target.value }; 270 + } 271 + 272 + function handleTextEditorKeyDown(event: KeyboardEvent) { 273 + if (event.key === 'Escape') { 274 + event.preventDefault(); 275 + cancelTextEditing(); 276 + return; 277 + } 278 + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { 279 + event.preventDefault(); 280 + commitTextEditing(); 281 + } 282 + } 283 + 284 + function handleTextEditorBlur() { 285 + commitTextEditing(); 286 + } 287 + 288 + function handlePointerLeave() { 289 + setHandleHover(null); 104 290 } 105 291 106 292 const selectTool = new SelectTool(); ··· 112 298 const tools = createToolMap([selectTool, rectTool, ellipseTool, lineTool, arrowTool, textTool]); 113 299 114 300 let currentToolId = $state<ToolId>('select'); 301 + let editorSnapshot = $state(store.getState()); 115 302 let historyViewerOpen = $state(false); 116 303 117 304 store.subscribe((state) => { 118 305 currentToolId = state.ui.toolId; 306 + editorSnapshot = state; 119 307 }); 120 308 121 309 function handleToolChange(toolId: ToolId) { ··· 165 353 166 354 const command = new SnapshotCommand('Bring Forward', 'doc', before, EditorState.clone(after)); 167 355 store.executeCommand(command); 356 + syncHandleState(); 168 357 } 169 358 170 359 function handleSendBackward() { ··· 203 392 204 393 const command = new SnapshotCommand('Send Backward', 'doc', before, EditorState.clone(after)); 205 394 store.executeCommand(command); 395 + syncHandleState(); 206 396 } 207 397 208 398 function handleDuplicate() { ··· 253 443 254 444 const command = new SnapshotCommand('Duplicate', 'doc', before, EditorState.clone(after)); 255 445 store.executeCommand(command); 446 + syncHandleState(); 256 447 } 257 448 258 449 function handleNudge(arrowKey: string, largeNudge: boolean) { ··· 295 486 const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; 296 487 const command = new SnapshotCommand('Nudge', 'doc', before, EditorState.clone(after)); 297 488 store.executeCommand(command); 489 + syncHandleState(); 298 490 } 299 491 300 492 function applyActionWithHistory(action: Action) { 301 493 const before = store.getState(); 302 494 const nextState = routeAction(before, action, tools); 303 495 if (statesEqual(before, nextState)) { 496 + syncHandleState(); 304 497 return; 305 498 } 306 499 ··· 313 506 EditorState.clone(nextState) 314 507 ); 315 508 store.executeCommand(command); 509 + syncHandleState(); 316 510 } 317 511 318 512 function handleAction(action: Action) { 513 + if (textEditor && (action.type === 'pointer-down' || action.type === 'pointer-up')) { 514 + commitTextEditing(); 515 + } 516 + 517 + if ( 518 + action.type === 'pointer-move' && 519 + 'world' in action && 520 + !panState.isPanning && 521 + !panState.spaceHeld 522 + ) { 523 + const hover = selectTool.getHandleAtPoint(store.getState(), action.world); 524 + setHandleHover(hover); 525 + } 526 + 527 + if (action.type === 'pointer-move' && (panState.isPanning || panState.spaceHeld)) { 528 + setHandleHover(null); 529 + } 530 + 319 531 if (action.type === 'key-down' && action.key === ' ') { 320 532 panState.spaceHeld = true; 533 + setHandleHover(null); 534 + refreshCursor(); 321 535 return; 322 536 } 323 537 324 538 if (action.type === 'key-up' && action.key === ' ') { 325 539 panState.spaceHeld = false; 326 540 panState.isPanning = false; 541 + refreshCursor(); 327 542 return; 328 543 } 329 544 330 545 if (action.type === 'pointer-down' && action.button === 0 && panState.spaceHeld) { 331 546 panState.isPanning = true; 332 547 panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 548 + refreshCursor(); 333 549 return; 334 550 } 335 551 ··· 340 556 const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); 341 557 store.setState((state) => ({ ...state, camera: newCamera })); 342 558 panState.lastScreen = { x: action.screen.x, y: action.screen.y }; 559 + refreshCursor(); 343 560 return; 344 561 } 345 562 346 563 if (action.type === 'pointer-up' && action.button === 0 && panState.isPanning) { 347 564 panState.isPanning = false; 565 + refreshCursor(); 348 566 return; 349 567 } 350 568 ··· 353 571 } 354 572 355 573 const actionWithSnap = applySnapping(action); 574 + if ('world' in actionWithSnap) { 575 + pointerState.snappedWorld = actionWithSnap.world ?? null; 576 + } 356 577 357 578 if (actionWithSnap.type === 'pointer-down' && actionWithSnap.button === 0) { 358 579 pointerState.isPointerDown = true; 580 + setHandleHover(null); 581 + refreshCursor(); 359 582 pendingCommandStart = EditorState.clone(store.getState()); 360 583 const changed = applyImmediateAction(actionWithSnap); 361 584 if (!changed) { ··· 375 598 376 599 if (actionWithSnap.type === 'pointer-up' && actionWithSnap.button === 0) { 377 600 pointerState.isPointerDown = false; 601 + setHandleHover(null); 602 + refreshCursor(); 378 603 if (pendingCommandStart) { 379 604 const committed = commitPendingCommand(actionWithSnap, pendingCommandStart); 380 605 pendingCommandStart = null; ··· 382 607 return; 383 608 } 384 609 } 610 + pointerState.snappedWorld = null; 385 611 } 386 612 387 613 if (actionWithSnap.type === 'key-down') { ··· 435 661 const before = store.getState(); 436 662 const nextState = routeAction(before, action, tools); 437 663 if (statesEqual(before, nextState)) { 664 + syncHandleState(); 438 665 return false; 439 666 } 440 667 store.setState(() => nextState); 668 + syncHandleState(); 441 669 return true; 442 670 } 443 671 ··· 446 674 const nextState = routeAction(before, action, tools); 447 675 const finalState = statesEqual(before, nextState) ? before : nextState; 448 676 if (statesEqual(startState, finalState)) { 677 + syncHandleState(); 449 678 return false; 450 679 } 451 680 const kind = getCommandKind(startState, finalState); ··· 457 686 EditorState.clone(finalState) 458 687 ); 459 688 store.executeCommand(command); 689 + syncHandleState(); 460 690 return true; 461 691 } 462 692 ··· 560 790 renderer = createRenderer(canvas!, store, { 561 791 snapProvider, 562 792 cursorProvider, 563 - pointerStateProvider 793 + pointerStateProvider, 794 + handleProvider 564 795 }); 565 796 inputAdapter = createInputAdapter({ 566 797 canvas: canvas!, ··· 609 840 {store} 610 841 {getViewport} 611 842 {canvas} /> 612 - <canvas bind:this={canvas}></canvas> 843 + <div class="canvas-container"> 844 + <canvas 845 + bind:this={canvas} 846 + ondblclick={handleCanvasDoubleClick} 847 + onpointerleave={handlePointerLeave}></canvas> 848 + {#if textEditor} 849 + {@const layout = getTextEditorLayout()} 850 + {#if layout} 851 + <textarea 852 + bind:this={textEditorEl} 853 + class="canvas-text-editor" 854 + style={`left:${layout.left}px;top:${layout.top}px;width:${layout.width}px;height:${layout.height}px;font-size:${layout.fontSize}px;`} 855 + value={textEditor.value} 856 + oninput={handleTextEditorInput} 857 + onkeydown={handleTextEditorKeyDown} 858 + onblur={handleTextEditorBlur} 859 + spellcheck="false"></textarea> 860 + {/if} 861 + {/if} 862 + </div> 613 863 <HistoryViewer {store} bind:open={historyViewerOpen} onClose={handleHistoryClose} /> 614 864 <StatusBar {store} cursor={cursorStore} persistence={persistenceStatusStore} snap={snapStore} /> 615 865 </div> ··· 623 873 flex-direction: column; 624 874 } 625 875 626 - canvas { 876 + .canvas-container { 627 877 flex: 1; 628 878 min-height: 0; 879 + position: relative; 880 + } 881 + 882 + .canvas-container canvas { 883 + width: 100%; 884 + height: 100%; 629 885 display: block; 630 886 touch-action: none; 631 887 cursor: default; 888 + } 889 + 890 + .canvas-text-editor { 891 + position: absolute; 892 + border: 1px solid var(--accent); 893 + background: var(--surface); 894 + color: var(--text); 895 + padding: 4px; 896 + transform-origin: top left; 897 + resize: none; 898 + outline: none; 899 + line-height: 1.2; 900 + font-family: inherit; 901 + z-index: 2; 902 + box-shadow: 903 + 0 0 0 1px rgba(0, 0, 0, 0.05), 904 + 0 8px 20px rgba(0, 0, 0, 0.15); 632 905 } 633 906 </style>
+209 -3
apps/web/src/lib/components/Toolbar.svelte
··· 1 1 <script lang="ts"> 2 - import type { Box2, EditorState, Store, ToolId } from 'inkfinite-core'; 2 + import type { 3 + ArrowShape, 4 + Box2, 5 + EditorState as EditorStateType, 6 + EllipseShape, 7 + LineShape, 8 + RectShape, 9 + ShapeRecord, 10 + Store, 11 + TextShape, 12 + ToolId 13 + } from 'inkfinite-core'; 3 14 import { 15 + EditorState, 4 16 exportToSVG, 5 17 exportViewportToPNG, 6 18 getSelectedShapes, 7 19 getShapesOnCurrentPage, 8 - shapeBounds 20 + shapeBounds, 21 + SnapshotCommand 9 22 } from 'inkfinite-core'; 10 23 11 24 type Viewport = { width: number; height: number }; ··· 21 34 22 35 let { currentTool, onToolChange, onHistoryClick, store, getViewport, canvas }: Props = $props(); 23 36 24 - let editorState = $derived<EditorState>(store.getState()); 37 + const DEFAULT_FILL_COLOR = '#4a90e2'; 38 + const DEFAULT_STROKE_COLOR = '#2e5c8a'; 39 + 40 + let editorState = $derived<EditorStateType>(store.getState()); 25 41 let zoomMenuOpen = $state(false); 26 42 let zoomMenuEl = $state<HTMLDivElement | null>(null); 27 43 let zoomButtonEl = $state<HTMLButtonElement | null>(null); ··· 29 45 let exportMenuEl = $state<HTMLDivElement | null>(null); 30 46 let exportButtonEl = $state<HTMLButtonElement | null>(null); 31 47 48 + let fillColorValue = $state(DEFAULT_FILL_COLOR); 49 + let strokeColorValue = $state(DEFAULT_STROKE_COLOR); 50 + let fillDisabled = $state(true); 51 + let strokeDisabled = $state(true); 52 + 32 53 $effect(() => { 33 54 editorState = store.getState(); 34 55 const unsubscribe = store.subscribe((state) => { 35 56 editorState = state; 36 57 }); 37 58 return () => unsubscribe(); 59 + }); 60 + 61 + $effect(() => { 62 + const selection = getSelectedShapes(editorState); 63 + const fillable = selection.filter(shapeSupportsFill); 64 + const strokable = selection.filter(shapeSupportsStroke); 65 + fillDisabled = fillable.length === 0; 66 + strokeDisabled = strokable.length === 0; 67 + if (fillable.length > 0) { 68 + const shared = getSharedColor(fillable, (shape) => 69 + shape.type === 'text' ? shape.props.color : 'fill' in shape.props ? shape.props.fill : null 70 + ); 71 + if (shared) { 72 + fillColorValue = shared; 73 + } 74 + } 75 + if (strokable.length > 0) { 76 + const shared = getSharedColor(strokable, (shape) => shape.props.stroke ?? null); 77 + if (shared) { 78 + strokeColorValue = shared; 79 + } 80 + } 38 81 }); 39 82 40 83 $effect(() => { ··· 207 250 const blob = new Blob([text], { type: 'text/plain' }); 208 251 downloadBlob(blob, filename); 209 252 } 253 + 254 + function shapeSupportsFill(shape: ShapeRecord): shape is RectShape | EllipseShape | TextShape { 255 + return shape.type === 'rect' || shape.type === 'ellipse' || shape.type === 'text'; 256 + } 257 + 258 + function shapeSupportsStroke( 259 + shape: ShapeRecord 260 + ): shape is RectShape | EllipseShape | LineShape | ArrowShape { 261 + return ( 262 + shape.type === 'rect' || 263 + shape.type === 'ellipse' || 264 + shape.type === 'line' || 265 + shape.type === 'arrow' 266 + ); 267 + } 268 + 269 + function getSharedColor<T extends ShapeRecord>( 270 + shapes: T[], 271 + extract: (shape: T) => string | null | undefined 272 + ): string | null { 273 + if (shapes.length === 0) { 274 + return null; 275 + } 276 + const first = extract(shapes[0]); 277 + if (!first) { 278 + return null; 279 + } 280 + for (let index = 1; index < shapes.length; index++) { 281 + if (extract(shapes[index]) !== first) { 282 + return null; 283 + } 284 + } 285 + return first; 286 + } 287 + 288 + function applyFillColor(color: string) { 289 + const state = store.getState(); 290 + const targets = getSelectedShapes(state).filter(shapeSupportsFill); 291 + if (targets.length === 0) { 292 + return; 293 + } 294 + const before = EditorState.clone(state); 295 + const newShapes = { ...state.doc.shapes }; 296 + for (const shape of targets) { 297 + if (shape.type === 'text') { 298 + const updated: TextShape = { ...shape, props: { ...shape.props, color } }; 299 + newShapes[shape.id] = updated; 300 + } else if (shape.type === 'rect') { 301 + const updated: RectShape = { ...shape, props: { ...shape.props, fill: color } }; 302 + newShapes[shape.id] = updated; 303 + } else if (shape.type === 'ellipse') { 304 + const updated: EllipseShape = { ...shape, props: { ...shape.props, fill: color } }; 305 + newShapes[shape.id] = updated; 306 + } 307 + } 308 + const after = { ...state, doc: { ...state.doc, shapes: newShapes } }; 309 + const command = new SnapshotCommand('Set fill color', 'doc', before, EditorState.clone(after)); 310 + store.executeCommand(command); 311 + } 312 + 313 + function applyStrokeColor(color: string) { 314 + const state = store.getState(); 315 + const targets = getSelectedShapes(state).filter(shapeSupportsStroke); 316 + if (targets.length === 0) { 317 + return; 318 + } 319 + const before = EditorState.clone(state); 320 + const newShapes = { ...state.doc.shapes }; 321 + for (const shape of targets) { 322 + switch (shape.type) { 323 + case 'rect': { 324 + const updated: RectShape = { ...shape, props: { ...shape.props, stroke: color } }; 325 + newShapes[shape.id] = updated; 326 + break; 327 + } 328 + case 'ellipse': { 329 + const updated: EllipseShape = { ...shape, props: { ...shape.props, stroke: color } }; 330 + newShapes[shape.id] = updated; 331 + break; 332 + } 333 + case 'line': { 334 + const updated: LineShape = { ...shape, props: { ...shape.props, stroke: color } }; 335 + newShapes[shape.id] = updated; 336 + break; 337 + } 338 + case 'arrow': { 339 + const updated: ArrowShape = { ...shape, props: { ...shape.props, stroke: color } }; 340 + newShapes[shape.id] = updated; 341 + break; 342 + } 343 + } 344 + } 345 + const after = { ...state, doc: { ...state.doc, shapes: newShapes } }; 346 + const command = new SnapshotCommand( 347 + 'Set stroke color', 348 + 'doc', 349 + before, 350 + EditorState.clone(after) 351 + ); 352 + store.executeCommand(command); 353 + } 354 + 355 + function handleFillChange(event: Event) { 356 + const input = event.currentTarget as HTMLInputElement; 357 + fillColorValue = input.value; 358 + applyFillColor(input.value); 359 + } 360 + 361 + function handleStrokeChange(event: Event) { 362 + const input = event.currentTarget as HTMLInputElement; 363 + strokeColorValue = input.value; 364 + applyStrokeColor(input.value); 365 + } 210 366 </script> 211 367 212 368 <div class="toolbar" role="toolbar" aria-label="Drawing tools"> ··· 223 379 <span class="toolbar__tool-label">{tool.label}</span> 224 380 </button> 225 381 {/each} 382 + 383 + <div class="toolbar__colors" aria-label="Color controls"> 384 + <label class="toolbar__color-control"> 385 + <span>Fill</span> 386 + <input 387 + type="color" 388 + value={fillColorValue} 389 + onchange={handleFillChange} 390 + disabled={fillDisabled} 391 + aria-label="Fill color" /> 392 + </label> 393 + <label class="toolbar__color-control"> 394 + <span>Stroke</span> 395 + <input 396 + type="color" 397 + value={strokeColorValue} 398 + onchange={handleStrokeChange} 399 + disabled={strokeDisabled} 400 + aria-label="Stroke color" /> 401 + </label> 402 + </div> 226 403 227 404 <div class="toolbar__divider"></div> 228 405 ··· 382 559 background-color: var(--border); 383 560 margin: 0 8px; 384 561 height: 40px; 562 + } 563 + 564 + .toolbar__colors { 565 + display: flex; 566 + gap: 12px; 567 + align-items: center; 568 + } 569 + 570 + .toolbar__color-control { 571 + display: flex; 572 + flex-direction: column; 573 + gap: 4px; 574 + font-size: 11px; 575 + color: var(--text-muted); 576 + } 577 + 578 + .toolbar__color-control input[type='color'] { 579 + width: 40px; 580 + height: 30px; 581 + border: 1px solid var(--border); 582 + border-radius: 6px; 583 + padding: 0; 584 + background: transparent; 585 + cursor: pointer; 586 + } 587 + 588 + .toolbar__color-control input[type='color']:disabled { 589 + opacity: 0.4; 590 + cursor: not-allowed; 385 591 } 386 592 387 593 .toolbar__zoom,
+6
apps/web/src/lib/tests/Canvas.history.test.ts
··· 87 87 onAction(state: any) { 88 88 return state; 89 89 } 90 + getHandleAtPoint() { 91 + return null; 92 + } 93 + getActiveHandle() { 94 + return null; 95 + } 90 96 } 91 97 92 98 class MockStore {
+101 -131
apps/web/src/lib/tests/Toolbar.accessibility.test.ts
··· 1 + import { Store } from "inkfinite-core"; 1 2 import { beforeEach, describe, expect, it } from "vitest"; 2 3 import { cleanup, render } from "vitest-browser-svelte"; 3 4 import Toolbar from "../components/Toolbar.svelte"; 4 - import { Store } from "inkfinite-core"; 5 5 6 6 describe("Toolbar accessibility", () => { 7 - beforeEach(() => { 8 - cleanup(); 9 - }); 7 + beforeEach(() => { 8 + cleanup(); 9 + }); 10 10 11 - it("should have proper ARIA labels on tool buttons", () => { 12 - const store = new Store(); 13 - const { container } = render(Toolbar, { 14 - props: { 15 - currentTool: "select", 16 - onToolChange: () => {}, 17 - store, 18 - getViewport: () => ({ width: 800, height: 600 }), 19 - }, 20 - }); 11 + it("should have proper ARIA labels on tool buttons", () => { 12 + const store = new Store(); 13 + const { container } = render(Toolbar, { 14 + target: document.body, 15 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 16 + }); 21 17 22 - const selectButton = container.querySelector('[data-tool-id="select"]'); 23 - expect(selectButton?.getAttribute("aria-label")).toBe("Select"); 24 - expect(selectButton?.getAttribute("aria-pressed")).toBe("true"); 18 + const selectButton = container.querySelector("[data-tool-id=\"select\"]"); 19 + expect(selectButton?.getAttribute("aria-label")).toBe("Select"); 20 + expect(selectButton?.getAttribute("aria-pressed")).toBe("true"); 25 21 26 - const rectButton = container.querySelector('[data-tool-id="rect"]'); 27 - expect(rectButton?.getAttribute("aria-label")).toBe("Rectangle"); 28 - expect(rectButton?.getAttribute("aria-pressed")).toBe("false"); 29 - }); 22 + const rectButton = container.querySelector("[data-tool-id=\"rect\"]"); 23 + expect(rectButton?.getAttribute("aria-label")).toBe("Rectangle"); 24 + expect(rectButton?.getAttribute("aria-pressed")).toBe("false"); 25 + }); 30 26 31 - it("should have ARIA attributes on zoom button", () => { 32 - const store = new Store(); 33 - const { container } = render(Toolbar, { 34 - props: { 35 - currentTool: "select", 36 - onToolChange: () => {}, 37 - store, 38 - getViewport: () => ({ width: 800, height: 600 }), 39 - }, 40 - }); 27 + it("should have ARIA attributes on zoom button", () => { 28 + const store = new Store(); 29 + const { container } = render(Toolbar, { 30 + target: document.body, 31 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 32 + }); 41 33 42 - const zoomButton = container.querySelector(".toolbar__zoom-button"); 43 - expect(zoomButton?.getAttribute("aria-label")).toBe("Zoom level"); 44 - expect(zoomButton?.getAttribute("aria-haspopup")).toBe("true"); 45 - expect(zoomButton?.getAttribute("aria-expanded")).toBe("false"); 46 - }); 34 + const zoomButton = container.querySelector(".toolbar__zoom-button"); 35 + expect(zoomButton?.getAttribute("aria-label")).toBe("Zoom level"); 36 + expect(zoomButton?.getAttribute("aria-haspopup")).toBe("true"); 37 + expect(zoomButton?.getAttribute("aria-expanded")).toBe("false"); 38 + }); 47 39 48 - it("should have proper menu roles when zoom menu is open", async () => { 49 - const store = new Store(); 50 - const { container } = render(Toolbar, { 51 - props: { 52 - currentTool: "select", 53 - onToolChange: () => {}, 54 - store, 55 - getViewport: () => ({ width: 800, height: 600 }), 56 - }, 57 - }); 40 + it("should have proper menu roles when zoom menu is open", async () => { 41 + const store = new Store(); 42 + const { container } = render(Toolbar, { 43 + target: document.body, 44 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 45 + }); 58 46 59 - const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 60 - zoomButton.click(); 47 + const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 48 + zoomButton.click(); 61 49 62 - await new Promise((resolve) => setTimeout(resolve, 0)); 50 + await new Promise((resolve) => setTimeout(resolve, 0)); 63 51 64 - const zoomMenu = container.querySelector(".toolbar__zoom-menu"); 65 - expect(zoomMenu?.getAttribute("role")).toBe("menu"); 66 - expect(zoomMenu?.getAttribute("aria-label")).toBe("Zoom options"); 52 + const zoomMenu = container.querySelector(".toolbar__zoom-menu"); 53 + expect(zoomMenu?.getAttribute("role")).toBe("menu"); 54 + expect(zoomMenu?.getAttribute("aria-label")).toBe("Zoom options"); 67 55 68 - const menuItems = container.querySelectorAll(".toolbar__zoom-menu .toolbar__menu-item"); 69 - menuItems.forEach((item) => { 70 - expect(item.getAttribute("role")).toBe("menuitem"); 71 - expect(item.getAttribute("aria-label")).toBeTruthy(); 72 - }); 73 - }); 56 + const menuItems = container.querySelectorAll(".toolbar__zoom-menu .toolbar__menu-item"); 57 + menuItems.forEach((item) => { 58 + expect(item.getAttribute("role")).toBe("menuitem"); 59 + expect(item.getAttribute("aria-label")).toBeTruthy(); 60 + }); 61 + }); 74 62 75 - it("should have ARIA attributes on export button", () => { 76 - const store = new Store(); 77 - const { container } = render(Toolbar, { 78 - props: { 79 - currentTool: "select", 80 - onToolChange: () => {}, 81 - store, 82 - getViewport: () => ({ width: 800, height: 600 }), 83 - }, 84 - }); 63 + it("should have ARIA attributes on export button", () => { 64 + const store = new Store(); 65 + const { container } = render(Toolbar, { 66 + target: document.body, 67 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 68 + }); 85 69 86 - const exportButton = container.querySelector(".toolbar__export-button"); 87 - expect(exportButton?.getAttribute("aria-label")).toBe("Export drawing"); 88 - expect(exportButton?.getAttribute("aria-haspopup")).toBe("true"); 89 - expect(exportButton?.getAttribute("aria-expanded")).toBe("false"); 90 - }); 70 + const exportButton = container.querySelector(".toolbar__export-button"); 71 + expect(exportButton?.getAttribute("aria-label")).toBe("Export drawing"); 72 + expect(exportButton?.getAttribute("aria-haspopup")).toBe("true"); 73 + expect(exportButton?.getAttribute("aria-expanded")).toBe("false"); 74 + }); 91 75 92 - it("should have proper menu roles when export menu is open", async () => { 93 - const store = new Store(); 94 - const { container } = render(Toolbar, { 95 - props: { 96 - currentTool: "select", 97 - onToolChange: () => {}, 98 - store, 99 - getViewport: () => ({ width: 800, height: 600 }), 100 - }, 101 - }); 76 + it("should have proper menu roles when export menu is open", async () => { 77 + const store = new Store(); 78 + const { container } = render(Toolbar, { 79 + target: document.body, 80 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 81 + }); 102 82 103 - const exportButton = container.querySelector(".toolbar__export-button") as HTMLButtonElement; 104 - exportButton.click(); 83 + const exportButton = container.querySelector(".toolbar__export-button") as HTMLButtonElement; 84 + exportButton.click(); 105 85 106 - await new Promise((resolve) => setTimeout(resolve, 0)); 86 + await new Promise((resolve) => setTimeout(resolve, 0)); 107 87 108 - const exportMenu = container.querySelector(".toolbar__export-menu"); 109 - expect(exportMenu?.getAttribute("role")).toBe("menu"); 110 - expect(exportMenu?.getAttribute("aria-label")).toBe("Export options"); 88 + const exportMenu = container.querySelector(".toolbar__export-menu"); 89 + expect(exportMenu?.getAttribute("role")).toBe("menu"); 90 + expect(exportMenu?.getAttribute("aria-label")).toBe("Export options"); 111 91 112 - const menuItems = container.querySelectorAll(".toolbar__export-menu .toolbar__menu-item"); 113 - expect(menuItems.length).toBe(3); 114 - menuItems.forEach((item) => { 115 - expect(item.getAttribute("role")).toBe("menuitem"); 116 - expect(item.getAttribute("aria-label")).toBeTruthy(); 117 - }); 118 - }); 92 + const menuItems = container.querySelectorAll(".toolbar__export-menu .toolbar__menu-item"); 93 + expect(menuItems.length).toBe(3); 94 + menuItems.forEach((item) => { 95 + expect(item.getAttribute("role")).toBe("menuitem"); 96 + expect(item.getAttribute("aria-label")).toBeTruthy(); 97 + }); 98 + }); 119 99 120 - it("should have visible focus states on buttons", () => { 121 - const store = new Store(); 122 - const { container } = render(Toolbar, { 123 - props: { 124 - currentTool: "select", 125 - onToolChange: () => {}, 126 - store, 127 - getViewport: () => ({ width: 800, height: 600 }), 128 - }, 129 - }); 100 + it("should have visible focus states on buttons", () => { 101 + const store = new Store(); 102 + const { container } = render(Toolbar, { 103 + target: document.body, 104 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 105 + }); 130 106 131 - const selectButton = container.querySelector(".toolbar__tool-button") as HTMLElement; 132 - selectButton.focus(); 107 + const selectButton = container.querySelector(".toolbar__tool-button") as HTMLElement; 108 + selectButton.focus(); 133 109 134 - const style = window.getComputedStyle(selectButton); 135 - // Focus styles are defined in CSS, just verify the button can receive focus 136 - expect(document.activeElement).toBe(selectButton); 137 - }); 110 + expect(document.activeElement).toBe(selectButton); 111 + }); 138 112 139 - it("should update aria-expanded when menus are toggled", async () => { 140 - const store = new Store(); 141 - const { container } = render(Toolbar, { 142 - props: { 143 - currentTool: "select", 144 - onToolChange: () => {}, 145 - store, 146 - getViewport: () => ({ width: 800, height: 600 }), 147 - }, 148 - }); 113 + it("should update aria-expanded when menus are toggled", async () => { 114 + const store = new Store(); 115 + const { container } = render(Toolbar, { 116 + target: document.body, 117 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 118 + }); 149 119 150 - const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 120 + const zoomButton = container.querySelector(".toolbar__zoom-button") as HTMLButtonElement; 151 121 152 - expect(zoomButton.getAttribute("aria-expanded")).toBe("false"); 122 + expect(zoomButton.getAttribute("aria-expanded")).toBe("false"); 153 123 154 - zoomButton.click(); 155 - await new Promise((resolve) => setTimeout(resolve, 0)); 124 + zoomButton.click(); 125 + await new Promise((resolve) => setTimeout(resolve, 0)); 156 126 157 - expect(zoomButton.getAttribute("aria-expanded")).toBe("true"); 127 + expect(zoomButton.getAttribute("aria-expanded")).toBe("true"); 158 128 159 - zoomButton.click(); 160 - await new Promise((resolve) => setTimeout(resolve, 0)); 129 + zoomButton.click(); 130 + await new Promise((resolve) => setTimeout(resolve, 0)); 161 131 162 - expect(zoomButton.getAttribute("aria-expanded")).toBe("false"); 163 - }); 132 + expect(zoomButton.getAttribute("aria-expanded")).toBe("false"); 133 + }); 164 134 });
+114
apps/web/src/lib/tests/Toolbar.colors.test.ts
··· 1 + import { EditorState, ShapeRecord, Store } from "inkfinite-core"; 2 + import { beforeEach, describe, expect, it } from "vitest"; 3 + import { cleanup, render } from "vitest-browser-svelte"; 4 + import Toolbar from "../components/Toolbar.svelte"; 5 + 6 + function createStoreWithRect() { 7 + const store = new Store(); 8 + const base = EditorState.create(); 9 + const pageId = "page:rect"; 10 + const rect = ShapeRecord.createRect( 11 + pageId, 12 + 0, 13 + 0, 14 + { w: 100, h: 50, fill: "#4a90e2", stroke: "#2e5c8a", radius: 4 }, 15 + "shape:rect", 16 + ); 17 + store.setState(() => ({ 18 + doc: { 19 + pages: { [pageId]: { id: pageId, name: "Page", shapeIds: [rect.id] } }, 20 + shapes: { [rect.id]: rect }, 21 + bindings: {}, 22 + }, 23 + ui: { currentPageId: pageId, selectionIds: [rect.id], toolId: "select" }, 24 + camera: base.camera, 25 + })); 26 + return store; 27 + } 28 + 29 + function createStoreWithLine() { 30 + const store = new Store(); 31 + const base = EditorState.create(); 32 + const pageId = "page:line"; 33 + const line = ShapeRecord.createLine(pageId, 0, 0, { 34 + a: { x: 0, y: 0 }, 35 + b: { x: 50, y: 0 }, 36 + stroke: "#495057", 37 + width: 2, 38 + }, "shape:line"); 39 + store.setState(() => ({ 40 + doc: { 41 + pages: { [pageId]: { id: pageId, name: "Page", shapeIds: [line.id] } }, 42 + shapes: { [line.id]: line }, 43 + bindings: {}, 44 + }, 45 + ui: { currentPageId: pageId, selectionIds: [line.id], toolId: "select" }, 46 + camera: base.camera, 47 + })); 48 + return store; 49 + } 50 + 51 + describe("Toolbar color controls", () => { 52 + beforeEach(() => { 53 + cleanup(); 54 + }); 55 + 56 + it("updates fill color for selected shapes", () => { 57 + const store = createStoreWithRect(); 58 + const { container } = render(Toolbar, { 59 + target: document.body, 60 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 61 + }); 62 + 63 + const input = container.querySelector("input[aria-label=\"Fill color\"]") as HTMLInputElement | null; 64 + expect(input).toBeTruthy(); 65 + if (!input) return; 66 + input.value = "#ff3366"; 67 + input.dispatchEvent(new Event("change", { bubbles: true })); 68 + 69 + const updated = store.getState().doc.shapes["shape:rect"]; 70 + expect(updated?.type).toBe("rect"); 71 + if (updated?.type !== "rect") { 72 + throw new Error("Expected rect shape"); 73 + } 74 + expect(updated.props.fill).toBe("#ff3366"); 75 + }); 76 + 77 + it("updates stroke color for selectable shapes", () => { 78 + const store = createStoreWithRect(); 79 + const { container } = render(Toolbar, { 80 + target: document.body, 81 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 82 + }); 83 + 84 + const input = container.querySelector("input[aria-label=\"Stroke color\"]") as HTMLInputElement | null; 85 + expect(input).toBeTruthy(); 86 + if (!input) return; 87 + input.value = "#222299"; 88 + input.dispatchEvent(new Event("change", { bubbles: true })); 89 + 90 + const updated = store.getState().doc.shapes["shape:rect"]; 91 + expect(updated?.type).toBe("rect"); 92 + if (updated?.type !== "rect") { 93 + throw new Error("Expected rect shape"); 94 + } 95 + expect(updated.props.stroke).toBe("#222299"); 96 + }); 97 + 98 + it("disables fill control when selection has no fillable shapes", () => { 99 + const store = createStoreWithLine(); 100 + const { container } = render(Toolbar, { 101 + target: document.body, 102 + props: { currentTool: "select", onToolChange: () => {}, store, getViewport: () => ({ width: 800, height: 600 }) }, 103 + }); 104 + 105 + const fillInput = container.querySelector("input[aria-label=\"Fill color\"]") as HTMLInputElement | null; 106 + const strokeInput = container.querySelector("input[aria-label=\"Stroke color\"]") as HTMLInputElement | null; 107 + expect(fillInput).toBeTruthy(); 108 + expect(strokeInput).toBeTruthy(); 109 + if (!fillInput || !strokeInput) return; 110 + 111 + expect(fillInput.disabled).toBe(true); 112 + expect(strokeInput.disabled).toBe(false); 113 + }); 114 + });
+258 -2
packages/core/src/tools.ts
··· 1 1 import type { Action } from "./actions"; 2 2 import { hitTestPoint, shapeBounds } from "./geom"; 3 - import { Box2, Vec2 } from "./math"; 3 + import { Box2, Vec2, Vec2 as Vec2Ops } from "./math"; 4 4 import { BindingRecord, createId, ShapeRecord } from "./model"; 5 5 import type { EditorState, ToolId } from "./reactivity"; 6 6 import { getCurrentPage } from "./reactivity"; ··· 114 114 marqueeStart: Vec2 | null; 115 115 /** Marquee selection end point in world coordinates */ 116 116 marqueeEnd: Vec2 | null; 117 + /** Active resize/rotate handle identifier */ 118 + activeHandle: HandleKind | null; 119 + /** Shape being manipulated by handle */ 120 + handleShapeId: string | null; 121 + /** Bounds snapshot at the time handle drag started */ 122 + handleStartBounds: Box2 | null; 123 + /** Initial shapes snapshot for handle drags */ 124 + handleInitialShapes: Map<string, ShapeRecord>; 125 + /** Rotation pivot in world coordinates */ 126 + rotationCenter: Vec2 | null; 127 + /** Starting angle for rotation handle */ 128 + rotationStartAngle: number | null; 117 129 }; 118 130 131 + type RectHandle = "nw" | "n" | "ne" | "e" | "se" | "s" | "sw" | "w"; 132 + 133 + type HandleKind = RectHandle | "rotate" | "line-start" | "line-end"; 134 + 135 + const HANDLE_HIT_RADIUS = 10; 136 + const ROTATE_HANDLE_OFFSET = 40; 137 + const MIN_RESIZE_SIZE = 5; 138 + 119 139 /** 120 140 * Select tool - allows selecting and moving shapes 121 141 * ··· 138 158 initialShapePositions: new Map(), 139 159 marqueeStart: null, 140 160 marqueeEnd: null, 161 + activeHandle: null, 162 + handleShapeId: null, 163 + handleStartBounds: null, 164 + handleInitialShapes: new Map(), 165 + rotationCenter: null, 166 + rotationStartAngle: null, 141 167 }; 142 168 } 143 169 ··· 176 202 */ 177 203 private handlePointerDown(state: EditorState, action: Action): EditorState { 178 204 if (action.type !== "pointer-down") return state; 205 + 206 + const handleHit = this.hitTestHandle(state, action.world); 207 + if (handleHit) { 208 + return this.beginHandleDrag(state, handleHit.shape, handleHit.handle, action.world); 209 + } 179 210 180 211 const hitShapeId = hitTestPoint(state, action.world); 181 212 182 213 return hitShapeId ? this.handleShapeClick(state, hitShapeId, action) : this.handleEmptyClick(state, action); 183 214 } 184 215 216 + private hitTestHandle(state: EditorState, point: Vec2): { handle: HandleKind; shape: ShapeRecord } | null { 217 + if (state.ui.selectionIds.length !== 1) { 218 + return null; 219 + } 220 + const shapeId = state.ui.selectionIds[0]; 221 + const shape = state.doc.shapes[shapeId]; 222 + if (!shape) { 223 + return null; 224 + } 225 + const handles = this.getHandlePositions(shape); 226 + for (const handle of handles) { 227 + if (Vec2Ops.dist(point, handle.position) <= HANDLE_HIT_RADIUS) { 228 + return { handle: handle.id, shape }; 229 + } 230 + } 231 + return null; 232 + } 233 + 234 + private beginHandleDrag(state: EditorState, shape: ShapeRecord, handle: HandleKind, point: Vec2): EditorState { 235 + this.toolState.activeHandle = handle; 236 + this.toolState.handleShapeId = shape.id; 237 + this.toolState.handleStartBounds = shapeBounds(shape); 238 + this.toolState.handleInitialShapes.clear(); 239 + this.toolState.handleInitialShapes.set(shape.id, ShapeRecord.clone(shape)); 240 + this.toolState.isDragging = false; 241 + this.toolState.dragStartWorld = point; 242 + const bounds = this.toolState.handleStartBounds; 243 + this.toolState.rotationCenter = bounds 244 + ? { x: (bounds.min.x + bounds.max.x) / 2, y: (bounds.min.y + bounds.max.y) / 2 } 245 + : null; 246 + this.toolState.rotationStartAngle = this.toolState.rotationCenter 247 + ? Math.atan2(point.y - this.toolState.rotationCenter.y, point.x - this.toolState.rotationCenter.x) 248 + : null; 249 + return state; 250 + } 251 + 185 252 /** 186 253 * Handle clicking on a shape 187 254 */ ··· 239 306 private handlePointerMove(state: EditorState, action: Action): EditorState { 240 307 if (action.type !== "pointer-move") return state; 241 308 309 + if (this.toolState.activeHandle && this.toolState.handleShapeId) { 310 + return this.handleHandleDrag(state, action); 311 + } 312 + 242 313 if (this.toolState.isDragging && this.toolState.dragStartWorld) { 243 314 return this.handleDragMove(state, action); 244 315 } else if (this.toolState.marqueeStart) { ··· 248 319 return state; 249 320 } 250 321 322 + private handleHandleDrag(state: EditorState, action: Action): EditorState { 323 + if (action.type !== "pointer-move" || !this.toolState.handleShapeId || !this.toolState.activeHandle) { 324 + return state; 325 + } 326 + const shapeId = this.toolState.handleShapeId; 327 + const currentShape = state.doc.shapes[shapeId]; 328 + const initialShape = this.toolState.handleInitialShapes.get(shapeId); 329 + if (!currentShape || !initialShape) { 330 + return state; 331 + } 332 + 333 + let updated: ShapeRecord | null = null; 334 + if (this.toolState.activeHandle === "rotate") { 335 + updated = this.rotateShape(initialShape, action.world); 336 + } else if (this.toolState.activeHandle === "line-start" || this.toolState.activeHandle === "line-end") { 337 + updated = this.resizeLineShape(initialShape, action.world, this.toolState.activeHandle); 338 + } else if (this.toolState.handleStartBounds) { 339 + updated = this.resizeRectLikeShape( 340 + initialShape, 341 + this.toolState.handleStartBounds, 342 + action.world, 343 + this.toolState.activeHandle, 344 + ); 345 + } 346 + 347 + if (!updated) { 348 + return state; 349 + } 350 + 351 + return { ...state, doc: { ...state.doc, shapes: { ...state.doc.shapes, [shapeId]: updated } } }; 352 + } 353 + 251 354 /** 252 355 * Handle dragging selected shapes 253 356 */ ··· 291 394 newState = this.completeMarqueeSelection(state); 292 395 } 293 396 397 + this.toolState.activeHandle = null; 398 + this.toolState.handleShapeId = null; 399 + this.toolState.handleStartBounds = null; 400 + this.toolState.handleInitialShapes.clear(); 401 + this.toolState.rotationCenter = null; 402 + this.toolState.rotationStartAngle = null; 294 403 this.toolState.isDragging = false; 295 404 this.toolState.dragStartWorld = null; 296 405 this.toolState.initialShapePositions.clear(); ··· 389 498 initialShapePositions: new Map(), 390 499 marqueeStart: null, 391 500 marqueeEnd: null, 501 + activeHandle: null, 502 + handleShapeId: null, 503 + handleStartBounds: null, 504 + handleInitialShapes: new Map(), 505 + rotationCenter: null, 506 + rotationStartAngle: null, 392 507 }; 393 508 } 394 509 ··· 398 513 getMarqueeBounds(): Box2 | null { 399 514 if (!this.toolState.marqueeStart || !this.toolState.marqueeEnd) return null; 400 515 return Box2.fromPoints([this.toolState.marqueeStart, this.toolState.marqueeEnd]); 516 + } 517 + 518 + getHandleAtPoint(state: EditorState, point: Vec2): HandleKind | null { 519 + const hit = this.hitTestHandle(state, point); 520 + return hit?.handle ?? null; 521 + } 522 + 523 + getActiveHandle(): HandleKind | null { 524 + return this.toolState.activeHandle; 525 + } 526 + 527 + private getHandlePositions(shape: ShapeRecord): Array<{ id: HandleKind; position: Vec2 }> { 528 + const handles: Array<{ id: HandleKind; position: Vec2 }> = []; 529 + if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { 530 + const bounds = shapeBounds(shape); 531 + const minX = bounds.min.x; 532 + const maxX = bounds.max.x; 533 + const minY = bounds.min.y; 534 + const maxY = bounds.max.y; 535 + const centerX = (minX + maxX) / 2; 536 + const centerY = (minY + maxY) / 2; 537 + handles.push( 538 + { id: "nw", position: { x: minX, y: minY } }, 539 + { id: "n", position: { x: centerX, y: minY } }, 540 + { id: "ne", position: { x: maxX, y: minY } }, 541 + { id: "e", position: { x: maxX, y: centerY } }, 542 + { id: "se", position: { x: maxX, y: maxY } }, 543 + { id: "s", position: { x: centerX, y: maxY } }, 544 + { id: "sw", position: { x: minX, y: maxY } }, 545 + { id: "w", position: { x: minX, y: centerY } }, 546 + { id: "rotate", position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET } }, 547 + ); 548 + } else if (shape.type === "line" || shape.type === "arrow") { 549 + const start = this.localToWorld(shape, shape.props.a); 550 + const end = this.localToWorld(shape, shape.props.b); 551 + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 552 + } 553 + return handles; 554 + } 555 + 556 + private resizeRectLikeShape( 557 + initial: ShapeRecord, 558 + bounds: Box2, 559 + pointer: Vec2, 560 + handle: HandleKind, 561 + ): ShapeRecord | null { 562 + if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") { 563 + return null; 564 + } 565 + let minX = bounds.min.x; 566 + let maxX = bounds.max.x; 567 + let minY = bounds.min.y; 568 + let maxY = bounds.max.y; 569 + 570 + const clampX = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 571 + const clampY = (value: number) => Math.min(Math.max(value, -1e6), 1e6); 572 + 573 + switch (handle) { 574 + case "nw": { 575 + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 576 + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 577 + break; 578 + } 579 + case "n": { 580 + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 581 + break; 582 + } 583 + case "ne": { 584 + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 585 + minY = Math.min(clampY(pointer.y), maxY - MIN_RESIZE_SIZE); 586 + break; 587 + } 588 + case "e": { 589 + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 590 + break; 591 + } 592 + case "se": { 593 + maxX = Math.max(clampX(pointer.x), minX + MIN_RESIZE_SIZE); 594 + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 595 + break; 596 + } 597 + case "s": { 598 + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 599 + break; 600 + } 601 + case "sw": { 602 + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 603 + maxY = Math.max(clampY(pointer.y), minY + MIN_RESIZE_SIZE); 604 + break; 605 + } 606 + case "w": { 607 + minX = Math.min(clampX(pointer.x), maxX - MIN_RESIZE_SIZE); 608 + break; 609 + } 610 + } 611 + 612 + const width = Math.max(maxX - minX, MIN_RESIZE_SIZE); 613 + const height = Math.max(maxY - minY, MIN_RESIZE_SIZE); 614 + 615 + if (initial.type === "text") { 616 + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width } }; 617 + } 618 + 619 + // @ts-expect-error union mismatch 620 + return { ...initial, x: minX, y: minY, props: { ...initial.props, w: width, h: height } }; 621 + } 622 + 623 + private resizeLineShape(initial: ShapeRecord, pointer: Vec2, handle: "line-start" | "line-end"): ShapeRecord | null { 624 + if (initial.type !== "line" && initial.type !== "arrow") { 625 + return null; 626 + } 627 + const startWorld = this.localToWorld(initial, initial.props.a); 628 + const endWorld = this.localToWorld(initial, initial.props.b); 629 + const newStart = handle === "line-start" ? pointer : startWorld; 630 + const newEnd = handle === "line-end" ? pointer : endWorld; 631 + const newProps = { ...initial.props, a: { x: 0, y: 0 }, b: { x: newEnd.x - newStart.x, y: newEnd.y - newStart.y } }; 632 + return { ...initial, x: newStart.x, y: newStart.y, props: newProps }; 633 + } 634 + 635 + private rotateShape(initial: ShapeRecord, pointer: Vec2): ShapeRecord | null { 636 + if (!this.toolState.rotationCenter || this.toolState.rotationStartAngle === null) { 637 + return null; 638 + } 639 + if (initial.type !== "rect" && initial.type !== "ellipse" && initial.type !== "text") { 640 + return null; 641 + } 642 + const currentAngle = Math.atan2( 643 + pointer.y - this.toolState.rotationCenter.y, 644 + pointer.x - this.toolState.rotationCenter.x, 645 + ); 646 + const delta = currentAngle - this.toolState.rotationStartAngle; 647 + return { ...initial, rot: initial.rot + delta }; 648 + } 649 + 650 + private localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { 651 + if (shape.rot === 0) { 652 + return { x: shape.x + point.x, y: shape.y + point.y }; 653 + } 654 + const cos = Math.cos(shape.rot); 655 + const sin = Math.sin(shape.rot); 656 + return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 401 657 } 402 658 } 403 659 ··· 1129 1385 text: "Text", 1130 1386 fontSize: 16, 1131 1387 fontFamily: "sans-serif", 1132 - color: "#000000", 1388 + color: "#1f2933", 1133 1389 }, shapeId); 1134 1390 1135 1391 const newPage = { ...currentPage, shapeIds: [...currentPage.shapeIds, shapeId] };
+29 -1
packages/core/tests/tools.test.ts
··· 1 1 import { describe, expect, it } from "vitest"; 2 2 import { Action, Modifiers, PointerButtons } from "../src/actions"; 3 3 import { Vec2 } from "../src/math"; 4 + import type { TextProps } from "../src/model"; 4 5 import { EditorState } from "../src/reactivity"; 5 6 import type { Tool } from "../src/tools"; 6 - import { createToolMap, routeAction, switchTool } from "../src/tools"; 7 + import { createToolMap, routeAction, switchTool, TextTool } from "../src/tools"; 7 8 8 9 describe("Tools", () => { 9 10 describe("Tool interface", () => { ··· 444 445 "rect:exit", 445 446 "select:enter", 446 447 ]); 448 + }); 449 + }); 450 + 451 + describe("TextTool", () => { 452 + it("uses a readable default color", () => { 453 + const tool = new TextTool(); 454 + const state = EditorState.create(); 455 + const pageId = "page:default"; 456 + const withPage = { 457 + ...state, 458 + doc: { ...state.doc, pages: { [pageId]: { id: pageId, name: "Page", shapeIds: [] } } }, 459 + ui: { ...state.ui, currentPageId: pageId }, 460 + }; 461 + 462 + const action = Action.pointerDown( 463 + Vec2.create(0, 0), 464 + Vec2.create(100, 200), 465 + 0, 466 + PointerButtons.create(true, false, false), 467 + Modifiers.create(), 468 + ); 469 + const nextState = tool.onAction(withPage, action); 470 + const shapeId = nextState.ui.selectionIds[0]; 471 + const createdShape = shapeId ? nextState.doc.shapes[shapeId] : null; 472 + 473 + expect(createdShape?.type).toBe("text"); 474 + expect((createdShape?.props as TextProps).color).toBe("#1f2933"); 447 475 }); 448 476 }); 449 477 });
+149 -10
packages/renderer/src/index.ts
··· 9 9 ShapeRecord, 10 10 Store, 11 11 TextShape, 12 + Vec2, 12 13 Viewport, 13 14 } from "inkfinite-core"; 14 - import { getShapesOnCurrentPage, resolveArrowEndpoints } from "inkfinite-core"; 15 + import { getShapesOnCurrentPage, resolveArrowEndpoints, shapeBounds } from "inkfinite-core"; 15 16 16 17 export interface Renderer { 17 18 /** ··· 27 28 28 29 export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; 29 30 31 + export type PointerVisualState = { isPointerDown: boolean; snappedWorld?: Vec2 | null }; 32 + 33 + export type HandleRenderState = { hover: string | null; active: string | null } | null | undefined; 34 + 30 35 export type RendererOptions = { 31 36 snapProvider?: { get(): SnapSettings }; 32 37 cursorProvider?: { get(): CursorState }; 33 - pointerStateProvider?: { get(): { isPointerDown: boolean } }; 38 + pointerStateProvider?: { get(): PointerVisualState }; 39 + handleProvider?: { get(): HandleRenderState }; 34 40 }; 35 41 36 42 /** ··· 94 100 const snapSettings = options?.snapProvider?.get(); 95 101 const cursorState = options?.cursorProvider?.get(); 96 102 const pointerState = options?.pointerStateProvider?.get(); 97 - drawScene(context, state, viewport, snapSettings, cursorState, pointerState); 103 + const handleState = options?.handleProvider?.get(); 104 + drawScene(context, state, viewport, snapSettings, cursorState, pointerState, handleState); 98 105 } 99 106 100 107 /** ··· 150 157 viewport: Viewport, 151 158 snapSettings?: SnapSettings, 152 159 cursorState?: CursorState, 153 - pointerState?: { isPointerDown: boolean }, 160 + pointerState?: PointerVisualState, 161 + handleState?: HandleRenderState, 154 162 ) { 155 163 context.clearRect(0, 0, viewport.width, viewport.height); 156 164 ··· 165 173 drawShape(context, state, shape); 166 174 } 167 175 168 - drawSelection(context, state, shapes); 176 + drawSelection(context, state, shapes, handleState); 169 177 170 178 drawSnapGuides(context, state.camera, viewport, snapSettings, cursorState, pointerState); 171 179 ··· 241 249 viewport: Viewport, 242 250 snapSettings?: SnapSettings, 243 251 cursorState?: CursorState, 244 - pointerState?: { isPointerDown: boolean }, 252 + pointerState?: PointerVisualState, 245 253 ) { 246 - if (!snapSettings?.snapEnabled || !cursorState || !pointerState?.isPointerDown) { 254 + if (!snapSettings?.snapEnabled || !pointerState?.isPointerDown) { 247 255 return; 248 256 } 249 257 250 258 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; 259 + const guideWorld = pointerState.snappedWorld ?? cursorState?.cursorWorld; 260 + if (!guideWorld) { 261 + return; 262 + } 263 + const snappedX = pointerState.snappedWorld 264 + ? pointerState.snappedWorld.x 265 + : Math.round(guideWorld.x / gridSize) * gridSize; 266 + const snappedY = pointerState.snappedWorld 267 + ? pointerState.snappedWorld.y 268 + : Math.round(guideWorld.y / gridSize) * gridSize; 253 269 254 270 const halfWidth = viewport.width / (2 * camera.zoom); 255 271 const halfHeight = viewport.height / (2 * camera.zoom); ··· 476 492 /** 477 493 * Draw selection outlines for selected shapes 478 494 */ 479 - function drawSelection(context: CanvasRenderingContext2D, state: EditorState, shapes: ShapeRecord[]) { 495 + function drawSelection( 496 + context: CanvasRenderingContext2D, 497 + state: EditorState, 498 + shapes: ShapeRecord[], 499 + handleState?: HandleRenderState, 500 + ) { 480 501 const selectedIds = new Set(state.ui.selectionIds); 502 + const singleSelectionId = state.ui.selectionIds.length === 1 ? state.ui.selectionIds[0] : null; 481 503 482 504 for (const shape of shapes) { 483 505 if (!selectedIds.has(shape.id)) continue; ··· 526 548 } 527 549 528 550 context.restore(); 551 + 552 + if (singleSelectionId === shape.id) { 553 + drawHandles(context, state, shape, handleState); 554 + } 529 555 } 530 556 } 557 + 558 + type HandleVisual = { id: string; position: Vec2; connectorFrom?: Vec2 }; 559 + const ROTATE_HANDLE_OFFSET = 40; 560 + 561 + function drawHandles( 562 + context: CanvasRenderingContext2D, 563 + state: EditorState, 564 + shape: ShapeRecord, 565 + handleState?: HandleRenderState, 566 + ) { 567 + if (!handleState) { 568 + return; 569 + } 570 + const handles = getHandlesForShape(state, shape); 571 + if (handles.length === 0) { 572 + return; 573 + } 574 + 575 + for (const handle of handles) { 576 + if (handle.connectorFrom) { 577 + context.save(); 578 + context.strokeStyle = "rgba(37, 99, 235, 0.6)"; 579 + context.lineWidth = 1 / state.camera.zoom; 580 + context.beginPath(); 581 + context.moveTo(handle.connectorFrom.x, handle.connectorFrom.y); 582 + context.lineTo(handle.position.x, handle.position.y); 583 + context.stroke(); 584 + context.restore(); 585 + } 586 + 587 + context.save(); 588 + const isActive = handleState.active === handle.id; 589 + const isHover = handleState.hover === handle.id; 590 + const fill = isActive ? "#2563eb" : (isHover ? "#3b82f6" : "#ffffff"); 591 + const stroke = isActive || isHover ? "#2563eb" : "#1f2933"; 592 + const size = handle.id === "rotate" ? 6 / state.camera.zoom : 5 / state.camera.zoom; 593 + 594 + context.translate(handle.position.x, handle.position.y); 595 + context.lineWidth = 1 / state.camera.zoom; 596 + context.strokeStyle = stroke; 597 + context.fillStyle = fill; 598 + 599 + if (handle.id === "rotate") { 600 + context.beginPath(); 601 + context.arc(0, 0, size, 0, Math.PI * 2); 602 + context.fill(); 603 + context.stroke(); 604 + } else { 605 + const d = size; 606 + context.beginPath(); 607 + context.rect(-d, -d, d * 2, d * 2); 608 + context.fill(); 609 + context.stroke(); 610 + } 611 + 612 + context.restore(); 613 + } 614 + } 615 + 616 + function getHandlesForShape(state: EditorState, shape: ShapeRecord): HandleVisual[] { 617 + const handles: HandleVisual[] = []; 618 + if (shape.type === "rect" || shape.type === "ellipse" || shape.type === "text") { 619 + const bounds = shapeBounds(shape); 620 + const minX = bounds.min.x; 621 + const maxX = bounds.max.x; 622 + const minY = bounds.min.y; 623 + const maxY = bounds.max.y; 624 + const centerX = (minX + maxX) / 2; 625 + const centerY = (minY + maxY) / 2; 626 + handles.push( 627 + { id: "nw", position: { x: minX, y: minY } }, 628 + { id: "n", position: { x: centerX, y: minY } }, 629 + { id: "ne", position: { x: maxX, y: minY } }, 630 + { id: "e", position: { x: maxX, y: centerY } }, 631 + { id: "se", position: { x: maxX, y: maxY } }, 632 + { id: "s", position: { x: centerX, y: maxY } }, 633 + { id: "sw", position: { x: minX, y: maxY } }, 634 + { id: "w", position: { x: minX, y: centerY } }, 635 + { 636 + id: "rotate", 637 + position: { x: centerX, y: minY - ROTATE_HANDLE_OFFSET }, 638 + connectorFrom: { x: centerX, y: minY }, 639 + }, 640 + ); 641 + return handles; 642 + } 643 + 644 + if (shape.type === "line") { 645 + const start = localToWorld(shape, shape.props.a); 646 + const end = localToWorld(shape, shape.props.b); 647 + handles.push({ id: "line-start", position: start }, { id: "line-end", position: end }); 648 + return handles; 649 + } 650 + 651 + if (shape.type === "arrow") { 652 + const resolved = resolveArrowEndpoints(state, shape.id); 653 + if (resolved) { 654 + handles.push({ id: "line-start", position: resolved.a }, { id: "line-end", position: resolved.b }); 655 + } 656 + return handles; 657 + } 658 + 659 + return handles; 660 + } 661 + 662 + function localToWorld(shape: ShapeRecord, point: Vec2): Vec2 { 663 + if (shape.rot === 0) { 664 + return { x: shape.x + point.x, y: shape.y + point.y }; 665 + } 666 + const cos = Math.cos(shape.rot); 667 + const sin = Math.sin(shape.rot); 668 + return { x: shape.x + point.x * cos - point.y * sin, y: shape.y + point.x * sin + point.y * cos }; 669 + }