UI编辑器一批细节优化 #278

Merged
kdletters merged 44 commits from fix/ui-editor-minor-adjusts into master 2026-09-08 11:56:36 +08:00
3 changed files with 148 additions and 2 deletions
Showing only changes of commit 8242885c79 - Show all commits
@@ -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>
);
}