perf(canvas): 画布视口 - 交互期避免强制布局和整树样式重算
拖拽和缩放时用世界坐标定位浮层,暂停更新继承缩放变量,概览优先用缩略图并限制解码缓存。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
|
||||
if (notify) {
|
||||
|
||||
@@ -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<HTMLImageElement>; consumers: number };
|
||||
const preparations = new Map<string, SharedPreparation>();
|
||||
const preparedImages = new Map<string, { image: HTMLImageElement; pixels: number; expiresAt: number }>();
|
||||
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<HTMLImageElement> {
|
||||
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<typeof setTimeout>;
|
||||
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<HTMLImageElement> {
|
||||
function enqueueCanvasImage(src: string, signal: AbortSignal, timeoutMs: number): Promise<HTMLImageElement> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user