Files
site-builder/craft/src/hooks/useKeyboardShortcuts.ts
T
shadowdao 1558626b84 fix(delete): redirect Delete to owning component when target is empty linked node
Linked Craft.js nodes (column children of ColumnLayout, section-inner of
Section, etc.) are structurally non-deletable — actions.delete throws and
the error was silently swallowed. Empty layouts ended up undeletable from
the canvas because clicks always landed on the linked children that fill
the layout's visible area.

Adds findDeletableTarget(): when target is a linked node and ALL its
linked siblings are also empty (i.e., the layout itself is empty),
redirect deletion to the owning parent. Refuses to redirect when any
sibling has content, to protect against nuking a 3-col layout that has
content in other cols.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:42:17 -07:00

103 lines
2.9 KiB
TypeScript

import { useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../utils/craft-helpers';
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 = query.node(nodeId).toNodeTree();
actions.addNodeTree(tree, parentId);
}
}
}
} catch (err) {
console.error('Duplicate 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]);
}