perf(canvas): 画布视口 - 交互期避免强制布局和整树样式重算

拖拽和缩放时用世界坐标定位浮层,暂停更新继承缩放变量,概览优先用缩略图并限制解码缓存。
This commit is contained in:
ddcat666
2026-10-10 20:19:54 +08:00
parent dd66be15cb
commit 1f5ecca33a
6 changed files with 173 additions and 49 deletions

View File

@@ -53,6 +53,7 @@ export function ImageNodeContent(props: CanvasNodeContentProps) {
if (!hasImage) return <EmptyImageContent {...props} />;
return (
<ImageContent
renderLOD={props.renderLOD}
batchPreviewNodes={props.batchPreviewNodes}
node={props.node}
theme={props.theme}
@@ -463,6 +464,7 @@ export function EmptyMediaContent({ icon, label, color }: { icon: ReactNode; lab
}
export function ImageContent({
renderLOD = "full",
node,
theme,
isBatchRoot,
@@ -472,10 +474,11 @@ export function ImageContent({
batchOpening,
batchRecovering,
onToggleBatch,
}: Pick<CanvasNodeContentProps, "node" | "theme" | "isBatchRoot" | "batchCount" | "batchPreviewNodes" | "batchExpanded" | "batchOpening" | "batchRecovering" | "onToggleBatch">) {
}: Pick<CanvasNodeContentProps, "renderLOD" | "node" | "theme" | "isBatchRoot" | "batchCount" | "batchPreviewNodes" | "batchExpanded" | "batchOpening" | "batchRecovering" | "onToggleBatch">) {
const imageContainerRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(imageContainerRef);
const { url, loading, originalWidth, originalHeight } = useNodeResourceUrl(node, nearViewport, node.metadata?.imageLayer || node.metadata?.imageLayerGroup ? "original" : "thumbnail");
const nearViewport = useNearViewport(imageContainerRef, renderLOD !== "shell");
const resourceVariant = renderLOD === "full" && (node.metadata?.imageLayer || node.metadata?.imageLayerGroup) ? "original" : "thumbnail";
const { url, loading, originalWidth, originalHeight } = useNodeResourceUrl(node, nearViewport, resourceVariant);
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
const { updateMediaNode } = useCanvasNodeActions();
const measuredSizeRef = useRef<{ width: number; height: number } | null>(null);
@@ -518,7 +521,15 @@ export function ImageContent({
};
return (
<BatchFrame batchPreviewNodes={batchPreviewNodes} batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
<BatchFrame
batchPreviewNodes={renderLOD === "full" ? batchPreviewNodes : undefined}
batchCount={isBatchRoot && renderLOD === "full" ? batchCount : 0}
batchExpanded={batchExpanded}
batchOpening={batchOpening}
batchRecovering={batchRecovering}
theme={theme}
onToggleBatch={onToggleBatch}
>
<div ref={imageContainerRef} className="relative h-full w-full overflow-hidden rounded-[var(--node-radius)]">
<RetainedCanvasImage
identity={`${getActiveUserScope()}:${node.id}:${node.metadata?.storageKey || node.metadata?.content || "empty"}`}
@@ -691,9 +702,10 @@ export function useNodeResourceUrl(node: CanvasNodeData, eager: boolean, variant
return { url: current.url, loading: current.loading, originalWidth: isThumbnail ? current.originalWidth : undefined, originalHeight: isThumbnail ? current.originalHeight : undefined };
}
export function useNearViewport(ref: RefObject<Element | null>) {
export function useNearViewport(ref: RefObject<Element | null>, enabled = true) {
const [nearViewport, setNearViewport] = useState(false);
useEffect(() => {
if (!enabled || nearViewport) return;
const element = ref.current;
if (!element || typeof IntersectionObserver === "undefined") {
setNearViewport(true);
@@ -710,7 +722,7 @@ export function useNearViewport(ref: RefObject<Element | null>) {
);
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
}, [enabled, nearViewport, ref]);
return nearViewport;
}

View File

@@ -7,7 +7,7 @@ import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview } from "@/lib/canvas/canvas-live-viewport";
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
@@ -17,7 +17,7 @@ import { producedModelLabel } from "@/lib/canvas/produced-model";
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type Position, type ViewportTransform } from "@/types/canvas";
import { buildImageToolbarTools } from "./canvas-image-toolbar-tools";
import { CanvasGridSplitPicker } from "./canvas-grid-split-picker";
@@ -163,28 +163,41 @@ export function CanvasNodeToolbar({
}
let disposed = false;
let queued = false;
let liveViewport = viewport;
let liveDragOffset: Position | null = null;
let containerRect = container.getBoundingClientRect();
let toolbarWidth = toolbarRef.current?.offsetWidth || 0;
let toolbarHeight = toolbarRef.current?.offsetHeight || 44;
const update = () => {
const nodeRect = element.getBoundingClientRect();
const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2;
// 视口预览帧已经修改了世界层 transform;直接用节点世界坐标计算,
// 避免随后读取 getBoundingClientRect() 强制同步布局。
const scale = Math.max(liveViewport.k, 0.05);
const offsetX = liveDragOffset?.x || 0;
const offsetY = liveDragOffset?.y || 0;
const nodeLeft = liveViewport.x + (node.position.x + offsetX) * scale;
const nodeTop = liveViewport.y + (node.position.y + offsetY) * scale;
const nodeWidth = node.width * scale;
const preferredLeft = nodeLeft + nodeWidth / 2;
const halfToolbar = toolbarWidth / 2;
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
let left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
const above = nodeRect.top - containerRect.top - 30;
const above = nodeTop - 30;
let top = Math.max(toolbarHeight + 8, Math.min(above, containerRect.height - 8));
for (const panel of container.querySelectorAll<HTMLElement>("[data-canvas-node-panel]")) {
const panelRect = panel.getBoundingClientRect();
const panelLeft = panelRect.left - containerRect.left;
const panelRight = panelRect.right - containerRect.left;
const panelTop = panelRect.top - containerRect.top;
const panelBottom = panelRect.bottom - containerRect.top;
if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
// 交互期只走轻量定位;面板碰撞读取会在 transform 后触发 layout,
// 提交完成后的下一次更新再恢复精确避让。
if (container.dataset.canvasViewportInteracting !== "true") {
for (const panel of container.querySelectorAll<HTMLElement>("[data-canvas-node-panel]")) {
const panelRect = panel.getBoundingClientRect();
const panelLeft = panelRect.left - containerRect.left;
const panelRight = panelRect.right - containerRect.left;
const panelTop = panelRect.top - containerRect.top;
const panelBottom = panelRect.bottom - containerRect.top;
if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
}
}
if (toolbarRef.current) {
toolbarRef.current.style.transform = `translate3d(${left}px, ${top}px, 0)`;
@@ -192,7 +205,8 @@ export function CanvasNodeToolbar({
}
setAnchor((current) => current?.left === left && current.top === top ? current : { left, top });
};
const scheduleUpdate = () => {
const scheduleUpdate = (nextViewport?: ViewportTransform) => {
if (nextViewport) liveViewport = nextViewport;
if (queued || disposed) return;
queued = true;
queueMicrotask(() => {
@@ -213,11 +227,16 @@ export function CanvasNodeToolbar({
resizeObserver.observe(container);
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, scheduleUpdate);
const unsubscribeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
liveDragOffset = preview?.nodeIds.has(node.id) ? { x: preview.x, y: preview.y } : null;
scheduleUpdate();
});
window.addEventListener("resize", measure);
return () => {
disposed = true;
resizeObserver.disconnect();
unsubscribeViewport();
unsubscribeDrag();
window.removeEventListener("resize", measure);
};
}, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]);

View File

@@ -138,8 +138,9 @@ export function CanvasNodePanelOverlay({
liveViewport = nextViewport;
const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth);
panel.style.width = `${nextWidth}px`;
const nodeElement = container.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(node.id)}"]`);
const position = nodeElement ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
// 视口预览期间世界层刚写入 transform,读取节点布局会强制浏览器同步
// layout。节点位置和拖拽偏移已经在这里可用,直接用世界坐标计算浮层位置。
const position = getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset);
panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`;
};
update(viewport);
@@ -227,8 +228,7 @@ export function CanvasConnectionCreateMenu({
const menu = menuRef.current;
if (!container || !menu) return;
const update = (nextViewport: ViewportTransform) => {
const containerBounds = container.getBoundingClientRect();
const position = getConnectionMenuPosition(pending.position, nextViewport, { width: containerBounds.width, height: containerBounds.height }, menu.offsetWidth || menuWidth, menu.offsetHeight || menuHeight, gap);
const position = getConnectionMenuPosition(pending.position, nextViewport, { width: container.clientWidth, height: container.clientHeight }, menu.offsetWidth || menuWidth, menu.offsetHeight || menuHeight, gap);
menu.style.left = `${position.left}px`;
menu.style.top = `${position.top}px`;
};
@@ -427,17 +427,6 @@ function getConnectionMenuPosition(position: Position, viewport: ViewportTransfo
};
}
function getAttachedNodePanelPosition(nodeElement: HTMLElement, container: HTMLElement, panelWidth: number) {
const gap = 10;
const nodeRect = nodeElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
left: nodeRect.left - containerRect.left + nodeRect.width / 2 - panelWidth / 2,
top: nodeRect.bottom - containerRect.top + gap,
placement: "below" as const,
};
}
export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, _viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) {
const gap = 10;
const offsetX = dragOffset?.x || 0;