diff --git a/canvas/src/components/canvas/useKeyboardShortcuts.ts b/canvas/src/components/canvas/useKeyboardShortcuts.ts index 68a4e15b..2612f51c 100644 --- a/canvas/src/components/canvas/useKeyboardShortcuts.ts +++ b/canvas/src/components/canvas/useKeyboardShortcuts.ts @@ -2,6 +2,13 @@ import { useEffect } from "react"; import { useCanvasStore } from "@/store/canvas"; +import { type NodeChange, type Node } from "@xyflow/react"; +import type { WorkspaceNodeData } from "@/store/canvas"; + +/** Returns true if the node has any direct child in the node list. */ +function hasChildren(nodeId: string, nodes: Node[]): boolean { + return nodes.some((n) => n.data.parentId === nodeId); +} /** * Canvas-wide keyboard shortcuts. All bound to the document window so @@ -15,6 +22,8 @@ import { useCanvasStore } from "@/store/canvas"; * Cmd/Ctrl+[ — bump selected node backward in z-order * Z — zoom-to-team if the selected node has children * Arrow keys — move selected node 10px (50px with Shift) + * Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width) + * Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control) */ export function useKeyboardShortcuts() { useEffect(() => { @@ -84,9 +93,14 @@ export function useKeyboardShortcuts() { // Arrow-key node movement — Figma-style keyboard drag for keyboard users. // 10 px per press, 50 px with Shift held. Only fires when a node - // is selected and the target isn't a form control. + // is selected and the target isn't a form control. Skipped when a + // modifier key (Cmd/Ctrl/Alt) is held so those combos can be used + // for other shortcuts (e.g. Cmd+Arrow = resize). if ( !inInput && + !e.metaKey && + !e.ctrlKey && + !e.altKey && (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "ArrowLeft" || @@ -108,6 +122,44 @@ export function useKeyboardShortcuts() { else dx = step; state.moveNode(selectedId, dx, dy); } + + // Cmd/Ctrl+Arrow — keyboard-accessible node resize. + // ↑/↓ resizes height, ←/→ resizes width. + // 10 px per press (2 px with Shift for fine control). + // Uses the same onNodesChange('dimensions') path that NodeResizer uses. + if ( + !inInput && + (e.metaKey || e.ctrlKey) && + (e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight") + ) { + const state = useCanvasStore.getState(); + const selectedId = state.selectedNodeId; + if (!selectedId) return; + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; + e.preventDefault(); + const step = e.shiftKey ? 2 : 10; + const node = state.nodes.find((n) => n.id === selectedId); + if (!node) return; + const currentWidth = (node.width ?? 210) as number; + const currentHeight = (node.height ?? 110) as number; + const minWidth = hasChildren(node.id, state.nodes) ? 360 : 210; + const minHeight = hasChildren(node.id, state.nodes) ? 200 : 110; + let newWidth = currentWidth; + let newHeight = currentHeight; + if (e.key === "ArrowUp") newHeight = Math.max(minHeight, currentHeight - step); + else if (e.key === "ArrowDown") newHeight = currentHeight + step; + else if (e.key === "ArrowLeft") newWidth = Math.max(minWidth, currentWidth - step); + else newWidth = currentWidth + step; + const change: NodeChange = { + type: "dimensions", + id: selectedId, + dimensions: { width: newWidth, height: newHeight }, + }; + state.onNodesChange([change]); + } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); diff --git a/docs/design-system/canvas-audit-items.md b/docs/design-system/canvas-audit-items.md index 42414146..216fc981 100644 --- a/docs/design-system/canvas-audit-items.md +++ b/docs/design-system/canvas-audit-items.md @@ -56,7 +56,7 @@ canvas/src/ - **Framework:** `@xyflow/react` (React Flow) — DOM-based, not SVG/Canvas - **Node selection:** `aria-pressed` + border ring (`border-accent/70`) + shadow - **Node drag:** React Flow native drag + Arrow keys (10px/step, Shift 50px) — keyboard-accessible (PR #182) ✅ -- **Node resize:** `NodeResizer` component visible on selected card, keyboard-inaccessible +- **Node resize:** `NodeResizer` component visible on selected card; `Cmd/Ctrl+Arrow` keys resize (↑↓ height, ←→ width, 10px/step, Shift 2px) — keyboard-accessible ✅ - **Status:** Accessible via `aria-label` on node cards — "Alpha Workspace workspace — online" ### Edge Wiring ✅ @@ -76,7 +76,8 @@ canvas/src/ - All shortcuts in `useKeyboardShortcuts.ts` with `inInput` guard ✅ - Global `?` shortcut opens `KeyboardShortcutsDialog` (PR #175) ✅ - Dialog: portal-based, aria-modal, focus trap, Escape close ✅ -- Arrow keys move selected node 10px (50px with Shift) — keyboard node drag (this PR) ✅ +- Arrow keys move selected node 10px (50px with Shift) — keyboard node drag (PR #182) ✅ +- `Cmd/Ctrl+Arrow` resize selected node (↑↓ height, ←→ width, 10px, Shift 2px) ✅ - Hierarchy navigation (Enter/Shift+Enter), z-order (Cmd+]/[), zoom-to-team (Z) ✅ ### Focus Management ✅ (strong) @@ -112,7 +113,7 @@ canvas/src/ | MEDIUM | Keyboard shortcut help dialog | useKeyboardShortcuts.ts | ✅ Done (PR #175) | | MEDIUM | Keyboard-accessible node drag | WorkspaceNode.tsx, useDragHandlers.ts | ✅ Done (this PR) | | LOW | Keyboard-accessible edge anchors | A2AEdge.tsx, WorkspaceNode.tsx | ✅ Done | -| LOW | Node resize keyboard accessibility | WorkspaceNode.tsx (NodeResizer) | Not started | +| LOW | Keyboard-accessible node resize | useKeyboardShortcuts.ts, WorkspaceNode.tsx | ✅ Done | ---