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:
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||||
|
|
||||||
function isInputFocused(): boolean {
|
function isInputFocused(): boolean {
|
||||||
const el = document.activeElement;
|
const el = document.activeElement;
|
||||||
@@ -52,10 +53,8 @@ export function useKeyboardShortcuts() {
|
|||||||
try {
|
try {
|
||||||
const selected = query.getEvent('selected').all();
|
const selected = query.getEvent('selected').all();
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
const nodeId = selected[0];
|
const target = findDeletableTarget(query, selected[0]);
|
||||||
if (nodeId !== 'ROOT') {
|
if (target) actions.delete(target);
|
||||||
actions.delete(nodeId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete failed:', err);
|
console.error('Delete failed:', err);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useCallback, useRef } from 'react';
|
import React, { useEffect, useCallback, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { findDeletableTarget } from '../../utils/craft-helpers';
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -143,14 +144,18 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
}, [nodeId, actions, getParentId, onClose]);
|
}, [nodeId, actions, getParentId, onClose]);
|
||||||
|
|
||||||
const deleteNode = useCallback(() => {
|
const deleteNode = useCallback(() => {
|
||||||
if (!nodeId || nodeId === 'ROOT') return;
|
const target = findDeletableTarget(query, nodeId);
|
||||||
|
if (!target) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
actions.delete(nodeId);
|
actions.delete(target);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Delete failed:', e);
|
console.error('Delete failed:', e);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, onClose]);
|
}, [nodeId, actions, query, onClose]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
|
|||||||
28
craft/src/utils/craft-helpers.ts
Normal file
28
craft/src/utils/craft-helpers.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user