feat(canvas): 画布工作台 - 新增生成副本并修复大型画布性能、素材同步与删除校验
统一图片与视频副本的生成落点并保留参考连接。\n优化大型画布节点虚拟化、媒体懒加载、拖拽与详情进入路径。\n修复素材主版本 ID 同步及删除时的画布独立引用校验。
This commit is contained in:
@@ -38,6 +38,7 @@ type CanvasNodeContextMenuProps = {
|
||||
onRedo: () => void;
|
||||
onPaste: () => void;
|
||||
onCopyNode: () => void;
|
||||
onCreateGenerationCopy: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onSaveAsset: () => void;
|
||||
@@ -72,6 +73,7 @@ export function CanvasNodeContextMenu({
|
||||
onRedo,
|
||||
onPaste,
|
||||
onCopyNode,
|
||||
onCreateGenerationCopy,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onSaveAsset,
|
||||
@@ -201,6 +203,7 @@ export function CanvasNodeContextMenu({
|
||||
<MenuButton icon={<Copy />} label="复制节点" shortcut="⌘C" onClick={() => runAction(onCopyNode)} />
|
||||
{isImage ? <MenuButton icon={<Clipboard />} label="复制图片" disabled={!hasNodeContent} onClick={() => runAction(onCopyContent)} /> : null}
|
||||
<MenuButton icon={<Link2 />} label={isImage ? "复制图片地址" : "复制视频地址"} disabled={!canCopyMediaUrl} onClick={() => runAction(onCopyMediaUrl)} />
|
||||
<MenuButton icon={<Copy />} label="创建生成副本" onClick={() => runAction(onCreateGenerationCopy)} />
|
||||
<MenuButton icon={<Layers3 />} label="创建参数变体" shortcut="⌘D" onClick={() => runAction(onDuplicate)} />
|
||||
<MenuButton icon={<Trash2 />} label="删除节点" danger onClick={() => runAction(onDelete)} />
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dropdown, Input } from "antd";
|
||||
import { Download, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { Download, LoaderCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
|
||||
import { ProjectPreview } from "@/components/canvas/canvas-project-card";
|
||||
@@ -12,10 +12,12 @@ type CanvasFolderCardProps = {
|
||||
project: CanvasProject;
|
||||
projectName?: string;
|
||||
onClick: () => void;
|
||||
onPrefetch?: () => void;
|
||||
opening?: boolean;
|
||||
};
|
||||
|
||||
/** 画布库中的文件夹封面:单一卡片表面承载预览和信息,避免相邻卡片互相侵入。 */
|
||||
export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolderCardProps) {
|
||||
export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, opening = false }: CanvasFolderCardProps) {
|
||||
const renameProject = useCanvasStore((state) => state.renameProject);
|
||||
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
|
||||
const editingId = useCanvasUiStore((state) => state.editingProjectId);
|
||||
@@ -42,10 +44,11 @@ export function CanvasFolderCard({ project, projectName, onClick }: CanvasFolder
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing")}>
|
||||
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} onClick={() => !editing && onClick()} onKeyDown={handleOpenKeyDown}>
|
||||
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing", opening && "is-opening")} onPointerEnter={onPrefetch} onPointerDown={onPrefetch} onFocusCapture={onPrefetch}>
|
||||
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} aria-busy={opening} onClick={() => !editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
|
||||
<div className="canvas-folder-preview" aria-hidden="true">
|
||||
<ProjectPreview project={project} preferLatestImage />
|
||||
{opening ? <div className="canvas-folder-opening"><LoaderCircle className="size-5 animate-spin" /><span>正在打开</span></div> : null}
|
||||
</div>
|
||||
<div className="canvas-folder-body">
|
||||
<div className="canvas-folder-heading-row">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,6 +11,10 @@ export type CanvasNodeActionContextValue = {
|
||||
deleteNode?: (node: CanvasNodeData) => void;
|
||||
/** 合并式更新节点 metadata;扩展节点(如调色)在自己的面板里改参数时用。 */
|
||||
updateMetadata?: (nodeId: string, patch: CanvasNodeMetadata) => void;
|
||||
/** 更新单个节点;媒体尺寸回写使用它,避免触发全量节点时间戳归并。 */
|
||||
updateNode?: (nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => void;
|
||||
/** 合并延迟提交媒体测量结果,避免首屏图片同时解码时反复刷新画布。 */
|
||||
updateMediaNode?: (nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => void;
|
||||
/** 改节点宽高;图片首次量到真实尺寸后按比例校正节点用。 */
|
||||
resizeNode?: (nodeId: string, size: { width: number; height: number }) => void;
|
||||
/** 打开节点级肖像排查工作台;任务生命周期由画布页面持有。 */
|
||||
|
||||
@@ -419,7 +419,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
|
||||
|
||||
function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false, hydrateMediaPreview = false }: CanvasNodeContentProps) {
|
||||
const playerBoxRef = useRef<HTMLDivElement>(null);
|
||||
const { updateMetadata } = useCanvasNodeActions();
|
||||
const { updateMediaNode } = useCanvasNodeActions();
|
||||
const { url, loading } = useNodeResourceUrl(node, mediaActive);
|
||||
const subtitleEntries = node.metadata?.subtitleEntries || [];
|
||||
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
|
||||
@@ -434,7 +434,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
|
||||
if (video.videoWidth <= 0 || video.videoHeight <= 0) return;
|
||||
setVideoSize({ width: video.videoWidth, height: video.videoHeight });
|
||||
if (node.metadata?.naturalWidth !== video.videoWidth || node.metadata?.naturalHeight !== video.videoHeight) {
|
||||
updateMetadata?.(node.id, { naturalWidth: video.videoWidth, naturalHeight: video.videoHeight });
|
||||
updateMediaNode?.(node.id, (current) => ({ ...current, metadata: { ...current.metadata, naturalWidth: video.videoWidth, naturalHeight: video.videoHeight } }));
|
||||
}
|
||||
};
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
@@ -446,7 +446,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects, mediaActive = false
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMetadata, url]);
|
||||
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
|
||||
|
||||
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
|
||||
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} hydrateMediaPreview={hydrateMediaPreview} />;
|
||||
@@ -535,7 +535,8 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
const nearViewport = useNearViewport(imageContainerRef);
|
||||
const { url, loading } = useNodeResourceUrl(node, nearViewport);
|
||||
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
|
||||
const { resizeNode, updateMetadata } = useCanvasNodeActions();
|
||||
const { updateMediaNode } = useCanvasNodeActions();
|
||||
const measuredSizeRef = useRef<{ width: number; height: number } | null>(null);
|
||||
|
||||
/**
|
||||
* 让节点跟随图片真实比例。
|
||||
@@ -548,23 +549,34 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
* 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。
|
||||
*/
|
||||
const fitToImage = (element: HTMLImageElement) => {
|
||||
// LibTV 已提供原图尺寸和节点尺寸;960px 缩略图不能反向覆盖这些数据。
|
||||
if (importedFromLibTV) return;
|
||||
const naturalWidth = element.naturalWidth;
|
||||
const naturalHeight = element.naturalHeight;
|
||||
if (!naturalWidth || !naturalHeight) return;
|
||||
if (node.metadata?.naturalWidth !== naturalWidth || node.metadata?.naturalHeight !== naturalHeight) {
|
||||
updateMetadata?.(node.id, { naturalWidth, naturalHeight });
|
||||
}
|
||||
if (node.metadata?.freeResize || node.metadata?.manualSize) return;
|
||||
const size = fitNodeSize(naturalWidth, naturalHeight);
|
||||
// 差不到 1px 就别动,避免无意义的状态写入。
|
||||
if (Math.abs(size.width - node.width) < 1 && Math.abs(size.height - node.height) < 1) return;
|
||||
resizeNode?.(node.id, size);
|
||||
if (measuredSizeRef.current?.width === naturalWidth && measuredSizeRef.current.height === naturalHeight) return;
|
||||
measuredSizeRef.current = { width: naturalWidth, height: naturalHeight };
|
||||
updateMediaNode?.(node.id, (current) => {
|
||||
const metadata = current.metadata;
|
||||
const needsMetadata = metadata?.naturalWidth !== naturalWidth || metadata?.naturalHeight !== naturalHeight;
|
||||
if (current.metadata?.freeResize || current.metadata?.manualSize) {
|
||||
return needsMetadata ? { ...current, metadata: { ...metadata, naturalWidth, naturalHeight } } : current;
|
||||
}
|
||||
const size = fitNodeSize(naturalWidth, naturalHeight);
|
||||
const needsResize = Math.abs(size.width - current.width) >= 1 || Math.abs(size.height - current.height) >= 1;
|
||||
if (!needsMetadata && !needsResize) return current;
|
||||
return {
|
||||
...current,
|
||||
...(needsResize ? size : {}),
|
||||
metadata: needsMetadata ? { ...metadata, naturalWidth, naturalHeight } : metadata,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
{url ? <img src={url} alt={node.title} loading="lazy" decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} onLoad={(event) => fitToImage(event.currentTarget)} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
</div>
|
||||
</BatchFrame>
|
||||
);
|
||||
@@ -584,18 +596,23 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
: node.metadata?.previewContent
|
||||
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
|
||||
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
|
||||
// Inline data URLs are already local, but decoding thousands of them is
|
||||
// still expensive. Images must wait for the same viewport gate as remote
|
||||
// resources; otherwise DOM virtualization does not reduce image work.
|
||||
const isLazyVisual = node.type === CanvasNodeType.Image;
|
||||
const [url, setUrl] = useState(isRemoteResource || isLazyVisual ? "" : fallback);
|
||||
const [loading, setLoading] = useState(isRemoteResource && eager);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isRemoteResource) {
|
||||
setUrl(fallback);
|
||||
setUrl(isLazyVisual && !eager ? "" : fallback);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setUrl("");
|
||||
setLoading(eager);
|
||||
// 只有进入视口或被激活的节点才下载远程媒体;缓存层会复用已有 Blob URL 和 in-flight 请求。
|
||||
const resolve = eager ? cacheResourceObjectUrl(storageKey) : getCachedResourceObjectUrl(storageKey);
|
||||
void resolve.then((cached) => {
|
||||
if (!cancelled) setUrl(cached || (eager ? fallback : ""));
|
||||
@@ -605,7 +622,7 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [eager, fallback, isRemoteResource, storageKey]);
|
||||
}, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]);
|
||||
|
||||
return { url, loading };
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { resolveBackendApiUrl } from "@/stores/use-config-store";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ProjectPreviewMedia = { node: CanvasNodeData; url: string; storageKey?: string };
|
||||
const projectPreviewMediaCache = new WeakMap<CanvasNodeData[], { first?: ProjectPreviewMedia; latest?: ProjectPreviewMedia }>();
|
||||
|
||||
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
|
||||
return <button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
|
||||
<span className="app-canvas-create-preview"><Plus className="app-canvas-create-icon" /></span>
|
||||
@@ -106,16 +109,7 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
|
||||
}
|
||||
|
||||
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
|
||||
const mediaNodes = project.nodes
|
||||
.flatMap((node) => {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
|
||||
const url = getNodeMediaUrl(node);
|
||||
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
|
||||
});
|
||||
const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
|
||||
const media = preferLatestImage
|
||||
? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
|
||||
: imageNodes[0] || mediaNodes[0];
|
||||
const media = projectPreviewMedia(project.nodes, preferLatestImage);
|
||||
if (media) {
|
||||
const { node, url, storageKey } = media;
|
||||
return (
|
||||
@@ -145,6 +139,31 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
|
||||
);
|
||||
}
|
||||
|
||||
export function projectPreviewMedia(nodes: CanvasNodeData[], preferLatestImage = false) {
|
||||
let cached = projectPreviewMediaCache.get(nodes);
|
||||
if (!cached) {
|
||||
let firstMedia: ProjectPreviewMedia | undefined;
|
||||
let firstImage: ProjectPreviewMedia | undefined;
|
||||
let latestMedia: ProjectPreviewMedia | undefined;
|
||||
let latestImage: ProjectPreviewMedia | undefined;
|
||||
for (const node of nodes) {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) continue;
|
||||
const url = getNodeMediaUrl(node);
|
||||
if (!isPreviewUrl(url)) continue;
|
||||
const media = { node, url, storageKey: node.metadata?.storageKey };
|
||||
firstMedia ||= media;
|
||||
latestMedia = media;
|
||||
if (node.type === CanvasNodeType.Image) {
|
||||
firstImage ||= media;
|
||||
latestImage = media;
|
||||
}
|
||||
}
|
||||
cached = { first: firstImage || firstMedia, latest: latestImage || latestMedia };
|
||||
projectPreviewMediaCache.set(nodes, cached);
|
||||
}
|
||||
return preferLatestImage ? cached.latest : cached.first;
|
||||
}
|
||||
|
||||
function getNodeMediaUrl(node: CanvasNodeData) {
|
||||
const resourceId = resourceIdFromStorageKey(node.metadata?.storageKey);
|
||||
if (resourceId) return resourceFileUrl(resourceId);
|
||||
|
||||
11
web/src/lib/asset-remote-sync.ts
Normal file
11
web/src/lib/asset-remote-sync.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
123
web/src/lib/canvas/canvas-spatial-index.ts
Normal file
123
web/src/lib/canvas/canvas-spatial-index.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -50,9 +50,8 @@ export async function executeImageGeneration({
|
||||
const count = getGenerationCount(generationConfig.count);
|
||||
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
|
||||
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
|
||||
const isCopiedVariant = count === 1 && isImageNode && Boolean(sourceNode?.metadata?.copiedFromNodeId || sourceNode?.metadata?.versionOfNodeId || sourceNode?.title.endsWith(" Copy") || sourceNode?.title.includes(" · "));
|
||||
const reuseSourceNode = canGenerateImageInPlace(sourceNode) || isCopiedVariant;
|
||||
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && (Boolean(sourceNode?.metadata?.copiedFromNodeId) || sourceNode?.title.endsWith(" Copy"));
|
||||
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
|
||||
const directCopiedBatch = count > 1 && isImageNode && Boolean(sourceNode?.metadata?.content) && reuseSourceNode;
|
||||
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
|
||||
const referenceImages = generationContext.referenceImages;
|
||||
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
|
||||
@@ -289,4 +288,3 @@ export async function executeImageGeneration({
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { canGenerateMediaInPlace } from "@/lib/canvas/canvas-generation-layout";
|
||||
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { nextCanvasVersionLabel } from "@/lib/canvas/canvas-layout";
|
||||
import { buildAudioGenerationMetadata, buildVideoGenerationMetadata, generationReferenceUrls, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
@@ -33,9 +34,9 @@ export async function executeVideoGeneration({
|
||||
retryContext,
|
||||
}: CanvasGenerationExecution) {
|
||||
const spec = nodeSizeFromRatio(generationConfig.size, NODE_DEFAULT_SIZE[CanvasNodeType.Video].width, NODE_DEFAULT_SIZE[CanvasNodeType.Video].height) || NODE_DEFAULT_SIZE[CanvasNodeType.Video];
|
||||
const isEmptyVideoNode = sourceNode?.type === CanvasNodeType.Video && !sourceNode.metadata?.content;
|
||||
const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content);
|
||||
const videoId = isEmptyVideoNode ? nodeId : nanoid();
|
||||
const reuseSourceNode = canGenerateMediaInPlace(sourceNode, CanvasNodeType.Video);
|
||||
const isExistingVideoNode = sourceNode?.type === CanvasNodeType.Video && Boolean(sourceNode.metadata?.content) && !reuseSourceNode;
|
||||
const videoId = reuseSourceNode ? nodeId : nanoid();
|
||||
const versionRootId = isExistingVideoNode && sourceNode ? sourceNode.metadata?.versionOfNodeId || sourceNode.id : undefined;
|
||||
const parent = sourceNode?.position || { x: 0, y: 0 };
|
||||
const videoGenerationMetadata = buildVideoGenerationMetadata(sourceNode, generationContext, generationConfig);
|
||||
@@ -43,11 +44,11 @@ export async function executeVideoGeneration({
|
||||
id: videoId,
|
||||
type: CanvasNodeType.Video,
|
||||
title: effectivePrompt.slice(0, 32) || "Generated Video",
|
||||
position: isEmptyVideoNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
|
||||
width: isEmptyVideoNode ? sourceNode.width : spec.width,
|
||||
height: isEmptyVideoNode ? sourceNode.height : spec.height,
|
||||
position: reuseSourceNode ? sourceNode!.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y },
|
||||
width: reuseSourceNode ? sourceNode!.width : spec.width,
|
||||
height: reuseSourceNode ? sourceNode!.height : spec.height,
|
||||
metadata: {
|
||||
...(isEmptyVideoNode ? sourceNode.metadata || {} : {}),
|
||||
...(reuseSourceNode ? sourceNode?.metadata || {} : {}),
|
||||
prompt: effectivePrompt,
|
||||
status: NODE_STATUS_LOADING,
|
||||
errorDetails: undefined,
|
||||
@@ -69,7 +70,7 @@ export async function executeVideoGeneration({
|
||||
registerPendingNodeIds([videoId]);
|
||||
// 待生成版本先加入版本族,但只有成功结果才能替换当前主版本。
|
||||
setNodes((current) => {
|
||||
if (isEmptyVideoNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node));
|
||||
if (reuseSourceNode) return current.map((node) => (node.id === nodeId ? { ...node, ...videoNode } : node));
|
||||
if (!isExistingVideoNode || !sourceNode) return [...current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), videoNode];
|
||||
const rootId = versionRootId!;
|
||||
const nextLabel = nextCanvasVersionLabel(rootId, current);
|
||||
@@ -83,7 +84,7 @@ export async function executeVideoGeneration({
|
||||
];
|
||||
});
|
||||
// 重新生成已有视频时,新节点继承源视频的上游连接,与源视频保持并行关系,而不是作为其下游子节点。
|
||||
if (!isEmptyVideoNode) {
|
||||
if (!reuseSourceNode) {
|
||||
setConnections((current) => {
|
||||
if (!isExistingVideoNode) return [...current, { id: nanoid(), fromNodeId: nodeId, toNodeId: videoId }];
|
||||
return [...current, ...canvasConnections.filter((connection) => connection.toNodeId === nodeId).map((connection) => ({ ...connection, id: nanoid(), toNodeId: videoId }))];
|
||||
|
||||
@@ -24,6 +24,7 @@ type CanvasProjectContextMenuProps = {
|
||||
onRedo: () => void;
|
||||
onPaste: (position: Position) => void;
|
||||
onCopyNode: (nodeId: string) => void;
|
||||
onCreateGenerationCopy: (nodeId: string) => void;
|
||||
onDuplicate: (nodeId: string) => void;
|
||||
onDeleteNode: (nodeId: string) => void;
|
||||
onDeleteConnection: (connectionId: string) => void;
|
||||
@@ -69,6 +70,9 @@ export function CanvasProjectContextMenu({ menu, node, screenToCanvas, ...props
|
||||
onCopyNode={() => {
|
||||
if (menu.type === "node") props.onCopyNode(menu.nodeId);
|
||||
}}
|
||||
onCreateGenerationCopy={() => {
|
||||
if (menu.type === "node") props.onCreateGenerationCopy(menu.nodeId);
|
||||
}}
|
||||
onDuplicate={() => {
|
||||
if (menu.type === "node") props.onDuplicate(menu.nodeId);
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||
import { memo, useMemo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
@@ -84,10 +84,10 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
|
||||
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
const { viewportScale } = props;
|
||||
const activeMediaNodeId = resolveActiveCanvasMediaNodeId(props.selectedNodeIds, props.nodeById);
|
||||
const orderedVisibleNodes = [
|
||||
const orderedVisibleNodes = useMemo(() => [
|
||||
...props.visibleNodes.filter(isFrameNode),
|
||||
...sortCanvasNodesByStackOrder(props.visibleNodes.filter((node) => !isFrameNode(node)), props.nodeStackOrder),
|
||||
];
|
||||
], [props.nodeStackOrder, props.visibleNodes]);
|
||||
const framePreviewNodes = (node: CanvasNodeData) => {
|
||||
const assetFolderId = node.metadata?.folder?.assetFolderId;
|
||||
if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES;
|
||||
@@ -144,7 +144,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
|
||||
scale={viewportScale}
|
||||
isSelected={props.selectedNodeIds.has(node.id)}
|
||||
mediaActive={activeMediaNodeId === node.id}
|
||||
hydrateMediaPreview={activeMediaNodeId === null}
|
||||
hydrateMediaPreview={activeMediaNodeId === null && !props.reduceMediaEffects}
|
||||
isRelated={props.relatedNodeIds.has(node.id)}
|
||||
isFocusRelated={props.activeNodeId === node.id}
|
||||
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Dropdown, Modal, Select } from "antd";
|
||||
@@ -19,6 +19,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { saveCanvasDrawing, type CanvasDrawingRenderDraft } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { createCanvasProjectWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { listProjects } from "@/services/api/projects";
|
||||
import { loadCanvasProjectPage } from "@/lib/workspace-route-modules";
|
||||
|
||||
export default function CanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
@@ -31,6 +32,8 @@ export default function CanvasPage() {
|
||||
const [projectFilter, setProjectFilter] = useState("all");
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const [loadedProjectCount, setLoadedProjectCount] = useState(50);
|
||||
const [openingProjectId, setOpeningProjectId] = useState("");
|
||||
const openingProjectIdRef = useRef("");
|
||||
const hydrated = useCanvasStore((state) => state.hydrated);
|
||||
const projects = useCanvasStore((state) => state.projects);
|
||||
const importProject = useCanvasStore((state) => state.importProject);
|
||||
@@ -45,9 +48,16 @@ export default function CanvasPage() {
|
||||
const agentMode = mode === "new" || mode === "recent" || mode === "choose";
|
||||
const handoffMode = mode === "handoff";
|
||||
const forwardedQuery = agentMode || handoffMode ? `?${searchParams.toString()}` : "";
|
||||
const enterProject = (id: string) => {
|
||||
navigate(`/canvas/${id}${forwardedQuery}`);
|
||||
};
|
||||
const preloadProject = useCallback(() => {
|
||||
void loadCanvasProjectPage();
|
||||
}, []);
|
||||
const enterProject = useCallback((id: string) => {
|
||||
if (openingProjectIdRef.current) return;
|
||||
openingProjectIdRef.current = id;
|
||||
setOpeningProjectId(id);
|
||||
preloadProject();
|
||||
window.requestAnimationFrame(() => navigate(`/canvas/${id}${forwardedQuery}`));
|
||||
}, [forwardedQuery, navigate, preloadProject]);
|
||||
const createAndEnter = () => {
|
||||
void createCanvasProjectWithRemoteSync(`自由画布 ${projects.length + 1}`).then(({ id, syncError }) => {
|
||||
if (syncError) message.warning(syncError instanceof Error ? `画布已在本地创建,云端同步失败:${syncError.message}` : "画布已在本地创建,云端同步失败");
|
||||
@@ -342,6 +352,8 @@ export default function CanvasPage() {
|
||||
project={project}
|
||||
projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined}
|
||||
onClick={() => enterProject(project.id)}
|
||||
onPrefetch={preloadProject}
|
||||
opening={openingProjectId === project.id}
|
||||
/>
|
||||
))}
|
||||
</CollectionGrid>
|
||||
|
||||
@@ -76,7 +76,7 @@ import { CanvasFreeformEmptyState, CanvasLinkedProjectEmptyState, CanvasShortDra
|
||||
import { resolveCanvasEmptyStateKind } from "@/lib/canvas/canvas-starter";
|
||||
import { failedImageBatchChildren, markImageBatchRetrying, reconcileImageBatchRoot, restoreUnsubmittedImageBatchChild } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { createCanvasNode, getInputSummary, isHiddenBatchChild, persistCanvasWorkspaceMode, readCanvasWorkspaceMode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { stampCanvasNodeChanges } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { stampCanvasNodeChanges, updateCanvasNode, updateCanvasNodes } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { canvasAssetHandoffAttempt, finalizeCanvasAssetHandoff, uninsertedCanvasAssetHandoffPayloads } from "@/lib/canvas/canvas-asset-handoff";
|
||||
import { batchSourceRestriction } from "@/lib/canvas/canvas-batch-connection";
|
||||
import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard-progress";
|
||||
@@ -92,7 +92,7 @@ import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
|
||||
import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolbar";
|
||||
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
|
||||
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
|
||||
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
|
||||
import { CanvasNodeActionContext, type CanvasNodeActionContextValue } from "@/components/canvas/canvas-node-action-context";
|
||||
import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
|
||||
import { PortraitClearanceModal } from "@/components/canvas/portrait-clearance/portrait-clearance-modal";
|
||||
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
|
||||
@@ -131,6 +131,7 @@ import {
|
||||
type CanvasFolderStyle,
|
||||
type CanvasFolderTheme,
|
||||
type CanvasNodeData,
|
||||
type CanvasNodeMetadata,
|
||||
type CanvasMediaPerformanceMode,
|
||||
type StoryboardColumn,
|
||||
type StoryboardShotCount,
|
||||
@@ -1386,6 +1387,45 @@ function InfiniteCanvasPage() {
|
||||
setToolbarNodeId(null);
|
||||
setPortraitClearanceNodeId(node.id);
|
||||
}, []);
|
||||
const duplicateNodeFromContent = useCallback((node: CanvasNodeData) => duplicateNode(node.id), [duplicateNode]);
|
||||
const deleteNodeFromContent = useCallback((node: CanvasNodeData) => deleteNodes(new Set([node.id])), [deleteNodes]);
|
||||
const updateNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
|
||||
setNodesState((current) => {
|
||||
const next = updateCanvasNode(current, nodeId, update);
|
||||
nodesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const pendingMediaUpdatesRef = useRef(new Map<string, (node: CanvasNodeData) => CanvasNodeData>());
|
||||
const mediaUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const updateMediaNodeFromContent = useCallback((nodeId: string, update: (node: CanvasNodeData) => CanvasNodeData) => {
|
||||
const previous = pendingMediaUpdatesRef.current.get(nodeId);
|
||||
pendingMediaUpdatesRef.current.set(nodeId, previous ? (node) => update(previous(node)) : update);
|
||||
if (mediaUpdateTimerRef.current) return;
|
||||
mediaUpdateTimerRef.current = setTimeout(() => {
|
||||
const updates = pendingMediaUpdatesRef.current;
|
||||
pendingMediaUpdatesRef.current = new Map();
|
||||
mediaUpdateTimerRef.current = null;
|
||||
if (!updates.size) return;
|
||||
setNodesState((current) => {
|
||||
const next = updateCanvasNodes(current, updates);
|
||||
nodesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, 120);
|
||||
}, []);
|
||||
const updateNodeMetadataFromContent = useCallback((nodeId: string, patch: CanvasNodeMetadata) => {
|
||||
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
|
||||
}, [updateNodeFromContent]);
|
||||
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(() => ({
|
||||
download: downloadNodeImage,
|
||||
duplicate: duplicateNodeFromContent,
|
||||
deleteNode: deleteNodeFromContent,
|
||||
updateMetadata: updateNodeMetadataFromContent,
|
||||
updateNode: updateNodeFromContent,
|
||||
updateMediaNode: updateMediaNodeFromContent,
|
||||
openPortraitClearance,
|
||||
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
|
||||
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
|
||||
projectId,
|
||||
domainProjectId: currentProject?.projectId,
|
||||
@@ -2202,7 +2242,7 @@ function InfiniteCanvasPage() {
|
||||
onFileDragLeave={handleFileDragLeave}
|
||||
onFileDragOver={handleFileDragOver}
|
||||
>
|
||||
<CanvasNodeActionContext.Provider value={{ download: downloadNodeImage, duplicate: (node) => duplicateNode(node.id), deleteNode: (node) => deleteNodes(new Set([node.id])), updateMetadata: (nodeId, patch) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))), resizeNode: (nodeId, size) => setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, width: size.width, height: size.height } : node))), openPortraitClearance }}>
|
||||
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
|
||||
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
|
||||
<CanvasProjectWorldLayers
|
||||
projectId={projectId}
|
||||
@@ -2562,6 +2602,7 @@ function InfiniteCanvasPage() {
|
||||
onRedo={redoCanvas}
|
||||
onPaste={pasteAtPosition}
|
||||
onCopyNode={(nodeId) => copyNodesToClipboard(new Set([nodeId]))}
|
||||
onCreateGenerationCopy={(nodeId) => duplicateNode(nodeId, "copy")}
|
||||
onDuplicate={duplicateNode}
|
||||
onDeleteNode={(nodeId) => deleteNodes(new Set([nodeId]))}
|
||||
onDeleteConnection={deleteConnection}
|
||||
|
||||
@@ -340,13 +340,14 @@ export function useCanvasNodeOperations({
|
||||
setContextMenu((current) => current?.type === "connection" && current.connectionId === connectionId ? null : current);
|
||||
}, [commitConnections, connectionsRef, setContextMenu, setSelectedConnectionId]);
|
||||
|
||||
const duplicateNode = useCallback((nodeId: string) => {
|
||||
const duplicateNode = useCallback((nodeId: string, duplicateMode: "variant" | "copy" = "variant") => {
|
||||
const source = nodesRef.current.find((node) => node.id === nodeId);
|
||||
if (!source) return;
|
||||
const sources = isFrameNode(source) ? [source, ...getFrameChildren(source.id, nodesRef.current)] : [source];
|
||||
const idMap = new Map(sources.map((node, index) => [node.id, `${node.type}-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 7)}`]));
|
||||
const versionRootId = isFrameNode(source) ? undefined : source.metadata?.versionOfNodeId || source.id;
|
||||
const versionRootId = duplicateMode === "variant" && !isFrameNode(source) ? source.metadata?.versionOfNodeId || source.id : undefined;
|
||||
const versionLabel = versionRootId ? nextCanvasVersionLabel(versionRootId, nodesRef.current) : undefined;
|
||||
const copyTitle = duplicateMode === "copy" ? nextCopiedNodeTitle(source.title, nodesRef.current.map((node) => node.title)) : undefined;
|
||||
const copiedNodes = sources.map((node) => {
|
||||
const metadata = isolateCopiedNodeMetadata(node, idMap);
|
||||
if (node.type === CanvasNodeType.Drawing) {
|
||||
@@ -364,7 +365,7 @@ export function useCanvasNodeOperations({
|
||||
return {
|
||||
...node,
|
||||
id: idMap.get(node.id)!,
|
||||
title: node.id === source.id ? `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title,
|
||||
title: node.id === source.id ? copyTitle || `${node.title.replace(/ · [A-Z]$/, "")} · ${versionLabel || "副本"}` : node.title,
|
||||
position: { x: node.position.x + 36, y: node.position.y + 36 },
|
||||
parentId: node.parentId ? idMap.get(node.parentId) || node.parentId : undefined,
|
||||
metadata,
|
||||
@@ -379,7 +380,7 @@ export function useCanvasNodeOperations({
|
||||
}
|
||||
const id = idMap.get(source.id)!;
|
||||
const nextNodes = [
|
||||
...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true } } : node),
|
||||
...nodesRef.current.map((node) => node.id === source.id && versionRootId && !node.metadata?.versionLabel ? { ...node, title: `${node.title} · A`, metadata: { ...node.metadata, versionOfNodeId: versionRootId, versionLabel: "A", versionPrimary: true, generationResultPlacement: "replace-node" as const } } : node),
|
||||
...copiedNodes,
|
||||
];
|
||||
commitNodes(nextNodes);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import { startTransition, useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
@@ -6,12 +6,12 @@ import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, normalizeCanvasApp
|
||||
import type { CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import { removeCanvasDrawing } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { normalizeCanvasNodeTimestamps } from "@/lib/canvas/canvas-node-timestamps";
|
||||
import { hydrateAssistantImages, hydrateCanvasImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { hydrateAssistantImages, resetInterruptedGeneration } from "@/lib/canvas/canvas-project-generation";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
import { createCanvasProjectWithRemoteSync, deleteCanvasProjectsWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, CanvasNodeMetadata, ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
|
||||
import type { CanvasHistorySnapshot } from "./use-canvas-history";
|
||||
|
||||
type UseCanvasProjectLifecycleOptions = {
|
||||
@@ -130,12 +130,15 @@ export function useCanvasProjectLifecycle({
|
||||
const initialSessions = project.chatSessions || [];
|
||||
|
||||
// 先恢复可交互的节点和布局,媒体缓存/资源校验放到后台,避免首屏被远程资源拖住。
|
||||
applyRestoredProject(initialNodes, initialSessions);
|
||||
const [nodesResult, sessionsResult] = await Promise.allSettled([hydrateCanvasImages(initialNodes), hydrateAssistantImages(initialSessions)]);
|
||||
if (cancelled) return;
|
||||
if (nodesResult.status === "fulfilled") setNodes((current) => mergeHydratedNodeMedia(current, initialNodes, nodesResult.value));
|
||||
if (sessionsResult.status === "fulfilled") setChatSessions((current) => mergeHydratedSessions(current, sessionsResult.value));
|
||||
if (nodesResult.status === "rejected" || sessionsResult.status === "rejected") message.warning("部分本地媒体恢复失败,已使用项目记录继续打开");
|
||||
startTransition(() => applyRestoredProject(initialNodes, initialSessions));
|
||||
// 画布媒体由节点自己的视口观察器按需加载;打开时遍历并解析全部节点会让大画布形成 N+1 资源读取。
|
||||
void hydrateAssistantImages(initialSessions)
|
||||
.then((hydratedSessions) => {
|
||||
if (!cancelled) setChatSessions((current) => mergeHydratedSessions(current, hydratedSessions));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) message.warning("部分助手会话素材恢复失败,已使用项目记录继续打开");
|
||||
});
|
||||
};
|
||||
void restore();
|
||||
return () => {
|
||||
@@ -252,24 +255,6 @@ export function useCanvasProjectLifecycle({
|
||||
};
|
||||
}
|
||||
|
||||
const hydratedMediaMetadataKeys = ["content", "storageKey", "naturalWidth", "naturalHeight", "bytes", "mimeType", "durationMs", "hasAudio", "videoPreview"] as const satisfies readonly (keyof CanvasNodeMetadata)[];
|
||||
|
||||
function mergeHydratedNodeMedia(currentNodes: CanvasNodeData[], initialNodes: CanvasNodeData[], hydratedNodes: CanvasNodeData[]) {
|
||||
const initialById = new Map(initialNodes.map((node) => [node.id, node]));
|
||||
const hydratedById = new Map(hydratedNodes.map((node) => [node.id, node]));
|
||||
return currentNodes.map((node) => {
|
||||
const initial = initialById.get(node.id);
|
||||
const hydrated = hydratedById.get(node.id);
|
||||
if (!initial || !hydrated || node.metadata?.content !== initial.metadata?.content) return node;
|
||||
const metadata = { ...node.metadata } as CanvasNodeMetadata;
|
||||
hydratedMediaMetadataKeys.forEach((key) => {
|
||||
const value = hydrated.metadata?.[key];
|
||||
if (value !== undefined) (metadata as Record<string, unknown>)[key] = value;
|
||||
});
|
||||
return { ...node, metadata };
|
||||
});
|
||||
}
|
||||
|
||||
function mergeHydratedSessions(currentSessions: CanvasAssistantSession[], hydratedSessions: CanvasAssistantSession[]) {
|
||||
const hydratedById = new Map(hydratedSessions.map((session) => [session.id, session]));
|
||||
return currentSessions.map((session) => {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
|
||||
import { buildNodeGenerationInputs, type NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
|
||||
import { isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { sameNodeSemanticData } from "@/lib/canvas/canvas-project-domain";
|
||||
import { shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { canvasNodeRenderBudget, canvasNodeRenderPadding, CANVAS_MAX_RENDERED_CONNECTIONS, shouldReduceCanvasMediaEffects } from "@/lib/canvas/canvas-performance-mode";
|
||||
import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions";
|
||||
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index";
|
||||
import type { Skill } from "@/services/api/skills";
|
||||
import type { Asset, ImageAsset } from "@/stores/use-asset-store";
|
||||
import type { DirectorScene } from "@/types/director";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasDisplayConnection, type CanvasMediaPerformanceMode, type CanvasNodeData, type ContextMenuState, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
|
||||
|
||||
@@ -74,26 +75,65 @@ export function useCanvasRenderModel({
|
||||
}: UseCanvasRenderModelOptions) {
|
||||
const reduceMediaEffects = useMemo(() => shouldReduceCanvasMediaEffects(mediaPerformanceMode, nodes), [mediaPerformanceMode, nodes]);
|
||||
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
||||
const collapsedBatchChildIds = useMemo(() => {
|
||||
const hidden = new Set<string>();
|
||||
nodes.forEach((node) => {
|
||||
// These maps are consumed by both virtualization and node chrome. Building
|
||||
// them together keeps a metadata update from walking a 50k-node array six
|
||||
// separate times before React can paint the next frame.
|
||||
const nodeDerivedData = useMemo(() => {
|
||||
const collapsedBatchChildIds = new Set<string>();
|
||||
const renderHiddenNodeIds = new Set<string>();
|
||||
const frameChildrenById = new Map<string, CanvasNodeData[]>();
|
||||
const canvasImageNodes: CanvasNodeData[] = [];
|
||||
const batchRoots: CanvasNodeData[] = [];
|
||||
const batchMotionById = new Map<string, { x: number; y: number; index: number }>();
|
||||
const batchChildIndexByRootId = new Map<string, Map<string, number>>();
|
||||
|
||||
for (const node of nodes) {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) return;
|
||||
const root = rootId ? nodeById.get(rootId) : undefined;
|
||||
if (root && !root.metadata?.imageBatchExpanded) {
|
||||
collapsedBatchChildIds.add(node.id);
|
||||
if (!collapsingBatchIds.has(root.id)) renderHiddenNodeIds.add(node.id);
|
||||
}
|
||||
|
||||
if (rootId && collapsingBatchIds.has(rootId)) renderHiddenNodeIds.delete(node.id);
|
||||
const parent = node.parentId ? nodeById.get(node.parentId) : undefined;
|
||||
if (parent && isFrameNode(parent)) {
|
||||
const children = frameChildrenById.get(parent.id);
|
||||
if (children) children.push(node);
|
||||
else frameChildrenById.set(parent.id, [node]);
|
||||
if (parent.metadata?.frame?.collapsed) renderHiddenNodeIds.add(node.id);
|
||||
}
|
||||
|
||||
if (node.metadata?.isBatchRoot) batchRoots.push(node);
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.content && !collapsedBatchChildIds.has(node.id) && !(parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed)) {
|
||||
canvasImageNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of batchRoots) {
|
||||
const childIndex = new Map((root.metadata?.batchChildIds || []).map((childId, index) => [childId, index]));
|
||||
batchChildIndexByRootId.set(root.id, childIndex);
|
||||
}
|
||||
for (const node of nodes) {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) continue;
|
||||
const root = nodeById.get(rootId);
|
||||
if (root && !root.metadata?.imageBatchExpanded) hidden.add(node.id);
|
||||
});
|
||||
return hidden;
|
||||
}, [nodeById, nodes]);
|
||||
const renderHiddenNodeIds = useMemo(() => {
|
||||
const hidden = new Set(collapsedBatchChildIds);
|
||||
nodes.forEach((node) => {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (rootId && collapsingBatchIds.has(rootId)) hidden.delete(node.id);
|
||||
const parent = node.parentId ? nodeById.get(node.parentId) : null;
|
||||
if (parent && isFrameNode(parent) && parent.metadata?.frame?.collapsed) hidden.add(node.id);
|
||||
});
|
||||
return hidden;
|
||||
}, [collapsedBatchChildIds, collapsingBatchIds, nodeById, nodes]);
|
||||
const index = batchChildIndexByRootId.get(rootId)?.get(node.id) ?? 0;
|
||||
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
|
||||
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
|
||||
batchMotionById.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
|
||||
}
|
||||
|
||||
const batchChildCountById = new Map<string, number>();
|
||||
for (const root of batchRoots) {
|
||||
const childIndex = batchChildIndexByRootId.get(root.id);
|
||||
const liveChildCount = [...(childIndex?.keys() || [])].filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === root.id).length;
|
||||
batchChildCountById.set(root.id, liveChildCount);
|
||||
}
|
||||
|
||||
return { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds };
|
||||
}, [collapsingBatchIds, nodeById, nodes]);
|
||||
const { batchChildCountById, batchMotionById, canvasImageNodes, collapsedBatchChildIds, frameChildrenById, renderHiddenNodeIds } = nodeDerivedData;
|
||||
const connectionLayerBounds = useMemo(() => {
|
||||
const padding = (reduceMediaEffects ? 96 : 144) / Math.max(viewport.k, 0.05);
|
||||
const left = -viewport.x / viewport.k - padding;
|
||||
@@ -103,27 +143,70 @@ export function useCanvasRenderModel({
|
||||
return { left, top, width: Math.max(2, width), height: Math.max(2, height) };
|
||||
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
const renderBounds = useMemo(() => {
|
||||
const padding = (reduceMediaEffects ? 128 : 192) / viewport.k;
|
||||
const viewLeft = -viewport.x / viewport.k - padding;
|
||||
const viewTop = -viewport.y / viewport.k - padding;
|
||||
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
|
||||
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
|
||||
return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom };
|
||||
const enterPadding = canvasNodeRenderPadding(reduceMediaEffects, false) / viewport.k;
|
||||
const retainPadding = canvasNodeRenderPadding(reduceMediaEffects, true) / viewport.k;
|
||||
const viewLeft = -viewport.x / viewport.k;
|
||||
const viewTop = -viewport.y / viewport.k;
|
||||
const viewWidth = viewportSize.width / viewport.k;
|
||||
const viewHeight = viewportSize.height / viewport.k;
|
||||
return {
|
||||
enter: { left: viewLeft - enterPadding, top: viewTop - enterPadding, right: viewLeft + viewWidth + enterPadding, bottom: viewTop + viewHeight + enterPadding },
|
||||
retain: { left: viewLeft - retainPadding, top: viewTop - retainPadding, right: viewLeft + viewWidth + retainPadding, bottom: viewTop + viewHeight + retainPadding },
|
||||
};
|
||||
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
|
||||
const nodeSpatialIndexRef = useRef<{ source: CanvasNodeData[]; index: CanvasSpatialIndex<string> } | null>(null);
|
||||
const nodeSpatialIndex = useMemo(() => {
|
||||
const previous = nodeSpatialIndexRef.current;
|
||||
const geometryUnchanged = previous
|
||||
&& previous.source.length === nodes.length
|
||||
&& nodes.every((node, index) => {
|
||||
const old = previous.source[index];
|
||||
return old.id === node.id
|
||||
&& old.position.x === node.position.x
|
||||
&& old.position.y === node.position.y
|
||||
&& old.width === node.width
|
||||
&& old.height === node.height;
|
||||
});
|
||||
if (geometryUnchanged) return previous.index;
|
||||
const index = buildCanvasSpatialIndex(nodes.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node.id })));
|
||||
nodeSpatialIndexRef.current = { source: nodes, index };
|
||||
return index;
|
||||
}, [nodes]);
|
||||
const renderedNodeIdsRef = useRef<Set<string>>(new Set());
|
||||
const visibleNodes = useMemo(() => {
|
||||
const frames: CanvasNodeData[] = [];
|
||||
const regular: CanvasNodeData[] = [];
|
||||
nodes.forEach((node) => {
|
||||
const renderedNodeIds = renderedNodeIdsRef.current;
|
||||
const renderBudget = canvasNodeRenderBudget(viewport.k);
|
||||
const forcedNodeIds = new Set([...selectedNodeIds, ...(dragPreview?.nodeIds || [])].slice(0, renderBudget));
|
||||
const candidates = nodeSpatialIndex.query(renderBounds.retain, renderBudget + forcedNodeIds.size)
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||
const candidateIds = new Set(candidates.map((node) => node.id));
|
||||
for (const nodeId of forcedNodeIds) {
|
||||
if (candidateIds.has(nodeId)) continue;
|
||||
const node = nodeById.get(nodeId);
|
||||
if (node) candidates.push(node);
|
||||
}
|
||||
const prioritized = candidates.filter((node) => forcedNodeIds.has(node.id));
|
||||
const remaining = candidates.filter((node) => !forcedNodeIds.has(node.id)).slice(0, Math.max(0, renderBudget - prioritized.length));
|
||||
[...prioritized, ...remaining].forEach((node) => {
|
||||
if (renderHiddenNodeIds.has(node.id)) return;
|
||||
const retained = selectedNodeIds.has(node.id) || Boolean(dragPreview?.nodeIds.has(node.id));
|
||||
if (!retained && (node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom)) return;
|
||||
const retained = forcedNodeIds.has(node.id) || renderedNodeIds.has(node.id);
|
||||
const insideEnterBounds = node.position.x + node.width > renderBounds.enter.left
|
||||
&& node.position.x < renderBounds.enter.right
|
||||
&& node.position.y + node.height > renderBounds.enter.top
|
||||
&& node.position.y < renderBounds.enter.bottom;
|
||||
if (!retained && !insideEnterBounds) return;
|
||||
(isFrameNode(node) ? frames : regular).push(node);
|
||||
});
|
||||
return [...frames, ...regular];
|
||||
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
|
||||
}, [dragPreview, nodeById, nodeSpatialIndex, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
|
||||
useEffect(() => {
|
||||
renderedNodeIdsRef.current = new Set(visibleNodes.map((node) => node.id));
|
||||
}, [visibleNodes]);
|
||||
|
||||
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
|
||||
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
|
||||
const semanticNodesRef = useRef(nodes);
|
||||
const semanticNodes = useMemo(() => {
|
||||
const previous = semanticNodesRef.current;
|
||||
@@ -156,53 +239,24 @@ export function useCanvasRenderModel({
|
||||
|
||||
const selectedNodeBounds = useMemo(() => {
|
||||
if (selectedNodeIds.size < 2) return null;
|
||||
const selectedNodes = nodes.filter((node) => selectedNodeIds.has(node.id) && !renderHiddenNodeIds.has(node.id));
|
||||
const selectedNodes = [...selectedNodeIds]
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && !renderHiddenNodeIds.has(node.id)));
|
||||
if (selectedNodes.length < 2) return null;
|
||||
const left = Math.min(...selectedNodes.map((node) => node.position.x));
|
||||
const top = Math.min(...selectedNodes.map((node) => node.position.y));
|
||||
const right = Math.max(...selectedNodes.map((node) => node.position.x + node.width));
|
||||
const bottom = Math.max(...selectedNodes.map((node) => node.position.y + node.height));
|
||||
return { left, top, width: right - left, height: bottom - top, count: selectedNodes.length };
|
||||
}, [nodes, renderHiddenNodeIds, selectedNodeIds]);
|
||||
const selectedVideoNodes = useMemo(() => nodes
|
||||
.filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
|
||||
}, [nodeById, renderHiddenNodeIds, selectedNodeIds]);
|
||||
const selectedVideoNodes = useMemo(() => [...selectedNodeIds]
|
||||
.map((nodeId) => nodeById.get(nodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node && node.type === CanvasNodeType.Video && node.metadata?.content && !renderHiddenNodeIds.has(node.id)))
|
||||
.sort((a, b) => {
|
||||
const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
|
||||
const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
|
||||
return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
|
||||
}), [nodes, renderHiddenNodeIds, selectedNodeIds]);
|
||||
const batchChildCountById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.metadata?.isBatchRoot) return;
|
||||
const liveChildCount = (node.metadata.batchChildIds || []).filter((childId) => nodeById.get(childId)?.metadata?.batchRootId === node.id).length;
|
||||
map.set(node.id, liveChildCount);
|
||||
});
|
||||
return map;
|
||||
}, [nodeById, nodes]);
|
||||
const frameChildrenById = useMemo(() => {
|
||||
const map = new Map<string, CanvasNodeData[]>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) return;
|
||||
const children = map.get(node.parentId) || [];
|
||||
children.push(node);
|
||||
map.set(node.parentId, children);
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
const batchMotionById = useMemo(() => {
|
||||
const map = new Map<string, { x: number; y: number; index: number }>();
|
||||
nodes.forEach((node) => {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
if (!rootId) return;
|
||||
const root = nodeById.get(rootId);
|
||||
const index = root?.metadata?.batchChildIds?.indexOf(node.id) ?? 0;
|
||||
const stackX = root ? root.position.x + 34 + index * 14 : node.position.x;
|
||||
const stackY = root ? root.position.y + 14 + index * 8 : node.position.y;
|
||||
map.set(node.id, { x: stackX - node.position.x, y: stackY - node.position.y, index: Math.max(index, 0) });
|
||||
});
|
||||
return map;
|
||||
}, [nodeById, nodes]);
|
||||
}), [nodeById, renderHiddenNodeIds, selectedNodeIds]);
|
||||
const relatedHighlight = useMemo(() => {
|
||||
const nodeIds = new Set<string>();
|
||||
const connectionIds = new Set<string>();
|
||||
@@ -216,45 +270,89 @@ export function useCanvasRenderModel({
|
||||
});
|
||||
return { nodeIds, connectionIds };
|
||||
}, [activeNodeId, connections]);
|
||||
const displayConnections = useMemo(() => connections.flatMap((connection) => {
|
||||
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
|
||||
const fromNode = nodeById.get(connection.fromNodeId);
|
||||
const toNode = nodeById.get(connection.toNodeId);
|
||||
if (!fromNode || !toNode) return [];
|
||||
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
|
||||
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
|
||||
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
|
||||
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
|
||||
if (displayFrom.id === displayTo.id) return [];
|
||||
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
|
||||
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
|
||||
const connectionLeft = Math.min(from.position.x, to.position.x);
|
||||
const connectionTop = Math.min(from.position.y, to.position.y);
|
||||
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
|
||||
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
|
||||
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
|
||||
return [{ connection, from, to }];
|
||||
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
|
||||
const connectionSpatialIndex = useMemo(() => {
|
||||
const entries: CanvasSpatialIndexEntry<CanvasDisplayConnection>[] = [];
|
||||
const connectionIdsByNodeId = new Map<string, Set<string>>();
|
||||
connections.forEach((connection) => {
|
||||
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return;
|
||||
const fromNode = nodeById.get(connection.fromNodeId);
|
||||
const toNode = nodeById.get(connection.toNodeId);
|
||||
if (!fromNode || !toNode) return;
|
||||
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
|
||||
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
|
||||
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
|
||||
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
|
||||
if (displayFrom.id === displayTo.id) return;
|
||||
const left = Math.min(displayFrom.position.x, displayTo.position.x);
|
||||
const top = Math.min(displayFrom.position.y, displayTo.position.y);
|
||||
const right = Math.max(displayFrom.position.x + displayFrom.width, displayTo.position.x + displayTo.width);
|
||||
const bottom = Math.max(displayFrom.position.y + displayFrom.height, displayTo.position.y + displayTo.height);
|
||||
const value = { connection, from: displayFrom, to: displayTo };
|
||||
entries.push({ id: connection.id, bounds: { left, top, right, bottom }, value });
|
||||
for (const nodeId of new Set([connection.fromNodeId, connection.toNodeId, displayFrom.id, displayTo.id])) {
|
||||
const ids = connectionIdsByNodeId.get(nodeId) || new Set<string>();
|
||||
ids.add(connection.id);
|
||||
connectionIdsByNodeId.set(nodeId, ids);
|
||||
}
|
||||
});
|
||||
return { index: buildCanvasSpatialIndex(entries), connectionIdsByNodeId, entriesById: new Map(entries.map((entry) => [entry.id, entry.value])) };
|
||||
}, [collapsedBatchChildIds, connections, nodeById]);
|
||||
const displayConnections = useMemo(() => {
|
||||
const candidateById = new Map<string, CanvasDisplayConnection>();
|
||||
connectionSpatialIndex.index.query(renderBounds.retain, CANVAS_MAX_RENDERED_CONNECTIONS).forEach((display) => candidateById.set(display.connection.id, display));
|
||||
dragPreview?.nodeIds.forEach((nodeId) => {
|
||||
connectionSpatialIndex.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => {
|
||||
const display = connectionSpatialIndex.entriesById.get(connectionId);
|
||||
if (display) candidateById.set(connectionId, display);
|
||||
});
|
||||
});
|
||||
return [...candidateById.values()].flatMap(({ connection, from: sourceFrom, to: sourceTo }) => {
|
||||
const from = dragPreview?.nodeIds.has(sourceFrom.id) ? { ...sourceFrom, position: { x: sourceFrom.position.x + dragPreview.x, y: sourceFrom.position.y + dragPreview.y } } : sourceFrom;
|
||||
const to = dragPreview?.nodeIds.has(sourceTo.id) ? { ...sourceTo, position: { x: sourceTo.position.x + dragPreview.x, y: sourceTo.position.y + dragPreview.y } } : sourceTo;
|
||||
const connectionLeft = Math.min(from.position.x, to.position.x);
|
||||
const connectionTop = Math.min(from.position.y, to.position.y);
|
||||
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
|
||||
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
|
||||
if (connectionRight <= renderBounds.retain.left || connectionLeft >= renderBounds.retain.right || connectionBottom <= renderBounds.retain.top || connectionTop >= renderBounds.retain.bottom) return [];
|
||||
return [{ connection, from, to }];
|
||||
});
|
||||
}, [connectionSpatialIndex, dragPreview, renderBounds]);
|
||||
|
||||
const configInputsById = useMemo(() => {
|
||||
const map = new Map<string, NodeGenerationInput[]>();
|
||||
semanticNodes.forEach((node) => {
|
||||
if (node.type === CanvasNodeType.Config) map.set(node.id, buildNodeGenerationInputs(node.id, semanticNodes, connections));
|
||||
const configNodeIds = new Set<string>();
|
||||
visibleNodes.forEach((node) => {
|
||||
if (node.type === CanvasNodeType.Config) configNodeIds.add(node.id);
|
||||
});
|
||||
selectedNodeIds.forEach((nodeId) => {
|
||||
if (nodeById.get(nodeId)?.type === CanvasNodeType.Config) configNodeIds.add(nodeId);
|
||||
});
|
||||
if (dialogNodeId && nodeById.get(dialogNodeId)?.type === CanvasNodeType.Config) configNodeIds.add(dialogNodeId);
|
||||
configNodeIds.forEach((nodeId) => map.set(nodeId, buildNodeGenerationInputs(nodeId, semanticNodes, connections)));
|
||||
return map;
|
||||
}, [connections, semanticNodes]);
|
||||
}, [connections, dialogNodeId, nodeById, selectedNodeIds, semanticNodes, visibleNodes]);
|
||||
const activeDirectorNode = useMemo(() => semanticNodes.find((node) => node.id === directorNodeId) || null, [directorNodeId, semanticNodes]);
|
||||
const activeStylePresetId = useMemo(() => semanticNodes.find((node) => node.metadata?.workflowKind === "styleboard")?.metadata?.stylePresetId, [semanticNodes]);
|
||||
const activeScriptNode = useMemo(() => semanticNodes.find((node) => node.id === scriptEditorNodeId && node.type === CanvasNodeType.Script) || null, [scriptEditorNodeId, semanticNodes]);
|
||||
const activeDirectorScene = useMemo(() => directorScenes?.find((scene) => scene.id === activeDirectorNode?.metadata?.directorSceneId) || null, [activeDirectorNode?.metadata?.directorSceneId, directorScenes]);
|
||||
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId), [activeNodeId, connections, dialogNodeId, semanticNodes]);
|
||||
const resourceReferenceTargetNodes = useMemo(() => {
|
||||
const targetNodes = [...visibleNodes];
|
||||
const activeId = dialogNodeId || activeNodeId;
|
||||
if (activeId) {
|
||||
const activeNode = nodeById.get(activeId);
|
||||
if (activeNode) targetNodes.push(activeNode);
|
||||
}
|
||||
return targetNodes;
|
||||
}, [activeNodeId, dialogNodeId, nodeById, visibleNodes]);
|
||||
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(semanticNodes, connections, dialogNodeId || activeNodeId, resourceReferenceTargetNodes), [activeNodeId, connections, dialogNodeId, resourceReferenceTargetNodes, semanticNodes]);
|
||||
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
|
||||
const skillMentionReferences = useMemo(() => buildSkillMentionReferences(addedSkills), [addedSkills]);
|
||||
const mentionReferencesByNodeId = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
|
||||
semanticNodes.forEach((node) => map.set(node.id, [...buildNodeMentionReferences(node, semanticNodes, connections), ...skillMentionReferences]));
|
||||
const map = buildCanvasNodeMentionReferenceMap(semanticNodes, connections, visibleNodes);
|
||||
if (!skillMentionReferences.length) return map;
|
||||
map.forEach((references, nodeId) => map.set(nodeId, [...references, ...skillMentionReferences]));
|
||||
return map;
|
||||
}, [connections, semanticNodes, skillMentionReferences]);
|
||||
}, [connections, semanticNodes, skillMentionReferences, visibleNodes]);
|
||||
|
||||
return {
|
||||
activeDirectorNode,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction } from "react";
|
||||
|
||||
import { applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { calculateNodeAlignment, createNodeAlignmentContext, isHiddenBatchChild, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
|
||||
import { applyFrameDrop, findFrameDropTarget, getFrameChildIds, isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
|
||||
import { applyCanvasNodeDragPreview, applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { calculateNodeAlignment, createNodeAlignmentContext, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
|
||||
import { applyFrameDrop, buildCanvasFrameDropIndex, findFrameDropTargetFromIndex, getFrameChildIds, isFrameNode } from "@/lib/canvas/canvas-frame";
|
||||
import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex } from "@/lib/canvas/canvas-spatial-index";
|
||||
import type { CanvasNodeData, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type UseCanvasSelectionControllerOptions = {
|
||||
@@ -34,6 +35,8 @@ type DragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
draggedNodeIds: string[];
|
||||
draggedRenderNodeIds: string[];
|
||||
draggedRenderNodeIdSet: Set<string>;
|
||||
initialSelectedNodes: Array<{ id: string; x: number; y: number }>;
|
||||
};
|
||||
|
||||
@@ -44,6 +47,8 @@ const EMPTY_DRAG_STATE: DragState = {
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
draggedNodeIds: [],
|
||||
draggedRenderNodeIds: [],
|
||||
draggedRenderNodeIdSet: new Set(),
|
||||
initialSelectedNodes: [],
|
||||
};
|
||||
|
||||
@@ -75,12 +80,14 @@ export function useCanvasSelectionController({
|
||||
const lastFrameDropCheckRef = useRef(0);
|
||||
const selectionFrameRef = useRef<number | null>(null);
|
||||
const selectionBoundsElementRef = useRef<HTMLDivElement>(null);
|
||||
const selectionCandidatesRef = useRef<Array<{ id: string; left: number; top: number; right: number; bottom: number }>>([]);
|
||||
const selectionSpatialIndexRef = useRef<CanvasSpatialIndex<CanvasNodeData>>(buildCanvasSpatialIndex([]));
|
||||
const pendingSelectionPointRef = useRef<Position | null>(null);
|
||||
const selectionActivatedRef = useRef(false);
|
||||
const selectionBoxRef = useRef<SelectionBox | null>(null);
|
||||
const nodeDraggingRef = useRef(false);
|
||||
const dragRef = useRef<DragState>({ ...EMPTY_DRAG_STATE });
|
||||
const frameDropIndexRef = useRef(buildCanvasFrameDropIndex([]));
|
||||
const draggedNodesRef = useRef<CanvasNodeData[]>([]);
|
||||
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
|
||||
const [frameDropTargetId, setFrameDropTargetId] = useState<string | null>(null);
|
||||
const [isNodeDragging, setIsNodeDragging] = useState(false);
|
||||
@@ -89,7 +96,6 @@ export function useCanvasSelectionController({
|
||||
|
||||
const cancelSelectionBox = useCallback(() => {
|
||||
selectionBoxRef.current = null;
|
||||
selectionCandidatesRef.current = [];
|
||||
pendingSelectionPointRef.current = null;
|
||||
selectionActivatedRef.current = false;
|
||||
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
|
||||
@@ -125,9 +131,16 @@ export function useCanvasSelectionController({
|
||||
};
|
||||
selectionBoxRef.current = nextSelectionBox;
|
||||
selectionActivatedRef.current = false;
|
||||
selectionCandidatesRef.current = nodesRef.current
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current))
|
||||
.map((node) => ({ id: node.id, left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height }));
|
||||
const currentNodes = nodesRef.current;
|
||||
const nodeById = new Map(currentNodes.map((node) => [node.id, node]));
|
||||
const hiddenBatchChildIds = new Set(currentNodes.flatMap((node) => {
|
||||
const rootId = node.metadata?.batchRootId;
|
||||
const root = rootId ? nodeById.get(rootId) : undefined;
|
||||
return root && !root.metadata?.imageBatchExpanded ? [node.id] : [];
|
||||
}));
|
||||
selectionSpatialIndexRef.current = buildCanvasSpatialIndex(currentNodes
|
||||
.filter((node) => !hiddenBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed))
|
||||
.map((node) => ({ id: node.id, bounds: canvasNodeBounds(node), value: node })));
|
||||
setSelectedConnectionId(null);
|
||||
}, [cancelPendingConnectionCreate, nodesRef, onCanvasSelectionStart, screenToCanvas, selectedNodeIdsRef, setSelectedConnectionId]);
|
||||
|
||||
@@ -181,7 +194,11 @@ export function useCanvasSelectionController({
|
||||
const initialSelectedNodes = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y }));
|
||||
if (!initialSelectedNodes.length) return;
|
||||
|
||||
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, initialSelectedNodes };
|
||||
frameDropIndexRef.current = buildCanvasFrameDropIndex(currentNodes);
|
||||
draggedNodesRef.current = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked);
|
||||
const draggedRenderNodeIds = initialSelectedNodes.map((item) => item.id);
|
||||
const draggedRenderNodeIdSet = new Set(draggedRenderNodeIds);
|
||||
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, draggedRenderNodeIds, draggedRenderNodeIdSet, initialSelectedNodes };
|
||||
historyPausedRef.current = true;
|
||||
nodeDraggingRef.current = true;
|
||||
pendingNodeDragRef.current = { x: 0, y: 0 };
|
||||
@@ -189,8 +206,9 @@ export function useCanvasSelectionController({
|
||||
lastFrameDropCheckRef.current = 0;
|
||||
setIsNodeDragging(true);
|
||||
setAlignmentGuides({});
|
||||
setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) });
|
||||
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
setDragPreview({ x: 0, y: 0, nodeIds: draggedRenderNodeIdSet });
|
||||
applyCanvasNodeDragPreview(containerRef.current, { x: 0, y: 0, nodeIds: draggedRenderNodeIdSet });
|
||||
}, [containerRef, historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
|
||||
|
||||
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
|
||||
if (dragFrameRef.current) {
|
||||
@@ -209,6 +227,7 @@ export function useCanvasSelectionController({
|
||||
|
||||
historyPausedRef.current = false;
|
||||
nodeDraggingRef.current = false;
|
||||
applyCanvasNodeDragPreview(containerRef.current, null);
|
||||
setIsNodeDragging(false);
|
||||
setDragPreview(null);
|
||||
setAlignmentGuides({});
|
||||
@@ -218,7 +237,7 @@ export function useCanvasSelectionController({
|
||||
const initial = initialById.get(node.id);
|
||||
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
|
||||
});
|
||||
const targetId = findFrameDropTarget(positioned, draggedNodeIds);
|
||||
const targetId = findFrameDropTargetFromIndex(frameDropIndexRef.current, draggedNodesRef.current, draggedNodeIds, { x: dx, y: dy });
|
||||
const target = targetId ? positioned.find((node) => node.id === targetId) : undefined;
|
||||
const linkedFolder = target?.metadata?.folder?.assetFolderId ? target : undefined;
|
||||
// 素材库文件夹只建立归档关系,不把画布节点变成其本地子节点。
|
||||
@@ -228,6 +247,7 @@ export function useCanvasSelectionController({
|
||||
}
|
||||
setFrameDropTargetId(null);
|
||||
alignmentContextRef.current = null;
|
||||
draggedNodesRef.current = [];
|
||||
dragRef.current = { ...EMPTY_DRAG_STATE };
|
||||
if (wasClick && clickedNodeId) {
|
||||
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
||||
@@ -245,23 +265,22 @@ export function useCanvasSelectionController({
|
||||
const aligned = calculateNodeAlignment(alignmentContextRef.current, pendingNodeDragRef.current, 7 / viewportRef.current.k);
|
||||
const latest = aligned.offset;
|
||||
pendingAlignmentGuidesRef.current = aligned.guides;
|
||||
const initialById = new Map(dragRef.current.initialSelectedNodes.map((item) => [item.id, item]));
|
||||
const now = performance.now();
|
||||
if (now - lastFrameDropCheckRef.current >= 100) {
|
||||
lastFrameDropCheckRef.current = now;
|
||||
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
|
||||
const positioned = nodesRef.current.map((node) => {
|
||||
const initial = initialById.get(node.id);
|
||||
return initial ? { ...node, position: { x: initial.x + latest.x, y: initial.y + latest.y } } : node;
|
||||
});
|
||||
setFrameDropTargetId(findFrameDropTarget(positioned, draggedNodeIds));
|
||||
setFrameDropTargetId(findFrameDropTargetFromIndex(frameDropIndexRef.current, draggedNodesRef.current, draggedNodeIds, latest));
|
||||
}
|
||||
setDragPreview((current) => current ? { ...current, x: latest.x, y: latest.y } : current);
|
||||
applyCanvasNodeDragPreview(containerRef.current, {
|
||||
x: latest.x,
|
||||
y: latest.y,
|
||||
nodeIds: dragRef.current.draggedRenderNodeIdSet,
|
||||
});
|
||||
const nextGuides = dragRef.current.hasMoved ? pendingAlignmentGuidesRef.current : {};
|
||||
setAlignmentGuides((current) => current.vertical === nextGuides.vertical && current.horizontal === nextGuides.horizontal ? current : nextGuides);
|
||||
dragFrameRef.current = null;
|
||||
});
|
||||
}, [nodesRef, viewportRef]);
|
||||
}, [containerRef, viewportRef]);
|
||||
|
||||
const handlePointerMove = useCallback((event: PointerEvent) => {
|
||||
if (dragRef.current.isDraggingNode) {
|
||||
@@ -302,8 +321,7 @@ export function useCanvasSelectionController({
|
||||
selectionBoxRef.current = selection;
|
||||
applyCanvasSelectionPreview(containerRef.current, selection);
|
||||
const nextSelected = new Set<string>(selection.additive || selection.subtractive ? selection.initialSelectedNodeIds : []);
|
||||
selectionCandidatesRef.current.forEach((node) => {
|
||||
if (rectX >= node.right || rectX + rectW <= node.left || rectY >= node.bottom || rectY + rectH <= node.top) return;
|
||||
selectionSpatialIndexRef.current.query({ left: rectX, top: rectY, right: rectX + Math.max(rectW, 0.01), bottom: rectY + Math.max(rectH, 0.01) }).forEach((node) => {
|
||||
if (selection.subtractive) nextSelected.delete(node.id);
|
||||
else nextSelected.add(node.id);
|
||||
});
|
||||
@@ -337,6 +355,7 @@ export function useCanvasSelectionController({
|
||||
return () => {
|
||||
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
|
||||
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
|
||||
applyCanvasNodeDragPreview(containerRef.current, null);
|
||||
window.removeEventListener("mousemove", handleNodeDragMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createBrowserRouter, Navigate, Outlet } from "react-router";
|
||||
import { RequireAuth } from "@/components/auth/require-auth";
|
||||
import { RequireFeature } from "@/components/auth/require-feature";
|
||||
import { FullScreenLoader, WorkspaceRouteLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
import { loadAssetsPage, loadCanvasPage, loadCreatePage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
|
||||
import { loadAssetsPage, loadCanvasPage, loadCanvasProjectPage, loadCreatePage, loadProjectsPage, loadWalletPage } from "@/lib/workspace-route-modules";
|
||||
import UserLayout from "@/layouts/user-layout";
|
||||
import { AuthScene } from "@/pages/auth/auth-scene";
|
||||
import RouteErrorPage from "@/pages/route-error";
|
||||
@@ -36,10 +36,9 @@ const LoginPage = lazy(() => import("@/pages/auth/login"));
|
||||
const RegisterPage = lazy(() => import("@/pages/auth/register"));
|
||||
const ForgotPasswordPage = lazy(() => import("@/pages/auth/forgot-password"));
|
||||
const CanvasPage = lazy(loadCanvasPage);
|
||||
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
|
||||
const CanvasProjectPage = lazy(loadCanvasProjectPage);
|
||||
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
|
||||
const CreatePage = lazy(loadCreatePage);
|
||||
const HomePage = lazy(() => import("@/pages/home"));
|
||||
const NotFound = lazy(() => import("@/pages/not-found"));
|
||||
const SkillsPage = lazy(() => import("@/pages/skills"));
|
||||
const PluginsPage = lazy(() => import("@/pages/plugins"));
|
||||
@@ -96,9 +95,7 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
errorElement: <RouteErrorPage />,
|
||||
children: [
|
||||
{ path: "/", element: <Navigate to="/create" replace /> },
|
||||
{ path: "/create", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
|
||||
{ path: "/home", element: deferred(<HomePage />) },
|
||||
{ path: "/", element: <RequireAuth>{deferred(<CreatePage />)}</RequireAuth> },
|
||||
{
|
||||
path: "/tasks",
|
||||
element: (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { deleteRemoteAsset, deleteRemoteCanvasProject, getRemoteUserDataSnapshot, upsertRemoteAsset, upsertRemoteCanvasProject } from "@/services/api/user-data";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { assetForRemoteSync } from "@/lib/asset-remote-sync";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
@@ -184,7 +185,7 @@ async function saveRemoteUserDataBatch() {
|
||||
acknowledgedProjects.set(source.id, source);
|
||||
}
|
||||
for (const source of dirtyAssets) {
|
||||
const remotePayload = await ensureRemoteResourceReferences(source, uploaded);
|
||||
const remotePayload = await ensureRemoteResourceReferences(assetForRemoteSync(source), uploaded);
|
||||
await upsertRemoteAsset(remotePayload);
|
||||
acknowledgedAssets.set(source.id, source);
|
||||
}
|
||||
|
||||
@@ -260,6 +260,7 @@ function pendingGenerationAttempt(scope: string, projectId: string, effectKeys?:
|
||||
}
|
||||
|
||||
function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, durableProject: CanvasProject | undefined) {
|
||||
if (!hasGenerationEffectKeys(project) && !hasGenerationEffectKeys(durableProject)) return project;
|
||||
const durableNodes = new Map((durableProject?.nodes || []).map((node) => [node.id, node]));
|
||||
const durableSessions = new Map((durableProject?.chatSessions || []).map((session) => [session.id, session]));
|
||||
let changed = false;
|
||||
@@ -288,7 +289,7 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(localKeys) === JSON.stringify(durableKeys)) {
|
||||
if (sameGenerationEffectKeys(localKeys, durableKeys)) {
|
||||
nodes.push(node);
|
||||
continue;
|
||||
}
|
||||
@@ -318,7 +319,7 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (JSON.stringify(session.generationEffectKeys) === JSON.stringify(durableKeys)) {
|
||||
if (sameGenerationEffectKeys(session.generationEffectKeys, durableKeys)) {
|
||||
chatSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
@@ -341,6 +342,19 @@ function ordinaryCanvasProjectSnapshot(scope: string, project: CanvasProject, du
|
||||
return changed ? { ...project, nodes, chatSessions } : project;
|
||||
}
|
||||
|
||||
function hasGenerationEffectKeys(value: CanvasProject | undefined) {
|
||||
if (!value) return false;
|
||||
return value.nodes.some((node) => Boolean(node.metadata?.generationEffectKeys?.length))
|
||||
|| value.chatSessions.some((session) => Boolean(session.generationEffectKeys?.length));
|
||||
}
|
||||
|
||||
function sameGenerationEffectKeys(left?: readonly string[], right?: readonly string[]) {
|
||||
if (left === right) return true;
|
||||
if (!left?.length && !right?.length) return true;
|
||||
if (!left || !right || left.length !== right.length) return false;
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function ordinaryCanvasPersistenceState(scope: string, state: PersistedCanvasState, durableProjects: CanvasProject[]) {
|
||||
const durableById = new Map(durableProjects.map((project) => [project.id, project]));
|
||||
return {
|
||||
|
||||
@@ -3860,6 +3860,8 @@
|
||||
.canvas-folder-preview .canvas-project-media img { filter: saturate(.94) contrast(1.02); transition: transform 420ms var(--motion-ease-out), filter 240ms ease-out; }
|
||||
.canvas-folder-card:hover .canvas-folder-preview .canvas-project-media img { filter: saturate(1) contrast(1.04); transform: scale(1.025); }
|
||||
.canvas-folder-preview .canvas-project-preview-canvas { background: var(--canvas-cover-surface); }
|
||||
.canvas-folder-opening { position: absolute; inset: 0; z-index: 2; display: flex; align-items: center; justify-content: center; gap: var(--space-2); background: color-mix(in srgb, var(--background) 78%, transparent); color: var(--foreground); font-size: var(--fs-caption); font-weight: 600; backdrop-filter: blur(3px); }
|
||||
.canvas-folder-card.is-opening { pointer-events: none; }
|
||||
|
||||
.canvas-folder-body {
|
||||
display: flex;
|
||||
@@ -9324,6 +9326,13 @@
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 平移时优先保证世界层合成,暂时关闭每个媒体卡片的滤镜和阴影绘制。 */
|
||||
[data-canvas-viewport-interacting="true"] :where(.canvas-node-shell, .canvas-frame-shell, .canvas-folder-preview, .canvas-folder-front, .aceternity-comet-card) {
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */
|
||||
.canvas-world-layer {
|
||||
transform: translate(var(--canvas-live-x), var(--canvas-live-y)) scale(var(--canvas-live-scale-ratio));
|
||||
|
||||
@@ -340,6 +340,7 @@ export type CanvasNodeMetadata = {
|
||||
versionLabel?: string;
|
||||
versionPrimary?: boolean;
|
||||
copiedFromNodeId?: string;
|
||||
generationResultPlacement?: "replace-node" | "new-version";
|
||||
directorSceneId?: string;
|
||||
directorShotId?: string;
|
||||
directorPreviewNodeId?: string;
|
||||
|
||||
Reference in New Issue
Block a user