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

Merged
kdletters merged 44 commits from fix/ui-editor-minor-adjusts into master 2026-09-08 11:56:36 +08:00
6 changed files with 73 additions and 42 deletions
Showing only changes of commit 29b0b944bd - Show all commits
@@ -206,7 +206,6 @@ export function UiTreePanel({
}: UiTreePanelProps) {
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
const treeContainerRef = useRef<HTMLDivElement>(null);
const treeSizerRef = useRef<HTMLDivElement>(null);
const [treeHeight, setTreeHeight] = useState(0);
const [treeWidth, setTreeWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{
@@ -246,21 +245,29 @@ export function UiTreePanel({
return rows;
}, [root]);
const treeContentWidth = useMemo(
() =>
treeRows.reduce((width, { node, depth }) => {
const labelWidth = Math.max(
56,
(node.metadata.name || '未命名节点').length * 8,
);
return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52);
}, 0),
[treeRows],
);
useEffect(() => {
const sizer = treeSizerRef.current;
const container = treeContainerRef.current;
if (!sizer || !container) return;
if (!container) return;
const updateWidth = () => {
setTreeWidth(
Math.max(Math.ceil(sizer.scrollWidth), container.clientWidth),
);
setTreeWidth(Math.max(treeContentWidth, container.clientWidth));
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(sizer);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, [treeRows]);
}, [treeContentWidth]);
const handleMove: MoveHandler<UiNode> = ({
dragIds,
@@ -334,35 +341,6 @@ export function UiTreePanel({
ref={treeContainerRef}
className="relative min-h-0 flex-1 overflow-x-auto overflow-y-hidden"
>
<div
ref={treeSizerRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 invisible flex w-max flex-col whitespace-nowrap px-2 text-xs"
>
{treeRows.map(({ node, depth }) => (
<div
key={node.id}
className="flex h-[34px] items-center gap-1"
style={{ paddingLeft: depth * 16 }}
>
<span className="size-6 shrink-0" />
<span className="shrink-0">
{node.metadata.name || '未命名节点'}
</span>
<span className="inline-flex shrink-0 items-center gap-0.5 rounded-md px-1 py-0.5 text-[10px] leading-none">
<Puzzle
size={11}
className="text-orange-600"
fill="currentColor"
strokeWidth={1.5}
aria-hidden="true"
/>
<span>{node.components.length}</span>
</span>
<span className="w-[52px] shrink-0" />
</div>
))}
</div>
{root && treeHeight > 0 ? (
<Tree<UiNode>
ref={treeApiRef}
@@ -512,7 +512,10 @@ export function PreviewWorkspace({
onScaleFromCenter={scaleViewportFromCenter}
>
{(actions) => (
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg">
<div
className="absolute bottom-3 right-3 z-10 flex items-center gap-1 rounded-lg border border-(--platform-subpanel-border) bg-white/90 p-1 shadow-lg"
data-ui-editor-zoom-controls
>
<button
type="button"
className="grid size-8 place-items-center rounded hover:bg-black/5"
@@ -90,7 +90,7 @@ function RenderNode({
selectedNodeId,
resources,
onSelectNode,
consumeNodeClick = () => false,
consumeNodeClick,
onNodeContextMenu,
onNodePointerDown,
onNodePointerMove,
@@ -107,6 +107,8 @@ function RenderNode({
isRoot?: boolean;
parentContainer?: UiNode['layout']['container'];
}) {
const hasClickGestureConsumer = consumeNodeClick !== undefined;
const consumeClick = consumeNodeClick ?? (() => false);
const activePreviewTransforms = previewTransforms ?? EMPTY_PREVIEW_TRANSFORMS;
if (hiddenNodeIds.has(node.id)) return null;
const previewTransform = activePreviewTransforms.get(node.id);
@@ -158,11 +160,12 @@ function RenderNode({
hasDirectPointerGesture
? (event) => {
event.stopPropagation();
consumeNodeClick();
if (consumeClick()) return;
if (hasClickGestureConsumer) onSelectNode(node.id);
}
: (event) => {
event.stopPropagation();
if (consumeNodeClick()) return;
if (consumeClick()) return;
onSelectNode(node.id);
}
}
@@ -21,10 +21,11 @@ function isEditableTarget(target: EventTarget | null) {
function isInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
if (element?.closest('[data-ui-editor-zoom-controls]')) return false;
return Boolean(
(target instanceof HTMLElement && target.isContentEditable) ||
element?.closest(
'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
'button, a, input:not([type="range"]), textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]',
),
);
}
@@ -59,4 +59,49 @@ describe('ui editor keyboard shortcuts', () => {
expect(historyUndo).toHaveBeenCalledTimes(1);
window.removeEventListener('keydown', listener);
});
it('keeps node deletion available when the zoom slider has focus', () => {
const slider = document.createElement('input');
slider.type = 'range';
document.body.append(slider);
const deleteNode = vi.fn(() => ({ ok: true }));
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: 'node-1',
activeImageId: 'page-1',
deleteNode,
historyUndo: vi.fn(() => true),
historyRedo: vi.fn(() => true),
});
window.addEventListener('keydown', listener);
fireEvent.keyDown(slider, { key: 'Delete' });
expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1');
window.removeEventListener('keydown', listener);
});
it('keeps node deletion available after a zoom button is clicked', () => {
const zoomControls = document.createElement('div');
zoomControls.setAttribute('data-ui-editor-zoom-controls', '');
const zoomIn = document.createElement('button');
zoomControls.append(zoomIn);
document.body.append(zoomControls);
const deleteNode = vi.fn(() => ({ ok: true }));
const listener = (event: KeyboardEvent) =>
handleUiEditorKeyDown(event, {
selectedNodeId: 'node-1',
activeImageId: 'page-1',
deleteNode,
historyUndo: vi.fn(() => true),
historyRedo: vi.fn(() => true),
});
window.addEventListener('keydown', listener);
fireEvent.click(zoomIn);
fireEvent.keyDown(zoomIn, { key: 'Delete' });
expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1');
window.removeEventListener('keydown', listener);
});
});
@@ -11,6 +11,7 @@
- 保留 ```+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。
- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。
- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。
- 缩放滑杆即使处于焦点,也不得拦截 UI 节点的 Delete/Backspace 快捷删除。
- Windows/Linux 使用 `Ctrl`macOS 使用 `Cmd``+``=``NumpadAdd` 放大,`-``NumpadSubtract` 缩小,允许按键重复时连续缩放。
- 快捷键直接复用现有 `+`/`` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。
- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。