优化 UI 编辑器侧栏布局
将步骤概览移动到检查器上方并贴合右栏边缘 将左侧输入区拆为可折叠且独立滚动的伸缩面板 使 UI Tree 随面板高度稳定伸缩,避免展开尺寸反馈循环 补充可折叠侧栏 flex 布局回归测试
This commit is contained in:
@@ -37,7 +37,7 @@ export function BindingOverview({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
|
||||
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
|
||||
aria-label="绑定概览"
|
||||
>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
export function CollapsibleSidebarPanel({
|
||||
label,
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
grow = 1,
|
||||
}: {
|
||||
label: string;
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
grow?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-0 flex-col rounded-xl border border-(--platform-subpanel-border) bg-white/35"
|
||||
style={{ flex: open ? `${grow} 1 0px` : '0 0 auto' }}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={`shrink-0 transition-transform ${open ? '' : '-rotate-90'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<h2 className="m-0 truncate text-sm font-semibold">{title}</h2>
|
||||
</span>
|
||||
</button>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-3 pb-3">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import type { UITree } from '../../../features/ui-editor/types/UITree';
|
||||
|
||||
function countUiComponents(nodes: UiNode[]): number {
|
||||
return nodes.reduce(
|
||||
(total, node) => total + node.components.length + countUiComponents(node.children),
|
||||
(total, node) =>
|
||||
total + node.components.length + countUiComponents(node.children),
|
||||
0,
|
||||
);
|
||||
}
|
||||
@@ -26,7 +27,7 @@ export function ImportOverview({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
|
||||
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
|
||||
aria-label="导入概览"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -6,9 +6,7 @@ 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 { UiEditorInputProjection } from '../useUiEditorPage';
|
||||
import { BindingOverview } from './BindingOverview';
|
||||
import { ImportOverview } from './ImportOverview';
|
||||
import { RecognitionOverview } from './RecognitionOverview';
|
||||
import { CollapsibleSidebarPanel } from './CollapsibleSidebarPanel';
|
||||
import { UiTreePanel } from './UiTreePanel';
|
||||
|
||||
const SUPER_ROOT_ID = '__ui-editor-super-root__';
|
||||
@@ -16,7 +14,6 @@ const SUPER_ROOT_ID = '__ui-editor-super-root__';
|
||||
export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
const {
|
||||
projectPath,
|
||||
activeStep,
|
||||
imageOrder,
|
||||
activeImageId,
|
||||
previewUrls,
|
||||
@@ -76,15 +73,39 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
}, [uiTrees]);
|
||||
|
||||
return (
|
||||
<aside className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
|
||||
<section className="rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
Input
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">界面图</h2>
|
||||
</div>
|
||||
<aside className="flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-hidden border-r border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-3">
|
||||
<CollapsibleSidebarPanel label="Tree" title="UI Tree" grow={2}>
|
||||
<UiTreePanel
|
||||
root={superRoot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusRequest={focusRequest}
|
||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||
isNodePreviewVisible={input.isNodePreviewVisible}
|
||||
onSelectNode={(treeId, nodeId) => {
|
||||
input.selectDesignImage(treeId);
|
||||
input.selectNode(nodeId);
|
||||
}}
|
||||
onToggleNodeVisibility={(nodeId) =>
|
||||
input.toggleNodePreviewVisibility(nodeId)
|
||||
}
|
||||
onInsertNode={(treeId, parentId) => {
|
||||
input.insertNode(parentId, treeId);
|
||||
}}
|
||||
onInsertNodeAfter={(treeId, nodeId) => {
|
||||
input.insertNodeAfter(nodeId, treeId);
|
||||
}}
|
||||
onDeleteNode={(treeId, nodeId) => {
|
||||
input.deleteNode(nodeId, treeId);
|
||||
}}
|
||||
onMoveNode={input.moveNode}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
</CollapsibleSidebarPanel>
|
||||
|
||||
<CollapsibleSidebarPanel
|
||||
label="Input"
|
||||
title="界面图"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-lg border border-orange-200 bg-orange-50 text-orange-800 disabled:opacity-40"
|
||||
@@ -94,8 +115,9 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{imageOrder.length > 0 ? (
|
||||
imageOrder.map((id) => {
|
||||
const image = images[id];
|
||||
@@ -140,16 +162,12 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleSidebarPanel>
|
||||
|
||||
<section className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
Assets
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">独立素材</h2>
|
||||
</div>
|
||||
<CollapsibleSidebarPanel
|
||||
label="Assets"
|
||||
title="独立素材"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-lg border border-orange-200 bg-orange-50 text-orange-800 disabled:opacity-40"
|
||||
@@ -159,8 +177,9 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.values(sprites).map((sprite) => (
|
||||
<button
|
||||
key={sprite.asset_id}
|
||||
@@ -184,16 +203,12 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</CollapsibleSidebarPanel>
|
||||
|
||||
<section className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
Fonts
|
||||
</span>
|
||||
<h2 className="m-0 text-sm font-semibold">项目字体</h2>
|
||||
</div>
|
||||
<CollapsibleSidebarPanel
|
||||
label="Fonts"
|
||||
title="项目字体"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-lg border border-orange-200 bg-orange-50 text-orange-800 disabled:opacity-40"
|
||||
@@ -203,8 +218,9 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{Object.values(fonts).map((font) => {
|
||||
const loadState = fontFaces[font.asset_id];
|
||||
return (
|
||||
@@ -240,54 +256,7 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) {
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeStep === 'structure-recognition' ? (
|
||||
<RecognitionOverview
|
||||
uiTrees={uiTrees}
|
||||
onFocusStatusNode={(treeId, nodeId) => {
|
||||
input.focusNode(treeId, nodeId);
|
||||
input.highlightStatusField('layout_status');
|
||||
}}
|
||||
/>
|
||||
) : activeStep === 'visual-binding' ? (
|
||||
<BindingOverview
|
||||
uiTrees={uiTrees}
|
||||
sprites={sprites}
|
||||
onFocusStatusNode={(treeId, nodeId) => {
|
||||
input.focusNode(treeId, nodeId);
|
||||
input.highlightStatusField('components_status');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImportOverview images={images} sprites={sprites} uiTrees={uiTrees} />
|
||||
)}
|
||||
|
||||
<UiTreePanel
|
||||
root={superRoot}
|
||||
selectedNodeId={selectedNodeId}
|
||||
focusRequest={focusRequest}
|
||||
treeIdForNode={(nodeId) => treeIdByNodeId.get(nodeId) ?? null}
|
||||
isNodePreviewVisible={input.isNodePreviewVisible}
|
||||
onSelectNode={(treeId, nodeId) => {
|
||||
input.selectDesignImage(treeId);
|
||||
input.selectNode(nodeId);
|
||||
}}
|
||||
onToggleNodeVisibility={(nodeId) =>
|
||||
input.toggleNodePreviewVisibility(nodeId)
|
||||
}
|
||||
onInsertNode={(treeId, parentId) => {
|
||||
input.insertNode(parentId, treeId);
|
||||
}}
|
||||
onInsertNodeAfter={(treeId, nodeId) => {
|
||||
input.insertNodeAfter(nodeId, treeId);
|
||||
}}
|
||||
onDeleteNode={(treeId, nodeId) => {
|
||||
input.deleteNode(nodeId, treeId);
|
||||
}}
|
||||
onMoveNode={input.moveNode}
|
||||
isLocked={isLocked}
|
||||
/>
|
||||
</CollapsibleSidebarPanel>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -177,7 +177,7 @@ export function InspectorSidebar({
|
||||
<InspectorReadOnlyContext.Provider value={inspector.isLocked}>
|
||||
<aside
|
||||
aria-readonly={inspector.isLocked}
|
||||
className={`flex h-full min-h-0 min-w-0 flex-col overflow-x-hidden overflow-y-auto border-l p-4 ${
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto border-l p-4 ${
|
||||
inspector.isLocked
|
||||
? 'border-slate-300 bg-slate-100/90'
|
||||
: 'border-(--platform-subpanel-border) bg-(--platform-subpanel-fill)'
|
||||
|
||||
@@ -37,7 +37,7 @@ export function RecognitionOverview({
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
|
||||
className="shrink-0 border-b border-l border-(--platform-subpanel-border) bg-white/35 px-4 pb-3"
|
||||
aria-label="识别概览"
|
||||
>
|
||||
<span className="text-[10px] font-semibold tracking-wider text-(--platform-text-soft) uppercase">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronDown, Eye, EyeOff, Layers3 } from 'lucide-react';
|
||||
import { ChevronDown, Eye, EyeOff } from 'lucide-react';
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
useEffect,
|
||||
@@ -33,6 +33,7 @@ type UiTreePanelProps = {
|
||||
onDeleteNode: (treeId: UIDesignImageId, nodeId: NodeId) => void;
|
||||
onMoveNode: (request: UiNodeMoveRequest) => void;
|
||||
isLocked: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function TreeRow({
|
||||
@@ -121,8 +122,11 @@ export function UiTreePanel({
|
||||
onDeleteNode,
|
||||
onMoveNode,
|
||||
isLocked,
|
||||
className,
|
||||
}: UiTreePanelProps) {
|
||||
const treeApiRef = useRef<TreeApi<UiNode> | undefined>(undefined);
|
||||
const treeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [treeHeight, setTreeHeight] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
nodeId: NodeId;
|
||||
x: number;
|
||||
@@ -134,6 +138,18 @@ export function UiTreePanel({
|
||||
void treeApiRef.current?.scrollTo(focusRequest.nodeId, 'center');
|
||||
}, [focusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = treeContainerRef.current;
|
||||
if (!container) return;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
setTreeHeight(Math.floor(entry.contentRect.height));
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleMove: MoveHandler<UiNode> = ({
|
||||
dragIds,
|
||||
parentId,
|
||||
@@ -169,21 +185,17 @@ export function UiTreePanel({
|
||||
const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="relative mt-3 rounded-xl border border-(--platform-subpanel-border) bg-white/35 p-3"
|
||||
<div
|
||||
className={`relative flex h-full min-h-0 flex-col ${className ?? ''}`}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers3 size={15} />
|
||||
<h2 className="m-0 text-sm font-semibold">UI Tree</h2>
|
||||
</div>
|
||||
<div className="mt-3 h-64 min-h-0">
|
||||
{root ? (
|
||||
<div ref={treeContainerRef} className="min-h-0 flex-1">
|
||||
{root && treeHeight > 0 ? (
|
||||
<Tree<UiNode>
|
||||
ref={treeApiRef}
|
||||
data={[root]}
|
||||
width="100%"
|
||||
height={256}
|
||||
height={treeHeight}
|
||||
rowHeight={34}
|
||||
indent={16}
|
||||
openByDefault
|
||||
@@ -243,6 +255,6 @@ export function UiTreePanel({
|
||||
onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
mockUiDesignStateStore,
|
||||
type UiDesignStateStore,
|
||||
} from '../../features/ui-editor/uiDesignStateStore';
|
||||
import { BindingOverview } from './components/BindingOverview';
|
||||
import { EditorDialogs } from './components/EditorDialogs';
|
||||
import { ImportOverview } from './components/ImportOverview';
|
||||
import { InputSidebar } from './components/InputSidebar';
|
||||
import { InspectorSidebar } from './components/Inspector/InspectorSidebar';
|
||||
import { PreviewWorkspace } from './components/preview/PreviewWorkspace';
|
||||
import { RecognitionOverview } from './components/RecognitionOverview';
|
||||
import { ToolNavigation } from './components/ToolNavigation';
|
||||
import { WorkflowActionCard } from './components/WorkflowActionCard';
|
||||
import { UI_EDITOR_STEPS } from './model';
|
||||
@@ -125,7 +128,33 @@ export default function UiEditorPage({
|
||||
<WorkflowActionCard workflow={session.workflow} />
|
||||
<PreviewWorkspace canvas={session.canvas} />
|
||||
</div>
|
||||
<InspectorSidebar inspector={session.inspector} />
|
||||
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
{session.input.activeStep === 'structure-recognition' ? (
|
||||
<RecognitionOverview
|
||||
uiTrees={session.input.uiTrees}
|
||||
onFocusStatusNode={(treeId, nodeId) => {
|
||||
session.input.focusNode(treeId, nodeId);
|
||||
session.input.highlightStatusField('layout_status');
|
||||
}}
|
||||
/>
|
||||
) : session.input.activeStep === 'visual-binding' ? (
|
||||
<BindingOverview
|
||||
uiTrees={session.input.uiTrees}
|
||||
sprites={session.input.sprites}
|
||||
onFocusStatusNode={(treeId, nodeId) => {
|
||||
session.input.focusNode(treeId, nodeId);
|
||||
session.input.highlightStatusField('components_status');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ImportOverview
|
||||
images={session.input.images}
|
||||
sprites={session.input.sprites}
|
||||
uiTrees={session.input.uiTrees}
|
||||
/>
|
||||
)}
|
||||
<InspectorSidebar inspector={session.inspector} />
|
||||
</div>
|
||||
</div>
|
||||
<EditorDialogs dialogs={session.dialogs} />
|
||||
<ThemedModal
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CollapsibleSidebarPanel } from '../src/view/ui-editor/components/CollapsibleSidebarPanel';
|
||||
|
||||
describe('CollapsibleSidebarPanel', () => {
|
||||
it('uses a zero flex basis when panels expand', () => {
|
||||
render(
|
||||
<div className="flex h-96 flex-col gap-3">
|
||||
<CollapsibleSidebarPanel label="Tree" title="UI Tree" grow={2}>
|
||||
<div>tree content</div>
|
||||
</CollapsibleSidebarPanel>
|
||||
<CollapsibleSidebarPanel label="Input" title="界面图">
|
||||
<div>input content</div>
|
||||
</CollapsibleSidebarPanel>
|
||||
</div>,
|
||||
);
|
||||
|
||||
const treePanel = screen
|
||||
.getByRole('heading', { name: 'UI Tree' })
|
||||
.closest('section');
|
||||
const inputPanel = screen
|
||||
.getByRole('heading', { name: '界面图' })
|
||||
.closest('section');
|
||||
|
||||
expect(treePanel?.style.flex).toBe('2 1 0px');
|
||||
expect(inputPanel?.style.flex).toBe('1 1 0px');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /UI Tree/ }));
|
||||
expect(treePanel?.style.flex).toBe('0 0 auto');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /UI Tree/ }));
|
||||
expect(treePanel?.style.flex).toBe('2 1 0px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user