feat(site-builder): complete shortcut set on the command registry with a cheat sheet
Core editor shortcuts become registry commands run by the single useCommandShortcuts dispatcher: undo/redo, delete, duplicate (after + select), copy/paste, Alt+arrow move, 1/2/3 device, Cmd+S/Shift+P/P site commands and Cmd+/ cheat sheet. Tab/Esc no longer fire from buttons or dialogs. Context menu labels come from formatShortcut. Refs #39
This commit is contained in:
1 parent
d8ff295093
commit
c68e459430
13 files changed
+861
-513
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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]),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -2441,3 +2441,61 @@ body {
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ---- #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 ---- */
|
||||
Reference in new issue
Block a user