重构 Inspector 组件和 PreviewWorkspace 功能:

- 合并 InspectorContent 逻辑到 InspectorSidebar。
- 优化 Inspector 内容渲染,移除重复代码,支持组件统一删除操作。
- 添加节点交互逻辑接口 (`useNodeTransformInteraction`),提升 PreviewWorkspace 的可维护性。
This commit is contained in:
2026-08-14 20:24:43 +08:00
parent a213f5b503
commit 7c2be4abd2
3 changed files with 227 additions and 274 deletions
@@ -84,35 +84,6 @@ export function EditorDialogs({
</button>
</div>
</ThemedModal>
<ThemedModal
open={controller.pendingRoleChange !== null}
onClose={controller.cancelRoleChange}
ariaLabel="确认移除页面组件树"
panelClassName="w-[440px] rounded-2xl p-5"
>
<h2 className="m-0 text-base font-semibold"></h2>
<p className="text-sm leading-6 text-(--platform-text-soft)">
Page UI
Tree
</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={controller.cancelRoleChange}
>
</button>
<button
type="button"
className="rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white"
onClick={controller.confirmRoleChange}
>
</button>
</div>
</ThemedModal>
</>
);
}
@@ -11,6 +11,7 @@ import type { UIDesignImageRole } from '../../../../features/ui-editor/types/UID
import { UI_DESIGN_IMAGE_ROLES } from '../../model';
import type { UiEditorPageController } from '../../useUiEditorPage';
import { PrerequisiteIssues } from '../PrerequisiteIssues';
import { ComponentPanel } from './Components/ComponentPanel';
import { SpriteBorderEditor } from './SpriteBorder/SpriteBorderEditor';
import TransformEditor from './Transform/TransformEditor';
@@ -59,6 +60,68 @@ export function InspectorSidebar({
controller: UiEditorPageController;
}) {
const view = getInspectorView(controller);
let inspectorContent: ReactNode;
switch (view.kind) {
case 'node':
inspectorContent = (
<NodeInspector
node={view.node}
parentSize={view.parentSize}
readOnly={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
}
keepChildrenUnchanged={controller.keepChildrenUnchanged}
onKeepChildrenUnchangedChange={controller.setKeepChildrenUnchanged}
onMetadataChange={controller.setNodeMetadata}
onTransformChange={controller.setNodeTransform}
sprites={controller.sprites}
fonts={controller.editor.state.font_assets}
onSetComponents={controller.setNodeComponents}
onInsertComponent={controller.insertNodeComponent}
onDeleteComponent={controller.deleteNodeComponent}
onMoveComponent={controller.moveNodeComponent}
/>
);
break;
case 'sprite':
inspectorContent = (
<SpriteInspector
sprite={view.sprite}
spriteId={view.spriteId}
previewUrl={view.previewUrl}
referenceCount={view.referenceCount}
onNameChange={controller.setSpriteName}
onAssetTypeChange={controller.setSpriteAssetType}
onBorderChange={controller.setSpriteBorder}
onDelete={() => controller.requestSpriteRemoval(view.spriteId)}
deleteDisabled={controller.editor.isLocked}
/>
);
break;
case 'image':
inspectorContent = (
<ImageInspector
image={view.image}
imageId={view.imageId}
pageOptions={view.pageOptions}
onNameChange={controller.setImageName}
onDescriptionChange={controller.setImageDescription}
onRoleChange={controller.setImageRole}
onSlaveToChange={controller.setImageSlaveTo}
onDelete={() => controller.requestDesignImageRemoval(view.imageId)}
deleteDisabled={controller.editor.isLocked}
/>
);
break;
case 'empty':
inspectorContent = (
<p className="mt-4 text-xs text-(--platform-text-soft)">
</p>
);
break;
}
return (
<aside className="flex min-h-0 flex-col overflow-y-auto border-l border-(--platform-subpanel-border) bg-(--platform-subpanel-fill) p-4">
@@ -68,28 +131,13 @@ export function InspectorSidebar({
{INSPECTOR_VIEW_TITLES[view.kind]}
</h2>
</div>
<InspectorContent controller={controller} view={view} />
{inspectorContent}
{controller.issues ? (
<div className="mt-5">
<PrerequisiteIssues issues={controller.issues} />
</div>
) : null}
{view.kind === 'sprite' ? (
<DeleteResourceButton
label="删除"
disabled={controller.editor.isLocked}
onClick={() => controller.requestSpriteRemoval(view.spriteId)}
/>
) : view.kind === 'image' ? (
<DeleteResourceButton
label="删除"
disabled={controller.editor.isLocked}
onClick={() => controller.requestDesignImageRemoval(view.imageId)}
/>
) : null}
</aside>
);
}
@@ -137,75 +185,61 @@ function getInspectorView(controller: UiEditorPageController): InspectorView {
return { kind: 'empty' };
}
function InspectorContent({
controller,
view,
}: {
controller: UiEditorPageController;
view: InspectorView;
}) {
switch (view.kind) {
case 'node':
return (
<NodeInspector
node={view.node}
parentSize={view.parentSize}
readOnly={
controller.editor.isLocked ||
view.node.id === controller.treeForActiveImage?.root.id
}
onMetadataChange={controller.setNodeMetadata}
onTransformChange={controller.setNodeTransform}
/>
);
case 'sprite':
return (
<SpriteInspector
sprite={view.sprite}
spriteId={view.spriteId}
previewUrl={view.previewUrl}
referenceCount={view.referenceCount}
onNameChange={controller.setSpriteName}
onAssetTypeChange={controller.setSpriteAssetType}
onBorderChange={controller.setSpriteBorder}
/>
);
case 'image':
return (
<ImageInspector
image={view.image}
imageId={view.imageId}
pageOptions={view.pageOptions}
onNameChange={controller.setImageName}
onDescriptionChange={controller.setImageDescription}
onRoleChange={controller.setImageRole}
onSlaveToChange={controller.setImageSlaveTo}
/>
);
case 'empty':
return (
<p className="mt-4 text-xs text-(--platform-text-soft)">
</p>
);
}
}
function NodeInspector({
node,
parentSize,
readOnly,
keepChildrenUnchanged,
onKeepChildrenUnchangedChange,
onMetadataChange,
onTransformChange,
sprites,
fonts,
onSetComponents,
onInsertComponent,
onDeleteComponent,
onMoveComponent,
}: {
node: SelectedNode;
parentSize: UiEditorPageController['selectedNodeParentSize'];
readOnly: boolean;
keepChildrenUnchanged: boolean;
onKeepChildrenUnchangedChange: (value: boolean) => void;
onMetadataChange: UiEditorPageController['setNodeMetadata'];
onTransformChange: UiEditorPageController['setNodeTransform'];
sprites: UiEditorPageController['sprites'];
fonts: UiEditorPageController['editor']['state']['font_assets'];
onSetComponents: UiEditorPageController['setNodeComponents'];
onInsertComponent: UiEditorPageController['insertNodeComponent'];
onDeleteComponent: UiEditorPageController['deleteNodeComponent'];
onMoveComponent: UiEditorPageController['moveNodeComponent'];
}) {
return (
<div className="mt-4 space-y-4">
<div
className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 text-[10px]"
role="group"
aria-label="子节点布局行为"
>
<button
type="button"
aria-pressed={!keepChildrenUnchanged}
disabled={readOnly}
onClick={() => onKeepChildrenUnchangedChange(false)}
className={`flex-1 rounded-md px-2 py-1.5 transition ${!keepChildrenUnchanged ? 'bg-blue-600 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
>
</button>
<button
type="button"
aria-pressed={keepChildrenUnchanged}
disabled={readOnly}
onClick={() => onKeepChildrenUnchangedChange(true)}
className={`flex-1 rounded-md px-2 py-1.5 transition ${keepChildrenUnchanged ? 'bg-orange-500 text-white' : 'text-(--platform-text-soft) hover:bg-black/5'} disabled:cursor-not-allowed disabled:opacity-50`}
>
</button>
</div>
<InspectorInput
label="名称"
value={node.metadata.name}
@@ -232,9 +266,16 @@ function NodeInspector({
readOnly={readOnly}
onChange={onTransformChange}
/>
<p className="m-0 text-[10px] text-(--platform-text-soft)">
</p>
<ComponentPanel
components={node.components}
sprites={sprites}
fonts={fonts}
readOnly={readOnly}
onSetComponents={onSetComponents}
onInsertComponent={onInsertComponent}
onDeleteComponent={onDeleteComponent}
onMoveComponent={onMoveComponent}
/>
<ResourceId value={node.id} />
</div>
);
@@ -248,6 +289,8 @@ function SpriteInspector({
onNameChange,
onAssetTypeChange,
onBorderChange,
onDelete,
deleteDisabled,
}: {
sprite: SelectedSprite;
spriteId: SelectedSpriteId;
@@ -256,6 +299,8 @@ function SpriteInspector({
onNameChange: UiEditorPageController['setSpriteName'];
onAssetTypeChange: UiEditorPageController['setSpriteAssetType'];
onBorderChange: UiEditorPageController['setSpriteBorder'];
onDelete: () => void;
deleteDisabled: boolean;
}) {
return (
<div className="mt-4 space-y-4">
@@ -293,6 +338,11 @@ function SpriteInspector({
onChange={onBorderChange}
/>
<ResourceId value={spriteId} />
<DeleteResourceButton
label="删除"
disabled={deleteDisabled}
onClick={onDelete}
/>
</div>
);
}
@@ -305,6 +355,8 @@ function ImageInspector({
onDescriptionChange,
onRoleChange,
onSlaveToChange,
onDelete,
deleteDisabled,
}: {
image: ActiveImage;
imageId: ActiveImageId;
@@ -313,6 +365,8 @@ function ImageInspector({
onDescriptionChange: UiEditorPageController['setImageDescription'];
onRoleChange: UiEditorPageController['setImageRole'];
onSlaveToChange: UiEditorPageController['setImageSlaveTo'];
onDelete: () => void;
deleteDisabled: boolean;
}) {
return (
<div className="mt-4 space-y-4">
@@ -366,6 +420,11 @@ function ImageInspector({
</InspectorSelect>
) : null}
<ResourceId value={imageId} />
<DeleteResourceButton
label="删除"
disabled={deleteDisabled}
onClick={onDelete}
/>
</div>
);
}
@@ -1,3 +1,18 @@
import {
type CanvasViewport,
createPanDragState,
type DragState,
fitViewportToBounds,
moveViewportFromPan,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
} from '@genarrative/image-canvas-core';
import {
CanvasViewport as SharedCanvasViewport,
CanvasWorld,
ZoomControls,
} from '@genarrative/image-canvas-react';
import { Image as ImageIcon, Minus, Plus, ScanSearch } from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
@@ -6,108 +21,10 @@ import {
useRef,
useState,
} from 'react';
import { Image as ImageIcon, Minus, Plus, ScanSearch } from 'lucide-react';
import {
createPanDragState,
fitViewportToBounds,
moveViewportFromPan,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
type CanvasViewport,
type DragState,
} from '@genarrative/image-canvas-core';
import {
CanvasViewport as SharedCanvasViewport,
CanvasWorld,
ZoomControls,
} from '@genarrative/image-canvas-react';
import type { Node as UiNode } from '../../../features/ui-editor/types/Node';
import { tf2css } from '../../../features/ui-editor/utils/transform/tf2css';
import type { UiEditorPageController } from '../useUiEditorPage';
type NodeDragState = {
nodeId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startTransform: UiNode['transform'];
};
function PreviewNode({
node,
isRoot = false,
selectedNodeId,
onSelectNode,
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
}: {
node: UiNode;
isRoot?: boolean;
selectedNodeId: string | null;
onSelectNode: (id: string) => void;
onNodePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
node: UiNode,
) => void;
onNodePointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;
onNodePointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;
}) {
let geometry;
try {
geometry = tf2css(node.transform);
} catch {
// TODO: expose malformed node diagnostics without breaking the rest of the preview.
return null;
}
return (
<div
data-node-id={node.id}
className={`absolute min-h-0 min-w-0 ${isRoot ? 'cursor-default' : 'cursor-move'}`}
style={{
...geometry,
outline:
selectedNodeId === node.id
? '2px solid rgb(249 115 22)'
: '1px solid rgb(59 130 246 / 70%)',
outlineOffset: selectedNodeId === node.id ? 0 : -1,
backgroundColor:
selectedNodeId === node.id
? 'rgb(249 115 22 / 10%)'
: 'rgb(59 130 246 / 4%)',
}}
onClick={(event) => {
event.stopPropagation();
onSelectNode(node.id);
}}
onPointerDown={(event) => onNodePointerDown(event, node)}
onPointerMove={onNodePointerMove}
onPointerUp={onNodePointerUp}
onPointerCancel={onNodePointerUp}
title={node.metadata.name || undefined}
>
{node.metadata.name ? (
<span className="pointer-events-none absolute left-0 top-0 max-w-full truncate bg-blue-600/80 px-1 text-[9px] leading-4 text-white">
{node.metadata.name}
</span>
) : null}
{/* TODO: render supported Component variants once component preview is in scope. */}
{node.children.map((child) => (
<PreviewNode
key={child.id}
node={child}
selectedNodeId={selectedNodeId}
onSelectNode={onSelectNode}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
/>
))}
</div>
);
}
import type { UiEditorPageController } from '../../useUiEditorPage';
import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer';
import { useNodeTransformInteraction } from './useNodeTransformInteraction';
export function PreviewWorkspace({
controller,
@@ -118,13 +35,14 @@ export function PreviewWorkspace({
const viewportElementRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<CanvasViewport>({ x: 0, y: 0, scale: 0.5 });
const panRef = useRef<Extract<DragState, { kind: 'pan' }> | null>(null);
const nodeDragRef = useRef<NodeDragState | null>(null);
const [viewport, setViewportState] = useState<CanvasViewport>(
viewportRef.current,
);
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
const [spaceHeld, setSpaceHeld] = useState(false);
const tree = controller.treeForActiveImage;
const [renderMode, setRenderMode] =
useState<UiEditorRenderMode>('editor-overlay');
const tree = controller.treeForActiveImage ?? null;
const logicalSize = useMemo(() => {
if (!activeImage) return null;
const ppu = activeImage.pixels_per_unit;
@@ -146,6 +64,23 @@ export function PreviewWorkspace({
activeImage?.pixels_per_unit,
]);
const nodeInteractions = useNodeTransformInteraction({
activeImageId,
controller,
logicalSize,
spaceHeld,
tree,
viewportRef,
});
const {
onNodePointerDown,
onNodePointerMove,
onNodePointerUp,
onNodeResizePointerDown,
onNodeResizePointerMove,
onNodeResizePointerUp,
} = nodeInteractions;
const setViewport = useCallback((next: CanvasViewport) => {
viewportRef.current = next;
setViewportState(next);
@@ -241,54 +176,6 @@ export function PreviewWorkspace({
};
}, [fitToCanvas, logicalSize, setViewport]);
const handleNodePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>, node: UiNode) => {
if (event.button !== 0 || spaceHeld || node.id === tree?.root.id) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
controller.selectNode(node.id);
nodeDragRef.current = {
nodeId: node.id,
pointerId: event.pointerId,
startClientX: event.clientX,
startClientY: event.clientY,
startTransform: structuredClone(node.transform),
};
},
[controller, spaceHeld, tree?.root.id],
);
const handleNodePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
const drag = nodeDragRef.current;
if (!drag || drag.pointerId !== event.pointerId || !activeImageId) return;
const logicalDeltaX =
(event.clientX - drag.startClientX) / viewportRef.current.scale;
const logicalDeltaY =
(event.clientY - drag.startClientY) / viewportRef.current.scale;
const nextTransform = structuredClone(drag.startTransform);
nextTransform.offset_min[0] += logicalDeltaX;
nextTransform.offset_min[1] += logicalDeltaY;
nextTransform.offset_max[0] += logicalDeltaX;
nextTransform.offset_max[1] += logicalDeltaY;
controller.updateNodeTransform(activeImageId, drag.nodeId, nextTransform);
},
[activeImageId, controller],
);
const handleNodePointerUp = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation();
if (nodeDragRef.current?.pointerId !== event.pointerId) return;
nodeDragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
},
[],
);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button === 1 || (event.button === 0 && spaceHeld)) {
event.preventDefault();
@@ -346,12 +233,34 @@ export function PreviewWorkspace({
<h1 className="m-0 text-sm font-semibold">
{controller.activeToolLabel}
</h1>
<span className="text-[10px] text-(--platform-text-soft)">
{Object.keys(controller.images).length} ·{' '}
{Object.keys(controller.sprites).length}
</span>
<div className="flex items-center gap-3">
<div className="flex items-center rounded-lg border border-(--platform-subpanel-border) bg-white/70 p-0.5 text-[10px]">
<button
type="button"
className={`rounded-md px-2 py-1 ${renderMode === 'editor-overlay' ? 'bg-blue-600 text-white' : 'text-(--platform-text-soft)'}`}
aria-pressed={renderMode === 'editor-overlay'}
onClick={() => setRenderMode('editor-overlay')}
>
</button>
<button
type="button"
className={`rounded-md px-2 py-1 ${renderMode === 'final-preview' ? 'bg-orange-500 text-white' : 'text-(--platform-text-soft)'}`}
aria-pressed={renderMode === 'final-preview'}
onClick={() => setRenderMode('final-preview')}
>
</button>
</div>
<span className="text-[10px] text-(--platform-text-soft)">
{Object.keys(controller.images).length} ·{' '}
{Object.keys(controller.sprites).length}
</span>
</div>
</header>
{activeImage && previewUrls[activeImageId ?? ''] && logicalSize ? (
{activeImage &&
logicalSize &&
(renderMode === 'final-preview' || previewUrls[activeImageId ?? '']) ? (
<div className="relative min-h-0 flex-1">
<SharedCanvasViewport
ref={viewportElementRef}
@@ -375,23 +284,37 @@ export function PreviewWorkspace({
className="relative"
style={{ width: logicalSize.width, height: logicalSize.height }}
>
<img
src={previewUrls[activeImageId ?? '']}
alt={activeImage.metadata.name}
className="absolute inset-0 size-full object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
draggable={false}
/>
{tree?.root ? (
<PreviewNode
node={tree.root}
isRoot
selectedNodeId={controller.selectedNodeId}
onSelectNode={controller.selectNode}
onNodePointerDown={handleNodePointerDown}
onNodePointerMove={handleNodePointerMove}
onNodePointerUp={handleNodePointerUp}
{renderMode === 'editor-overlay' ? (
<img
src={previewUrls[activeImageId ?? '']}
alt={activeImage.metadata.name}
className="absolute inset-0 size-full object-fill shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
draggable={false}
/>
) : null}
) : (
<div
className="absolute inset-0 bg-[#f7f4ef] shadow-[0_18px_50px_rgb(67_48_37_/_14%)]"
aria-label="最终组件预览画布"
/>
)}
<UiTreeRenderer
tree={tree}
renderMode={renderMode}
selectedNodeId={controller.selectedNodeId}
resources={{
previewUrls,
sprites: controller.sprites,
fonts: editor.state.font_assets,
}}
onSelectNode={controller.selectNode}
onNodePointerDown={onNodePointerDown}
onNodePointerMove={onNodePointerMove}
onNodePointerUp={onNodePointerUp}
onNodeResizePointerDown={onNodeResizePointerDown}
onNodeResizePointerMove={onNodeResizePointerMove}
onNodeResizePointerUp={onNodeResizePointerUp}
viewportScale={viewport.scale}
/>
</div>
</CanvasWorld>
</SharedCanvasViewport>