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-12 15:01:30 -07:00
|
|
|
import { getClipboardNodeId, setClipboardNodeId } 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-12 15:01:30 -07:00
|
|
|
// Ctrl+C: copy selected node id to the shared clipboard
|
|
|
|
|
if (ctrl && (e.key === 'c' || e.key === 'C')) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
try {
|
|
|
|
|
const selected = query.getEvent('selected').all();
|
|
|
|
|
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
|
|
|
|
setClipboardNodeId(selected[0]);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Copy failed:', err);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ctrl+V: paste the clipboard node as a sibling of the current selection
|
|
|
|
|
if (ctrl && (e.key === 'v' || e.key === 'V')) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
try {
|
|
|
|
|
const sourceId = getClipboardNodeId();
|
|
|
|
|
if (!sourceId || !query.node(sourceId).get()) return;
|
|
|
|
|
|
|
|
|
|
const selected = query.getEvent('selected').all();
|
|
|
|
|
if (selected.length === 0) return;
|
|
|
|
|
const selectedId = selected[0];
|
|
|
|
|
|
|
|
|
|
let targetParent = 'ROOT';
|
|
|
|
|
if (selectedId !== 'ROOT') {
|
|
|
|
|
const node = query.node(selectedId).get();
|
|
|
|
|
targetParent = node?.data?.parent || 'ROOT';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tree = regenerateTreeIds(query.node(sourceId).toNodeTree());
|
|
|
|
|
actions.addNodeTree(tree, targetParent);
|
|
|
|
|
} 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]);
|
|
|
|
|
}
|