feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
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);
|
||||
|
||||
Reference in new issue
Block a user