import { useEffect } from 'react'; import { useEditor } from '@craftjs/core'; import { findDeletableTarget } from '../utils/craft-helpers'; import { regenerateTreeIds } from '../utils/craft-tree'; import { getClipboardTree, setClipboardTree } from './clipboard'; 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: deselect all if (e.key === 'Escape') { e.preventDefault(); try { actions.clearEvents(); } catch (err) { // Ignore } return; } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [actions, query]); }