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>
This commit is contained in:
2026-04-26 20:42:17 -07:00
parent 4acbeefaed
commit 1558626b84
3 changed files with 39 additions and 7 deletions

View File

@@ -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);

View File

@@ -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<ContextMenuProps> = ({
}, [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;

View File

@@ -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<string, string>);
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;
}
}