diff --git a/TODO.txt b/TODO.txt index 74b6333..85d7731 100644 --- a/TODO.txt +++ b/TODO.txt @@ -293,21 +293,32 @@ R4. Parity behaviors Goal: the UX crosses the "this is legit" threshold. -[ ] Snapping: - - snap move to grid - - snap to other shape edges/centers +[x] BEM-ify CSS classes + - Dialog, Sheet, Toolbar, and StatusBar now use BEM naming + - Fixed hardcoded white backgrounds in Dialog/Sheet (now use CSS vars) + - All text colors use proper CSS variables for dark mode support +[x] Panning viewport + - Hold space + drag to pan the canvas + - Camera.pan integration in Canvas.svelte +[x] Keyboard affordances: + - Arrow keys nudge selected shapes (1px, 10px with Shift) + - Ctrl/Cmd+D duplicates selected shapes + - Ctrl/Cmd+] brings shapes forward + - Ctrl/Cmd+[ sends shapes backward +[x] Accessibility: + - Tool buttons have ARIA labels and aria-pressed states + - Zoom and Export menus have proper ARIA attributes (haspopup, expanded, role=menu) + - Visible focus states on all interactive elements + - Checkboxes in StatusBar have ARIA labels + - All controls keyboard-navigable with Tab + +[ ] Editable Text +[ ] Snapping refinement + - Guideline positioning [ ] Handles: - resize handles for rect/ellipse - rotate handle - cursor affordances -[ ] Keyboard affordances: - - nudge with arrow keys - - duplicate (Ctrl/Cmd+D) - - bring forward/back -[ ] Accessibility: - - tool buttons navigable with keyboard - - visible focus states - - ARIA labels for controls (DoD): - A user can comfortably draw and edit without surprises. diff --git a/apps/web/.prettierrc b/apps/web/.prettierrc index 9faa8cc..dda7f98 100644 --- a/apps/web/.prettierrc +++ b/apps/web/.prettierrc @@ -2,18 +2,9 @@ "useTabs": true, "singleQuote": true, "trailingComma": "none", - "printWidth": 120, + "printWidth": 99, "objectWrap": "collapse", "bracketSameLine": true, - "plugins": [ - "prettier-plugin-svelte" - ], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ] + "plugins": [ "prettier-plugin-svelte" ], + "overrides": [ { "files": "*.svelte", "options": { "parser": "svelte" } } ] } diff --git a/apps/web/src/lib/canvas/Canvas.svelte b/apps/web/src/lib/canvas/Canvas.svelte index 32984b9..297522f 100644 --- a/apps/web/src/lib/canvas/Canvas.svelte +++ b/apps/web/src/lib/canvas/Canvas.svelte @@ -13,6 +13,7 @@ } from '$lib/status'; import { ArrowTool, + Camera, CursorStore, EditorState, EllipseTool, @@ -20,6 +21,7 @@ LineTool, RectTool, SelectTool, + ShapeRecord, SnapshotCommand, Store, TextTool, @@ -41,7 +43,11 @@ let repo: ReturnType | null = null; let sink: PersistenceSink | null = null; let persistenceManager: ReturnType | null = null; - const fallbackStatusStore = createStatusStore({ backend: 'indexeddb', state: 'saved', pendingWrites: 0 }); + const fallbackStatusStore = createStatusStore({ + backend: 'indexeddb', + state: 'saved', + pendingWrites: 0 + }); let persistenceStatusStore = $state(fallbackStatusStore); let activeBoardId: string | null = null; @@ -57,6 +63,7 @@ const cursorStore = new CursorStore(); const snapStore: SnapStore = createSnapStore(); const pointerState = $state({ isPointerDown: false }); + const panState = $state({ isPanning: false, spaceHeld: false, lastScreen: { x: 0, y: 0 } }); const snapProvider = { get: () => snapStore.get() }; const cursorProvider = { get: () => cursorStore.getState() }; const pointerStateProvider = { get: () => pointerState }; @@ -67,8 +74,33 @@ store.setState((state) => ({ ...state, doc: { pages: doc.pages, shapes: doc.shapes, bindings: doc.bindings }, - ui: { ...state.ui, currentPageId: firstPageId } + ui: { ...state.ui, currentPageId: firstPageId, selectionIds: [] } })); + initializeSelection(firstPageId, doc); + } + + function initializeSelection(pageId: string | null, doc: LoadedDoc) { + if (!pageId) { + return; + } + const page = doc.pages[pageId]; + const firstShapeId = page?.shapeIds[0]; + if (!firstShapeId) { + return; + } + const state = store.getState(); + if (state.ui.selectionIds.length === 1 && state.ui.selectionIds[0] === firstShapeId) { + return; + } + const before = EditorState.clone(state); + const after = { ...state, ui: { ...state.ui, selectionIds: [firstShapeId] } }; + const command = new SnapshotCommand( + 'Initialize Selection', + 'ui', + before, + EditorState.clone(after) + ); + store.executeCommand(command); } const selectTool = new SelectTool(); @@ -98,6 +130,173 @@ historyViewerOpen = false; } + function handleBringForward() { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + const currentPageId = currentState.ui.currentPageId; + + if (selectedIds.length === 0 || !currentPageId) { + return; + } + + const before = EditorState.clone(currentState); + const page = currentState.doc.pages[currentPageId]; + if (!page) return; + + const newShapeIds = [...page.shapeIds]; + + for (const shapeId of selectedIds) { + const currentIndex = newShapeIds.indexOf(shapeId); + if (currentIndex !== -1 && currentIndex < newShapeIds.length - 1) { + [newShapeIds[currentIndex], newShapeIds[currentIndex + 1]] = [ + newShapeIds[currentIndex + 1], + newShapeIds[currentIndex] + ]; + } + } + + const after = { + ...currentState, + doc: { + ...currentState.doc, + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } + } + }; + + const command = new SnapshotCommand('Bring Forward', 'doc', before, EditorState.clone(after)); + store.executeCommand(command); + } + + function handleSendBackward() { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + const currentPageId = currentState.ui.currentPageId; + + if (selectedIds.length === 0 || !currentPageId) { + return; + } + + const before = EditorState.clone(currentState); + const page = currentState.doc.pages[currentPageId]; + if (!page) return; + + const newShapeIds = [...page.shapeIds]; + + for (let i = selectedIds.length - 1; i >= 0; i--) { + const shapeId = selectedIds[i]; + const currentIndex = newShapeIds.indexOf(shapeId); + if (currentIndex > 0) { + [newShapeIds[currentIndex], newShapeIds[currentIndex - 1]] = [ + newShapeIds[currentIndex - 1], + newShapeIds[currentIndex] + ]; + } + } + + const after = { + ...currentState, + doc: { + ...currentState.doc, + pages: { ...currentState.doc.pages, [currentPageId]: { ...page, shapeIds: newShapeIds } } + } + }; + + const command = new SnapshotCommand('Send Backward', 'doc', before, EditorState.clone(after)); + store.executeCommand(command); + } + + function handleDuplicate() { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + + if (selectedIds.length === 0) { + return; + } + + const before = EditorState.clone(currentState); + const newShapes = { ...currentState.doc.shapes }; + const newPages = { ...currentState.doc.pages }; + const duplicatedIds: string[] = []; + + const DUPLICATE_OFFSET = 20; + + for (const shapeId of selectedIds) { + const shape = currentState.doc.shapes[shapeId]; + if (!shape) continue; + + const cloned = ShapeRecord.clone(shape); + const newId = `shape:${crypto.randomUUID()}`; + const duplicated = { + ...cloned, + id: newId, + x: shape.x + DUPLICATE_OFFSET, + y: shape.y + DUPLICATE_OFFSET + }; + + newShapes[newId] = duplicated; + duplicatedIds.push(newId); + + const currentPageId = currentState.ui.currentPageId; + if (currentPageId) { + const page = newPages[currentPageId]; + if (page) { + newPages[currentPageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; + } + } + } + + const after = { + ...currentState, + doc: { ...currentState.doc, shapes: newShapes, pages: newPages }, + ui: { ...currentState.ui, selectionIds: duplicatedIds } + }; + + const command = new SnapshotCommand('Duplicate', 'doc', before, EditorState.clone(after)); + store.executeCommand(command); + } + + function handleNudge(arrowKey: string, largeNudge: boolean) { + const currentState = store.getState(); + const selectedIds = currentState.ui.selectionIds; + + if (selectedIds.length === 0) { + return; + } + + const nudgeDistance = largeNudge ? 10 : 1; + let deltaX = 0; + let deltaY = 0; + + switch (arrowKey) { + case 'ArrowLeft': + deltaX = -nudgeDistance; + break; + case 'ArrowRight': + deltaX = nudgeDistance; + break; + case 'ArrowUp': + deltaY = -nudgeDistance; + break; + case 'ArrowDown': + deltaY = nudgeDistance; + break; + } + + const before = EditorState.clone(currentState); + const newShapes = { ...currentState.doc.shapes }; + + for (const shapeId of selectedIds) { + const shape = newShapes[shapeId]; + if (shape) { + newShapes[shapeId] = { ...shape, x: shape.x + deltaX, y: shape.y + deltaY }; + } + } + + const after = { ...currentState, doc: { ...currentState.doc, shapes: newShapes } }; + const command = new SnapshotCommand('Nudge', 'doc', before, EditorState.clone(after)); + store.executeCommand(command); + } + function applyActionWithHistory(action: Action) { const before = store.getState(); const nextState = routeAction(before, action, tools); @@ -107,11 +306,52 @@ const kind = getCommandKind(before, nextState); const commandName = describeAction(action, kind); - const command = new SnapshotCommand(commandName, kind, EditorState.clone(before), EditorState.clone(nextState)); + const command = new SnapshotCommand( + commandName, + kind, + EditorState.clone(before), + EditorState.clone(nextState) + ); store.executeCommand(command); } function handleAction(action: Action) { + if (action.type === 'key-down' && action.key === ' ') { + panState.spaceHeld = true; + return; + } + + if (action.type === 'key-up' && action.key === ' ') { + panState.spaceHeld = false; + panState.isPanning = false; + return; + } + + if (action.type === 'pointer-down' && action.button === 0 && panState.spaceHeld) { + panState.isPanning = true; + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; + return; + } + + if (action.type === 'pointer-move' && panState.isPanning) { + const deltaX = action.screen.x - panState.lastScreen.x; + const deltaY = action.screen.y - panState.lastScreen.y; + const currentCamera = store.getState().camera; + const newCamera = Camera.pan(currentCamera, { x: deltaX, y: deltaY }); + store.setState((state) => ({ ...state, camera: newCamera })); + panState.lastScreen = { x: action.screen.x, y: action.screen.y }; + return; + } + + if (action.type === 'pointer-up' && action.button === 0 && panState.isPanning) { + panState.isPanning = false; + return; + } + + if (panState.isPanning || panState.spaceHeld) { + return; + } + const actionWithSnap = applySnapping(action); if (actionWithSnap.type === 'pointer-down' && actionWithSnap.button === 0) { @@ -124,7 +364,11 @@ return; } - if (actionWithSnap.type === 'pointer-move' && pointerState.isPointerDown && pendingCommandStart) { + if ( + actionWithSnap.type === 'pointer-move' && + pointerState.isPointerDown && + pendingCommandStart + ) { void applyImmediateAction(actionWithSnap); return; } @@ -145,15 +389,43 @@ (actionWithSnap.modifiers.meta && navigator.platform.toUpperCase().includes('MAC')) || (actionWithSnap.modifiers.ctrl && !navigator.platform.toUpperCase().includes('MAC')); - if (isPrimary && !actionWithSnap.modifiers.shift && (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z')) { + if ( + isPrimary && + !actionWithSnap.modifiers.shift && + (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') + ) { store.undo(); return; } - if (isPrimary && actionWithSnap.modifiers.shift && (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z')) { + if ( + isPrimary && + actionWithSnap.modifiers.shift && + (actionWithSnap.key === 'z' || actionWithSnap.key === 'Z') + ) { store.redo(); return; } + + if (isPrimary && (actionWithSnap.key === 'd' || actionWithSnap.key === 'D')) { + handleDuplicate(); + return; + } + + if (isPrimary && actionWithSnap.key === ']') { + handleBringForward(); + return; + } + + if (isPrimary && actionWithSnap.key === '[') { + handleSendBackward(); + return; + } + + if (actionWithSnap.key.startsWith('Arrow')) { + handleNudge(actionWithSnap.key, actionWithSnap.modifiers.shift); + return; + } } applyActionWithHistory(actionWithSnap); @@ -285,7 +557,11 @@ return store.getState().camera; } - renderer = createRenderer(canvas!, store, { snapProvider, cursorProvider, pointerStateProvider }); + renderer = createRenderer(canvas!, store, { + snapProvider, + cursorProvider, + pointerStateProvider + }); inputAdapter = createInputAdapter({ canvas: canvas!, getCamera, diff --git a/apps/web/src/lib/components/Dialog.svelte b/apps/web/src/lib/components/Dialog.svelte index 987ac5b..a367ae4 100644 --- a/apps/web/src/lib/components/Dialog.svelte +++ b/apps/web/src/lib/components/Dialog.svelte @@ -61,10 +61,10 @@ {#if open} -