From 1558626b843f119552ef8b37dd59a665a8c09fe7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 26 Apr 2026 20:42:17 -0700 Subject: [PATCH] fix(delete): redirect Delete to owning component when target is empty linked node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- craft/src/hooks/useKeyboardShortcuts.ts | 7 ++--- craft/src/panels/context-menu/ContextMenu.tsx | 11 ++++++-- craft/src/utils/craft-helpers.ts | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 craft/src/utils/craft-helpers.ts 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; + } +}