perf(canvas): 画布渲染 - 降低大画布节点开销

This commit is contained in:
ddCat
2026-09-24 00:05:32 +08:00
parent 8822cb137c
commit 8a35a0ea98
17 changed files with 734 additions and 352 deletions

View File

@@ -5,6 +5,7 @@ import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, C
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod";
import { canvasConnectionTilt } from "@/lib/canvas/canvas-connection-tilt";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
@@ -20,6 +21,7 @@ type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type CanvasNodeProps = {
data: CanvasNodeData;
renderLOD?: CanvasNodeRenderLOD;
dragOffset?: Position;
scale: number;
isSelected: boolean;
@@ -68,6 +70,7 @@ type CanvasNodeProps = {
export const CanvasNode = React.memo(function CanvasNode({
data,
renderLOD = "full",
dragOffset,
scale,
isSelected,
@@ -128,6 +131,8 @@ export const CanvasNode = React.memo(function CanvasNode({
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const showStatusTrack = Boolean(resourceLabel || data.metadata?.locked || isBatchRoot || (isBatchChild && !readOnly) || (hasMediaContent && !readOnly));
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const effectiveRenderLOD: CanvasNodeRenderLOD = renderLOD === "full" || hovered || isEditingContent || isEditingTitle || mediaActive ? "full" : renderLOD;
const showChrome = effectiveRenderLOD === "full";
const nodeState = isFocusRelated ? "focus" : isConnectionTarget ? "target" : isSelected ? "selected" : isRelated && !isBatchChild ? "related" : "idle";
const showOutputConnection = getNodeDefinition(data.type)?.showOutputConnection !== false;
const assetTags = data.metadata?.assetTags?.filter((tag) => tag.trim()) || [];
@@ -272,6 +277,7 @@ export const CanvasNode = React.memo(function CanvasNode({
return (
<div
data-node-id={data.id}
data-node-lod={effectiveRenderLOD}
className={`node-element absolute flex select-none flex-col ${dragOffset ? "cursor-grabbing" : data.type === CanvasNodeType.Drawing ? "cursor-pointer" : "cursor-default"} ${isSelected && data.type === CanvasNodeType.Video ? "z-[var(--z-node-toolbar)]" : isSelected || isFocusRelated || isConnectionTarget ? "z-[var(--z-node-active)]" : "z-[var(--z-node)]"}`}
style={{
transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`,
@@ -289,7 +295,7 @@ export const CanvasNode = React.memo(function CanvasNode({
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<NodeExternalHeader
{showChrome ? <NodeExternalHeader
node={data}
scale={scale}
dimensionLabel={mediaDimensionLabel}
@@ -303,7 +309,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onEdit={() => setIsEditingTitle(true)}
onCommit={commitTitle}
onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }}
/>
/> : null}
<div
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
data-node-state={nodeState}
@@ -313,7 +319,8 @@ export const CanvasNode = React.memo(function CanvasNode({
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
border: isComposerNode ? "0" : "1px solid transparent",
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
boxShadow: isComposerNode || !showChrome ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
outline: !showChrome && isActive ? `1px solid ${theme.node.activeStroke}` : undefined,
"--connection-tilt-x": `${connectionTilt?.rotateX || 0}deg`,
"--connection-tilt-y": `${connectionTilt?.rotateY || 0}deg`,
transformOrigin: connectionTilt?.origin,
@@ -364,12 +371,13 @@ export const CanvasNode = React.memo(function CanvasNode({
}
>
{/* 节点状态徽章(对应 #97 决策2:左上角 loading/success/error,近距离确认信号)*/}
{data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? (
{showChrome && data.metadata?.status && data.metadata.status !== "idle" && data.type !== CanvasNodeType.Frame ? (
<NodeStatusBadge status={data.metadata.status} />
) : null}
<CanvasNodeContent
node={data}
theme={theme}
renderLOD={effectiveRenderLOD}
isEditingContent={isEditingContent}
textareaRef={textareaRef}
isBatchRoot={isBatchRoot}
@@ -393,7 +401,7 @@ export const CanvasNode = React.memo(function CanvasNode({
/>
</div>
{data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
{showChrome && data.type === CanvasNodeType.Text && data.metadata?.workflowKind !== "character" && !readOnly ? (
<div
className={`absolute bottom-[10%] left-1/2 z-[var(--node-z-overlay)] -translate-x-1/2 motion-safe:transition motion-safe:duration-200 ${isSelected ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"}`}
onMouseDown={(event) => event.stopPropagation()}
@@ -412,7 +420,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
) : null}
{data.metadata?.versionLabel ? (
{showChrome && data.metadata?.versionLabel ? (
<button
type="button"
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
@@ -430,7 +438,7 @@ export const CanvasNode = React.memo(function CanvasNode({
{data.metadata.versionLabel}
</button>
) : null}
{showStatusTrack ? (
{showChrome && showStatusTrack ? (
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
@@ -440,7 +448,7 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
) : null}
{/* 批次子图操作条:成功子项提供下载/副本/设为主图,失败子项提供重试/删除 */}
{isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? (
{showChrome && isBatchChild && !readOnly && (hasImageContent || data.metadata?.status === "error") && (hovered || isSelected) ? (
<div
className="absolute inset-x-0 bottom-2 z-[var(--node-z-overlay)] flex justify-center"
onMouseDown={(event) => event.stopPropagation()}
@@ -459,19 +467,19 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
) : null}
{/* 批次主图位(折叠根节点封面)常驻下载按钮 */}
{isBatchRoot && hasImageContent && !readOnly ? (
{showChrome && isBatchRoot && hasImageContent && !readOnly ? (
<div className="absolute bottom-2 right-2 z-[var(--node-z-overlay)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<BatchChildActionButton theme={theme} label="下载主图" icon={<Download className="size-3.5" />} onClick={() => downloadNode?.(data)} />
</div>
) : null}
{assetTags.length || (showImageInfo && hasImageContent) ? (
{showChrome && (assetTags.length || (showImageInfo && hasImageContent)) ? (
<div className="pointer-events-none absolute inset-x-3 bottom-3 z-[var(--node-z-overlay)] flex items-end justify-between gap-2">
{assetTags.length ? <AssetTagBadges tags={assetTags} theme={theme} /> : null}
{showImageInfo && hasImageContent ? <CanvasNodeImageInfo node={data} /> : null}
</div>
) : null}
{!readOnly && !data.metadata?.locked && (isSelected || hovered) ? <>
{showChrome && !readOnly && !data.metadata?.locked && (isSelected || hovered) ? <>
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
@@ -479,8 +487,8 @@ export const CanvasNode = React.memo(function CanvasNode({
</> : null}
</div>
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.BatchTable ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? <ConnectionSideRail side="right" scale={scale} theme={theme} visible={hovered} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
{(showChrome || forceInputVisible) && !readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.BatchTable ? <ConnectionSideRail side="left" scale={scale} theme={theme} visible={hovered || forceInputVisible} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
{showChrome && !readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && showOutputConnection ? <ConnectionSideRail side="right" scale={scale} theme={theme} visible={hovered} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
</div>
);
@@ -489,6 +497,7 @@ export const CanvasNode = React.memo(function CanvasNode({
function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProps) {
return (
previous.data === next.data &&
previous.renderLOD === next.renderLOD &&
previous.dragOffset?.x === next.dragOffset?.x &&
previous.dragOffset?.y === next.dragOffset?.y &&
previous.scale === next.scale &&