import { registerCommands, type Command, type CommandContext } from './commands'; import { findDeletableTarget, getAncestorIds, getParentId } from '../utils/craft-helpers'; import { regenerateTreeIds } from '../utils/craft-tree'; import { getClipboardTree, setClipboardTree } from '../hooks/clipboard'; import type { DeviceMode } from '../types'; /** * The editor's core keyboard commands (undo/redo, delete, duplicate, * copy/paste, move, device, shortcuts help) as registry entries. The one * dispatcher (`useCommandShortcuts`) runs them; the context menu and the * cheat sheet read their labels from here. Node commands act on EVERY selected * node (multi-select), skipping ROOT and any node whose ancestor is also * selected (it travels with the ancestor). */ /** Selected ids minus ROOT and minus descendants of other selected ids, in selection order. */ export function topLevelSelection(query: any, ids: string[]): string[] { const set = new Set(ids); return ids.filter((id) => { if (!id || id === 'ROOT') return false; try { return !getAncestorIds(query, id).some((a) => set.has(a)); } catch { return false; } }); } const hasSelection = (ctx: CommandContext) => topLevelSelection(ctx.query, ctx.selectedIds).length > 0; function siblingsOf(query: any, id: string): { parentId: string; siblings: string[] } | null { const parentId = getParentId(query, id); if (!parentId) return null; try { return { parentId, siblings: query.node(parentId).get()?.data?.nodes || [] }; } catch { return null; } } /** Insert `tree` under `parentId` at `index` (append when omitted); returns its root id. */ function insertTree(actions: any, tree: any, parentId: string, index?: number): string { if (index !== undefined) actions.addNodeTree(tree, parentId, index); else actions.addNodeTree(tree, parentId); return tree.rootNodeId; } function duplicateAll({ query, actions, selectedIds }: CommandContext): void { const created: string[] = []; for (const id of topLevelSelection(query, selectedIds)) { try { const sib = siblingsOf(query, id); const idx = sib ? sib.siblings.indexOf(id) : -1; // Linked nodes (columns, section inner) are not in `nodes` and cannot be copied alone. if (!sib || idx === -1) continue; const tree = regenerateTreeIds(query.node(id).toNodeTree()); created.push(insertTree(actions, tree, sib.parentId, idx + 1)); } catch (err) { console.error('Duplicate failed:', err); } } if (created.length) actions.selectNode(created.length === 1 ? created[0] : created); } function deleteAll({ query, actions, selectedIds }: CommandContext): void { const targets = new Set(); for (const id of topLevelSelection(query, selectedIds)) { const t = findDeletableTarget(query, id); if (t) targets.add(t); } targets.forEach((t) => { try { actions.delete(t); } catch (err) { console.error('Delete failed:', err); } }); } function pasteClipboard({ query, actions, selectedIds }: CommandContext): void { const clip = getClipboardTree(); if (!clip) return; const sel = topLevelSelection(query, selectedIds); let parentId = 'ROOT'; let index: number | undefined; const last = sel[sel.length - 1]; if (last) { const sib = siblingsOf(query, last); if (sib) { parentId = sib.parentId; const idx = sib.siblings.indexOf(last); if (idx !== -1) index = idx + 1; } } const tree = regenerateTreeIds(clip); actions.selectNode(insertTree(actions, tree, parentId, index)); } /** One step among siblings. Moving down walks the selection in reverse so a run of * adjacent nodes keeps its order; a node already at the edge stays put. */ function moveAll({ query, actions, selectedIds }: CommandContext, dir: -1 | 1): void { const ids = topLevelSelection(query, selectedIds); for (const id of dir === 1 ? [...ids].reverse() : ids) { try { const sib = siblingsOf(query, id); if (!sib) continue; const idx = sib.siblings.indexOf(id); if (idx === -1) continue; if (dir === -1 && idx > 0) actions.move(id, sib.parentId, idx - 1); // Craft's move index is the slot BEFORE removal, hence idx + 2. if (dir === 1 && idx < sib.siblings.length - 1) actions.move(id, sib.parentId, idx + 2); } catch (err) { console.error('Move failed:', err); } } } /** Cheat-sheet visibility: a module-level channel (like clipboard.ts) so a command can open it. */ const shortcutsListeners = new Set<() => void>(); export function subscribeShortcutsModal(cb: () => void): () => void { shortcutsListeners.add(cb); return () => { shortcutsListeners.delete(cb); }; } export function openShortcutsModal(): void { shortcutsListeners.forEach((cb) => cb()); } const deviceCommand = (device: DeviceMode, key: string): Command => ({ id: `view.device-${device}`, title: `${device.charAt(0).toUpperCase()}${device.slice(1)} view`, group: 'view', shortcut: key, run: ({ setDevice }) => setDevice(device), }); export const CORE_COMMANDS: Command[] = [ { id: 'edit.undo', title: 'Undo', group: 'edit', shortcut: 'mod+z', when: ({ query }) => query.history.canUndo(), run: ({ actions }) => actions.history.undo(), }, { id: 'edit.redo', title: 'Redo', group: 'edit', shortcut: 'mod+shift+z', shortcuts: ['mod+y'], when: ({ query }) => query.history.canRedo(), run: ({ actions }) => actions.history.redo(), }, { id: 'edit.delete', title: 'Delete', group: 'edit', shortcut: 'delete', shortcuts: ['backspace'], when: hasSelection, run: deleteAll, }, { id: 'edit.duplicate', title: 'Duplicate', group: 'edit', shortcut: 'mod+d', when: hasSelection, run: duplicateAll, }, { id: 'edit.copy', title: 'Copy', group: 'edit', shortcut: 'mod+c', when: hasSelection, // A detached tree snapshot, not an id: it must survive a page switch (see clipboard.ts). // v1: with several nodes selected, the first is copied. run: ({ query, selectedIds }) => { const [id] = topLevelSelection(query, selectedIds); if (id) setClipboardTree(query.node(id).toNodeTree()); }, }, { id: 'edit.paste', title: 'Paste', group: 'edit', shortcut: 'mod+v', when: () => !!getClipboardTree(), run: pasteClipboard, }, { id: 'edit.move-up', title: 'Move up', group: 'edit', shortcut: 'alt+arrowup', when: hasSelection, run: (ctx) => moveAll(ctx, -1), }, { id: 'edit.move-down', title: 'Move down', group: 'edit', shortcut: 'alt+arrowdown', when: hasSelection, run: (ctx) => moveAll(ctx, 1), }, deviceCommand('desktop', '1'), deviceCommand('tablet', '2'), deviceCommand('mobile', '3'), { id: 'help.shortcuts', title: 'Keyboard shortcuts', group: 'help', shortcut: 'mod+/', // '?' is Shift+/ on most layouts. shortcuts: ['mod+shift+?', 'mod+shift+/'], allowWhileTyping: true, run: () => openShortcutsModal(), }, ]; export function registerCoreCommands(): () => void { return registerCommands(CORE_COMMANDS); }