支持 UI 编辑器 Delete 删除选中节点

复用现有 deleteNode 命令处理 Delete 快捷键

过滤交互控件、修饰键和重复按键事件

清理删除子树后的无效节点选区并补充页面测试
This commit is contained in:
2026-09-04 11:35:58 +08:00
parent a2876eaf65
commit 5898d38083
3 changed files with 56 additions and 7 deletions
@@ -79,24 +79,48 @@ export default function UiEditorPage({
session.save.isLocked;
const historyUndo = session.history.undo;
const historyRedo = session.history.redo;
const selectedNodeId = session.input.selectedNodeId;
const activeImageId = session.input.activeImageId;
const deleteNode = session.input.deleteNode;
useEffect(() => {
if (session.save.isDirty) setGenerateSuccess(null);
}, [session.save.isDirty]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
const element = target instanceof HTMLElement ? target : null;
const isInteractiveTarget = (target: EventTarget | null) => {
const element = target instanceof Element ? target : null;
return Boolean(
element?.isContentEditable ||
element?.closest('input, textarea, select, [contenteditable="true"]'),
(target instanceof HTMLElement && target.isContentEditable) ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"]',
),
);
};
const onKeyDown = (event: KeyboardEvent) => {
if (
event.key === 'Delete' &&
!event.repeat &&
!event.defaultPrevented &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey &&
!isInteractiveTarget(event.target)
) {
if (selectedNodeId && activeImageId) {
const result = deleteNode(selectedNodeId, activeImageId);
if (result?.ok) {
event.preventDefault();
event.stopPropagation();
return;
}
}
}
if (
event.repeat ||
event.defaultPrevented ||
isEditableTarget(event.target) ||
isInteractiveTarget(event.target) ||
(!event.ctrlKey && !event.metaKey)
) {
return;
@@ -113,7 +137,7 @@ export default function UiEditorPage({
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyRedo, historyUndo]);
}, [activeImageId, deleteNode, historyRedo, historyUndo, selectedNodeId]);
async function save(afterReturn = false) {
if (await session.save.save()) {
@@ -437,6 +437,9 @@ export function useUiEditorSession(
);
return next.size === current.size ? current : next;
});
setSelectedNodeId((current) =>
current && !validNodeIds.has(current) ? null : current,
);
}, [editor.state.ui_trees]);
const isNodePreviewVisible = useCallback(
@@ -881,12 +884,17 @@ export function useUiEditorSession(
function deleteNode(nodeId: NodeId, treeId = activeImageId) {
if (!treeId) return;
const tree = editor.state.ui_trees.find(
(candidate) => candidate.src_ui_design === treeId,
);
const location = tree ? findUiNodeLocation(tree.root, nodeId) : null;
const deletedNodeIds = location ? collectUiNodeIds(location.node) : null;
const result = editor.deleteNode(treeId, nodeId);
if (!result.ok) {
setStatus('无法删除该节点。');
return result;
}
if (selectedNodeId === nodeId) setSelectedNodeId(null);
if (deletedNodeIds?.has(selectedNodeId ?? '')) setSelectedNodeId(null);
return result;
}
@@ -473,6 +473,23 @@ describe('UiEditorPage', () => {
expect(result.current.canvas.hiddenNodeIds.size).toBe(0);
});
it('clears selection when deleting a node removes the selected descendant', async () => {
const { result } = await renderLoadedSession(stateWithPages(['page']));
const rootId = 'page-root';
let parentId: string | undefined;
let childId: string | undefined;
act(() => {
parentId = result.current.input.insertNode(rootId, 'page')?.value;
childId = result.current.input.insertNode(parentId!, 'page')?.value;
result.current.input.selectNode(childId!);
});
act(() => result.current.input.deleteNode(parentId!, 'page'));
expect(result.current.canvas.selectedNodeId).toBeNull();
expect(result.current.history.canUndo).toBe(true);
});
it('keeps Inspector status highlighting separate from node navigation', async () => {
const { result } = await renderLoadedSession(stateWithPages(['page']));
const rootId = 'page-root';