merge: official v1.5.4 release
This commit is contained in:
commit
7f389875cb
189 files changed
+13222
-8821
No files matched your search
@@ -31,7 +31,7 @@ type CanvasProjectMediaDialogsProps = {
|
||||
onAnnotationEdit: (node: CanvasNodeData, payload: CanvasImageAnnotationPayload) => void;
|
||||
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
|
||||
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
|
||||
onImageEdit: (node: CanvasNodeData, payload: CanvasImageEditPayload) => void;
|
||||
onImageOperation: (node: CanvasNodeData, payload: CanvasImageEditPayload) => void;
|
||||
onLayerDecomposition: (node: CanvasNodeData, payload: CanvasImageLayerDecompositionPayload) => void;
|
||||
onDetectText: () => Promise<import("@/components/canvas/canvas-node-text-edit-dialog").CanvasImageTextLine[]>;
|
||||
onTextEdit: (node: CanvasNodeData, payload: CanvasImageTextEditPayload) => void;
|
||||
@@ -61,7 +61,7 @@ export function CanvasProjectMediaDialogs({
|
||||
onAnnotationEdit,
|
||||
onMaskEdit,
|
||||
onUpscale,
|
||||
onImageEdit,
|
||||
onImageOperation,
|
||||
onLayerDecomposition,
|
||||
onDetectText,
|
||||
onTextEdit,
|
||||
@@ -74,7 +74,7 @@ export function CanvasProjectMediaDialogs({
|
||||
{annotationEditNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationEditNode.metadata.content, storageKey: annotationEditNode.metadata.storageKey }} editMode open onClose={onCloseAnnotationEdit} onConfirm={(payload) => { if (typeof payload !== "string") onAnnotationEdit(annotationEditNode, payload); }} /> : null}
|
||||
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={{ ...config, model: maskEditNode.metadata.model || config.model, imageModel: maskEditNode.metadata.model || config.imageModel, size: maskEditNode.metadata.size || config.size, quality: maskEditNode.metadata.quality || config.quality, count: String(maskEditNode.metadata.count || config.count) }} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
|
||||
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
|
||||
{imageEditNode?.metadata?.content ? <CanvasNodeImageEditDialog dataUrl={imageEditNode.metadata.content} preset={imageEditPreset} config={{ ...config, model: imageEditNode.metadata.model || config.model, imageModel: imageEditNode.metadata.model || config.imageModel, size: imageEditNode.metadata.size || config.size, quality: imageEditNode.metadata.quality || config.quality }} open onClose={onCloseImageEdit} onConfirm={(payload) => onImageEdit(imageEditNode, payload)} /> : null}
|
||||
{imageEditNode?.metadata?.content ? <CanvasNodeImageEditDialog dataUrl={imageEditNode.metadata.content} preset={imageEditPreset} config={{ ...config, model: imageEditNode.metadata.model || config.model, imageModel: imageEditNode.metadata.model || config.imageModel, size: imageEditNode.metadata.size || config.size, quality: imageEditNode.metadata.quality || config.quality }} open onClose={onCloseImageEdit} onConfirm={(payload) => onImageOperation(imageEditNode, payload)} /> : null}
|
||||
{layerDecompositionNode?.metadata?.content ? <CanvasNodeLayerDecompositionDialog dataUrl={layerDecompositionNode.metadata.content} config={{ ...config, model: layerDecompositionNode.metadata.model || config.model, imageModel: layerDecompositionNode.metadata.model || config.imageModel, size: layerDecompositionNode.metadata.size || config.size, quality: layerDecompositionNode.metadata.quality || config.quality }} open onClose={onCloseLayerDecomposition} onConfirm={(payload) => onLayerDecomposition(layerDecompositionNode, payload)} /> : null}
|
||||
{textEditNode?.metadata?.content ? <CanvasNodeTextEditDialog dataUrl={textEditNode.metadata.content} open onClose={onCloseTextEdit} onDetect={onDetectText} onConfirm={(payload) => onTextEdit(textEditNode, payload)} /> : null}
|
||||
</>
|
||||
|
||||
+3226
-3247
File diff suppressed because it is too large.
Load diff
+330
-330
@@ -1,330 +1,330 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { App, Button } from "antd";
|
||||
import { Clapperboard, Eye, FileText, Image as ImageIcon, LockKeyhole, LogIn, Send, Share2, Video } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
|
||||
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
|
||||
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getPublicCanvasShare } from "@/services/api/canvas-share";
|
||||
import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type ContextMenu = { x: number; y: number; world: Position; nodeId?: string };
|
||||
type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: number; origins: Map<string, Position>; moved: boolean };
|
||||
|
||||
export default function SharedCanvasPage() {
|
||||
useCanvasThemeScope();
|
||||
const { token = "" } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const colorTheme = useCanvasThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const toolbarTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [title, setTitle] = useState("共享画布");
|
||||
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
|
||||
const [connections, setConnections] = useState<Awaited<ReturnType<typeof getPublicCanvasShare>>["project"]["connections"]>([]);
|
||||
const [appearance, setAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
|
||||
const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines");
|
||||
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState<Position | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
const unauthorized = useCallback(() => message.warning("未授权:分享画布仅供查看,该操作不会执行。"), [message]);
|
||||
const infoNode = nodes.find((node) => node.id === infoNodeId) || null;
|
||||
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
||||
const frameChildrenById = useMemo(() => {
|
||||
const result = new Map<string, CanvasNodeData[]>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) return;
|
||||
const children = result.get(node.parentId) || [];
|
||||
children.push(node);
|
||||
result.set(node.parentId, children);
|
||||
});
|
||||
return result;
|
||||
}, [nodes]);
|
||||
const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]);
|
||||
const visibleConnections = useMemo(() => connections.flatMap((connection) => {
|
||||
const resolved = resolveFrameConnection(connection, nodes);
|
||||
return resolved ? [{ connection, ...resolved }] : [];
|
||||
}), [connections, nodes]);
|
||||
const connectionBounds = useMemo(() => {
|
||||
if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 };
|
||||
const padding = 320;
|
||||
const left = Math.min(...nodes.map((node) => node.position.x)) - padding;
|
||||
const top = Math.min(...nodes.map((node) => node.position.y)) - padding;
|
||||
const right = Math.max(...nodes.map((node) => node.position.x + node.width)) + padding;
|
||||
const bottom = Math.max(...nodes.map((node) => node.position.y + node.height)) + padding;
|
||||
return { left, top, width: right - left, height: bottom - top };
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const themeBeforeShare = useCanvasThemeStore.getState().theme;
|
||||
let appliedShareTheme: typeof themeBeforeShare | null = null;
|
||||
let themeChangedAfterApply = false;
|
||||
const unsubscribeTheme = useCanvasThemeStore.subscribe((state, previous) => {
|
||||
if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true;
|
||||
});
|
||||
setLoading(true);
|
||||
getPublicCanvasShare(token).then(({ project }) => {
|
||||
if (!active) return;
|
||||
setTitle(project.title || "共享画布");
|
||||
setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize));
|
||||
setConnections(project.connections || []);
|
||||
const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare);
|
||||
setAppearance(nextAppearance);
|
||||
appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare);
|
||||
useCanvasThemeStore.getState().setTheme(appliedShareTheme);
|
||||
setBackgroundMode(project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE);
|
||||
const initial = project.viewport || { x: 0, y: 0, k: 1 };
|
||||
viewportRef.current = initial;
|
||||
setViewport(initial);
|
||||
}).catch((error) => {
|
||||
if (active) setLoadError(error instanceof Error ? error.message : "分享链接无效或已失效");
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribeTheme();
|
||||
if (appliedShareTheme && !themeChangedAfterApply && useCanvasThemeStore.getState().theme === appliedShareTheme) {
|
||||
useCanvasThemeStore.getState().setTheme(themeBeforeShare);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const next = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k };
|
||||
if (Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3) drag.moved = true;
|
||||
setDragOffset(next);
|
||||
};
|
||||
const onUp = (event: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const offset = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k };
|
||||
if (drag.moved) setNodes((current) => current.map((node) => {
|
||||
const origin = drag.origins.get(node.id);
|
||||
return origin ? { ...node, position: { x: origin.x + offset.x, y: origin.y + offset.y } } : node;
|
||||
}));
|
||||
else setInfoNodeId(drag.primaryId);
|
||||
dragRef.current = null;
|
||||
setDragOffset(null);
|
||||
document.body.style.cursor = "default";
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "default";
|
||||
if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const keepToolbar = useCallback((nodeId: string) => {
|
||||
if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current);
|
||||
toolbarTimerRef.current = null;
|
||||
setToolbarNodeId(nodeId);
|
||||
}, []);
|
||||
const hideToolbar = useCallback(() => {
|
||||
if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current);
|
||||
toolbarTimerRef.current = setTimeout(() => {
|
||||
toolbarTimerRef.current = null;
|
||||
setToolbarNodeId(null);
|
||||
}, 160);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
unauthorized();
|
||||
}
|
||||
if (event.key === "Escape") setContextMenu(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [unauthorized]);
|
||||
|
||||
const onViewportChange = useCallback((next: ViewportTransform) => {
|
||||
viewportRef.current = next;
|
||||
setViewport(next);
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
const setZoom = (scale: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const current = viewportRef.current;
|
||||
onViewportChange({
|
||||
x: rect.width / 2 - ((rect.width / 2 - current.x) / current.k) * scale,
|
||||
y: rect.height / 2 - ((rect.height / 2 - current.y) / current.k) * scale,
|
||||
k: scale,
|
||||
});
|
||||
};
|
||||
const resetViewport = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !nodes.length) return onViewportChange({ x: 0, y: 0, k: 1 });
|
||||
const rect = container.getBoundingClientRect();
|
||||
const left = Math.min(...nodes.map((node) => node.position.x));
|
||||
const top = Math.min(...nodes.map((node) => node.position.y));
|
||||
const right = Math.max(...nodes.map((node) => node.position.x + node.width));
|
||||
const bottom = Math.max(...nodes.map((node) => node.position.y + node.height));
|
||||
const scale = Math.min(1, Math.max(0.05, Math.min((rect.width - 120) / Math.max(right - left, 1), (rect.height - 140) / Math.max(bottom - top, 1))));
|
||||
onViewportChange({ x: rect.width / 2 - ((left + right) / 2) * scale, y: rect.height / 2 - ((top + bottom) / 2) * scale, k: scale });
|
||||
};
|
||||
const openContextMenu = (event: ReactMouseEvent, nodeId?: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const current = viewportRef.current;
|
||||
setContextMenu({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
world: { x: (event.clientX - rect.left - current.x) / current.k, y: (event.clientY - rect.top - current.y) / current.k },
|
||||
nodeId,
|
||||
});
|
||||
};
|
||||
const addNode = (type: CanvasNodeType) => {
|
||||
if (!contextMenu) return;
|
||||
const size = NODE_DEFAULT_SIZE[type];
|
||||
setNodes((current) => [...current, {
|
||||
id: `shared-${nanoid()}`,
|
||||
type,
|
||||
title: type === CanvasNodeType.Text ? "临时文本" : type === CanvasNodeType.Image ? "临时图片" : type === CanvasNodeType.Video ? "临时视频" : "临时分镜",
|
||||
position: contextMenu.world,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: type === CanvasNodeType.Text ? { content: "此节点只存在于当前浏览器页面,刷新后消失。" } : {},
|
||||
}]);
|
||||
setContextMenu(null);
|
||||
message.info("已添加临时节点,刷新页面后会消失");
|
||||
};
|
||||
const toggleFrame = (nodeId: string) => setNodes((current) => current.map((node) => {
|
||||
if (node.id !== nodeId || !isFrameNode(node)) return node;
|
||||
const collapsed = !node.metadata?.frame?.collapsed;
|
||||
const frame = node.metadata?.frame;
|
||||
return {
|
||||
...node,
|
||||
width: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_WIDTH : 240) : frame?.expandedWidth || node.width,
|
||||
height: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_HEIGHT : 144) : frame?.expandedHeight || node.height,
|
||||
metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } },
|
||||
};
|
||||
}));
|
||||
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => {
|
||||
if (node.type === CanvasNodeType.Script) return <SharedScriptNode node={node} onUnauthorized={unauthorized} />;
|
||||
if (node.type === CanvasNodeType.BatchTable) return <CanvasBatchTableNodeContent node={node} nodes={nodes} connections={connections} batch={node.metadata?.generationBatches?.at(-1)} theme={theme} readOnly onPatchTable={() => {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onAddTextColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onReplaceReference={() => {}} onUploadReference={() => {}} onRemoveReference={() => {}} onConnectStart={() => {}} />;
|
||||
return <SharedConfigNode node={node} onUnauthorized={unauthorized} />;
|
||||
}, [connections, nodes, theme, unauthorized]);
|
||||
const toolbarNodeKey = selectedNodeId;
|
||||
const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null;
|
||||
|
||||
if (loading) return <FullScreenLoader label="正在打开共享画布" detail="读取节点、连线和视图状态" />;
|
||||
if (loadError) return <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
|
||||
|
||||
return (
|
||||
<main className="relative h-screen overflow-hidden" style={{ background: resolveCanvasAppearance(appearance, colorTheme).background, color: theme.node.text }}>
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-[var(--z-panel-floating)] flex h-16 items-center justify-between px-5">
|
||||
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
|
||||
<Share2 className="size-4" style={{ color: theme.node.muted }} />
|
||||
<span className="max-w-[45vw] truncate text-base font-semibold">{title}</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs" style={{ color: theme.node.muted }}><Eye className="size-3.5" />只读分享</span>
|
||||
</div>
|
||||
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
|
||||
</header>
|
||||
|
||||
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
|
||||
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
|
||||
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
|
||||
</svg>
|
||||
{visibleNodes.map((node) => isFrameNode(node) ? <CanvasFrameNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} childNodes={frameChildrenById.get(node.id) || []} scale={viewport.k} isSelected={selectedNodeId === node.id} isDropTarget={false} readOnly onMouseDown={(event, nodeId) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
setSelectedNodeId(nodeId);
|
||||
setContextMenu(null);
|
||||
const dragged = [node, ...(frameChildrenById.get(nodeId) || [])];
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onFolderStyleChange={() => undefined} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
const target = nodes.find((item) => item.id === nodeId);
|
||||
if (!target) return;
|
||||
setSelectedNodeId(nodeId);
|
||||
setContextMenu(null);
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
|
||||
</InfiniteCanvas>
|
||||
|
||||
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onImageEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
|
||||
|
||||
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
|
||||
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>
|
||||
|
||||
{contextMenu ? <SharedContextMenu menu={contextMenu} onAdd={addNode} onInfo={() => { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null}
|
||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} readOnly onUnauthorized={unauthorized} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
return <div data-canvas-no-zoom className="absolute z-[var(--z-modal)] min-w-48 rounded-lg border p-1.5 shadow-xl" style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{menu.nodeId ? <><MenuButton icon={<Eye />} label="查看节点信息" onClick={onInfo} /><MenuButton icon={<LockKeyhole />} label="编辑或生成" onClick={onUnauthorized} /></> : <>
|
||||
<div className="px-2 py-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>添加临时节点</div>
|
||||
<MenuButton icon={<FileText />} label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} />
|
||||
<MenuButton icon={<ImageIcon />} label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} />
|
||||
<MenuButton icon={<Video />} label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} />
|
||||
<MenuButton icon={<Clapperboard />} label="分镜脚本" onClick={() => onAdd(CanvasNodeType.Script)} />
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) {
|
||||
return <button type="button" className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onClick}><span className="grid size-4 place-items-center [&>svg]:size-4">{icon}</span>{label}</button>;
|
||||
}
|
||||
|
||||
function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><ImageIcon className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span></div>
|
||||
<div className="min-h-0 flex-1 whitespace-pre-wrap break-words p-4 text-sm leading-6" style={{ color: theme.node.muted }}>{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}</div>
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-t px-3" style={{ borderColor: theme.node.stroke }}><Button size="small" icon={<Send className="size-3.5" />} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }}>生成</Button></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
const rows = node.metadata?.storyboard?.rows || [];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><Clapperboard className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span><span className="text-xs" style={{ color: theme.node.muted }}>{rows.length} 镜</span><button type="button" className="grid size-7 place-items-center rounded hover:bg-black/5 dark:hover:bg-white/10" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }} aria-label="一键创建视频节点"><Video className="size-3.5" /></button></div>
|
||||
<div data-canvas-wheel-scroll className="min-h-0 flex-1 overflow-y-auto" onWheel={(event) => event.stopPropagation()}>{rows.length ? rows.map((row) => <div key={row.id} className="grid grid-cols-[52px_72px_minmax(180px,1fr)_minmax(150px,.8fr)] border-b text-xs leading-5" style={{ minHeight: 48, borderColor: theme.node.stroke }}><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>#{row.shotNumber}</span><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke }}>{row.durationSeconds}s</span><span className="border-r px-3 py-2" style={{ borderColor: theme.node.stroke }}>{row.plotDescription || "-"}</span><span className="px-3 py-2" style={{ color: theme.node.muted }}>{row.dialogue || "-"}</span></div>) : <div className="grid h-full place-items-center text-sm" style={{ color: theme.node.muted }}>暂无分镜</div>}</div>
|
||||
{node.metadata?.composerContent ? <div className="max-h-24 shrink-0 overflow-y-auto border-t px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>{node.metadata.composerContent}</div> : null}
|
||||
</div>;
|
||||
}
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { App, Button } from "antd";
|
||||
import { Clapperboard, Eye, FileText, Image as ImageIcon, LockKeyhole, LogIn, Send, Share2, Video } from "lucide-react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { CanvasNodeToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-toolbar";
|
||||
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
|
||||
import { CanvasNode } from "@/components/canvas/canvas-node";
|
||||
import { CanvasBatchTableNodeContent } from "@/components/canvas/canvas-batch-table-node";
|
||||
import { CanvasZoomControls } from "@/components/canvas/canvas-zoom-controls";
|
||||
import { InfiniteCanvas } from "@/components/canvas/infinite-canvas";
|
||||
import { FullScreenLoader } from "@/components/ui/aceternity/full-screen-loader";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, isCanvasFolderNode, isFrameNode, isNodeHiddenByCollapsedFrame, resolveFrameConnection } from "@/lib/canvas/canvas-frame";
|
||||
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
|
||||
import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references";
|
||||
import { getPublicCanvasShare } from "@/services/api/canvas-share";
|
||||
import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type ContextMenu = { x: number; y: number; world: Position; nodeId?: string };
|
||||
type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: number; origins: Map<string, Position>; moved: boolean };
|
||||
|
||||
export default function SharedCanvasPage() {
|
||||
useCanvasThemeScope();
|
||||
const { token = "" } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const colorTheme = useCanvasThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewportRef = useRef<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const toolbarTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [title, setTitle] = useState("共享画布");
|
||||
const [nodes, setNodes] = useState<CanvasNodeData[]>([]);
|
||||
const [connections, setConnections] = useState<Awaited<ReturnType<typeof getPublicCanvasShare>>["project"]["connections"]>([]);
|
||||
const [appearance, setAppearance] = useState<CanvasAppearance>(() => canvasAppearanceForTheme(colorTheme));
|
||||
const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines");
|
||||
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState<Position | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenu | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
|
||||
const unauthorized = useCallback(() => message.warning("未授权:分享画布仅供查看,该操作不会执行。"), [message]);
|
||||
const infoNode = nodes.find((node) => node.id === infoNodeId) || null;
|
||||
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
||||
const frameChildrenById = useMemo(() => {
|
||||
const result = new Map<string, CanvasNodeData[]>();
|
||||
nodes.forEach((node) => {
|
||||
if (!node.parentId) return;
|
||||
const children = result.get(node.parentId) || [];
|
||||
children.push(node);
|
||||
result.set(node.parentId, children);
|
||||
});
|
||||
return result;
|
||||
}, [nodes]);
|
||||
const visibleNodes = useMemo(() => nodes.filter((node) => !isNodeHiddenByCollapsedFrame(node, nodes)), [nodes]);
|
||||
const visibleConnections = useMemo(() => connections.flatMap((connection) => {
|
||||
const resolved = resolveFrameConnection(connection, nodes);
|
||||
return resolved ? [{ connection, ...resolved }] : [];
|
||||
}), [connections, nodes]);
|
||||
const connectionBounds = useMemo(() => {
|
||||
if (!nodes.length) return { left: -1, top: -1, width: 2, height: 2 };
|
||||
const padding = 320;
|
||||
const left = Math.min(...nodes.map((node) => node.position.x)) - padding;
|
||||
const top = Math.min(...nodes.map((node) => node.position.y)) - padding;
|
||||
const right = Math.max(...nodes.map((node) => node.position.x + node.width)) + padding;
|
||||
const bottom = Math.max(...nodes.map((node) => node.position.y + node.height)) + padding;
|
||||
return { left, top, width: right - left, height: bottom - top };
|
||||
}, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const themeBeforeShare = useCanvasThemeStore.getState().theme;
|
||||
let appliedShareTheme: typeof themeBeforeShare | null = null;
|
||||
let themeChangedAfterApply = false;
|
||||
const unsubscribeTheme = useCanvasThemeStore.subscribe((state, previous) => {
|
||||
if (appliedShareTheme && state.theme !== previous.theme && state.theme !== appliedShareTheme) themeChangedAfterApply = true;
|
||||
});
|
||||
setLoading(true);
|
||||
getPublicCanvasShare(token).then(({ project }) => {
|
||||
if (!active) return;
|
||||
setTitle(project.title || "共享画布");
|
||||
setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize));
|
||||
setConnections(project.connections || []);
|
||||
const nextAppearance = project.appearance ? normalizeCanvasAppearance(project.appearance, themeBeforeShare) : canvasAppearanceForTheme(themeBeforeShare);
|
||||
setAppearance(nextAppearance);
|
||||
appliedShareTheme = canvasAppearanceBaseTheme(nextAppearance, themeBeforeShare);
|
||||
useCanvasThemeStore.getState().setTheme(appliedShareTheme);
|
||||
setBackgroundMode(project.backgroundMode || DEFAULT_CANVAS_BACKGROUND_MODE);
|
||||
const initial = project.viewport || { x: 0, y: 0, k: 1 };
|
||||
viewportRef.current = initial;
|
||||
setViewport(initial);
|
||||
}).catch((error) => {
|
||||
if (active) setLoadError(error instanceof Error ? error.message : "分享链接无效或已失效");
|
||||
}).finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribeTheme();
|
||||
if (appliedShareTheme && !themeChangedAfterApply && useCanvasThemeStore.getState().theme === appliedShareTheme) {
|
||||
useCanvasThemeStore.getState().setTheme(themeBeforeShare);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const next = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k };
|
||||
if (Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3) drag.moved = true;
|
||||
setDragOffset(next);
|
||||
};
|
||||
const onUp = (event: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const offset = { x: (event.clientX - drag.startX) / viewportRef.current.k, y: (event.clientY - drag.startY) / viewportRef.current.k };
|
||||
if (drag.moved) setNodes((current) => current.map((node) => {
|
||||
const origin = drag.origins.get(node.id);
|
||||
return origin ? { ...node, position: { x: origin.x + offset.x, y: origin.y + offset.y } } : node;
|
||||
}));
|
||||
else setInfoNodeId(drag.primaryId);
|
||||
dragRef.current = null;
|
||||
setDragOffset(null);
|
||||
document.body.style.cursor = "default";
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "default";
|
||||
if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const keepToolbar = useCallback((nodeId: string) => {
|
||||
if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current);
|
||||
toolbarTimerRef.current = null;
|
||||
setToolbarNodeId(nodeId);
|
||||
}, []);
|
||||
const hideToolbar = useCallback(() => {
|
||||
if (toolbarTimerRef.current) clearTimeout(toolbarTimerRef.current);
|
||||
toolbarTimerRef.current = setTimeout(() => {
|
||||
toolbarTimerRef.current = null;
|
||||
setToolbarNodeId(null);
|
||||
}, 160);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
unauthorized();
|
||||
}
|
||||
if (event.key === "Escape") setContextMenu(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [unauthorized]);
|
||||
|
||||
const onViewportChange = useCallback((next: ViewportTransform) => {
|
||||
viewportRef.current = next;
|
||||
setViewport(next);
|
||||
setContextMenu(null);
|
||||
}, []);
|
||||
const setZoom = (scale: number) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const current = viewportRef.current;
|
||||
onViewportChange({
|
||||
x: rect.width / 2 - ((rect.width / 2 - current.x) / current.k) * scale,
|
||||
y: rect.height / 2 - ((rect.height / 2 - current.y) / current.k) * scale,
|
||||
k: scale,
|
||||
});
|
||||
};
|
||||
const resetViewport = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !nodes.length) return onViewportChange({ x: 0, y: 0, k: 1 });
|
||||
const rect = container.getBoundingClientRect();
|
||||
const left = Math.min(...nodes.map((node) => node.position.x));
|
||||
const top = Math.min(...nodes.map((node) => node.position.y));
|
||||
const right = Math.max(...nodes.map((node) => node.position.x + node.width));
|
||||
const bottom = Math.max(...nodes.map((node) => node.position.y + node.height));
|
||||
const scale = Math.min(1, Math.max(0.05, Math.min((rect.width - 120) / Math.max(right - left, 1), (rect.height - 140) / Math.max(bottom - top, 1))));
|
||||
onViewportChange({ x: rect.width / 2 - ((left + right) / 2) * scale, y: rect.height / 2 - ((top + bottom) / 2) * scale, k: scale });
|
||||
};
|
||||
const openContextMenu = (event: ReactMouseEvent, nodeId?: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const current = viewportRef.current;
|
||||
setContextMenu({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
world: { x: (event.clientX - rect.left - current.x) / current.k, y: (event.clientY - rect.top - current.y) / current.k },
|
||||
nodeId,
|
||||
});
|
||||
};
|
||||
const addNode = (type: CanvasNodeType) => {
|
||||
if (!contextMenu) return;
|
||||
const size = NODE_DEFAULT_SIZE[type];
|
||||
setNodes((current) => [...current, {
|
||||
id: `shared-${nanoid()}`,
|
||||
type,
|
||||
title: type === CanvasNodeType.Text ? "临时文本" : type === CanvasNodeType.Image ? "临时图片" : type === CanvasNodeType.Video ? "临时视频" : "临时分镜",
|
||||
position: contextMenu.world,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: type === CanvasNodeType.Text ? { content: "此节点只存在于当前浏览器页面,刷新后消失。" } : {},
|
||||
}]);
|
||||
setContextMenu(null);
|
||||
message.info("已添加临时节点,刷新页面后会消失");
|
||||
};
|
||||
const toggleFrame = (nodeId: string) => setNodes((current) => current.map((node) => {
|
||||
if (node.id !== nodeId || !isFrameNode(node)) return node;
|
||||
const collapsed = !node.metadata?.frame?.collapsed;
|
||||
const frame = node.metadata?.frame;
|
||||
return {
|
||||
...node,
|
||||
width: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_WIDTH : 240) : frame?.expandedWidth || node.width,
|
||||
height: collapsed ? (isCanvasFolderNode(node) ? FOLDER_COLLAPSED_HEIGHT : 144) : frame?.expandedHeight || node.height,
|
||||
metadata: { ...node.metadata, frame: { collapsed, expandedWidth: collapsed ? node.width : frame?.expandedWidth || node.width, expandedHeight: collapsed ? node.height : frame?.expandedHeight || node.height } },
|
||||
};
|
||||
}));
|
||||
const renderSharedNode = useCallback((node: CanvasNodeData): ReactNode => {
|
||||
if (node.type === CanvasNodeType.Script) return <SharedScriptNode node={node} onUnauthorized={unauthorized} />;
|
||||
if (node.type === CanvasNodeType.BatchTable) return <CanvasBatchTableNodeContent node={node} nodes={nodes} connections={connections} batch={node.metadata?.generationBatches?.at(-1)} theme={theme} readOnly onPatchTable={() => {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onUploadReference={() => {}} onConnectStart={() => {}} />;
|
||||
return <SharedConfigNode node={node} onUnauthorized={unauthorized} />;
|
||||
}, [connections, nodes, theme, unauthorized]);
|
||||
const toolbarNodeKey = selectedNodeId;
|
||||
const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null;
|
||||
|
||||
if (loading) return <FullScreenLoader label="正在打开共享画布" detail="读取节点、连线和视图状态" />;
|
||||
if (loadError) return <div className="grid h-screen place-items-center px-5" style={{ background: theme.canvas.background }}><WorkspaceState icon="error" title="分享链接不可用" description={loadError} action={<Link to="/"><Button>返回首页</Button></Link>} /></div>;
|
||||
|
||||
return (
|
||||
<main className="relative h-screen overflow-hidden" style={{ background: resolveCanvasAppearance(appearance, colorTheme).background, color: theme.node.text }}>
|
||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-[var(--z-panel-floating)] flex h-16 items-center justify-between px-5">
|
||||
<div className="pointer-events-auto flex min-w-0 items-center gap-3">
|
||||
<Share2 className="size-4" style={{ color: theme.node.muted }} />
|
||||
<span className="max-w-[45vw] truncate text-base font-semibold">{title}</span>
|
||||
<span className="inline-flex items-center gap-1 text-xs" style={{ color: theme.node.muted }}><Eye className="size-3.5" />只读分享</span>
|
||||
</div>
|
||||
<Link className="pointer-events-auto" to="/login"><Button type="text" icon={<LogIn className="size-4" />}>登录</Button></Link>
|
||||
</header>
|
||||
|
||||
<InfiniteCanvas containerRef={containerRef} viewport={viewport} appearance={appearance} backgroundMode={backgroundMode} onViewportChange={onViewportChange} onViewportPreviewChange={(next) => { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}>
|
||||
<svg className="absolute overflow-visible" viewBox={`${connectionBounds.left} ${connectionBounds.top} ${connectionBounds.width} ${connectionBounds.height}`} style={{ left: connectionBounds.left, top: connectionBounds.top, width: connectionBounds.width, height: connectionBounds.height, pointerEvents: "none", zIndex: 0 }}>
|
||||
{visibleConnections.map(({ connection, from, to }) => <ConnectionPath key={connection.id} connection={connection} from={from} to={to} active={false} onSelect={() => setInfoNodeId(to.id)} />)}
|
||||
</svg>
|
||||
{visibleNodes.map((node) => isFrameNode(node) ? <CanvasFrameNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} childNodes={frameChildrenById.get(node.id) || []} scale={viewport.k} isSelected={selectedNodeId === node.id} isDropTarget={false} readOnly onMouseDown={(event, nodeId) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
setSelectedNodeId(nodeId);
|
||||
setContextMenu(null);
|
||||
const dragged = [node, ...(frameChildrenById.get(nodeId) || [])];
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onFolderStyleChange={() => undefined} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
const target = nodes.find((item) => item.id === nodeId);
|
||||
if (!target) return;
|
||||
setSelectedNodeId(nodeId);
|
||||
setContextMenu(null);
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: [nodeId], startX: event.clientX, startY: event.clientY, origins: new Map([[nodeId, target.position]]), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onConnectStart={unauthorized} onResize={() => undefined} onContentChange={unauthorized} onRetry={unauthorized} onOpenTaskDetails={unauthorized} onViewImage={(target) => setInfoNodeId(target.id)} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} />)}
|
||||
</InfiniteCanvas>
|
||||
|
||||
<CanvasNodeToolbar node={dragRef.current ? null : toolbarNode} viewport={viewport} containerRef={containerRef} onKeep={keepToolbar} onLeave={hideToolbar} onInfo={(node) => setInfoNodeId(node.id)} onEditText={unauthorized} onDecreaseFont={unauthorized} onIncreaseFont={unauthorized} onToggleDialog={unauthorized} onAnnotate={unauthorized} onAnnotationEdit={unauthorized} onTextEdit={unauthorized} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onRemoveBackground={unauthorized} onLayerDecomposition={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={unauthorized} onLighting={unauthorized} onPanorama={unauthorized} onViewImage={unauthorized} onExtractVideoFrames={unauthorized} onExtractAudioFromVideo={unauthorized} onTrimVideoSegments={unauthorized} extractingVideoFrames={false} extractingAudio={false} trimmingVideo={false} onSubtitles={unauthorized} onTimeline={unauthorized} onReversePrompt={unauthorized} onRetry={unauthorized} onToggleFreeResize={unauthorized} onToggleLocked={unauthorized} onDelete={unauthorized} />
|
||||
|
||||
<div className="absolute bottom-5 left-5 z-[var(--z-panel-floating)]"><CanvasZoomControls scale={viewport.k} containerRef={containerRef} onScaleChange={setZoom} onFitContent={resetViewport} isMiniMapOpen={false} onToggleMiniMap={unauthorized} onOpenShortcuts={unauthorized} /></div>
|
||||
<div className="pointer-events-none absolute bottom-5 right-5 z-[var(--z-panel-floating)] max-w-[340px] text-right text-xs leading-5" style={{ color: theme.node.muted }}>访客操作仅在当前页面临时生效</div>
|
||||
|
||||
{contextMenu ? <SharedContextMenu menu={contextMenu} onAdd={addNode} onInfo={() => { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null}
|
||||
<CanvasNodeInfoModal node={infoNode} open={Boolean(infoNode)} onClose={() => setInfoNodeId(null)} readOnly onUnauthorized={unauthorized} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
return <div data-canvas-no-zoom className="absolute z-[var(--z-modal)] min-w-48 rounded-lg border p-1.5 shadow-xl" style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{menu.nodeId ? <><MenuButton icon={<Eye />} label="查看节点信息" onClick={onInfo} /><MenuButton icon={<LockKeyhole />} label="编辑或生成" onClick={onUnauthorized} /></> : <>
|
||||
<div className="px-2 py-1.5 text-[var(--fs-label)]" style={{ color: theme.node.muted }}>添加临时节点</div>
|
||||
<MenuButton icon={<FileText />} label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} />
|
||||
<MenuButton icon={<ImageIcon />} label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} />
|
||||
<MenuButton icon={<Video />} label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} />
|
||||
<MenuButton icon={<Clapperboard />} label="分镜脚本" onClick={() => onAdd(CanvasNodeType.Script)} />
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) {
|
||||
return <button type="button" className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-sm transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onClick}><span className="grid size-4 place-items-center [&>svg]:size-4">{icon}</span>{label}</button>;
|
||||
}
|
||||
|
||||
function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><ImageIcon className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span></div>
|
||||
<div className="min-h-0 flex-1 whitespace-pre-wrap break-words p-4 text-sm leading-6" style={{ color: theme.node.muted }}>{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}</div>
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-t px-3" style={{ borderColor: theme.node.stroke }}><Button size="small" icon={<Send className="size-3.5" />} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }}>生成</Button></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) {
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
const rows = node.metadata?.storyboard?.rows || [];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><Clapperboard className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span><span className="text-xs" style={{ color: theme.node.muted }}>{rows.length} 镜</span><button type="button" className="grid size-7 place-items-center rounded hover:bg-black/5 dark:hover:bg-white/10" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }} aria-label="一键创建视频节点"><Video className="size-3.5" /></button></div>
|
||||
<div data-canvas-wheel-scroll className="min-h-0 flex-1 overflow-y-auto" onWheel={(event) => event.stopPropagation()}>{rows.length ? rows.map((row) => <div key={row.id} className="grid grid-cols-[52px_72px_minmax(180px,1fr)_minmax(150px,.8fr)] border-b text-xs leading-5" style={{ minHeight: 48, borderColor: theme.node.stroke }}><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>#{row.shotNumber}</span><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke }}>{row.durationSeconds}s</span><span className="border-r px-3 py-2" style={{ borderColor: theme.node.stroke }}>{row.plotDescription || "-"}</span><span className="px-3 py-2" style={{ color: theme.node.muted }}>{row.dialogue || "-"}</span></div>) : <div className="grid h-full place-items-center text-sm" style={{ color: theme.node.muted }}>暂无分镜</div>}</div>
|
||||
{node.metadata?.composerContent ? <div className="max-h-24 shrink-0 overflow-y-auto border-t px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>{node.metadata.composerContent}</div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,245 +1,212 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { MAX_BATCH_REFERENCE_COLUMNS, batchInputColumns, batchPromptForRow, batchReferenceColumns, batchTextInputColumns, createBatchRow, createBatchRowsFromColumns, moveBatchReferenceCell, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas";
|
||||
import type { BatchGenerationSettings } from "@/components/canvas/batch-generation-settings-dialog";
|
||||
|
||||
type Options = {
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
|
||||
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
|
||||
};
|
||||
|
||||
type PendingBatchGen = {
|
||||
nodeId: string;
|
||||
rows: CanvasBatchRow[];
|
||||
concurrency: number;
|
||||
};
|
||||
|
||||
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
|
||||
const { message } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
|
||||
const [batchGenDialog, setBatchGenDialog] = useState<{ open: boolean; pending: PendingBatchGen | null }>({ open: false, pending: null });
|
||||
|
||||
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
|
||||
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 3, rows: [], ...node.metadata?.batchTable, ...patch } } }));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node?.metadata?.batchTable) return;
|
||||
patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const addRow = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: [...table.rows, createBatchRow(table.operation)] });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const removeRow = useCallback((nodeId: string, rowId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const addReferenceColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = batchReferenceColumns(table);
|
||||
if (columns.length >= MAX_BATCH_REFERENCE_COLUMNS) return message.info("最多支持 10 组参考图");
|
||||
const nextIndex = columns.length + 1;
|
||||
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const addTextColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = table.textColumns || [];
|
||||
if (columns.length >= 4) return message.info("最多支持 4 组文字");
|
||||
patchTable(nodeId, { textColumns: [...columns, { id: `text-${nanoid()}`, label: `文字 ${columns.length + 1}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = node?.metadata?.batchTable;
|
||||
if (!node || !table) return false;
|
||||
const nodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
|
||||
const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
|
||||
const input = nodeById.get(inputNodeId);
|
||||
return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey);
|
||||
}));
|
||||
if (!columns.some((column) => column.length)) {
|
||||
if (!silent) message.warning("请先把图片节点连接到批量创作表");
|
||||
return false;
|
||||
}
|
||||
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
|
||||
const textColumns = batchTextInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
|
||||
const input = nodeById.get(inputNodeId);
|
||||
return input?.type === CanvasNodeType.Text && Boolean(input.metadata?.content || input.metadata?.prompt);
|
||||
}));
|
||||
const rowsWithText = rows.map((row, index) => ({
|
||||
...row,
|
||||
textNodeIds: textColumns.flatMap((column) => {
|
||||
const input = column.length === 1 ? column[0] : column[index];
|
||||
return input ? [input] : [];
|
||||
}),
|
||||
}));
|
||||
if (!rows.length) {
|
||||
if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图");
|
||||
return false;
|
||||
}
|
||||
const rowsChanged = JSON.stringify(table.rows) !== JSON.stringify(rowsWithText);
|
||||
if (!rowsChanged) return false;
|
||||
patchTable(nodeId, { rows: rowsWithText });
|
||||
if (!silent) message.success(`已按连线创建 ${rows.length} 行任务`);
|
||||
return true;
|
||||
}, [connectionsRef, message, nodesRef, patchTable]);
|
||||
|
||||
const fillRowsFromConnections = useCallback((nodeId: string) => {
|
||||
syncRowsFromConnections(nodeId);
|
||||
}, [syncRowsFromConnections]);
|
||||
|
||||
const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId);
|
||||
if (nextTable !== table) patchTable(nodeId, nextTable);
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex);
|
||||
if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const executeBatchGeneration = useCallback((pending: PendingBatchGen, settings: BatchGenerationSettings) => {
|
||||
const { nodeId, rows, concurrency } = pending;
|
||||
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = sourceNode?.metadata?.batchTable;
|
||||
if (!sourceNode || !table) return;
|
||||
|
||||
const mergedConfig = { ...effectiveConfig, ...settings };
|
||||
|
||||
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const nextNodes = [...nodesRef.current];
|
||||
let nextConnections = [...connectionsRef.current];
|
||||
const outputByRowId = new Map<string, string>();
|
||||
const targets: Array<{ rowId: string; nodeId: string }> = [];
|
||||
rows.forEach((row, index) => {
|
||||
const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1;
|
||||
const prompt = batchPromptForRow(table, row).trim();
|
||||
const metadata = {
|
||||
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
|
||||
prompt,
|
||||
composerContent: [prompt, ...(row.textNodeIds || []).map((textNodeId) => {
|
||||
const textNode = nodesRef.current.find((node) => node.id === textNodeId);
|
||||
return textNode?.metadata?.content || textNode?.metadata?.prompt || "";
|
||||
})].filter(Boolean).join("\n\n"),
|
||||
model: buildGenerationConfig(mergedConfig, undefined, "image").model,
|
||||
size: mergedConfig.size,
|
||||
quality: mergedConfig.quality,
|
||||
transparentBackground: mergedConfig.transparentBackground,
|
||||
count: Number(mergedConfig.count) || 1,
|
||||
generationMode: "image" as const,
|
||||
generationType: "edit" as const,
|
||||
workflowKind: "final" as const,
|
||||
workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`,
|
||||
status: "idle" as const,
|
||||
batchSourceNodeId: nodeId,
|
||||
batchRowId: row.id,
|
||||
batchOperation: table.operation,
|
||||
import { useCallback, type Dispatch, type SetStateAction } from "react";
|
||||
import { App } from "antd";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { batchInputColumns, batchPromptForRow, batchReferenceColumns, batchReferenceHandleId, createBatchRowsFromColumns, createInheritedBatchRow, moveBatchReferenceCell, removeLastBatchReferenceColumn, reorderBatchReferenceColumns } from "@/lib/canvas/canvas-batch-table";
|
||||
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildGenerationConfig, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { CanvasNodeType, type CanvasBatchRow, type CanvasBatchTableData, type CanvasConnection, type CanvasGenerationBatchMode, type CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
type Options = {
|
||||
nodesRef: { current: CanvasNodeData[] };
|
||||
connectionsRef: { current: CanvasConnection[] };
|
||||
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
|
||||
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
|
||||
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
enqueueGenerationBatch: (sourceNodeId: string, mode: CanvasGenerationBatchMode, targets: Array<{ rowId: string; nodeId: string }>, options?: { concurrency?: number }) => string | undefined;
|
||||
};
|
||||
|
||||
export function useCanvasBatchTable({ nodesRef, connectionsRef, setNodes, setConnections, setSelectedNodeIds, enqueueGenerationBatch }: Options) {
|
||||
const { message, modal } = App.useApp();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
|
||||
const patchTable = useCallback((nodeId: string, patch: Partial<CanvasBatchTableData>) => {
|
||||
setNodes((current) => current.map((node) => node.id !== nodeId ? node : { ...node, metadata: { ...node.metadata, batchTable: { operation: "try_on", concurrency: 10, rows: [], ...node.metadata?.batchTable, ...patch } } }));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateRow = useCallback((nodeId: string, rowId: string, patch: Partial<CanvasBatchRow>) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
if (!node?.metadata?.batchTable) return;
|
||||
patchTable(nodeId, { rows: node.metadata.batchTable.rows.map((row) => row.id === rowId ? { ...row, ...patch } : row) });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const addRow = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: [...table.rows, createInheritedBatchRow(table.operation, table.rows)] });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const removeRow = useCallback((nodeId: string, rowId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
patchTable(nodeId, { rows: table.rows.filter((row) => row.id !== rowId) });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const addReferenceColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = batchReferenceColumns(table);
|
||||
if (columns.length >= 6) return message.info("最多支持 6 组参考图");
|
||||
const nextIndex = columns.length + 1;
|
||||
patchTable(nodeId, { referenceColumns: [...columns, { id: `reference-${nanoid()}`, label: `参考图 ${nextIndex}` }] });
|
||||
}, [message, nodesRef, patchTable]);
|
||||
|
||||
const removeReferenceColumn = useCallback((nodeId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const columns = batchReferenceColumns(table);
|
||||
const nextTable = removeLastBatchReferenceColumn(table);
|
||||
if (!nextTable) return message.info("至少保留 1 组参考图");
|
||||
const removed = columns.at(-1);
|
||||
patchTable(nodeId, nextTable);
|
||||
if (removed) {
|
||||
const handleId = batchReferenceHandleId(removed.id);
|
||||
setConnections((current) => current.filter((connection) => !(connection.toNodeId === nodeId && connection.toHandleId === handleId)));
|
||||
}
|
||||
}, [message, nodesRef, patchTable, setConnections]);
|
||||
|
||||
const syncRowsFromConnections = useCallback((nodeId: string, silent = false) => {
|
||||
const node = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = node?.metadata?.batchTable;
|
||||
if (!node || !table) return false;
|
||||
const nodeById = new Map(nodesRef.current.map((item) => [item.id, item]));
|
||||
const columns = batchInputColumns(node, connectionsRef.current).map((column) => column.filter((inputNodeId) => {
|
||||
const input = nodeById.get(inputNodeId);
|
||||
return input?.type === CanvasNodeType.Image && Boolean(input.metadata?.content || input.metadata?.storageKey);
|
||||
}));
|
||||
if (!columns.some((column) => column.length)) {
|
||||
if (!silent) message.warning("请先把图片节点连接到批量创作表");
|
||||
return false;
|
||||
}
|
||||
const rows = createBatchRowsFromColumns(table.operation, columns, table.rows);
|
||||
if (!rows.length) {
|
||||
if (!silent) message.warning("批量换装至少需要一张人物图和一张服装图");
|
||||
return false;
|
||||
}
|
||||
if (JSON.stringify(rows) === JSON.stringify(table.rows)) return true;
|
||||
patchTable(nodeId, { rows });
|
||||
if (!silent) {
|
||||
const added = Math.max(0, rows.length - table.rows.length);
|
||||
message.success(added ? `已同步连线并新增 ${added} 行,原有任务均已保留` : "已同步最新连线,原有任务均已保留");
|
||||
}
|
||||
return true;
|
||||
}, [connectionsRef, message, nodesRef, patchTable]);
|
||||
|
||||
const fillRowsFromConnections = useCallback((nodeId: string) => {
|
||||
syncRowsFromConnections(nodeId);
|
||||
}, [syncRowsFromConnections]);
|
||||
|
||||
const reorderReferenceColumns = useCallback((nodeId: string, fromColumnId: string, toColumnId: string) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const nextTable = reorderBatchReferenceColumns(table, fromColumnId, toColumnId);
|
||||
if (nextTable !== table) patchTable(nodeId, nextTable);
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const moveReferenceCell = useCallback((nodeId: string, sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => {
|
||||
const table = nodesRef.current.find((item) => item.id === nodeId)?.metadata?.batchTable;
|
||||
if (!table) return;
|
||||
const nextTable = moveBatchReferenceCell(table, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex);
|
||||
if (nextTable !== table) patchTable(nodeId, { rows: nextTable.rows });
|
||||
}, [nodesRef, patchTable]);
|
||||
|
||||
const generateRows = useCallback(async (nodeId: string, requestedRowIds?: string[]) => {
|
||||
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = sourceNode?.metadata?.batchTable;
|
||||
if (!sourceNode || !table) return;
|
||||
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
if (!isAiConfigReady(effectiveConfig, imageModel)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const generationConfig = buildGenerationConfig(effectiveConfig, undefined, "image");
|
||||
const imageResolution = /^(1k|2k|4k)$/i.test(generationConfig.quality) ? generationConfig.quality.toUpperCase() : "由尺寸决定";
|
||||
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
|
||||
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
|
||||
const rows = table.rows.filter((row) => {
|
||||
const inputNodeIds = row.inputNodeIds.filter(Boolean);
|
||||
if (!row.enabled || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false;
|
||||
if (table.operation === "try_on" && inputNodeIds.length < 2) return false;
|
||||
if (!inputNodeIds.length || inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
|
||||
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
|
||||
if (output && activeNodeIds.has(output.id)) return false;
|
||||
// 操作列的单行生成允许对已完成结果重新生成;顶部批量按钮仍只提交未完成项。
|
||||
if (requested) return true;
|
||||
return !output?.metadata?.content;
|
||||
});
|
||||
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
|
||||
const confirmed = await new Promise<boolean>((resolve) => modal.confirm({
|
||||
title: `确认提交 ${rows.length} 个批量图片任务`,
|
||||
content: [
|
||||
`模型:${modelDisplayName(effectiveConfig, generationConfig.model) || generationConfig.model}`,
|
||||
`出图数量:${rows.length} 张(每行 1 张)`,
|
||||
`尺寸:${generationConfig.size || "默认"}`,
|
||||
`质量:${generationConfig.quality || "默认"}`,
|
||||
`分辨率:${imageResolution}`,
|
||||
`并发:${table.concurrency}`,
|
||||
"这些任务可能消耗积分或产生外部模型费用。",
|
||||
].join("\n"),
|
||||
okText: "确认生成",
|
||||
cancelText: "取消",
|
||||
centered: true,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
}));
|
||||
if (!confirmed) return;
|
||||
|
||||
const imageSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Image];
|
||||
const nextNodes = [...nodesRef.current];
|
||||
let nextConnections = [...connectionsRef.current];
|
||||
const outputByRowId = new Map<string, string>();
|
||||
const targets: Array<{ rowId: string; nodeId: string }> = [];
|
||||
rows.forEach((row, index) => {
|
||||
const existingIndex = row.outputNodeId ? nextNodes.findIndex((node) => node.id === row.outputNodeId && node.type === CanvasNodeType.Image) : -1;
|
||||
const prompt = batchPromptForRow(table, row).trim();
|
||||
const metadata = {
|
||||
...(existingIndex >= 0 ? resetGenerationTaskMetadata(nextNodes[existingIndex].metadata) : {}),
|
||||
prompt,
|
||||
composerContent: prompt,
|
||||
model: generationConfig.model,
|
||||
size: generationConfig.size,
|
||||
quality: generationConfig.quality,
|
||||
transparentBackground: generationConfig.transparentBackground,
|
||||
count: 1,
|
||||
generationMode: "image" as const,
|
||||
generationType: "edit" as const,
|
||||
workflowKind: "final" as const,
|
||||
workflowTitle: `${table.operation === "try_on" ? "换装" : "创意"}任务 ${index + 1}`,
|
||||
status: "idle" as const,
|
||||
batchSourceNodeId: nodeId,
|
||||
batchRowId: row.id,
|
||||
batchOperation: table.operation,
|
||||
batchInputNodeIds: row.inputNodeIds,
|
||||
cameraControl: settings.cameraControl,
|
||||
};
|
||||
const output = existingIndex >= 0
|
||||
? { ...nextNodes[existingIndex], metadata }
|
||||
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);
|
||||
output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`;
|
||||
if (existingIndex >= 0) nextNodes[existingIndex] = output;
|
||||
else nextNodes.push(output);
|
||||
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
|
||||
row.inputNodeIds.forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
|
||||
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
|
||||
outputByRowId.set(row.id, output.id);
|
||||
targets.push({ rowId: row.id, nodeId: output.id });
|
||||
});
|
||||
const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id);
|
||||
nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } };
|
||||
nodesRef.current = nextNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
setSelectedNodeIds(new Set(targets.map((target) => target.nodeId)));
|
||||
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
|
||||
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, message, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
|
||||
|
||||
const generateRows = useCallback((nodeId: string, requestedRowIds?: string[]) => {
|
||||
const sourceNode = nodesRef.current.find((item) => item.id === nodeId);
|
||||
const table = sourceNode?.metadata?.batchTable;
|
||||
if (!sourceNode || !table) return;
|
||||
const imageModel = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
if (!isAiConfigReady(effectiveConfig, imageModel)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const activeNodeIds = new Set((sourceNode.metadata?.generationBatches || []).filter((batch) => batch.mode === "batch_image").flatMap((batch) => batch.items.filter((item) => ["waiting", "submitting", "queued", "running"].includes(item.status)).map((item) => item.nodeId)));
|
||||
const requested = requestedRowIds?.length ? new Set(requestedRowIds) : null;
|
||||
const rows = table.rows.filter((row) => {
|
||||
if (row.enabled === false || (requested && !requested.has(row.id)) || !batchPromptForRow(table, row).trim()) return false;
|
||||
if (table.operation === "try_on" && row.inputNodeIds.length < 2) return false;
|
||||
if (!row.inputNodeIds.length || row.inputNodeIds.some((id) => !nodesRef.current.some((node) => node.id === id && node.type === CanvasNodeType.Image && Boolean(node.metadata?.content || node.metadata?.storageKey)))) return false;
|
||||
const output = row.outputNodeId ? nodesRef.current.find((node) => node.id === row.outputNodeId) : undefined;
|
||||
return !output?.metadata?.content && (!output || !activeNodeIds.has(output.id));
|
||||
});
|
||||
if (!rows.length) return message.info("没有可提交的未完成任务,请检查参考图和提示词");
|
||||
|
||||
setBatchGenDialog({ open: true, pending: { nodeId, rows, concurrency: table.concurrency } });
|
||||
}, [effectiveConfig, isAiConfigReady, message, nodesRef]);
|
||||
|
||||
const closeBatchGenDialog = useCallback(() => {
|
||||
setBatchGenDialog({ open: false, pending: null });
|
||||
}, []);
|
||||
|
||||
const confirmBatchGenDialog = useCallback((settings: BatchGenerationSettings) => {
|
||||
if (batchGenDialog.pending) {
|
||||
executeBatchGeneration(batchGenDialog.pending, settings);
|
||||
}
|
||||
setBatchGenDialog({ open: false, pending: null });
|
||||
}, [batchGenDialog.pending, executeBatchGeneration]);
|
||||
|
||||
const dialogConfig = useMemo(() => effectiveConfig, [effectiveConfig]);
|
||||
|
||||
return {
|
||||
addReferenceColumn,
|
||||
addTextColumn,
|
||||
addRow,
|
||||
fillRowsFromConnections,
|
||||
generateRows,
|
||||
moveReferenceCell,
|
||||
patchTable,
|
||||
removeRow,
|
||||
reorderReferenceColumns,
|
||||
syncRowsFromConnections,
|
||||
updateRow,
|
||||
batchGenDialogOpen: batchGenDialog.open,
|
||||
batchGenDialogRowCount: batchGenDialog.pending?.rows.length ?? 0,
|
||||
batchGenDialogConcurrency: batchGenDialog.pending?.concurrency ?? 1,
|
||||
batchGenDialogConfig: dialogConfig,
|
||||
closeBatchGenDialog,
|
||||
confirmBatchGenDialog,
|
||||
};
|
||||
}
|
||||
const output = existingIndex >= 0
|
||||
? { ...nextNodes[existingIndex], metadata }
|
||||
: createCanvasNode(CanvasNodeType.Image, { x: sourceNode.position.x + sourceNode.width + 120 + imageSpec.width / 2, y: sourceNode.position.y + index * (imageSpec.height + 32) + imageSpec.height / 2 }, metadata);
|
||||
output.title = `${table.operation === "try_on" ? "换装" : "创意"} · ${index + 1}`;
|
||||
if (existingIndex >= 0) nextNodes[existingIndex] = output;
|
||||
else nextNodes.push(output);
|
||||
nextConnections = nextConnections.filter((connection) => connection.toNodeId !== output.id);
|
||||
row.inputNodeIds.filter(Boolean).forEach((inputNodeId) => nextConnections.push({ id: nanoid(), fromNodeId: inputNodeId, toNodeId: output.id }));
|
||||
nextConnections.push({ id: nanoid(), fromNodeId: sourceNode.id, toNodeId: output.id, relation: "batch-output", storyboardRowId: row.id });
|
||||
outputByRowId.set(row.id, output.id);
|
||||
targets.push({ rowId: row.id, nodeId: output.id });
|
||||
});
|
||||
const sourceIndex = nextNodes.findIndex((node) => node.id === sourceNode.id);
|
||||
nextNodes[sourceIndex] = { ...sourceNode, metadata: { ...sourceNode.metadata, batchTable: { ...table, rows: table.rows.map((row) => outputByRowId.has(row.id) ? { ...row, outputNodeId: outputByRowId.get(row.id) } : row) } } };
|
||||
nodesRef.current = nextNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
setConnections(nextConnections);
|
||||
setSelectedNodeIds(new Set(targets.map((target) => target.nodeId)));
|
||||
if (enqueueGenerationBatch(nodeId, "batch_image", targets, { concurrency: table.concurrency })) message.success(`${targets.length} 个任务已加入并发队列`);
|
||||
}, [connectionsRef, effectiveConfig, enqueueGenerationBatch, isAiConfigReady, message, modal, nodesRef, setConnections, setNodes, setSelectedNodeIds]);
|
||||
|
||||
return { addReferenceColumn, addRow, fillRowsFromConnections, generateRows, moveReferenceCell, patchTable, removeReferenceColumn, removeRow, reorderReferenceColumns, syncRowsFromConnections, updateRow };
|
||||
}
|
||||
@@ -962,11 +962,6 @@ export function useCanvasMediaTools({
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
const openImageEditNode = useCallback((node: CanvasNodeData) => {
|
||||
setImageEditPreset(null);
|
||||
setImageEditNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
const openBackgroundRemoval = useCallback((node: CanvasNodeData) => {
|
||||
setImageEditPreset("remove-background");
|
||||
setImageEditNodeId(node.id);
|
||||
@@ -1247,7 +1242,6 @@ export function useCanvasMediaTools({
|
||||
setMaskEditNodeId,
|
||||
setImageEditNodeId,
|
||||
setImageEditPreset,
|
||||
openImageEditNode,
|
||||
openBackgroundRemoval,
|
||||
openLayerDecomposition,
|
||||
decomposeImageLayers,
|
||||
|
||||
@@ -309,7 +309,11 @@ export function useCanvasProjectLifecycle({
|
||||
|
||||
const renameCurrentProject = useCallback((title: string) => {
|
||||
renameProject(projectId, title);
|
||||
}, [projectId, renameProject]);
|
||||
// 标题是画布列表和分享入口的元数据,重命名后立即提交,避免只停留在浏览器缓存。
|
||||
void saveRemoteUserDataNow(projectId).catch((error) => {
|
||||
message.warning(error instanceof Error ? `名称已更新到本地,云端同步将在后台重试:${error.message}` : "名称已更新到本地,云端同步将在后台重试");
|
||||
});
|
||||
}, [message, projectId, renameProject]);
|
||||
|
||||
const persistLocalEdits = useCallback(async () => {
|
||||
const snapshot = { nodes: nodesRef.current, connections: connectionsRef.current, chatSessions, activeChatId, canvasAppearance, backgroundMode, showImageInfo };
|
||||
|
||||
Reference in new issue
Block a user