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);
|
||||
|
||||
Reference in New Issue
Block a user