feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验

统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
ddCat
2026-09-01 14:57:05 +08:00
parent 434f63e153
commit 3f4a9ea3d0
45 changed files with 1348 additions and 286 deletions

View File

@@ -0,0 +1,11 @@
import type { Asset } from "@/stores/use-asset-store";
const PRIMARY_VERSION_ID_MAX_LENGTH = 36;
export function assetForRemoteSync(asset: Asset): Asset {
const primaryVersionId = asset.primaryVersionId?.trim();
if (!primaryVersionId || [...primaryVersionId].length <= PRIMARY_VERSION_ID_MAX_LENGTH) return asset;
const { primaryVersionId: _invalidPrimaryVersionId, ...remoteAsset } = asset;
return remoteAsset as Asset;
}

View File

@@ -1,4 +1,5 @@
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Position } from "@/types/canvas";
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
export const FRAME_HEADER_HEIGHT = 36;
export const FRAME_PADDING = 24;
@@ -52,23 +53,44 @@ export function findFrameDropTarget(nodes: CanvasNodeData[], draggedNodeIds: Set
return (
[...nodes]
.reverse()
.find((frame) => {
if (!isFrameNode(frame) || (frame.metadata?.frame?.collapsed && !isCanvasFolderNode(frame)) || draggedNodeIds.has(frame.id)) return false;
const canContain = frame.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : isCanvasFolderNode(frame) ? canFolderContain : canFrameContain;
if (!dragged.every(canContain)) return false;
const left = frame.position.x;
const top = frame.position.y + (isCanvasFolderNode(frame) && frame.metadata?.frame?.collapsed ? 0 : FRAME_HEADER_HEIGHT);
const right = frame.position.x + frame.width;
const bottom = frame.position.y + frame.height;
return dragged.every((node) => {
const centerX = node.position.x + node.width / 2;
const centerY = node.position.y + node.height / 2;
return centerX >= left && centerX <= right && centerY >= top && centerY <= bottom;
});
})?.id || null
.find((frame) => isValidFrameDropTarget(frame, dragged, draggedNodeIds))?.id || null
);
}
export type CanvasFrameDropIndex = CanvasSpatialIndex<CanvasNodeData>;
export function buildCanvasFrameDropIndex(nodes: CanvasNodeData[]): CanvasFrameDropIndex {
return buildCanvasSpatialIndex(nodes.filter(isFrameNode).map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
}
/** Finds a drop target using the drag preview offset without rebuilding all node positions. */
export function findFrameDropTargetFromIndex(index: CanvasFrameDropIndex, draggedNodes: CanvasNodeData[], draggedNodeIds: Set<string>, offset: Position) {
if (!draggedNodes.length) return null;
const centers = draggedNodes.map((node) => ({ x: node.position.x + offset.x + node.width / 2, y: node.position.y + offset.y + node.height / 2 }));
const left = Math.min(...centers.map((center) => center.x)) - 0.01;
const top = Math.min(...centers.map((center) => center.y)) - 0.01;
const right = Math.max(...centers.map((center) => center.x)) + 0.01;
const bottom = Math.max(...centers.map((center) => center.y)) + 0.01;
return [...index.query({ left, top, right, bottom })]
.reverse()
.find((frame) => isValidFrameDropTarget(frame, draggedNodes, draggedNodeIds, offset))?.id || null;
}
function isValidFrameDropTarget(frame: CanvasNodeData, dragged: CanvasNodeData[], draggedNodeIds: Set<string>, offset: Position = { x: 0, y: 0 }) {
if (!isFrameNode(frame) || (frame.metadata?.frame?.collapsed && !isCanvasFolderNode(frame)) || draggedNodeIds.has(frame.id)) return false;
const canContain = frame.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : isCanvasFolderNode(frame) ? canFolderContain : canFrameContain;
if (!dragged.every(canContain)) return false;
const left = frame.position.x;
const top = frame.position.y + (isCanvasFolderNode(frame) && frame.metadata?.frame?.collapsed ? 0 : FRAME_HEADER_HEIGHT);
const right = frame.position.x + frame.width;
const bottom = frame.position.y + frame.height;
return dragged.every((node) => {
const centerX = node.position.x + offset.x + node.width / 2;
const centerY = node.position.y + offset.y + node.height / 2;
return centerX >= left && centerX <= right && centerY >= top && centerY <= bottom;
});
}
export function applyFrameDrop(nodes: CanvasNodeData[], draggedNodeIds: Set<string>, frameId: string | null) {
const target = frameId ? nodes.find((node) => node.id === frameId && isFrameNode(node)) : null;
const canContain = target?.metadata?.folder?.assetFolderId ? canLinkedFolderArchive : target && isCanvasFolderNode(target) ? canFolderContain : canFrameContain;

View File

@@ -10,7 +10,15 @@ const BATCH_CHILD_GAP = 36;
const NODE_CLEARANCE = 36;
export function canGenerateImageInPlace(sourceNode: CanvasNodeData | undefined) {
return sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
return canGenerateMediaInPlace(sourceNode, CanvasNodeType.Image);
}
export function canGenerateMediaInPlace(sourceNode: CanvasNodeData | undefined, mediaType: typeof CanvasNodeType.Image | typeof CanvasNodeType.Video | typeof CanvasNodeType.Audio) {
if (sourceNode?.type !== mediaType) return false;
if (sourceNode.metadata?.generationResultPlacement) return sourceNode.metadata.generationResultPlacement === "replace-node";
if (!sourceNode.metadata?.content) return true;
// 兼容显式落点字段引入前创建的复制节点和版本节点。
return Boolean(sourceNode.metadata?.copiedFromNodeId || sourceNode.metadata?.versionOfNodeId);
}
export function imageGenerationGroupSize(rootSize: Size, imageSize: Size, childCount: number): Size {

View File

@@ -3,6 +3,21 @@ import type { SelectionBox, ViewportTransform } from "@/types/canvas";
export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
export const CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT = "canvas:graphics-viewport-preview";
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
export const CANVAS_NODE_DRAG_PREVIEW_EVENT = "canvas:node-drag-preview";
export type CanvasNodeDragPreview = {
x: number;
y: number;
nodeIds: ReadonlySet<string>;
};
type NodeDragPreviewDomState = {
elementsById: Map<string, HTMLElement>;
previousIds: Set<string>;
};
const nodeDragPreviewDomStates = new WeakMap<HTMLDivElement, NodeDragPreviewDomState>();
const liveViewportElements = new WeakMap<HTMLDivElement, { worldLayer: HTMLElement | null; gridLayer: HTMLElement | null }>();
// 空间网格点模式的点半径(像素单位)。远距时使用更小半径,避免点阵糊成一团。
export function canvasDotPx(scale: number): string {
@@ -19,19 +34,35 @@ export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewpo
const gridSize = 48 * viewport.k;
const dotGridSize = canvasDotGridPx(viewport.k);
const committedScale = Number(container.style.getPropertyValue("--canvas-committed-scale")) || viewport.k;
container.style.setProperty("--canvas-live-x", `${viewport.x}px`);
container.style.setProperty("--canvas-live-y", `${viewport.y}px`);
let elements = liveViewportElements.get(container);
if (!elements) {
elements = {
worldLayer: container.querySelector<HTMLElement>("[data-canvas-world-layer]"),
gridLayer: container.querySelector<HTMLElement>("[data-canvas-grid-layer]"),
};
liveViewportElements.set(container, elements);
}
const worldLayer = elements.worldLayer;
if (worldLayer) {
// 平移期间直接更新合成层,避免修改容器继承变量导致所有节点重新计算样式。
worldLayer.style.transformOrigin = "0 0";
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)));
container.style.setProperty("--canvas-live-scale-ratio", String(viewport.k / committedScale));
container.style.setProperty("--canvas-grid-size", `${gridSize}px`);
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
container.style.setProperty("--canvas-dot-grid-size", `${dotGridSize}px`);
container.style.setProperty("--canvas-dot-grid-x", `${viewport.x % dotGridSize}px`);
container.style.setProperty("--canvas-dot-grid-y", `${viewport.y % dotGridSize}px`);
container.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k));
const gridLayer = elements.gridLayer;
if (gridLayer) {
// 网格是唯一需要每帧更新背景偏移的元素,不把这些变量放在画布根节点上继承。
gridLayer.style.setProperty("--canvas-grid-size", `${gridSize}px`);
gridLayer.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
gridLayer.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
gridLayer.style.setProperty("--canvas-dot-grid-size", `${dotGridSize}px`);
gridLayer.style.setProperty("--canvas-dot-grid-x", `${viewport.x % dotGridSize}px`);
gridLayer.style.setProperty("--canvas-dot-grid-y", `${viewport.y % dotGridSize}px`);
gridLayer.style.setProperty("--canvas-dot-size", canvasDotPx(viewport.k));
}
// 图形层必须逐帧跟随 DOM 世界层;浮层和滚动通知仍可按原频率节流。
container.dispatchEvent(new CustomEvent<ViewportTransform>(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, { detail: viewport }));
if (notify) {
@@ -53,6 +84,53 @@ export function subscribeCanvasViewportPreview(container: HTMLDivElement, listen
return () => container.removeEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);
}
/**
* Applies transient node movement without changing React state. Only nodes
* currently mounted in the virtualized world are touched; the committed
* positions are still written by the drag-end path.
*/
export function applyCanvasNodeDragPreview(container: HTMLDivElement | null, preview: CanvasNodeDragPreview | null) {
if (!container) return;
let state = nodeDragPreviewDomStates.get(container);
if (!state) {
state = { elementsById: new Map(), previousIds: new Set() };
nodeDragPreviewDomStates.set(container, state);
}
for (const nodeId of state.previousIds) {
state.elementsById.get(nodeId)?.style.removeProperty("translate");
}
state.previousIds.clear();
if (preview) {
// Build the lookup once per drag session. A single DOM scan is much
// cheaper than one selector query per selected node on every frame.
if (state.elementsById.size === 0) {
container.querySelectorAll<HTMLElement>("[data-node-id]").forEach((element) => {
const nodeId = element.dataset.nodeId;
if (nodeId) state?.elementsById.set(nodeId, element);
});
}
for (const nodeId of preview.nodeIds) {
const element = state.elementsById.get(nodeId);
if (!element || !element.isConnected) continue;
element.style.setProperty("translate", `${preview.x}px ${preview.y}px`);
state.previousIds.add(nodeId);
}
} else {
state.elementsById.clear();
}
container.dispatchEvent(new CustomEvent<CanvasNodeDragPreview | null>(CANVAS_NODE_DRAG_PREVIEW_EVENT, { detail: preview }));
}
export function subscribeCanvasNodeDragPreview(container: HTMLDivElement, listener: (preview: CanvasNodeDragPreview | null) => void) {
const handlePreview = (event: Event) => listener((event as CustomEvent<CanvasNodeDragPreview | null>).detail);
container.addEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
return () => container.removeEventListener(CANVAS_NODE_DRAG_PREVIEW_EVENT, handlePreview);
}
export function applyCanvasSelectionPreview(container: HTMLDivElement | null, selection: SelectionBox) {
container?.dispatchEvent(new CustomEvent<SelectionBox>(CANVAS_SELECTION_PREVIEW_EVENT, { detail: selection }));
}

