diff --git a/craft/src/hooks/useKeyboardShortcuts.ts b/craft/src/hooks/useKeyboardShortcuts.ts index 9096c8e..b920f5a 100644 --- a/craft/src/hooks/useKeyboardShortcuts.ts +++ b/craft/src/hooks/useKeyboardShortcuts.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useEditor } from '@craftjs/core'; +import { findDeletableTarget } from '../utils/craft-helpers'; function isInputFocused(): boolean { const el = document.activeElement; @@ -52,10 +53,8 @@ export function useKeyboardShortcuts() { try { const selected = query.getEvent('selected').all(); if (selected.length > 0) { - const nodeId = selected[0]; - if (nodeId !== 'ROOT') { - actions.delete(nodeId); - } + const target = findDeletableTarget(query, selected[0]); + if (target) actions.delete(target); } } catch (err) { console.error('Delete failed:', err); diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx index 98c9ef8..abcefd8 100644 --- a/craft/src/panels/context-menu/ContextMenu.tsx +++ b/craft/src/panels/context-menu/ContextMenu.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useCallback, useRef } from 'react'; import { useEditor } from '@craftjs/core'; +import { findDeletableTarget } from '../../utils/craft-helpers'; interface ContextMenuProps { visible: boolean; @@ -143,14 +144,18 @@ export const ContextMenu: React.FC = ({ }, [nodeId, actions, getParentId, onClose]); const deleteNode = useCallback(() => { - if (!nodeId || nodeId === 'ROOT') return; + const target = findDeletableTarget(query, nodeId); + if (!target) { + onClose(); + return; + } try { - actions.delete(nodeId); + actions.delete(target); } catch (e) { console.error('Delete failed:', e); } onClose(); - }, [nodeId, actions, onClose]); + }, [nodeId, actions, query, onClose]); if (!visible) return null; diff --git a/craft/src/utils/craft-helpers.ts b/craft/src/utils/craft-helpers.ts new file mode 100644 index 0000000..d283aa7 --- /dev/null +++ b/craft/src/utils/craft-helpers.ts @@ -0,0 +1,28 @@ +/** + * Find the actual deletable target for a node. Linked nodes (owned by parent + * components like ColumnLayout columns or Section inner) are structurally + * non-deletable in Craft.js — actions.delete() throws on them. When the + * selected node is an empty linked node and all its linked siblings are also + * empty (i.e., the whole layout is empty), redirect deletion to the owning + * parent so the layout itself goes away. Otherwise return null to refuse + * deletion — protects against nuking a layout that has content in other cols. + */ +export function findDeletableTarget(query: any, nodeId: string | null | undefined): string | null { + if (!nodeId || nodeId === 'ROOT') return null; + try { + const node = query.node(nodeId).get(); + const parentId: string | null | undefined = node?.data?.parent; + if (!parentId) return nodeId; + const parent = query.node(parentId).get(); + const linkedIds = Object.values((parent?.data?.linkedNodes || {}) as Record); + if (!linkedIds.includes(nodeId)) return nodeId; + const allEmpty = linkedIds.every((id) => { + const sib = query.node(id).get(); + return ((sib?.data?.nodes as string[] | undefined) || []).length === 0; + }); + if (!allEmpty) return null; + return findDeletableTarget(query, parentId); + } catch { + return null; + } +}