diff --git a/web/src/components/canvas/canvas-node-media-content.tsx b/web/src/components/canvas/canvas-node-media-content.tsx index acb88ec98..243084579 100644 --- a/web/src/components/canvas/canvas-node-media-content.tsx +++ b/web/src/components/canvas/canvas-node-media-content.tsx @@ -53,6 +53,7 @@ export function ImageNodeContent(props: CanvasNodeContentProps) { if (!hasImage) return ; return ( ) { +}: Pick) { const imageContainerRef = useRef(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 ( - +
) { +export function useNearViewport(ref: RefObject, 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) { ); observer.observe(element); return () => observer.disconnect(); - }, [ref]); + }, [enabled, nearViewport, ref]); return nearViewport; } diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index c3ee9f636..2f3f5a7bc 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -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("[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("[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]); diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index 9bb404f03..5ffd53cc5 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -138,8 +138,9 @@ export function CanvasNodePanelOverlay({ liveViewport = nextViewport; const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth); panel.style.width = `${nextWidth}px`; - const nodeElement = container.querySelector(`[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; diff --git a/web/src/lib/canvas/canvas-live-viewport.ts b/web/src/lib/canvas/canvas-live-viewport.ts index cabfac325..856249dd9 100644 --- a/web/src/lib/canvas/canvas-live-viewport.ts +++ b/web/src/lib/canvas/canvas-live-viewport.ts @@ -48,9 +48,13 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo worldLayer.style.transform = `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / committedScale})`; worldLayer.style.willChange = container.dataset.canvasViewportInteracting === "true" ? "transform" : ""; } - container.style.setProperty("--canvas-live-scale", String(viewport.k)); - // 外置节点标题用同一帧逆倍率抵消世界层缩放,避免等待 React 提交后再校正尺寸。 - container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05))); + // 交互期不要修改根容器上的继承变量。它们会让整棵节点 DOM 子树失效并 + // 触发样式重算;交互结束后由 CanvasViewport 的 committed render 一次性校正。 + // 标题和选框在交互期暂时跟随世界层缩放,换取连续帧的合成路径。 + if (container.dataset.canvasViewportInteracting !== "true") { + container.style.setProperty("--canvas-live-scale", String(viewport.k)); + container.style.setProperty("--canvas-live-inverse-scale", String(1 / Math.max(viewport.k, 0.05))); + } // 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。 container.dispatchEvent(new CustomEvent(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport })); if (notify) { diff --git a/web/src/services/canvas-image-loader.ts b/web/src/services/canvas-image-loader.ts index 3ea55d3fe..0e45ebf3e 100644 --- a/web/src/services/canvas-image-loader.ts +++ b/web/src/services/canvas-image-loader.ts @@ -1,5 +1,96 @@ +import { getActiveUserScope } from "@/lib/user-scope"; + const MAX_ACTIVE_IMAGE_PREPARATIONS = 4; const IMAGE_PREPARATION_TIMEOUT_MS = 60_000; +const MAX_CACHED_IMAGES = 48; +const MAX_CACHED_PIXELS = 16_000_000; +const IMAGE_CACHE_TTL_MS = 60_000; + +type SharedPreparation = { controller: AbortController; promise: Promise; consumers: number }; +const preparations = new Map(); +const preparedImages = new Map(); +let preparedPixels = 0; +let preparationScope = ""; + +/** Share work across nodes and LOD remounts without letting one consumer cancel the others. */ +export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = IMAGE_PREPARATION_TIMEOUT_MS): Promise { + if (signal.aborted) return Promise.reject(abortError()); + const scope = getActiveUserScope(); + if (scope !== preparationScope) { + preparationScope = scope; + preparedImages.clear(); + preparedPixels = 0; + for (const pending of preparations.values()) pending.controller.abort(); + preparations.clear(); + } + for (const [key, value] of preparedImages) { + if (value.expiresAt <= Date.now()) { + preparedPixels -= value.pixels; + preparedImages.delete(key); + } + } + const cached = preparedImages.get(src); + if (cached) { + preparedImages.delete(src); + preparedImages.set(src, cached); + return Promise.resolve(cached.image); + } + let pending = preparations.get(src); + if (!pending) { + const controller = new AbortController(); + const entry: SharedPreparation = { controller, consumers: 0, promise: enqueueCanvasImage(src, controller.signal, timeoutMs) }; + entry.promise = entry.promise + .then((image) => { + if (controller.signal.aborted || scope !== getActiveUserScope() || preparations.get(src) !== entry) throw abortError(); + const pixels = image.naturalWidth * image.naturalHeight; + if (pixels > 0 && pixels <= MAX_CACHED_PIXELS && !src.startsWith("blob:")) { + while (preparedImages.size >= MAX_CACHED_IMAGES || preparedPixels + pixels > MAX_CACHED_PIXELS) { + const oldest = preparedImages.keys().next().value; + if (oldest === undefined) break; + preparedPixels -= preparedImages.get(oldest)!.pixels; + preparedImages.delete(oldest); + } + preparedImages.set(src, { image, pixels, expiresAt: Date.now() + IMAGE_CACHE_TTL_MS }); + preparedPixels += pixels; + } + return image; + }) + .finally(() => { + if (preparations.get(src) === entry) preparations.delete(src); + }); + pending = entry; + preparations.set(src, pending); + } + const shared = pending; + shared.consumers += 1; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (image?: HTMLImageElement, error?: unknown) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + shared.consumers -= 1; + if (error !== undefined) reject(error); + else resolve(image!); + }; + const onAbort = () => { + finish(undefined, abortError()); + // React can immediately remount the same image. Allow that consumer + // to join before cancelling an otherwise unreferenced job. + queueMicrotask(() => { + if (shared.consumers === 0 && preparations.get(src) === shared) { + preparations.delete(src); + shared.controller.abort(); + } + }); + }; + signal.addEventListener("abort", onAbort, { once: true }); + shared.promise.then( + (image) => finish(image), + (error) => finish(undefined, error), + ); + }); +} type ImagePreparationJob = { src: string; @@ -10,6 +101,7 @@ type ImagePreparationJob = { settled: boolean; image?: HTMLImageElement; timeout?: ReturnType; + timeoutMs: number; onAbort: () => void; }; @@ -17,10 +109,10 @@ const queue: ImagePreparationJob[] = []; let activePreparations = 0; /** Load and decode one image under a shared concurrency budget. Queued work is cancellable. */ -export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = IMAGE_PREPARATION_TIMEOUT_MS): Promise { +function enqueueCanvasImage(src: string, signal: AbortSignal, timeoutMs: number): Promise { if (signal.aborted) return Promise.reject(abortError()); return new Promise((resolve, reject) => { - const job: ImagePreparationJob = { src, signal, resolve, reject, started: false, settled: false, onAbort: () => {} }; + const job: ImagePreparationJob = { src, signal, resolve, reject, started: false, settled: false, timeoutMs, onAbort: () => {} }; job.onAbort = () => { if (job.settled) return; if (!job.started) { @@ -34,7 +126,7 @@ export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = }; signal.addEventListener("abort", job.onAbort, { once: true }); queue.push(job); - pump(timeoutMs); + pump(); }); } @@ -42,7 +134,7 @@ export function canvasImagePreparationStats() { return { active: activePreparations, queued: queue.length, limit: MAX_ACTIVE_IMAGE_PREPARATIONS }; } -function pump(timeoutMs: number) { +function pump() { while (activePreparations < MAX_ACTIVE_IMAGE_PREPARATIONS && queue.length) { const job = queue.shift()!; if (job.settled || job.signal.aborted) { @@ -54,13 +146,14 @@ function pump(timeoutMs: number) { job.timeout = setTimeout(() => { job.image?.removeAttribute("src"); settle(job, undefined, new Error("图片加载或解码超时")); - }, timeoutMs); + }, job.timeoutMs); const image = new Image(); job.image = image; image.decoding = "async"; image.onload = () => { if (job.settled || job.signal.aborted) return; + image.onload = null; void image.decode().then( () => settle(job, image), (error) => settle(job, undefined, error), @@ -77,9 +170,13 @@ function settle(job: ImagePreparationJob, image?: HTMLImageElement, error?: unkn job.settled = true; job.signal.removeEventListener("abort", job.onAbort); if (job.timeout) clearTimeout(job.timeout); + if (job.image) { + job.image.onload = null; + job.image.onerror = null; + } if (job.started) { activePreparations = Math.max(0, activePreparations - 1); - pump(IMAGE_PREPARATION_TIMEOUT_MS); + pump(); } if (error !== undefined) job.reject(error); else if (image) job.resolve(image); diff --git a/web/test/canvas-grid-viewport.test.tsx b/web/test/canvas-grid-viewport.test.tsx index 1ba88868e..3297cb44d 100644 --- a/web/test/canvas-grid-viewport.test.tsx +++ b/web/test/canvas-grid-viewport.test.tsx @@ -70,8 +70,9 @@ describe("canvas screen-space background", () => { for (const viewport of viewports) { applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewport, false); expect(world.style.transform).toBe(`translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / 0.5})`); - expect(properties.get("--canvas-live-scale")).toBe(String(viewport.k)); - expect(properties.get("--canvas-live-inverse-scale")).toBe(String(1 / viewport.k)); + // 交互期不改继承变量,避免整棵节点子树在每一帧强制重算样式。 + expect(properties.has("--canvas-live-scale")).toBe(false); + expect(properties.has("--canvas-live-inverse-scale")).toBe(false); expect(gridWrites).toEqual([]); } expect(graphics).toEqual(viewports); @@ -81,6 +82,8 @@ describe("canvas screen-space background", () => { container.dataset.canvasViewportInteracting = "false"; applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewports[0]); + expect(properties.get("--canvas-live-scale")).toBe("1"); + expect(properties.get("--canvas-live-inverse-scale")).toBe("1"); expect(world.style.willChange).toBe(""); expect(previews).toEqual([viewports[0]]); expect(scrolls).toBe(1);