View File

@@ -1,5 +1,5 @@
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import type { CanvasNodeData, CanvasNodeMetadata, StoryboardRow } from "@/types/canvas";
import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type StoryboardRow } from "@/types/canvas";
const COPY_TITLE_SUFFIX = /^(.*)_copy(\d+)$/i;
@@ -62,6 +62,9 @@ export function isolateCopiedNodeMetadata(node: CanvasNodeData, idMap: ReadonlyM
delete metadata.versionPrimary;
metadata.copiedFromNodeId = node.id;
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) {
metadata.generationResultPlacement = "replace-node";
}
metadata.frame = node.metadata?.frame ? { ...node.metadata.frame } : undefined;
metadata.referenceSetId = remapOwnedNodeId(node.metadata?.referenceSetId, idMap);
metadata.referenceAssetNodeIds = node.metadata?.referenceAssetNodeIds

View File

@@ -50,10 +50,15 @@ export function normalizeCanvasNodeTimestamps(nodes: CanvasNodeData[], fallback:
export function stampCanvasNodeChanges(previousNodes: CanvasNodeData[], nextNodes: CanvasNodeData[], now = new Date().toISOString()) {
if (previousNodes === nextNodes) return previousNodes;
const previousById = new Map(previousNodes.map((node) => [node.id, node]));
// Most canvas edits preserve order and replace one node. Avoid allocating a
// 50k-entry id map and comparing metadata for every untouched node in that
// hot path. The map fallback still handles inserts, deletes and reorders.
const sameOrder = previousNodes.length === nextNodes.length && previousNodes.every((node, index) => node.id === nextNodes[index]?.id);
const previousById = sameOrder ? null : new Map(previousNodes.map((node) => [node.id, node]));
let changed = false;
const stamped = nextNodes.map((node) => {
const previous = previousById.get(node.id);
const stamped = nextNodes.map((node, index) => {
const previous = sameOrder ? previousNodes[index] : previousById?.get(node.id);
if (previous === node) return node;
const createdAt = canvasNodeCreatedAt(node, canvasNodeCreatedAt(previous || node, now)) || now;
const meaningfulChange = previous ? canvasNodeMeaningfullyChanged(previous, node) : false;
const updatedAt = previous
@@ -68,6 +73,33 @@ export function stampCanvasNodeChanges(previousNodes: CanvasNodeData[], nextNode
return changed ? stamped : nextNodes;
}
/**
* Update one node without routing through the generic full-array timestamp
* reconciliation. This is used by media metadata callbacks, which can fire
* in bursts while a large canvas is first becoming visible.
*/
export function updateCanvasNode(nodes: CanvasNodeData[], nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData, now = new Date().toISOString()) {
return updateCanvasNodes(nodes, new Map([[nodeId, update]]), now);
}
export function updateCanvasNodes(nodes: CanvasNodeData[], updates: ReadonlyMap<string, (node: CanvasNodeData) => CanvasNodeData>, now = new Date().toISOString()) {
if (!updates.size) return nodes;
let changed = false;
const next = nodes.map((current) => {
const update = updates.get(current.id);
if (!update) return current;
const updated = update(current);
if (updated === current) return current;
changed = true;
const createdAt = canvasNodeCreatedAt(updated, canvasNodeCreatedAt(current, now)) || now;
const updatedAt = canvasNodeMeaningfullyChanged(current, updated)
? now
: canvasNodeUpdatedAt(updated, canvasNodeUpdatedAt(current, createdAt)) || createdAt;
return updated.createdAt === createdAt && updated.updatedAt === updatedAt ? updated : { ...updated, createdAt, updatedAt };
});
return changed ? next : nodes;
}
function canvasNodeMeaningfullyChanged(previous: CanvasNodeData, next: CanvasNodeData) {
if (
previous.type !== next.type

View File

@@ -2,6 +2,12 @@ import { CanvasNodeType, type CanvasMediaPerformanceMode, type CanvasNodeData }
const STORAGE_KEY = "canvas-media-performance-mode";
// The DOM budget is deliberately below the connection budget. Image-heavy
// canvases pay a texture/compositing cost per mounted card, even when the
// cards themselves are already virtualized spatially.
export const CANVAS_MAX_RENDERED_NODES = 720;
export const CANVAS_MAX_RENDERED_CONNECTIONS = 5000;
export function readCanvasMediaPerformanceMode(): CanvasMediaPerformanceMode {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
@@ -26,6 +32,17 @@ export function shouldReduceCanvasMediaEffects(mode: CanvasMediaPerformanceMode,
return nodes.length >= 80 || mediaCount >= 32;
}
export function canvasNodeRenderPadding(reduceMediaEffects: boolean, previouslyRendered: boolean) {
if (previouslyRendered) return reduceMediaEffects ? 640 : 384;
return reduceMediaEffects ? 128 : 192;
}
export function canvasNodeRenderBudget(scale: number) {
if (scale < 0.14) return 280;
if (scale < 0.28) return 420;
return CANVAS_MAX_RENDERED_NODES;
}
export function resolveActiveCanvasMediaNodeId(selectedNodeIds: ReadonlySet<string>, nodeById: ReadonlyMap<string, CanvasNodeData>) {
if (selectedNodeIds.size !== 1) return null;
const nodeId = selectedNodeIds.values().next().value;

View File

@@ -550,7 +550,8 @@ export function supportsVideoReferenceAudio(config: AiConfig) {
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
const configWidth = NODE_DEFAULT_SIZE[CanvasNodeType.Config].width;
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
return nodes.map((node) => {
let changed = false;
const reset = nodes.map((node) => {
const mediaNode = ensureMediaNodeMinimumSize(node);
const resizedNode =
mediaNode.type === CanvasNodeType.Config && (mediaNode.width < configWidth || mediaNode.height < configHeight)
@@ -558,8 +559,11 @@ export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
: mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height
? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height }
: mediaNode;
return resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode;
const restoredNode = resizedNode.metadata?.status === "loading" ? { ...resizedNode, metadata: { ...resizedNode.metadata, errorDetails: "正在从任务中心恢复生成状态..." } } : resizedNode;
if (restoredNode !== node) changed = true;
return restoredNode;
});
return changed ? reset : nodes;
}
export function isGenerationCanceled(error: unknown) {

View File

@@ -74,17 +74,56 @@ export function buildAssetMentionReferences(assets: Asset[]): CanvasResourceRefe
});
}
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null, targetNodes?: CanvasNodeData[]) {
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
const sourceNodes = targetNodes ? uniqueCanvasNodes([...targetNodes, ...contextNodes]) : nodes;
const globalReferences = labelResourceNodes(sourceNodes.filter(isResourceNode), false);
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
}
function uniqueCanvasNodes(nodes: CanvasNodeData[]) {
const seen = new Set<string>();
return nodes.filter((node) => {
if (seen.has(node.id)) return false;
seen.add(node.id);
return true;
});
}
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
}
export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], connections: CanvasConnection[], targetNodes: CanvasNodeData[] = nodes) {
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const resourceInputsByTargetId = new Map<string, CanvasNodeData[]>();
const configTargetBySourceId = new Map<string, string>();
for (const connection of connections) {
const source = nodeById.get(connection.fromNodeId);
const target = nodeById.get(connection.toNodeId);
if (!source || !target) continue;
if (isResourceNode(source)) {
const inputs = resourceInputsByTargetId.get(target.id) || [];
inputs.push(source);
resourceInputsByTargetId.set(target.id, inputs);
}
if (target.type === CanvasNodeType.Config && !configTargetBySourceId.has(source.id)) {
configTargetBySourceId.set(source.id, target.id);
}
}
const referencesByNodeId = new Map<string, CanvasResourceReference[]>();
for (const node of targetNodes) {
const configTargetId = configTargetBySourceId.get(node.id);
const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== node.id) : [];
const ownInputs = resourceInputsByTargetId.get(node.id) || [];
const inputs = configInputs.length ? configInputs : ownInputs.length ? ownInputs : isResourceNode(node) ? [node] : [];
referencesByNodeId.set(node.id, labelResourceNodes(inputs, true));
}
return referencesByNodeId;
}
export function buildOrderedCanvasResourceReferences(nodes: CanvasNodeData[], active = true) {
return labelResourceNodes(nodes.filter(isResourceNode), active);
}

View File

@@ -0,0 +1,123 @@
export type CanvasSpatialBounds = {
left: number;
top: number;
right: number;
bottom: number;
};
export type CanvasSpatialIndexEntry<T> = {
id: string;
bounds: CanvasSpatialBounds;
value: T;
};
type Bucket = number[];
export type CanvasSpatialIndex<T> = {
query: (bounds: CanvasSpatialBounds, limit?: number) => T[];
};
const DEFAULT_CELL_SIZE = 1024;
const MAX_BUCKET_COVERAGE = 256;
/**
* A uniform grid keeps viewport queries proportional to nearby content. The
* original entry order is restored after bucket de-duplication so stacking
* behavior remains deterministic.
*/
export function buildCanvasSpatialIndex<T>(entries: CanvasSpatialIndexEntry<T>[], cellSize = DEFAULT_CELL_SIZE): CanvasSpatialIndex<T> {
const normalizedCellSize = Number.isFinite(cellSize) && cellSize > 0 ? cellSize : DEFAULT_CELL_SIZE;
const buckets = new Map<string, Bucket>();
const largeEntryIndexes: number[] = [];
const validEntries: CanvasSpatialIndexEntry<T>[] = [];
entries.forEach((entry) => {
const bounds = normalizeBounds(entry.bounds);
if (!bounds || !entry.id) return;
const index = validEntries.push({ ...entry, bounds }) - 1;
const minColumn = Math.floor(bounds.left / normalizedCellSize);
const maxColumn = Math.floor((bounds.right - Number.EPSILON) / normalizedCellSize);
const minRow = Math.floor(bounds.top / normalizedCellSize);
const maxRow = Math.floor((bounds.bottom - Number.EPSILON) / normalizedCellSize);
if ((maxColumn - minColumn + 1) * (maxRow - minRow + 1) > MAX_BUCKET_COVERAGE) {
largeEntryIndexes.push(index);
return;
}
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
const key = `${column}:${row}`;
const bucket = buckets.get(key);
if (bucket) bucket.push(index);
else buckets.set(key, [index]);
}
}
});
return {
query(bounds, limit) {
const normalizedBounds = normalizeBounds(bounds);
if (!normalizedBounds || validEntries.length === 0) return [];
const resultLimit = Number.isFinite(limit) && (limit || 0) > 0 ? Math.floor(limit as number) : null;
const minColumn = Math.floor(normalizedBounds.left / normalizedCellSize);
const maxColumn = Math.floor((normalizedBounds.right - Number.EPSILON) / normalizedCellSize);
const minRow = Math.floor(normalizedBounds.top / normalizedCellSize);
const maxRow = Math.floor((normalizedBounds.bottom - Number.EPSILON) / normalizedCellSize);
if (resultLimit) {
const candidateIndexes = new Set<number>();
const resultIndexes: number[] = [];
const addBucket = (bucket: Bucket | undefined) => {
if (!bucket) return false;
for (const index of bucket) {
if (candidateIndexes.has(index)) continue;
candidateIndexes.add(index);
const entry = validEntries[index];
if (!intersects(entry.bounds, normalizedBounds)) continue;
resultIndexes.push(index);
if (resultIndexes.length >= resultLimit) return true;
}
return false;
};
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
if (addBucket(buckets.get(`${column}:${row}`))) return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
}
}
for (const index of largeEntryIndexes) {
if (candidateIndexes.has(index)) continue;
const entry = validEntries[index];
if (!intersects(entry.bounds, normalizedBounds)) continue;
resultIndexes.push(index);
if (resultIndexes.length >= resultLimit) break;
}
return resultIndexes.sort((left, right) => left - right).map((index) => validEntries[index].value);
}
const candidateIndexes = new Set<number>();
for (let row = minRow; row <= maxRow; row += 1) {
for (let column = minColumn; column <= maxColumn; column += 1) {
buckets.get(`${column}:${row}`)?.forEach((index) => candidateIndexes.add(index));
}
}
largeEntryIndexes.forEach((index) => candidateIndexes.add(index));
return [...candidateIndexes]
.sort((left, right) => left - right)
.map((index) => validEntries[index])
.filter((entry) => intersects(entry.bounds, normalizedBounds))
.map((entry) => entry.value);
},
};
}
export function canvasNodeBounds(node: { position: { x: number; y: number }; width: number; height: number }): CanvasSpatialBounds {
return { left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height };
}
function normalizeBounds(bounds: CanvasSpatialBounds | undefined): CanvasSpatialBounds | null {
if (!bounds) return null;
const { left, top, right, bottom } = bounds;
if (![left, top, right, bottom].every(Number.isFinite) || right <= left || bottom <= top) return null;
return { left, top, right, bottom };
}
function intersects(left: CanvasSpatialBounds, right: CanvasSpatialBounds) {
return left.right > right.left && left.left < right.right && left.bottom > right.top && left.top < right.bottom;
}

View File

@@ -8,12 +8,14 @@ const workspaceRouteLoaders = {
export const loadAssetsPage = workspaceRouteLoaders.assets;
export const loadCanvasPage = workspaceRouteLoaders.canvas;
export const loadCanvasProjectPage = () => import("@/pages/canvas/project");
export const loadCreatePage = workspaceRouteLoaders.create;
export const loadProjectsPage = workspaceRouteLoaders.projects;
export const loadWalletPage = workspaceRouteLoaders.wallet;
export function preloadWorkspaceRoute(pathnameOrSlug: string) {
const slug = pathnameOrSlug.replace(/^\//, "").split("/", 1)[0] as keyof typeof workspaceRouteLoaders;
const load = workspaceRouteLoaders[slug];
// 根路径就是创作页,预加载时仍映射到其内部模块名。
const slug = pathnameOrSlug.replace(/^\//, "").split("/", 1)[0] || "create";
const load = workspaceRouteLoaders[slug as keyof typeof workspaceRouteLoaders];
if (load) void load();
}