新增UI树节点快捷删除
在可删除节点行提供桌面端垃圾桶按钮 复用右键删除入口并对后代节点显示确认弹窗
This commit is contained in:
@@ -14,3 +14,20 @@ export function collectUiNodeIds(root: UiNode): Set<NodeId> {
|
||||
visitUiNodes(root, (node) => ids.add(node.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null {
|
||||
if (root.id === nodeId) return root;
|
||||
for (const child of root.children) {
|
||||
const found = findUiNode(child, nodeId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function countUiNodeDescendants(root: UiNode): number {
|
||||
let count = 0;
|
||||
for (const child of root.children) {
|
||||
count += 1 + countUiNodeDescendants(child);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ThemedModal } from '../../../components/modal/ThemedModal';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
|
||||
export type UiNodeDeleteRequest = {
|
||||
nodeId: NodeId;
|
||||
nodeLabel: string;
|
||||
descendantCount: number;
|
||||
};
|
||||
|
||||
type UiNodeDeleteConfirmModalProps = {
|
||||
request: UiNodeDeleteRequest | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: (nodeId: NodeId) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function UiNodeDeleteConfirmModal({
|
||||
request,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
disabled = false,
|
||||
}: UiNodeDeleteConfirmModalProps) {
|
||||
return (
|
||||
<ThemedModal
|
||||
open={request !== null}
|
||||
onClose={onCancel}
|
||||
ariaLabel="确认删除节点"
|
||||
panelClassName="w-[420px] rounded-2xl p-5"
|
||||
>
|
||||
<h2 className="m-0 text-base font-semibold">确认删除节点?</h2>
|
||||
<p className="text-sm leading-6 text-(--platform-text-soft)">
|
||||
将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '}
|
||||
个后代节点,是否继续?
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-(--platform-subpanel-border) px-3 py-2 text-xs"
|
||||
onClick={onCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (request) onConfirm(request.nodeId);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
|
||||
import { ChevronDown, Eye, EyeOff, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
@@ -13,12 +13,20 @@ import {
|
||||
type TreeApi,
|
||||
} from 'react-arborist';
|
||||
|
||||
import {
|
||||
countUiNodeDescendants,
|
||||
findUiNode,
|
||||
} from '../../../features/ui-editor/treeUtils';
|
||||
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
|
||||
import type { NodeId } from '../../../features/ui-editor/types/NodeId';
|
||||
import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId';
|
||||
import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest';
|
||||
import type { UiEditorNodeFocusRequest } from '../model';
|
||||
import { UiNodeContextMenu } from './UiNodeContextMenu';
|
||||
import {
|
||||
UiNodeDeleteConfirmModal,
|
||||
type UiNodeDeleteRequest,
|
||||
} from './UiNodeDeleteConfirmModal';
|
||||
|
||||
type UiTreePanelProps = {
|
||||
root: UiNode | null;
|
||||
@@ -44,11 +52,17 @@ function TreeRow({
|
||||
isNodeVisible,
|
||||
onToggleNodeVisibility,
|
||||
onOpenContextMenu,
|
||||
onRequestDelete,
|
||||
canDelete,
|
||||
deleteDisabled,
|
||||
}: NodeRendererProps<UiNode> & {
|
||||
onSelectNode: (id: NodeId) => void;
|
||||
isNodeVisible: (nodeId: NodeId) => boolean;
|
||||
onToggleNodeVisibility: (id: NodeId) => void;
|
||||
onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void;
|
||||
onRequestDelete: (id: NodeId) => void;
|
||||
canDelete: boolean;
|
||||
deleteDisabled: boolean;
|
||||
}) {
|
||||
const data = node.data;
|
||||
const nodeLabel = data.metadata.name || '未命名节点';
|
||||
@@ -105,6 +119,21 @@ function TreeRow({
|
||||
>
|
||||
{isVisible ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`删除节点“${nodeLabel}”`}
|
||||
title={canDelete ? `删除节点“${nodeLabel}”` : '页面根节点不可删除'}
|
||||
disabled={!canDelete || deleteDisabled}
|
||||
aria-disabled={!canDelete || deleteDisabled}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-red-700 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-35"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onRequestDelete(data.id);
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,6 +161,9 @@ export function UiTreePanel({
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<
|
||||
(UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest) return;
|
||||
@@ -184,6 +216,35 @@ export function UiTreePanel({
|
||||
const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []);
|
||||
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
|
||||
|
||||
const requestDelete = (nodeId: NodeId) => {
|
||||
if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
const treeId = treeIdForNode(nodeId);
|
||||
const node = findUiNode(root, nodeId);
|
||||
if (!treeId || !node) return;
|
||||
const descendantCount = countUiNodeDescendants(node);
|
||||
if (descendantCount === 0) {
|
||||
onDeleteNode(treeId, nodeId);
|
||||
return;
|
||||
}
|
||||
setPendingDelete({
|
||||
treeId,
|
||||
nodeId,
|
||||
nodeLabel: node.metadata.name || '未命名节点',
|
||||
descendantCount,
|
||||
});
|
||||
};
|
||||
|
||||
const confirmDelete = (nodeId: NodeId) => {
|
||||
if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) {
|
||||
return;
|
||||
}
|
||||
const { treeId } = pendingDelete;
|
||||
setPendingDelete(null);
|
||||
onDeleteNode(treeId, nodeId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
|
||||
@@ -224,6 +285,12 @@ export function UiTreePanel({
|
||||
}}
|
||||
isNodeVisible={isNodePreviewVisible}
|
||||
onToggleNodeVisibility={onToggleNodeVisibility}
|
||||
onRequestDelete={requestDelete}
|
||||
canDelete={
|
||||
props.node.data.id !== root.id &&
|
||||
!pageRootIds.has(props.node.data.id)
|
||||
}
|
||||
deleteDisabled={isLocked}
|
||||
onOpenContextMenu={(event, nodeId) =>
|
||||
nodeId === root.id
|
||||
? undefined
|
||||
@@ -252,9 +319,15 @@ export function UiTreePanel({
|
||||
onClose={() => setContextMenu(null)}
|
||||
onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)}
|
||||
onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)}
|
||||
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
|
||||
onDelete={requestDelete}
|
||||
/>
|
||||
) : null}
|
||||
<UiNodeDeleteConfirmModal
|
||||
request={pendingDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={confirmDelete}
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user