Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7351b647be | |||
| e463253c43 |
@@ -16,6 +16,14 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
keys: ["Esc"],
|
||||
description: "Close context menu, clear selection, or deselect",
|
||||
},
|
||||
{
|
||||
keys: ["↑↓←→"],
|
||||
description: "Nudge selected node 10px; hold Shift for 50px",
|
||||
},
|
||||
{
|
||||
keys: ["Cmd", "↑↓←→"],
|
||||
description: "Resize selected node (↑↓ height, ←→ width); hold Shift for fine control (2px)",
|
||||
},
|
||||
{
|
||||
keys: ["Enter"],
|
||||
description: "Descend into selected node's first child",
|
||||
@@ -177,7 +185,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
|
||||
<div className="overflow-y-auto p-5 space-y-5">
|
||||
{SHORTCUT_GROUPS.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft mb-2.5">
|
||||
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-mid mb-2.5">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
@@ -193,7 +201,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
|
||||
{shortcut.keys.map((k, j) => (
|
||||
<span key={j} className="flex items-center gap-0.5">
|
||||
{j > 0 && (
|
||||
<span className="text-[9px] text-ink-soft mx-0.5">
|
||||
<span className="text-[9px] text-ink-mid mx-0.5">
|
||||
+
|
||||
</span>
|
||||
)}
|
||||
@@ -212,7 +220,7 @@ export function KeyboardShortcutsDialog({ open, onClose }: Props) {
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-line bg-surface-sunken/30 shrink-0">
|
||||
<p className="text-[10px] text-ink-soft text-center">
|
||||
<p className="text-[10px] text-ink-mid text-center">
|
||||
Press{" "}
|
||||
<kbd className="inline-flex items-center rounded border border-line/70 bg-surface-sunken/70 px-1.5 py-0.5 text-[10px] font-medium text-ink font-mono">
|
||||
Esc
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { useKeyboardShortcuts } from "../canvas/useKeyboardShortcuts";
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
// Must be declared before the hook import so vi.mock hoisting covers it.
|
||||
const onNodesChangeMock = vi.fn();
|
||||
const selectNodeMock = vi.fn();
|
||||
const clearSelectionMock = vi.fn();
|
||||
const closeContextMenuMock = vi.fn();
|
||||
const bumpZOrderMock = vi.fn();
|
||||
|
||||
const mockStore = {
|
||||
selectedNodeId: "ws-1" as string | null,
|
||||
selectedNodeIds: new Set<string>(),
|
||||
nodes: [
|
||||
{ id: "ws-1", position: { x: 100, y: 200 }, data: { parentId: null } },
|
||||
] as Array<{ id: string; position: { x: number; y: number }; data: { parentId: string | null } }>,
|
||||
contextMenu: null,
|
||||
closeContextMenu: closeContextMenuMock,
|
||||
selectNode: selectNodeMock,
|
||||
clearSelection: clearSelectionMock,
|
||||
onNodesChange: onNodesChangeMock,
|
||||
bumpZOrder: bumpZOrderMock,
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector?: (s: typeof mockStore) => unknown) =>
|
||||
selector ? selector(mockStore) : mockStore,
|
||||
{ getState: () => mockStore }
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Test harness ──────────────────────────────────────────────────────────────
|
||||
// Renders a component that mounts the hook. The hook registers its window
|
||||
// keydown listener via useEffect, so the event must fire AFTER render.
|
||||
function TestHarness() {
|
||||
useKeyboardShortcuts();
|
||||
return null;
|
||||
}
|
||||
|
||||
function dispatchKeydown(
|
||||
key: string,
|
||||
opts: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean } = {}
|
||||
) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...opts })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useKeyboardShortcuts — arrow-key node movement", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStore.selectedNodeId = "ws-1";
|
||||
mockStore.nodes = [
|
||||
{ id: "ws-1", position: { x: 100, y: 200 }, data: { parentId: null } },
|
||||
];
|
||||
});
|
||||
|
||||
it("nudges node right by 20px on ArrowRight", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight");
|
||||
expect(onNodesChangeMock).toHaveBeenCalledTimes(1);
|
||||
const changesArr = onNodesChangeMock.mock.calls[0][0] as unknown[];
|
||||
const change = changesArr[0] as { type: string; id: string; position: { x: number; y: number }; dragging: boolean };
|
||||
expect(change).toMatchObject({ type: "position", id: "ws-1" });
|
||||
expect(change.position).toEqual({ x: 120, y: 200 });
|
||||
});
|
||||
|
||||
// Helper: extracts the first change object from the most recent mock call
|
||||
const getLastChange = () => {
|
||||
const lastArgs = onNodesChangeMock.mock.calls.at(-1)!;
|
||||
const changesArr = lastArgs[0] as unknown[];
|
||||
return changesArr[0] as { type: string; id: string; position: { x: number; y: number }; dragging: boolean };
|
||||
};
|
||||
|
||||
it("nudges node left by 20px on ArrowLeft", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowLeft");
|
||||
expect(getLastChange().position).toEqual({ x: 80, y: 200 });
|
||||
});
|
||||
|
||||
it("nudges node down by 20px on ArrowDown", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowDown");
|
||||
expect(getLastChange().position).toEqual({ x: 100, y: 220 });
|
||||
});
|
||||
|
||||
it("nudges node up by 20px on ArrowUp", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowUp");
|
||||
expect(getLastChange().position).toEqual({ x: 100, y: 180 });
|
||||
});
|
||||
|
||||
it("uses 100px step when Shift is held", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight", { shiftKey: true });
|
||||
expect(getLastChange().position).toEqual({ x: 200, y: 200 });
|
||||
});
|
||||
|
||||
it("composes nudges: second press uses updated store position", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight");
|
||||
// Simulate the store having updated after first nudge
|
||||
mockStore.nodes[0].position.x = 120;
|
||||
dispatchKeydown("ArrowRight");
|
||||
// The second nudge should start from x=120, adding 20 → x=140
|
||||
const lastChange = getLastChange();
|
||||
expect(lastChange.position).toEqual({ x: 140, y: 200 });
|
||||
});
|
||||
|
||||
it("does not call onNodesChange when no node is selected", () => {
|
||||
mockStore.selectedNodeId = null;
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight");
|
||||
expect(onNodesChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call onNodesChange when selected node is not in nodes list", () => {
|
||||
mockStore.selectedNodeId = "ws-nonexistent";
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight");
|
||||
expect(onNodesChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits dragging: false on the position change", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight");
|
||||
expect(getLastChange().dragging).toBe(false);
|
||||
});
|
||||
|
||||
it("emits type: 'position' discriminator", () => {
|
||||
render(<TestHarness />);
|
||||
dispatchKeydown("ArrowRight");
|
||||
expect(getLastChange().type).toBe("position");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { NodeChange } from "@xyflow/react";
|
||||
|
||||
// px per arrow-key press. Shift doubles it for large moves.
|
||||
const NUDGE_PX = 20;
|
||||
const NUDGE_PX_FAST = 100;
|
||||
|
||||
/**
|
||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||
@@ -9,6 +14,7 @@ import { useCanvasStore } from "@/store/canvas";
|
||||
* into an input (`inInput` short-circuits handling).
|
||||
*
|
||||
* Esc — close context menu, clear selection, deselect
|
||||
* Arrow keys — nudge selected node by 20px; Shift doubles to 100px
|
||||
* Enter — descend into selected node's first child
|
||||
* Shift+Enter — ascend to selected node's parent
|
||||
* Cmd/Ctrl+] — bump selected node forward in z-order
|
||||
@@ -65,6 +71,32 @@ export function useKeyboardShortcuts() {
|
||||
state.bumpZOrder(id, e.key === "]" ? 1 : -1);
|
||||
}
|
||||
|
||||
// Arrow-key node movement — Figma/design-tool parity for keyboard users.
|
||||
// Moves the selected node by NUDGE_PX (20px); Shift doubles it (100px).
|
||||
if (!inInput && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
const state = useCanvasStore.getState();
|
||||
const id = state.selectedNodeId;
|
||||
if (!id) return;
|
||||
const step = e.shiftKey ? NUDGE_PX_FAST : NUDGE_PX;
|
||||
const dx =
|
||||
e.key === "ArrowLeft" ? -step :
|
||||
e.key === "ArrowRight" ? step : 0;
|
||||
const dy =
|
||||
e.key === "ArrowUp" ? -step :
|
||||
e.key === "ArrowDown" ? step : 0;
|
||||
const node = state.nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
const newPosition = { x: node.position.x + dx, y: node.position.y + dy };
|
||||
const change: NodeChange = {
|
||||
type: "position",
|
||||
id,
|
||||
position: newPosition,
|
||||
dragging: false,
|
||||
};
|
||||
state.onNodesChange([change]);
|
||||
}
|
||||
|
||||
if (!inInput && (e.key === "z" || e.key === "Z")) {
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
|
||||
Reference in New Issue
Block a user