UI编辑器一批细节优化 #278
@@ -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}
|
||||
|
||||
+4
-1
@@ -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` 改变缩放。
|
||||
- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。
|
||||
|
||||
Reference in New Issue
Block a user