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

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

No files matched your search

@@ -3,7 +3,7 @@ import { Group, Leafer, Path, Rect } from "leafer-ui";
import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasSelectionPreview, type CanvasNodeDragPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport";
import type { CanvasTheme } from "@/lib/canvas-theme";
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
@@ -37,6 +37,18 @@ type LeaferScene = {
type UnderlayScene = LeaferScene & {
connections: Group;
connectionEntries: Map<string, ConnectionSceneEntry>;
connectionIdsByNodeId: Map<string, Set<string>>;
dragPreview: CanvasNodeDragPreview | null;
dragPreviewConnectionIds: Set<string>;
};
type ConnectionSceneEntry = {
path: Path;
connection: CanvasDisplayConnection["connection"];
from: CanvasNodeData;
to: CanvasNodeData;
signature: string;
};
type OverlayScene = LeaferScene & {
@@ -104,11 +116,15 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => {
syncSelection(overlay.selection, selection, propsRef.current.theme);
});
const unsubscribeNodeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
applyConnectionDragPreview(underlay, propsRef.current, preview);
});
resize();
return () => {
unsubscribe();
unsubscribeSelection();
unsubscribeNodeDrag();
resizeObserver.disconnect();
window.removeEventListener("resize", resize);
underlay.leafer.destroy(true);
@@ -173,7 +189,7 @@ function createUnderlayScene(host: HTMLDivElement): UnderlayScene {
const connections = new Group({ hittable: false });
world.add(connections);
leafer.add(world);
return { leafer, world, host, connections };
return { leafer, world, host, connections, connectionEntries: new Map(), connectionIdsByNodeId: new Map(), dragPreview: null, dragPreviewConnectionIds: new Set() };
}
function createOverlayScene(host: HTMLDivElement): OverlayScene {
@@ -194,22 +210,109 @@ function createOverlayScene(host: HTMLDivElement): OverlayScene {
}
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
scene.connections.removeAll(true);
props.displayConnections.forEach(({ connection, from, to }) => {
const nextIds = new Set(props.displayConnections.map(({ connection }) => connection.id));
for (const [connectionId, entry] of scene.connectionEntries) {
if (nextIds.has(connectionId)) continue;
entry.path.remove();
scene.connectionEntries.delete(connectionId);
}
scene.connectionIdsByNodeId.clear();
const previewIds = scene.dragPreview ? scene.dragPreview.nodeIds : null;
for (const { connection, from, to } of props.displayConnections) {
const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id);
const path = new Path({
path: canvasConnectionPath(connection, from, to, props.scriptScrollTopById[from.id] || 0, props.scriptScrollTopById[to.id] || 0).pathD,
stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted,
strokeWidth: emphasized ? 1.6 : 1,
strokeScaleFixed: true,
strokeCap: "round",
opacity: emphasized ? 0.52 : 0.24,
hittable: false,
});
scene.connections.add(path);
const signature = connectionSceneSignature(connection, from, to, props, emphasized);
let entry = scene.connectionEntries.get(connection.id);
if (!entry) {
const path = new Path({ hittable: false });
scene.connections.add(path);
entry = { path, connection, from, to, signature: "" };
scene.connectionEntries.set(connection.id, entry);
}
entry.connection = connection;
entry.from = from;
entry.to = to;
if (entry.signature !== signature || scene.dragPreview) {
syncConnectionPath(entry, props, scene.dragPreview, previewIds);
entry.signature = signature;
}
for (const nodeId of [from.id, to.id]) {
const connectionIds = scene.connectionIdsByNodeId.get(nodeId) || new Set<string>();
connectionIds.add(connection.id);
scene.connectionIdsByNodeId.set(nodeId, connectionIds);
}
}
scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, scene.dragPreview);
}
function connectionSceneSignature(connection: CanvasDisplayConnection["connection"], from: CanvasNodeData, to: CanvasNodeData, props: CanvasLeaferGraphicsLayerProps, emphasized: boolean) {
return [
connection.id,
connection.fromNodeId,
connection.toNodeId,
connection.fromHandleId || "",
connection.toHandleId || "",
from.id,
from.position.x,
from.position.y,
from.width,
from.height,
to.id,
to.position.x,
to.position.y,
to.width,
to.height,
props.scriptScrollTopById[from.id] || 0,
props.scriptScrollTopById[to.id] || 0,
emphasized ? "active" : "idle",
props.theme.accent.primary,
props.theme.node.muted,
].join("|");
}
function syncConnectionPath(entry: ConnectionSceneEntry, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null, previewIds: ReadonlySet<string> | null = preview?.nodeIds || null) {
const from = translatePreviewNode(entry.from, previewIds, preview);
const to = translatePreviewNode(entry.to, previewIds, preview);
const emphasized = props.selectedConnectionId === entry.connection.id || props.relatedConnectionIds.has(entry.connection.id);
entry.path.set({
path: canvasConnectionPath(entry.connection, from, to, props.scriptScrollTopById[entry.from.id] || 0, props.scriptScrollTopById[entry.to.id] || 0).pathD,
stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted,
strokeWidth: emphasized ? 1.6 : 1,
strokeScaleFixed: true,
strokeCap: "round",
opacity: emphasized ? 0.52 : 0.24,
hittable: false,
});
}
function translatePreviewNode(node: CanvasNodeData, previewIds: ReadonlySet<string> | null, preview: CanvasNodeDragPreview | null) {
if (!preview || !previewIds?.has(node.id) || (preview.x === 0 && preview.y === 0)) return node;
return { ...node, position: { x: node.position.x + preview.x, y: node.position.y + preview.y } };
}
function collectPreviewConnectionIds(scene: UnderlayScene, preview: CanvasNodeDragPreview | null) {
const connectionIds = new Set<string>();
if (!preview) return connectionIds;
for (const nodeId of preview.nodeIds) {
scene.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => connectionIds.add(connectionId));
}
return connectionIds;
}
function applyConnectionDragPreview(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null) {
const affectedConnectionIds = new Set(scene.dragPreviewConnectionIds);
const previewIds = preview?.nodeIds || null;
collectPreviewConnectionIds(scene, preview).forEach((connectionId) => affectedConnectionIds.add(connectionId));
scene.dragPreview = preview;
scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, preview);
for (const connectionId of affectedConnectionIds) {
const entry = scene.connectionEntries.get(connectionId);
if (!entry) continue;
syncConnectionPath(entry, props, preview, previewIds);
}
}
function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) {
const selection = props.selectionBox;
scene.selection.visible = Boolean(selection);