Files
site-builder/craft/src/editor/core-commands.ts
T
shadowdao c68e459430 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
2026-10-07 11:47:55 -07:00

228 lines
7.1 KiB
TypeScript

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);
}