2026-04-05 18:31:16 -07:00
|
|
|
import { useEffect } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
2026-04-26 20:42:17 -07:00
|
|
|
import { findDeletableTarget } from '../utils/craft-helpers';
|
2026-07-12 12:18:44 -07:00
|
|
|
import { regenerateTreeIds } from '../utils/craft-tree';
|
2026-07-14 07:36:00 -07:00
|
|
|
import { getClipboardTree, setClipboardTree } from './clipboard';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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) {
|
2026-04-26 20:42:17 -07:00
|
|
|
const target = findDeletableTarget(query, selected[0]);
|
|
|
|
|
if (target) actions.delete(target);
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
} 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) {
|
2026-07-12 12:18:44 -07:00
|
|
|
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
|
2026-04-05 18:31:16 -07:00
|
|
|
actions.addNodeTree(tree, parentId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Duplicate failed:', err);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 07:36:00 -07:00
|
|
|
// 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).
|
2026-07-12 15:01:30 -07:00
|
|
|
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
try {
|
|
|
|
|
const selected = query.getEvent('selected').all();
|
|
|
|
|
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
2026-07-14 07:36:00 -07:00
|
|
|
setClipboardTree(query.node(selected[0]).toNodeTree());
|
2026-07-12 15:01:30 -07:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Copy failed:', err);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 07:36:00 -07:00
|
|
|
// 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.
|
2026-07-12 15:01:30 -07:00
|
|
|
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
try {
|
2026-07-14 07:36:00 -07:00
|
|
|
const clip = getClipboardTree();
|
|
|
|
|
if (!clip) return;
|
2026-07-12 15:01:30 -07:00
|
|
|
|
|
|
|
|
const selected = query.getEvent('selected').all();
|
2026-07-14 07:36:00 -07:00
|
|
|
const selectedId = selected.length > 0 ? selected[0] : null;
|
2026-07-12 15:01:30 -07:00
|
|
|
|
2026-07-14 07:36:00 -07:00
|
|
|
let targetParentId = 'ROOT';
|
|
|
|
|
let insertIndex: number | undefined;
|
|
|
|
|
if (selectedId && selectedId !== 'ROOT') {
|
2026-07-12 15:01:30 -07:00
|
|
|
const node = query.node(selectedId).get();
|
2026-07-14 07:36:00 -07:00
|
|
|
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.
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-12 15:01:30 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-14 07:36:00 -07:00
|
|
|
const tree = regenerateTreeIds(clip);
|
|
|
|
|
if (insertIndex !== undefined) {
|
|
|
|
|
actions.addNodeTree(tree, targetParentId, insertIndex);
|
|
|
|
|
} else {
|
|
|
|
|
actions.addNodeTree(tree, targetParentId);
|
|
|
|
|
}
|
|
|
|
|
actions.selectNode(tree.rootNodeId);
|
2026-07-12 15:01:30 -07:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Paste failed:', err);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
// 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]);
|
|
|
|
|
}
|