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
228 lines
7.1 KiB
TypeScript
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);
|
|
}
|