diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index c596dd0..72d0314 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -15,6 +15,7 @@ import { LayerFocusProvider } from '../panels/left/LayerFocusContext'; import { LoadGate } from './LoadGate'; import { DirtyTracker } from './DirtyTracker'; import { Breadcrumb } from './Breadcrumb'; +import { ShortcutsModalHost } from '../panels/topbar/ShortcutsModal'; import { DeviceProvider } from '../state/DeviceContext'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; @@ -58,7 +59,7 @@ const EditorShellBody: React.FC = () => { const { menuState, show: showMenu, hide: hideMenu } = useContextMenu(); const { query } = useEditor(); - // Register keyboard shortcuts + // Register the core commands and mount the single keydown dispatcher. useKeyboardShortcuts(); const handleContextMenu = useCallback((e: React.MouseEvent) => { @@ -101,6 +102,7 @@ const EditorShellBody: React.FC = () => { {isMobile && } {isMobile && } + ; /** Not shown in the palette / menu (still runnable by shortcut and id). */ @@ -97,7 +99,7 @@ export function useCommandContext(): CommandContext { // Collect a string key (not an array) so the hook only re-renders when the // selection actually changes. const { actions, query, selKey } = useEditor((state) => ({ - selKey: Array.from(state.events.selected || []).join('\n'), + selKey: Array.from(state.events?.selected || []).join('\n'), })); const { device, setDevice } = useDevice(); const pages = usePages(); @@ -158,11 +160,25 @@ export function useRunCommand(): (id: string) => Promise { }, []); } +/** Keys that are also how focus / dialogs are driven: Tab walks focus and Esc + * dismisses. They only belong to the canvas when focus is on it (or the body), + * never while a button, link or dialog holds focus. */ +function focusOutsideCanvas(el: Element | null): boolean { + // An open modal owns Esc / Tab even when focus has not moved into it. + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return true; + if (!el || el === document.body) return false; + if (el.closest('[role="dialog"], [aria-modal="true"], .modal-backdrop')) return true; + return !!el.closest('button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"]'); +} + +function shortcutsOf(cmd: Command): string[] { + return cmd.shortcut ? [cmd.shortcut, ...(cmd.shortcuts ?? [])] : []; +} + /** * Document-level keydown dispatcher: first registered command whose shortcut - * matches and whose `when()` passes runs, with preventDefault. NOT mounted by - * W0-commands -- issue #39 mounts it (and retires useKeyboardShortcuts) so two - * listeners never double-fire. + * matches and whose `when()` passes runs, with preventDefault. Mounted once, by + * useKeyboardShortcuts (#39), so two listeners never double-fire. */ export function useCommandShortcuts(): void { const ctx = useCommandContext(); @@ -174,7 +190,9 @@ export function useCommandShortcuts(): void { const typing = isTypingTarget(document.activeElement); const c = liveContext(ref.current); for (const cmd of getCommands()) { - if (!cmd.shortcut || !matchShortcut(e, cmd.shortcut)) continue; + const hit = shortcutsOf(cmd).find((spec) => matchShortcut(e, spec)); + if (!hit) continue; + if ((hit === 'tab' || hit === 'shift+tab' || hit === 'escape') && focusOutsideCanvas(document.activeElement)) continue; if (typing && !cmd.allowWhileTyping) continue; if (!isEnabled(cmd, c)) continue; e.preventDefault(); diff --git a/craft/src/editor/core-commands.test.tsx b/craft/src/editor/core-commands.test.tsx new file mode 100644 index 0000000..2c64415 --- /dev/null +++ b/craft/src/editor/core-commands.test.tsx @@ -0,0 +1,111 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { SELECTION_STATE } from './selection-fixture'; +import { CORE_COMMANDS, topLevelSelection } from './core-commands'; +import { setClipboardTree } from '../hooks/clipboard'; +import type { Command, CommandContext } from './commands'; + +let h: EditorHarness; +beforeEach(() => { + setClipboardTree(null); + h = renderEditorHarness({ initialState: SELECTION_STATE }); +}); +afterEach(() => { h.unmount(); setClipboardTree(null); }); + +const cmd = (id: string): Command => CORE_COMMANDS.find((c) => c.id === id)!; +const ctx = (ids: string[], extra: Partial = {}): CommandContext => + ({ query: h.query, actions: h.actions, selectedIds: ids, setDevice: vi.fn(), ...extra }) as any; +const kids = (id: string) => h.query.node(id).get().data.nodes as string[]; +const NODE_CMDS = ['edit.delete', 'edit.duplicate', 'edit.copy', 'edit.move-up', 'edit.move-down']; + +describe('when() guards', () => { + test.each(NODE_CMDS)('%s is disabled with no selection or only ROOT selected', (id) => { + expect(cmd(id).when!(ctx([]))).toBe(false); + expect(cmd(id).when!(ctx(['ROOT']))).toBe(false); + expect(cmd(id).when!(ctx(['b1']))).toBe(true); + }); + + test('ROOT selected: running a node command changes nothing', () => { + const before = h.getSerialized(); + for (const id of NODE_CMDS) h.act(() => { cmd(id).run(ctx(['ROOT'])); }); + expect(h.getSerialized()).toBe(before); + }); + + test('paste requires a clipboard tree', () => { + expect(cmd('edit.paste').when!(ctx(['b1']))).toBe(false); + setClipboardTree(h.query.node('b1').toNodeTree()); + expect(cmd('edit.paste').when!(ctx(['b1']))).toBe(true); + }); + + test('undo / redo follow the history', () => { + expect(cmd('edit.redo').when!(ctx([]))).toBe(false); + h.act(() => { cmd('edit.delete').run(ctx(['b1'])); }); + expect(cmd('edit.undo').when!(ctx([]))).toBe(true); + h.act(() => { cmd('edit.undo').run(ctx([])); }); + expect(kids('col-0')).toEqual(['b1', 'b2']); + expect(cmd('edit.redo').when!(ctx([]))).toBe(true); + }); +}); + +describe('registry shape', () => { + test('ids and shortcuts', () => { + const spec = (id: string) => [cmd(id).shortcut, ...(cmd(id).shortcuts ?? [])]; + expect(spec('edit.undo')).toEqual(['mod+z']); + expect(spec('edit.redo')).toEqual(['mod+shift+z', 'mod+y']); + expect(spec('edit.delete')).toEqual(['delete', 'backspace']); + expect(spec('edit.duplicate')).toEqual(['mod+d']); + expect(spec('edit.move-up')).toEqual(['alt+arrowup']); + expect(spec('view.device-mobile')).toEqual(['3']); + expect(spec('help.shortcuts')[0]).toBe('mod+/'); + expect(new Set(CORE_COMMANDS.map((c) => c.id)).size).toBe(CORE_COMMANDS.length); + }); +}); + +describe('multi-select', () => { + test('topLevelSelection drops ROOT and nodes under another selected node', () => { + expect(topLevelSelection(h.query, ['ROOT', 'sec', 'b1', 'sec2'])).toEqual(['sec', 'sec2']); + }); + + test('a selected node plus its own child resolves to the ancestor only (a populated column is refused)', () => { + h.act(() => { cmd('edit.delete').run(ctx(['b1', 'col-0'])); }); + expect(kids('col-0')).toEqual(['b1', 'b2']); + }); + + test('delete of two siblings', () => { + h.act(() => { cmd('edit.delete').run(ctx(['b1', 'b2'])); }); + expect(kids('col-0')).toEqual([]); + }); + + test('duplicate copies each after its source and selects all copies', () => { + h.act(() => { cmd('edit.duplicate').run(ctx(['b1', 'b2'])); }); + const order = kids('col-0'); + expect(order).toHaveLength(4); + expect([order[0], order[2]]).toEqual(['b1', 'b2']); + expect(h.query.getEvent('selected').all().sort()).toEqual([order[1], order[3]].sort()); + }); + + test('move down keeps a selected run in order', () => { + h.act(() => { cmd('edit.duplicate').run(ctx(['b2'])); }); + const [, , c] = [0, 1, 2].map((i) => kids('col-0')[i]); + expect(kids('col-0')[2]).toBe(c); + h.act(() => { cmd('edit.move-down').run(ctx(['b1', 'b2'])); }); + // b2 was followed by its copy; b2 steps down first, then b1 + expect(kids('col-0')).toHaveLength(3); + expect(kids('col-0').indexOf('b1')).toBeGreaterThan(-1); + expect(kids('col-0').indexOf('b1')).toBeLessThan(kids('col-0').indexOf('b2')); + }); + + test('linked nodes (columns) are not duplicated or moved', () => { + const before = h.getSerialized(); + h.act(() => { cmd('edit.duplicate').run(ctx(['col-0'])); cmd('edit.move-down').run(ctx(['col-0'])); }); + expect(h.getSerialized()).toBe(before); + }); +}); + +describe('device commands', () => { + test('call setDevice with their mode', () => { + const setDevice = vi.fn(); + cmd('view.device-tablet').run(ctx([], { setDevice })); + expect(setDevice).toHaveBeenCalledWith('tablet'); + }); +}); diff --git a/craft/src/editor/core-commands.ts b/craft/src/editor/core-commands.ts new file mode 100644 index 0000000..266c939 --- /dev/null +++ b/craft/src/editor/core-commands.ts @@ -0,0 +1,227 @@ +import { registerCommands, type Command, type CommandContext } from './commands'; +import { findDeletableTarget, getAncestorIds, getParentId } from '../utils/craft-helpers'; +import { regenerateTreeIds } from '../utils/craft-tree'; +import { getClipboardTree, setClipboardTree } from '../hooks/clipboard'; +import type { DeviceMode } from '../types'; + +/** + * The editor's core keyboard commands (undo/redo, delete, duplicate, + * copy/paste, move, device, shortcuts help) as registry entries. The one + * dispatcher (`useCommandShortcuts`) runs them; the context menu and the + * cheat sheet read their labels from here. Node commands act on EVERY selected + * node (multi-select), skipping ROOT and any node whose ancestor is also + * selected (it travels with the ancestor). + */ + +/** Selected ids minus ROOT and minus descendants of other selected ids, in selection order. */ +export function topLevelSelection(query: any, ids: string[]): string[] { + const set = new Set(ids); + return ids.filter((id) => { + if (!id || id === 'ROOT') return false; + try { + return !getAncestorIds(query, id).some((a) => set.has(a)); + } catch { + return false; + } + }); +} + +const hasSelection = (ctx: CommandContext) => topLevelSelection(ctx.query, ctx.selectedIds).length > 0; + +function siblingsOf(query: any, id: string): { parentId: string; siblings: string[] } | null { + const parentId = getParentId(query, id); + if (!parentId) return null; + try { + return { parentId, siblings: query.node(parentId).get()?.data?.nodes || [] }; + } catch { + return null; + } +} + +/** Insert `tree` under `parentId` at `index` (append when omitted); returns its root id. */ +function insertTree(actions: any, tree: any, parentId: string, index?: number): string { + if (index !== undefined) actions.addNodeTree(tree, parentId, index); + else actions.addNodeTree(tree, parentId); + return tree.rootNodeId; +} + +function duplicateAll({ query, actions, selectedIds }: CommandContext): void { + const created: string[] = []; + for (const id of topLevelSelection(query, selectedIds)) { + try { + const sib = siblingsOf(query, id); + const idx = sib ? sib.siblings.indexOf(id) : -1; + // Linked nodes (columns, section inner) are not in `nodes` and cannot be copied alone. + if (!sib || idx === -1) continue; + const tree = regenerateTreeIds(query.node(id).toNodeTree()); + created.push(insertTree(actions, tree, sib.parentId, idx + 1)); + } catch (err) { + console.error('Duplicate failed:', err); + } + } + if (created.length) actions.selectNode(created.length === 1 ? created[0] : created); +} + +function deleteAll({ query, actions, selectedIds }: CommandContext): void { + const targets = new Set(); + for (const id of topLevelSelection(query, selectedIds)) { + const t = findDeletableTarget(query, id); + if (t) targets.add(t); + } + targets.forEach((t) => { + try { + actions.delete(t); + } catch (err) { + console.error('Delete failed:', err); + } + }); +} + +function pasteClipboard({ query, actions, selectedIds }: CommandContext): void { + const clip = getClipboardTree(); + if (!clip) return; + const sel = topLevelSelection(query, selectedIds); + let parentId = 'ROOT'; + let index: number | undefined; + const last = sel[sel.length - 1]; + if (last) { + const sib = siblingsOf(query, last); + if (sib) { + parentId = sib.parentId; + const idx = sib.siblings.indexOf(last); + if (idx !== -1) index = idx + 1; + } + } + const tree = regenerateTreeIds(clip); + actions.selectNode(insertTree(actions, tree, parentId, index)); +} + +/** One step among siblings. Moving down walks the selection in reverse so a run of + * adjacent nodes keeps its order; a node already at the edge stays put. */ +function moveAll({ query, actions, selectedIds }: CommandContext, dir: -1 | 1): void { + const ids = topLevelSelection(query, selectedIds); + for (const id of dir === 1 ? [...ids].reverse() : ids) { + try { + const sib = siblingsOf(query, id); + if (!sib) continue; + const idx = sib.siblings.indexOf(id); + if (idx === -1) continue; + if (dir === -1 && idx > 0) actions.move(id, sib.parentId, idx - 1); + // Craft's move index is the slot BEFORE removal, hence idx + 2. + if (dir === 1 && idx < sib.siblings.length - 1) actions.move(id, sib.parentId, idx + 2); + } catch (err) { + console.error('Move failed:', err); + } + } +} + +/** Cheat-sheet visibility: a module-level channel (like clipboard.ts) so a command can open it. */ +const shortcutsListeners = new Set<() => void>(); +export function subscribeShortcutsModal(cb: () => void): () => void { + shortcutsListeners.add(cb); + return () => { + shortcutsListeners.delete(cb); + }; +} +export function openShortcutsModal(): void { + shortcutsListeners.forEach((cb) => cb()); +} + +const deviceCommand = (device: DeviceMode, key: string): Command => ({ + id: `view.device-${device}`, + title: `${device.charAt(0).toUpperCase()}${device.slice(1)} view`, + group: 'view', + shortcut: key, + run: ({ setDevice }) => setDevice(device), +}); + +export const CORE_COMMANDS: Command[] = [ + { + id: 'edit.undo', + title: 'Undo', + group: 'edit', + shortcut: 'mod+z', + when: ({ query }) => query.history.canUndo(), + run: ({ actions }) => actions.history.undo(), + }, + { + id: 'edit.redo', + title: 'Redo', + group: 'edit', + shortcut: 'mod+shift+z', + shortcuts: ['mod+y'], + when: ({ query }) => query.history.canRedo(), + run: ({ actions }) => actions.history.redo(), + }, + { + id: 'edit.delete', + title: 'Delete', + group: 'edit', + shortcut: 'delete', + shortcuts: ['backspace'], + when: hasSelection, + run: deleteAll, + }, + { + id: 'edit.duplicate', + title: 'Duplicate', + group: 'edit', + shortcut: 'mod+d', + when: hasSelection, + run: duplicateAll, + }, + { + id: 'edit.copy', + title: 'Copy', + group: 'edit', + shortcut: 'mod+c', + when: hasSelection, + // A detached tree snapshot, not an id: it must survive a page switch (see clipboard.ts). + // v1: with several nodes selected, the first is copied. + run: ({ query, selectedIds }) => { + const [id] = topLevelSelection(query, selectedIds); + if (id) setClipboardTree(query.node(id).toNodeTree()); + }, + }, + { + id: 'edit.paste', + title: 'Paste', + group: 'edit', + shortcut: 'mod+v', + when: () => !!getClipboardTree(), + run: pasteClipboard, + }, + { + id: 'edit.move-up', + title: 'Move up', + group: 'edit', + shortcut: 'alt+arrowup', + when: hasSelection, + run: (ctx) => moveAll(ctx, -1), + }, + { + id: 'edit.move-down', + title: 'Move down', + group: 'edit', + shortcut: 'alt+arrowdown', + when: hasSelection, + run: (ctx) => moveAll(ctx, 1), + }, + deviceCommand('desktop', '1'), + deviceCommand('tablet', '2'), + deviceCommand('mobile', '3'), + { + id: 'help.shortcuts', + title: 'Keyboard shortcuts', + group: 'help', + shortcut: 'mod+/', + // '?' is Shift+/ on most layouts. + shortcuts: ['mod+shift+?', 'mod+shift+/'], + allowWhileTyping: true, + run: () => openShortcutsModal(), + }, +]; + +export function registerCoreCommands(): () => void { + return registerCommands(CORE_COMMANDS); +} diff --git a/craft/src/editor/selection-commands.test.tsx b/craft/src/editor/selection-commands.test.tsx index 3ffdf2f..e2249f7 100644 --- a/craft/src/editor/selection-commands.test.tsx +++ b/craft/src/editor/selection-commands.test.tsx @@ -78,7 +78,7 @@ describe('command definitions', () => { expect(byId['select.next-sibling'].shortcut).toBe('tab'); expect(byId['select.prev-sibling'].shortcut).toBe('shift+tab'); expect(byId['select.first-child'].shortcut).toBeUndefined(); - const ctx = (ids: string[]) => ({ selectedIds: ids }) as any; + const ctx = (ids: string[]) => ({ selectedIds: ids, query: h.query }) as any; for (const c of selectionCommands) { expect(c.group).toBe('select'); expect(c.when!(ctx(['b1']))).toBe(true); @@ -86,6 +86,10 @@ describe('command definitions', () => { expect(c.when!(ctx(['ROOT']))).toBe(false); expect(c.when!(ctx(['a', 'b']))).toBe(false); } + // Sibling cycling only claims Tab when it can move: a lone node ('cols') is left to the browser. + expect(byId['select.next-sibling'].when!(ctx(['cols']))).toBe(false); + expect(byId['select.prev-sibling'].when!(ctx(['cols']))).toBe(false); + expect(byId['select.parent'].when!(ctx(['cols']))).toBe(true); __resetCommandsForTests(); expect(getCommand('select.parent')).toBeUndefined(); }); diff --git a/craft/src/editor/selection-commands.ts b/craft/src/editor/selection-commands.ts index c78438a..b52d672 100644 --- a/craft/src/editor/selection-commands.ts +++ b/craft/src/editor/selection-commands.ts @@ -80,6 +80,11 @@ export function selectFirstChild(query: any, actions: any, id: string): string | const oneNonRootSelected = (ctx: { selectedIds: string[] }) => ctx.selectedIds.length === 1 && ctx.selectedIds[0] !== 'ROOT'; +// Sibling cycling only claims Tab when it will move the selection, so focus +// navigation still works with a lone node. +const hasSiblings = (ctx: { query: any; selectedIds: string[] }) => + oneNonRootSelected(ctx) && getSiblingIds(ctx.query, ctx.selectedIds[0]).length >= 2; + export const selectionCommands: Command[] = [ { id: 'select.parent', @@ -94,7 +99,7 @@ export const selectionCommands: Command[] = [ title: 'Select next sibling', group: 'select', shortcut: 'tab', - when: oneNonRootSelected, + when: hasSiblings, run: ({ query, actions, selectedIds }) => void selectNextSibling(query, actions, selectedIds[0]), }, { @@ -102,7 +107,7 @@ export const selectionCommands: Command[] = [ title: 'Select previous sibling', group: 'select', shortcut: 'shift+tab', - when: oneNonRootSelected, + when: hasSiblings, run: ({ query, actions, selectedIds }) => void selectPrevSibling(query, actions, selectedIds[0]), }, { diff --git a/craft/src/hooks/useKeyboardShortcuts.test.tsx b/craft/src/hooks/useKeyboardShortcuts.test.tsx index bf94eed..a6a5dd2 100644 --- a/craft/src/hooks/useKeyboardShortcuts.test.tsx +++ b/craft/src/hooks/useKeyboardShortcuts.test.tsx @@ -1,344 +1,262 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; -import { createRoot, Root } from 'react-dom/client'; -import { act } from 'react-dom/test-utils'; -import type { NodeTree, Node } from '@craftjs/core'; import { useKeyboardShortcuts } from './useKeyboardShortcuts'; import { getClipboardTree, setClipboardTree } from './clipboard'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { SELECTION_STATE } from '../editor/selection-fixture'; +import { registerSelectionCommands } from '../editor/selection-commands'; +import { __resetCommandsForTests, registerCommand } from '../editor/commands'; +import { DeviceProvider, useDevice } from '../state/DeviceContext'; +import { ShortcutsModalHost } from '../panels/topbar/ShortcutsModal'; /** - * Regression coverage: Ctrl/Cmd+V must run the copied subtree through - * `regenerateTreeIds` BEFORE handing it to `actions.addNodeTree`. Pasting - * with the source node's ORIGINAL ids re-inserted them into the live Craft.js - * node map, producing duplicate ids and corrupting the tree -- that bug is - * exactly what this suite guards against. Also covers the sibling-parent / - * ROOT-fallback targeting, the empty-clipboard no-op, and the existing - * input-focus guard. - * - * Also covers the cross-page clipboard fix: copy stores a detached TREE - * SNAPSHOT (`setClipboardTree`), not a bare node id -- so paste never needs - * to re-resolve the original node via `query.node(id)`, which is exactly - * what breaks once the canvas has been re-deserialized to a different page - * (see `hooks/clipboard.ts` and the cross-page integration test in - * `test-utils/integration/duplicate-paste.integration.test.tsx`). - * - * Mock pattern mirrors PageContext.pure-updaters.test.tsx / - * PageContext.slug.test.tsx: a fake `useEditor` exposing `query`/`actions`, - * mounted via a bare consumer component, with REAL `keydown` events - * dispatched on `document` (where useKeyboardShortcuts attaches its - * listener) to exercise the actual handler rather than calling it directly. - * - * `regenerateTreeIds` itself is NOT mocked -- we partially mock - * `../utils/craft-tree` to wrap the real implementation in a `vi.fn()` so we - * can assert it was called, while still getting genuinely fresh ids back. + * Keyboard layer on the REAL editor: every key goes through the one registry + * dispatcher (useCommandShortcuts), nothing is mocked in @craftjs/core. + * Fixture: ROOT > sec > sec-in > cols > col-0 (b1, b2) / col-1, plus sec2. */ -const addNodeTreeMock = vi.fn(); -const selectNodeMock = vi.fn(); -let selectedIds: string[] = []; +let h: EditorHarness; +let device = 'desktop'; -function makeNode(id: string, parent: string | null, children: string[] = []): Node { - return { - id, - data: { - props: {}, - type: { resolvedName: 'Container' }, - name: 'Container', - displayName: 'Container', - isCanvas: false, - parent, - linkedNodes: {}, - nodes: children, - hidden: false, - }, - info: {}, - events: { selected: false, dragged: false, hovered: false }, - dom: null, - related: {}, - rules: {}, - _hydrationTimestamp: 0, - } as unknown as Node; -} - -// The tree `toNodeTree()` returns for the node that was Ctrl+C'd -- a root -// with one child, so regeneration has more than one id to remap. -const COPIED_TREE: NodeTree = { - rootNodeId: 'copied-root-1', - nodes: { - 'copied-root-1': makeNode('copied-root-1', null, ['copied-child-1']), - 'copied-child-1': makeNode('copied-child-1', 'copied-root-1'), - }, -}; - -const nodeStore: Record = { - 'selected-1': { data: { parent: 'parent-container-1' } }, - 'parent-container-1': { data: { parent: null, nodes: ['other-sibling', 'selected-1'] } }, - ROOT: { data: { parent: null, nodes: [] } }, - 'copied-root-1': { data: { parent: 'wherever-it-originally-lived' } }, -}; - -function makeQueryNode(id: string) { - return { - get: () => nodeStore[id] ?? null, - toNodeTree: () => { - if (id !== 'copied-root-1') { - throw new Error(`unexpected toNodeTree() call for "${id}"`); - } - return COPIED_TREE; - }, - }; -} - -// Delegates to the REAL useEditor when `useRealEditor` is set (the selection -// navigation suite below runs on the real harness); otherwise the fake above. -let useRealEditor = false; -vi.mock('@craftjs/core', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - useEditor: (...args: any[]) => { - if (useRealEditor) return (actual.useEditor as any)(...args); - return { - query: { - getEvent: () => ({ all: () => selectedIds }), - node: (id: string) => makeQueryNode(id), - }, - actions: { - addNodeTree: addNodeTreeMock, - selectNode: selectNodeMock, - history: { undo: vi.fn(), redo: vi.fn() }, - delete: vi.fn(), - clearEvents: vi.fn(), - }, - }; - }, - }; -}); - -// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted -// trees genuinely get fresh ids) but wrap it in a spy so we can assert the -// handler actually calls it, rather than only inferring that from the -// output. -vi.mock('../utils/craft-tree', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - regenerateTreeIds: vi.fn(actual.regenerateTreeIds), - }; -}); - -import { regenerateTreeIds } from '../utils/craft-tree'; -const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds); - -let container: HTMLDivElement; -let root: Root; - -function render(ui: React.ReactElement) { - container = document.createElement('div'); - document.body.appendChild(container); - act(() => { - root = createRoot(container); - root.render(ui); - }); -} - -function unmount() { - act(() => { - root.unmount(); - }); - container.remove(); -} - -function pressKey(key: string, opts: Partial = {}) { - act(() => { - document.dispatchEvent( - new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true, ...opts }), - ); - }); -} - -const Consumer: React.FC = () => { +const Probe: React.FC = () => { useKeyboardShortcuts(); + device = useDevice().device; return null; }; beforeEach(() => { - selectedIds = []; - addNodeTreeMock.mockClear(); - selectNodeMock.mockClear(); - regenerateTreeIdsMock.mockClear(); + __resetCommandsForTests(); setClipboardTree(null); + device = 'desktop'; + h = renderEditorHarness({ + initialState: SELECTION_STATE, + wrap: (tree) => {tree}, + }); + registerSelectionCommands(); + h.mountChild(<>); }); afterEach(() => { + h.unmount(); + __resetCommandsForTests(); setClipboardTree(null); - if (root) unmount(); + vi.restoreAllMocks(); + (document.activeElement as HTMLElement | null)?.blur?.(); }); -describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => { - test('Ctrl+C copies the selected node`s subtree (a tree snapshot, not a bare id) to the clipboard', () => { - render(); - selectedIds = ['copied-root-1']; +const sel = () => h.query.getEvent('selected').all() as string[]; +const kids = (id: string) => h.query.node(id).get().data.nodes as string[]; +const key = (k: string, init: KeyboardEventInit = {}) => { + const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }); + h.act(() => { document.dispatchEvent(ev); }); + return ev; +}; +const mod = { ctrlKey: true }; - pressKey('c'); +function withFocus(el: T, fn: () => void) { + document.body.appendChild(el); + el.focus(); + try { fn(); } finally { el.remove(); } +} - const clip = getClipboardTree(); - expect(clip).not.toBeNull(); - expect(clip!.rootNodeId).toBe('copied-root-1'); - expect(Object.keys(clip!.nodes).sort()).toEqual(['copied-child-1', 'copied-root-1']); - }); +/** jsdom never reports isContentEditable, so stub the active element. */ +function withEditable(fn: () => void) { + const spy = vi.spyOn(document, 'activeElement', 'get') + .mockReturnValue({ tagName: 'H1', isContentEditable: true, closest: () => null } as unknown as Element); + try { fn(); } finally { spy.mockRestore(); } +} - test('Ctrl+V pastes as a sibling of the selection, immediately after it, with FRESH ids', () => { - render(); - - selectedIds = ['copied-root-1']; - pressKey('c'); - expect(getClipboardTree()!.rootNodeId).toBe('copied-root-1'); - - selectedIds = ['selected-1']; - pressKey('v'); - - // regenerateTreeIds actually ran before the tree was handed to Craft.js. - expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1); - expect(regenerateTreeIdsMock).toHaveBeenCalledWith(getClipboardTree()); - - expect(addNodeTreeMock).toHaveBeenCalledTimes(1); - const [pastedTree, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0]; - - // Sibling of the current selection: selected-1's data.parent. - expect(targetParent).toBe('parent-container-1'); - // Immediately after selected-1 (index 1 among parent-container-1's - // children), matching duplicate()'s "insert right after" UX. - expect(insertIndex).toBe(2); - - // The regression this guards: pasted ids must be fresh, never reuse the - // ids the copied node already occupies in the live Craft.js tree. - expect(pastedTree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId); - const originalIds = new Set(Object.keys(COPIED_TREE.nodes)); - const pastedIds = new Set(Object.keys(pastedTree.nodes)); - expect(pastedIds.size).toBe(originalIds.size); - for (const id of pastedIds) { - expect(originalIds.has(id)).toBe(false); - } - - // The new copy is selected, same as duplicate()'s existing UX. - expect(selectNodeMock).toHaveBeenCalledWith(pastedTree.rootNodeId); - }); - - test('Ctrl+V with selection at ROOT falls back to appending into ROOT', () => { - render(); - - selectedIds = ['copied-root-1']; - pressKey('c'); - - selectedIds = ['ROOT']; - pressKey('v'); - - expect(addNodeTreeMock).toHaveBeenCalledTimes(1); - const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0]; - expect(targetParent).toBe('ROOT'); - expect(insertIndex).toBeUndefined(); - }); - - test('Ctrl+V with nothing selected falls back to appending into ROOT', () => { - render(); - - selectedIds = ['copied-root-1']; - pressKey('c'); - - selectedIds = []; - pressKey('v'); - - expect(addNodeTreeMock).toHaveBeenCalledTimes(1); - const [, targetParent, insertIndex] = addNodeTreeMock.mock.calls[0]; - expect(targetParent).toBe('ROOT'); - expect(insertIndex).toBeUndefined(); - }); - - test('Ctrl+V with an empty clipboard is a no-op', () => { - render(); - selectedIds = ['selected-1']; - - pressKey('v'); - - expect(addNodeTreeMock).not.toHaveBeenCalled(); - expect(regenerateTreeIdsMock).not.toHaveBeenCalled(); - }); - - test('shortcuts are ignored while focus is in an input element', () => { - const input = document.createElement('input'); - document.body.appendChild(input); - input.focus(); - expect(document.activeElement).toBe(input); - - render(); - selectedIds = ['selected-1']; - - pressKey('c'); - expect(getClipboardTree()).toBeNull(); - - setClipboardTree(COPIED_TREE); - pressKey('v'); - expect(addNodeTreeMock).not.toHaveBeenCalled(); - - input.remove(); - }); - - test('shortcuts are ignored while focus is in a contentEditable element', () => { - // jsdom does not implement `HTMLElement.isContentEditable` (it always - // reports false regardless of the contentEditable attribute -- a known - // jsdom limitation), so a real contentEditable + focus() can't exercise - // this branch of the guard. Stub `document.activeElement` directly with - // a fake element that reports isContentEditable: true, matching what - // the guard (`isInputFocused` in useKeyboardShortcuts.ts) actually reads. - const fakeEditable = { tagName: 'DIV', isContentEditable: true } as unknown as Element; - const activeElementSpy = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(fakeEditable); - - render(); - selectedIds = ['selected-1']; - - pressKey('c'); - expect(getClipboardTree()).toBeNull(); - - activeElementSpy.mockRestore(); - }); -}); - - -// ---- Selection navigation (Esc = parent, Tab = siblings) on the REAL editor ---- - -import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; -import { SELECTION_STATE } from '../editor/selection-fixture'; - -describe('selection navigation (real editor)', () => { - let h: EditorHarness; - const Probe: React.FC = () => { useKeyboardShortcuts(); return null; }; - - beforeEach(() => { - useRealEditor = true; +describe('single dispatcher', () => { + test('mounting the shell hooks adds exactly one document keydown listener', () => { + h.unmount(); + __resetCommandsForTests(); + const spy = vi.spyOn(document, 'addEventListener'); h = renderEditorHarness({ initialState: SELECTION_STATE }); h.mountChild(); + expect(spy.mock.calls.filter(([t]) => t === 'keydown')).toHaveLength(1); }); - afterEach(() => { - h.unmount(); - useRealEditor = false; - (document.activeElement as HTMLElement | null)?.blur?.(); +}); + +describe('duplicate (mod+d)', () => { + test('inserts the copy right AFTER the source and selects it', () => { + h.act(() => h.actions.selectNode('b1')); + const ev = key('d', mod); + expect(ev.defaultPrevented).toBe(true); + const order = kids('col-0'); + expect(order).toHaveLength(3); + expect(order[0]).toBe('b1'); + expect(order[2]).toBe('b2'); + expect(sel()).toEqual([order[1]]); + }); +}); + +describe('move among siblings (alt+arrow)', () => { + test('alt+arrowdown moves b1 below b2; alt+arrowup moves it back', () => { + h.act(() => h.actions.selectNode('b1')); + key('ArrowDown', { altKey: true }); + expect(kids('col-0')).toEqual(['b2', 'b1']); + key('ArrowUp', { altKey: true }); + expect(kids('col-0')).toEqual(['b1', 'b2']); }); - const sel = () => h.query.getEvent('selected').all(); - const key = (k: string, init: KeyboardEventInit = {}) => { - const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }); - h.act(() => { document.dispatchEvent(ev); }); - return ev; - }; + test('at the edge it is a no-op', () => { + h.act(() => h.actions.selectNode('b1')); + key('ArrowUp', { altKey: true }); + expect(kids('col-0')).toEqual(['b1', 'b2']); + }); +}); +describe('device keys 1/2/3', () => { + test('switch the shared device', () => { + key('2'); + expect(device).toBe('tablet'); + key('3'); + expect(device).toBe('mobile'); + key('1'); + expect(device).toBe('desktop'); + }); + + test('are ignored while typing in an input', () => { + withFocus(document.createElement('input'), () => { + key('3'); + expect(device).toBe('desktop'); + }); + }); + + test('are ignored while a contentEditable heading is focused', () => { + withEditable(() => { + key('3'); + expect(device).toBe('desktop'); + }); + }); + + test('are not hijacked by modifier combos', () => { + key('3', mod); + expect(device).toBe('desktop'); + }); +}); + +describe('site commands (mod+s)', () => { + test('runs the registered site.save and prevents the browser Save dialog', () => { + const run = vi.fn(); + registerCommand({ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, run }); + const ev = key('s', mod); + expect(run).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(true); + }); + + test('still fires with a text field focused', () => { + const run = vi.fn(); + registerCommand({ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, run }); + withFocus(document.createElement('input'), () => { key('s', mod); }); + expect(run).toHaveBeenCalledTimes(1); + }); +}); + +describe('cheat sheet (mod+/)', () => { + const dialog = () => document.body.querySelector('[role="dialog"]'); + + test('mod+/ opens it, listing the live commands; Escape closes it', () => { + expect(dialog()).toBeNull(); + key('/', mod); + expect(dialog()).not.toBeNull(); + expect(dialog()!.textContent).toContain('Keyboard shortcuts'); + expect(dialog()!.textContent).toContain('Duplicate'); + key('Escape'); + expect(dialog()).toBeNull(); + }); + + test('mod+shift+? opens it too', () => { + key('?', { ...mod, shiftKey: true }); + expect(dialog()).not.toBeNull(); + }); + + test('Esc closing the dialog does not also climb the canvas selection', () => { + h.act(() => h.actions.selectNode('b1')); + key('/', mod); + key('Escape'); + expect(sel()).toEqual(['b1']); + }); +}); + +describe('copy / paste (mod+c / mod+v)', () => { + test('copy stores a detached tree snapshot of the selection', () => { + h.act(() => h.actions.selectNode('b1')); + key('c', mod); + const clip = getClipboardTree(); + expect(clip!.rootNodeId).toBe('b1'); + }); + + test('paste lands right after the selected node with FRESH ids, and selects it', () => { + h.act(() => h.actions.selectNode('b1')); + key('c', mod); + key('v', mod); + const order = kids('col-0'); + expect(order).toHaveLength(3); + expect(order[0]).toBe('b1'); + expect(order[1]).not.toBe('b1'); + expect(new Set(order).size).toBe(3); + expect(sel()).toEqual([order[1]]); + }); + + test('paste with nothing selected appends to ROOT', () => { + h.act(() => h.actions.selectNode('b1')); + key('c', mod); + h.act(() => h.actions.clearEvents()); + key('v', mod); + const rootKids = kids('ROOT'); + expect(rootKids).toHaveLength(3); + expect(rootKids.slice(0, 2)).toEqual(['sec', 'sec2']); + }); + + test('paste with an empty clipboard is a no-op and not prevented', () => { + h.act(() => h.actions.selectNode('b1')); + const ev = key('v', mod); + expect(kids('col-0')).toEqual(['b1', 'b2']); + expect(ev.defaultPrevented).toBe(false); + }); + + test('ignored while focus is in an input', () => { + h.act(() => h.actions.selectNode('b1')); + withFocus(document.createElement('input'), () => { key('c', mod); }); + expect(getClipboardTree()).toBeNull(); + }); + + test('ignored while a contentEditable is focused', () => { + h.act(() => h.actions.selectNode('b1')); + withEditable(() => { key('c', mod); }); + expect(getClipboardTree()).toBeNull(); + }); +}); + +describe('delete / undo / redo', () => { + test('Delete removes the selection; mod+z restores; mod+y redoes', () => { + h.act(() => h.actions.selectNode('b1')); + key('Delete'); + expect(kids('col-0')).toEqual(['b2']); + key('z', mod); + expect(kids('col-0')).toEqual(['b1', 'b2']); + key('y', mod); + expect(kids('col-0')).toEqual(['b2']); + key('z', mod); + key('z', { ...mod, shiftKey: true }); + expect(kids('col-0')).toEqual(['b2']); + }); + + test('Backspace in an input does not delete the node', () => { + h.act(() => h.actions.selectNode('b1')); + withFocus(document.createElement('input'), () => { key('Backspace'); }); + expect(kids('col-0')).toEqual(['b1', 'b2']); + }); +}); + +describe('selection navigation', () => { test('Esc climbs button -> column -> columns -> section, then clears; never ROOT', () => { h.act(() => h.actions.selectNode('b1')); const seen: string[][] = []; for (let i = 0; i < 4; i++) { key('Escape'); seen.push(sel()); } expect(seen).toEqual([['col-0'], ['cols'], ['sec'], []]); key('Escape'); - expect(sel()).toEqual([]); expect(sel()).not.toContain('ROOT'); }); @@ -350,18 +268,36 @@ describe('selection navigation (real editor)', () => { expect(sel()).toEqual(['b1']); key('Tab', { shiftKey: true }); expect(sel()).toEqual(['b2']); - key('Tab', { shiftKey: true }); - expect(sel()).toEqual(['b1']); }); test('Tab is not intercepted with an focused', () => { h.act(() => h.actions.selectNode('b1')); - const input = document.createElement('input'); - document.body.appendChild(input); - input.focus(); - const ev = key('Tab'); - input.remove(); - expect(ev.defaultPrevented).toBe(false); + withFocus(document.createElement('input'), () => { + expect(key('Tab').defaultPrevented).toBe(false); + }); + expect(sel()).toEqual(['b1']); + }); + + test('Tab is not intercepted while a