Merge issue-39 (Refs #39)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:58:26 -07:00
commit 2b41cd46ba
13 files changed
+861 -513

No files matched your search

+3 -1
View File
@@ -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 = () => {
</div>
{isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ShortcutsModalHost />
<ContextMenu
visible={menuState.visible}
x={menuState.x}
+23 -5
View File
@@ -36,6 +36,8 @@ export interface Command {
keywords?: string[];
/** Shortcut spec string, see utils/shortcuts.ts ('mod+d', 'alt+arrowup'). */
shortcut?: string;
/** Extra shortcut specs that trigger the same command ('mod+y' for redo). */
shortcuts?: string[];
when?(ctx: CommandContext): boolean;
run(ctx: CommandContext): void | Promise<void>;
/** 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<boolean> {
}, []);
}
/** 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();
+111
View File
@@ -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> = {}): 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');
});
});
+227
View File
@@ -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<string>();
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);
}
+5 -1
View File
@@ -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();
});
+7 -2
View File
@@ -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]),
},
{
+249 -314
View File
@@ -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<string, { data: { parent: string | null; nodes?: string[] } }> = {
'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<typeof import('@craftjs/core')>();
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<typeof import('../utils/craft-tree')>();
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<KeyboardEventInit> = {}) {
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) => <DeviceProvider>{tree}</DeviceProvider>,
});
registerSelectionCommands();
h.mountChild(<><Probe /><ShortcutsModalHost /></>);
});
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(<Consumer />);
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<T extends HTMLElement>(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(<Consumer />);
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(<Consumer />);
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(<Consumer />);
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(<Consumer />);
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(<Consumer />);
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(<Consumer />);
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(<Probe />);
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 <input> 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 <button> (breadcrumb crumb, panel button) has focus', () => {
h.act(() => h.actions.selectNode('b1'));
withFocus(document.createElement('button'), () => {
expect(key('Tab').defaultPrevented).toBe(false);
expect(key('Tab', { shiftKey: true }).defaultPrevented).toBe(false);
});
expect(sel()).toEqual(['b1']);
});
test('Tab is not intercepted for focus inside a dialog', () => {
h.act(() => h.actions.selectNode('b1'));
const dlg = document.createElement('div');
dlg.setAttribute('role', 'dialog');
const inner = document.createElement('div');
inner.tabIndex = 0;
dlg.appendChild(inner);
document.body.appendChild(dlg);
inner.focus();
expect(key('Tab').defaultPrevented).toBe(false);
dlg.remove();
expect(sel()).toEqual(['b1']);
});
@@ -369,6 +305,5 @@ describe('selection navigation (real editor)', () => {
expect(key('Tab').defaultPrevented).toBe(false);
h.act(() => h.actions.selectNode('cols'));
expect(key('Tab').defaultPrevented).toBe(false);
expect(sel()).toEqual(['cols']);
});
});
+10 -186
View File
@@ -1,189 +1,13 @@
import { useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers';
import { regenerateTreeIds } from '../utils/craft-tree';
import { escapeSelection, selectNextSibling, selectPrevSibling } from '../editor/selection-commands';
import { getClipboardTree, setClipboardTree } from './clipboard';
import { useCommandShortcuts } from '../editor/commands';
import { registerCoreCommands } from '../editor/core-commands';
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if ((el as HTMLElement).isContentEditable) return true;
return false;
}
export function useKeyboardShortcuts() {
const { actions, query } = useEditor((state) => {
const selectedIds = state.events.selected;
const selectedId = selectedIds ? Array.from(selectedIds)[0] : null;
return { selectedId };
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip when typing in inputs
if (isInputFocused()) return;
const ctrl = e.ctrlKey || e.metaKey;
// Ctrl+Z: Undo
if (ctrl && !e.shiftKey && e.key === 'z') {
e.preventDefault();
try {
actions.history.undo();
} catch (err) {
// No more undo steps
}
return;
}
// Ctrl+Y or Ctrl+Shift+Z: Redo
if ((ctrl && e.key === 'y') || (ctrl && e.shiftKey && e.key === 'z') || (ctrl && e.shiftKey && e.key === 'Z')) {
e.preventDefault();
try {
actions.history.redo();
} catch (err) {
// No more redo steps
}
return;
}
// Delete/Backspace: delete selected node
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0) {
const target = findDeletableTarget(query, selected[0]);
if (target) actions.delete(target);
}
} catch (err) {
console.error('Delete failed:', err);
}
return;
}
// Ctrl+D: duplicate selected
if (ctrl && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0) {
const nodeId = selected[0];
if (nodeId !== 'ROOT') {
const node = query.node(nodeId).get();
const parentId = node?.data?.parent;
if (parentId) {
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
actions.addNodeTree(tree, parentId);
}
}
}
} catch (err) {
console.error('Duplicate failed:', err);
}
return;
}
// Ctrl+C: copy the selected node's subtree (a detached snapshot, not
// just its id -- see clipboard.ts for why: an id-based clipboard can't
// survive a page switch, since the copied id no longer exists in
// `query` once the canvas is re-deserialized to a different page).
if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
setClipboardTree(query.node(selected[0]).toNodeTree());
}
} catch (err) {
console.error('Copy failed:', err);
}
return;
}
// Ctrl+V: paste the clipboard tree as a sibling of the current
// selection (immediately after it, matching duplicate()'s UX), or
// append to ROOT when nothing is selected. Works regardless of which
// page is currently on the canvas -- the clipboard tree is a detached
// snapshot, not a reference to a node that may no longer exist here.
if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
try {
const clip = getClipboardTree();
if (!clip) return;
const selected = query.getEvent('selected').all();
const selectedId = selected.length > 0 ? selected[0] : null;
let targetParentId = 'ROOT';
let insertIndex: number | undefined;
if (selectedId && selectedId !== 'ROOT') {
const node = query.node(selectedId).get();
const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
targetParentId = parentId;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) insertIndex = idx + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
}
}
const tree = regenerateTreeIds(clip);
if (insertIndex !== undefined) {
actions.addNodeTree(tree, targetParentId, insertIndex);
} else {
actions.addNodeTree(tree, targetParentId);
}
actions.selectNode(tree.rootNodeId);
} catch (err) {
console.error('Paste failed:', err);
}
return;
}
// Escape: select the parent; at the top level (parent is ROOT) clear.
if (e.key === 'Escape') {
e.preventDefault();
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
escapeSelection(query, actions, selected[0]);
} else {
actions.clearEvents();
}
} catch (err) {
// Ignore
}
return;
}
// Tab / Shift+Tab: cycle siblings of the selected node (wraps). Only
// intercepted when it will actually move the selection, so focus
// navigation still works with nothing selected or a lone node.
if (e.key === 'Tab' && !ctrl && !e.altKey) {
try {
const selected = query.getEvent('selected').all();
if (selected.length > 0 && selected[0] !== 'ROOT') {
const moved = e.shiftKey
? selectPrevSibling(query, actions, selected[0])
: selectNextSibling(query, actions, selected[0]);
if (moved) e.preventDefault();
}
} catch (err) {
// Ignore
}
return;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [actions, query]);
/**
* Registers the core editor commands and mounts the single document keydown
* dispatcher. Every shortcut is a registry command (editor/core-commands.ts,
* selection-commands.ts, TopBar's site.*); this hook owns no key handling.
*/
export function useKeyboardShortcuts(): void {
useEffect(() => registerCoreCommands(), []);
useCommandShortcuts();
}
+14 -4
View File
@@ -4,6 +4,8 @@ import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { getCommand } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps {
@@ -25,6 +27,12 @@ interface MenuItem {
dividerAfter?: boolean;
}
/** Shortcut label for a registry command, so the menu never drifts from the keymap. */
const shortcutLabel = (id: string): string | undefined => {
const spec = getCommand(id)?.shortcut;
return spec ? formatShortcut(spec) : undefined;
};
export const ContextMenu: React.FC<ContextMenuProps> = ({
visible,
x,
@@ -179,21 +187,21 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
{
label: 'Duplicate',
icon: 'clone',
shortcut: 'Ctrl+D',
shortcut: shortcutLabel('edit.duplicate'),
action: duplicate,
disabled: isRoot,
},
{
label: 'Copy',
icon: 'files-o',
shortcut: 'Ctrl+C',
shortcut: shortcutLabel('edit.copy'),
action: copyNode,
disabled: isRoot,
},
{
label: 'Paste',
icon: 'clipboard',
shortcut: 'Ctrl+V',
shortcut: shortcutLabel('edit.paste'),
action: pasteNode,
disabled: !getClipboardTree(),
dividerAfter: true,
@@ -201,12 +209,14 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
{
label: 'Move Up',
icon: 'arrow-up',
shortcut: shortcutLabel('edit.move-up'),
action: moveUp,
disabled: isRoot,
},
{
label: 'Move Down',
icon: 'arrow-down',
shortcut: shortcutLabel('edit.move-down'),
action: moveDown,
disabled: isRoot,
},
@@ -225,7 +235,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
{
label: 'Delete',
icon: 'trash',
shortcut: 'Del',
shortcut: shortcutLabel('edit.delete'),
action: deleteNode,
danger: true,
disabled: isRoot,
@@ -0,0 +1,66 @@
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { ShortcutsModal } from './ShortcutsModal';
import { __resetCommandsForTests, registerCommands, getCommands } from '../../editor/commands';
import { CORE_COMMANDS } from '../../editor/core-commands';
import { formatShortcut } from '../../utils/shortcuts';
let container: HTMLDivElement;
let root: Root;
const show = (open = true) => {
act(() => { root.render(<ShortcutsModal open={open} onClose={() => {}} />); });
};
beforeEach(() => {
__resetCommandsForTests();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
__resetCommandsForTests();
});
const rows = () => Array.from(document.body.querySelectorAll('.shortcuts-row')) as HTMLElement[];
describe('ShortcutsModal', () => {
test('renders nothing when closed', () => {
registerCommands(CORE_COMMANDS);
show(false);
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
});
test('lists every registered command that has a shortcut, grouped, with formatted keys', () => {
registerCommands(CORE_COMMANDS);
registerCommands([
{ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', run: () => {} },
{ id: 'site.templates', title: 'Templates', group: 'site', run: () => {} },
]);
show();
const expected = getCommands().filter((c) => c.shortcut).map((c) => c.id).sort();
expect(rows().map((r) => r.dataset.command).sort()).toEqual(expected);
expect(rows().some((r) => r.dataset.command === 'site.templates')).toBe(false);
const groups = Array.from(document.body.querySelectorAll('.shortcuts-group')).map((g) => (g as HTMLElement).dataset.group);
expect(groups).toEqual(['edit', 'view', 'site', 'help']);
const save = rows().find((r) => r.dataset.command === 'site.save')!;
expect(save.textContent).toContain('Save');
expect(save.querySelector('kbd')!.textContent).toBe(formatShortcut('mod+s'));
// secondary alternates are shown for redo (mod+y) but not the '?' duplicate of the help key
const help = rows().find((r) => r.dataset.command === 'help.shortcuts')!;
expect(help.querySelectorAll('kbd')).toHaveLength(1);
const redo = rows().find((r) => r.dataset.command === 'edit.redo')!;
expect(redo.querySelectorAll('kbd')).toHaveLength(2);
});
test('has an accessible dialog role and title', () => {
show();
const d = document.body.querySelector('[role="dialog"]')!;
expect(d.getAttribute('aria-label')).toBe('Keyboard shortcuts');
expect(d.textContent).toContain('Keyboard shortcuts');
});
});
@@ -0,0 +1,70 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import { subscribeShortcutsModal } from '../../editor/core-commands';
import { Modal } from '../../ui/Modal';
import { getCommands, subscribeCommands, type CommandGroup } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
const GROUP_ORDER: CommandGroup[] = ['edit', 'select', 'view', 'insert', 'page', 'site', 'help'];
const GROUP_LABELS: Record<CommandGroup, string> = {
edit: 'Edit',
select: 'Selection',
view: 'View',
insert: 'Insert',
page: 'Page',
site: 'Site',
help: 'Help',
};
/** Keyboard cheat sheet: every registered command that has a shortcut, grouped. */
export const ShortcutsModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const commands = useSyncExternalStore(subscribeCommands, getCommands, getCommands);
const groups = GROUP_ORDER.map((g) => ({
id: g,
items: commands.filter((c) => c.group === g && c.shortcut),
})).filter((g) => g.items.length > 0);
return (
<Modal
open={open}
onClose={onClose}
backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Keyboard shortcuts' }}
>
<div className="shortcuts-modal">
<div className="shortcuts-modal-header">
<h2 className="shortcuts-modal-title">Keyboard shortcuts</h2>
<button type="button" className="shortcuts-modal-close" aria-label="Close" onClick={onClose}>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="shortcuts-modal-body">
{groups.map((g) => (
<section key={g.id} className="shortcuts-group" data-group={g.id}>
<h3 className="shortcuts-group-title">{GROUP_LABELS[g.id]}</h3>
<ul className="shortcuts-list">
{g.items.map((c) => (
<li key={c.id} className="shortcuts-row" data-command={c.id}>
<span className="shortcuts-row-title">{c.title}</span>
<span className="shortcuts-row-keys">
{[c.shortcut!, ...(c.shortcuts ?? [])]
.filter((s, i, a) => i === 0 || (!/shift\+[?/]$/.test(s) && a.indexOf(s) === i))
.map((s) => (
<kbd key={s}>{formatShortcut(s)}</kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</Modal>
);
};
/** Owns the open state: `help.shortcuts` (Cmd+/) opens it through core-commands' channel. */
export const ShortcutsModalHost: React.FC = () => {
const [open, setOpen] = useState(false);
useEffect(() => subscribeShortcutsModal(() => setOpen(true)), []);
return <ShortcutsModal open={open} onClose={() => setOpen(false)} />;
};
+18
View File
@@ -20,6 +20,8 @@ import { SaveStatusPill, SaveStatus } from './SaveStatusPill';
import { useSaveGuard } from '../../state/SaveGuardContext';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { useRegisterCommands } from '../../editor/commands';
import { TopBarCommandMenu } from './TopBarCommandMenu';
interface TopBarProps {
/** Item 10: canvas dashed guide outlines, default ON. */
@@ -295,6 +297,20 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
}
}, [query, headerPage, footerPage, whpConfig, design, pages, activePageId]);
// Site-level commands: the keyboard layer (Cmd+S / Cmd+Shift+P / Cmd+P), the
// "..." command menu and the shortcuts cheat sheet all read these. Re-registered
// when the handlers change so `run` never calls a stale closure. Save / preview /
// publish also fire while a text field has focus (the browser would otherwise
// open its own Save / Print dialog).
useRegisterCommands([
{ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, when: () => canSave, run: () => handleSave() },
...(isWHP ? [{ id: 'site.publish', title: 'Publish', group: 'site' as const, shortcut: 'mod+shift+p', allowWhileTyping: true, when: () => canSave, run: () => handlePublish() }] : []),
{ id: 'site.preview', title: 'Preview', group: 'site', shortcut: 'mod+p', allowWhileTyping: true, run: () => handlePreview() },
{ id: 'site.templates', title: 'Templates', group: 'site', when: () => canSave, run: ({ openTemplates }) => openTemplates() },
{ id: 'site.head-code', title: 'Custom head code', group: 'site', when: () => canSave, run: ({ openHeadCode }) => openHeadCode() },
{ id: 'site.report-issue', title: 'Report an issue', group: 'site', run: () => setReportOpen(true) },
], [canSave, isWHP, handleSave, handlePublish, handlePreview]);
// Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
// Preview, Head Code, the guides toggle, and the device switcher moved
@@ -472,6 +488,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
<i className="fa fa-bug" />
</button>
<TopBarCommandMenu />
{/* Draft badge: saved changes that aren't live yet */}
{isWHP && isDraft && !published && (
<span className="publish-badge draft">
+58
View File
@@ -2557,3 +2557,61 @@ body {
}
.palette-dot:first-child { margin-left: 0; }
/* ---- #39 shortcuts modal ---- */
.shortcuts-modal {
width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--color-bg-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, 8px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.shortcuts-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
}
.shortcuts-modal-title { margin: 0; font-size: 15px; font-weight: 600; }
.shortcuts-modal-close {
background: transparent;
border: 0;
color: var(--color-text-muted);
cursor: pointer;
padding: 4px 6px;
}
.shortcuts-modal-close:hover { color: var(--color-text); }
.shortcuts-modal-body { padding: 8px 16px 16px; overflow-y: auto; }
.shortcuts-group-title {
margin: 14px 0 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-dim);
}
.shortcuts-list { list-style: none; margin: 0; padding: 0; }
.shortcuts-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 5px 0;
font-size: 13px;
}
.shortcuts-row-keys { display: inline-flex; gap: 6px; flex-shrink: 0; }
.shortcuts-row-keys kbd {
font: inherit;
font-size: 12px;
padding: 1px 7px;
color: var(--color-text-muted);
background: var(--color-bg-hover);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
/* ---- end #39 shortcuts modal ---- */