diff --git a/fix_remove_ref.py b/fix_remove_ref.py new file mode 100644 index 000000000..3cdc4e395 --- /dev/null +++ b/fix_remove_ref.py @@ -0,0 +1,20 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\project.tsx" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +old_upload = ' onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }}' +new_upload = ''' onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} + onRemoveReference={(rowId, columnIndex) => { + const table = nodesRef.current.find((n) => n.id === contentNode.id)?.metadata?.batchTable; + if (!table) return; + const row = table.rows.find((r) => r.id === rowId); + if (!row) return; + const newInputNodeIds = [...row.inputNodeIds]; + newInputNodeIds[columnIndex] = ""; + updateBatchRow(contentNode.id, rowId, { inputNodeIds: newInputNodeIds }); + }}''' +content = content.replace(old_upload, new_upload, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/fix_shared.py b/fix_shared.py new file mode 100644 index 000000000..4a5708727 --- /dev/null +++ b/fix_shared.py @@ -0,0 +1,10 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\pages\canvas\shared.tsx" +with open(path, "r", encoding="utf-8") as f: + content = f.read() +content = content.replace( + 'onUploadReference={() => {}} onConnectStart={() => {}}', + 'onUploadReference={() => {}} onRemoveReference={() => {}} onConnectStart={() => {}}' +) +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/fix_thumbnail_delete.py b/fix_thumbnail_delete.py new file mode 100644 index 000000000..60171a418 --- /dev/null +++ b/fix_thumbnail_delete.py @@ -0,0 +1,168 @@ +path = r"D:\yingce\open-ai-canvas-main\web\src\components\canvas\canvas-batch-table-node.tsx" +with open(path, "r", encoding="utf-8") as f: + content = f.read() + +# 1. Add onRemoveReference to Props +old_props = " onUploadReference: (rowId: string, columnIndex: number, file: File) => void;\n onConnectStart: (event: ReactPointerEvent, handleId: string) => void;" +new_props = " onUploadReference: (rowId: string, columnIndex: number, file: File) => void;\n onRemoveReference: (rowId: string, columnIndex: number) => void;\n onConnectStart: (event: ReactPointerEvent, handleId: string) => void;" +content = content.replace(old_props, new_props, 1) + +# 2. Add hoveredCell ref and context menu state after existing refs +old_refs = " const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null);\n const lastPointerRef = useRef({ x: 0, y: 0 });" +new_refs = """ const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); + const lastPointerRef = useRef({ x: 0, y: 0 }); + const hoveredCellRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; cell: ReferenceCell } | null>(null);""" +content = content.replace(old_refs, new_refs, 1) + +# 3. Add keyboard delete handler effect after existing useEffect block +old_effect_end = " }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]);" +new_effect_end = """ }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + + // Delete / Backspace to remove hovered reference + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + const target = event.target as HTMLElement; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const cell = hoveredCellRef.current; + if (!cell || readOnly) return; + event.preventDefault(); + event.stopPropagation(); + onRemoveReference(cell.rowId, cell.columnIndex); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [readOnly, onRemoveReference]); + + // Hide context menu on outside click + useEffect(() => { + if (!contextMenu) return; + const hide = () => setContextMenu(null); + window.addEventListener("pointerdown", hide); + return () => window.removeEventListener("pointerdown", hide); + }, [contextMenu]);""" +content = content.replace(old_effect_end, new_effect_end, 1) + +# 4. Destructure onRemoveReference +old_destructure = "onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false" +new_destructure = "onReplaceReference, onUploadReference, onRemoveReference, onConnectStart, onConnectDrop, readOnly = false" +content = content.replace(old_destructure, new_destructure, 1) + +# 5. Pass hover tracking and onRemove to ReferenceThumbnail in the rows +old_thumb = """ { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + />""" +new_thumb = """ onRemoveReference(row.id, columnIndex)} + onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ x: event.clientX, y: event.clientY, cell: { rowId: row.id, columnIndex } }); }} + onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} + onPointerDown={handleReferencePointerDown} + onPointerMove={handleReferencePointerMove} + onPointerUp={handleReferencePointerUp} + onPointerCancel={handleReferencePointerCancel} + onLostPointerCapture={handleReferencePointerCancel} + />""" +content = content.replace(old_thumb, new_thumb, 1) + +# 6. Add context menu overlay before closing div +old_close = """ + + ); +} + +function ReferenceColumnHeader""" +new_close = """ + {contextMenu ? ( +
e.stopPropagation()} + > + +
+ ) : null} + + ); +} + +function ReferenceColumnHeader""" +content = content.replace(old_close, new_close, 1) + +# 7. Update ReferenceThumbnail props type +old_thumb_props = " suppressClickRef: MutableRefObject;\n onReplace: (node: CanvasNodeData) => void;\n onUpload: () => void;" +new_thumb_props = " suppressClickRef: MutableRefObject;\n hoveredCellRef: MutableRefObject;\n onReplace: (node: CanvasNodeData) => void;\n onRemove: () => void;\n onContextMenu: (event: ReactMouseEvent) => void;\n onUpload: () => void;" +content = content.replace(old_thumb_props, new_thumb_props, 1) + +# 8. Update ReferenceThumbnail function signature +old_thumb_sig = "function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {" +new_thumb_sig = "function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onContextMenu, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: {" +content = content.replace(old_thumb_sig, new_thumb_sig, 1) + +# 9. Add hover tracking to both thumbnail buttons (empty and filled) +# For empty button - add onMouseEnter/Leave and onContextMenu +old_empty_btn = ''' onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} + aria-label={`${columnId},点击上传图片`}''' +new_empty_btn = ''' onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} + aria-label={`${columnId},点击上传图片`}''' +content = content.replace(old_empty_btn, new_empty_btn, 1) + +# For filled button - add onMouseEnter/Leave and onContextMenu +old_filled_btn = ''' onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} + {...cellData}''' +new_filled_btn = ''' onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} + {...cellData}''' +content = content.replace(old_filled_btn, new_filled_btn, 1) + +# 10. Need to import ReactMouseEvent type +old_import = 'import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";' +new_import = 'import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";' +content = content.replace(old_import, new_import, 1) + +with open(path, "w", encoding="utf-8") as f: + f.write(content) +print("done") diff --git a/web/src/components/canvas/canvas-batch-table-node.tsx b/web/src/components/canvas/canvas-batch-table-node.tsx index baaa29ce3..5286f6ed3 100644 --- a/web/src/components/canvas/canvas-batch-table-node.tsx +++ b/web/src/components/canvas/canvas-batch-table-node.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; import { Button, Input, Select, Switch, Tooltip } from "antd"; import { GripVertical, Image as ImageIcon, LoaderCircle, Play, Plus, RefreshCw, Rows3, Trash2, Upload } from "lucide-react"; @@ -26,6 +26,7 @@ type Props = { onMoveReferenceCell: (sourceRowId: string, sourceColumnIndex: number, targetRowId: string, targetColumnIndex: number) => void; onReplaceReference: (node: CanvasNodeData) => void; onUploadReference: (rowId: string, columnIndex: number, file: File) => void; + onRemoveReference: (rowId: string, columnIndex: number) => void; onConnectStart: (event: ReactPointerEvent, handleId: string) => void; onConnectDrop?: (event: ReactPointerEvent, handleId: string) => void; readOnly?: boolean; @@ -38,7 +39,7 @@ const OPERATION_OPTIONS = [ type ReferenceCell = { rowId: string; columnIndex: number }; -export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { +export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, theme, onPatchTable, onAddRow, onRemoveRow, onUpdateRow, onFillRows, onGenerate, onRetryItem, onAddReferenceColumn, onAddTextColumn, onReorderReferenceColumns, onMoveReferenceCell, onReplaceReference, onUploadReference, onRemoveReference, onConnectStart, onConnectDrop, readOnly = false }: Props) { const table = node.metadata?.batchTable || { operation: "try_on" as const, concurrency: 10, rows: [] }; const referenceColumns = batchReferenceColumns(table); const textColumns = batchTextColumns(table); @@ -57,6 +58,8 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t const dropCellRef = useRef(null); const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null); const lastPointerRef = useRef({ x: 0, y: 0 }); + const hoveredCellRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; cell: ReferenceCell } | null>(null); const suppressClickRef = useRef(false); const uploadTargetRef = useRef<{ rowId: string; columnIndex: number } | null>(null); const uploadInputRef = useRef(null); @@ -149,6 +152,30 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t }; }, [cancelReferenceDrag, finishReferenceDrag, updateReferenceDrag]); + // Delete / Backspace to remove hovered reference + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + const target = event.target as HTMLElement; + if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; + const cell = hoveredCellRef.current; + if (!cell || readOnly) return; + event.preventDefault(); + event.stopPropagation(); + onRemoveReference(cell.rowId, cell.columnIndex); + }; + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [readOnly, onRemoveReference]); + + // Hide context menu on outside click + useEffect(() => { + if (!contextMenu) return; + const hide = () => setContextMenu(null); + window.addEventListener("pointerdown", hide); + return () => window.removeEventListener("pointerdown", hide); + }, [contextMenu]); + const handleReferencePointerDown = useCallback((event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => { if (readOnly || !hasMaterial) return; event.preventDefault(); @@ -293,7 +320,10 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t isDraggingCell={draggingCell?.rowId === row.id && draggingCell.columnIndex === columnIndex} isDropTarget={dropCell?.rowId === row.id && dropCell.columnIndex === columnIndex} suppressClickRef={suppressClickRef} + hoveredCellRef={hoveredCellRef} onReplace={onReplaceReference} + onRemove={() => onRemoveReference(row.id, columnIndex)} + onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); setContextMenu({ x: event.clientX, y: event.clientY, cell: { rowId: row.id, columnIndex } }); }} onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }} onPointerDown={handleReferencePointerDown} onPointerMove={handleReferencePointerMove} @@ -351,6 +381,23 @@ export function CanvasBatchTableNodeContent({ node, nodes, connections, batch, t
连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。
)} + {contextMenu ? ( +
e.stopPropagation()} + > + +
+ ) : null} ); } @@ -497,7 +544,7 @@ function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasThe return
{content || "未连接文字"}
; } -function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, onReplace, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { +function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnId, draggingColumnId, isDraggingCell, isDropTarget, suppressClickRef, hoveredCellRef, onReplace, onRemove, onContextMenu, onUpload, onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onLostPointerCapture }: { node?: CanvasNodeData; theme: CanvasTheme; readOnly: boolean; @@ -508,7 +555,10 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI isDraggingCell: boolean; isDropTarget: boolean; suppressClickRef: MutableRefObject; + hoveredCellRef: MutableRefObject; onReplace: (node: CanvasNodeData) => void; + onRemove: () => void; + onContextMenu: (event: ReactMouseEvent) => void; onUpload: () => void; onPointerDown: (event: ReactPointerEvent, rowId: string, columnIndex: number, hasMaterial: boolean) => void; onPointerMove: (event: ReactPointerEvent) => void; @@ -528,6 +578,9 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI style={{ borderColor: isDropTarget ? theme.accent.primary : theme.node.stroke, background: `${theme.node.panel}88`, ...dropStyle }} disabled={readOnly} onClick={(event) => { event.stopPropagation(); if (!suppressClickRef.current) onUpload(); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} aria-label={`${columnId},点击上传图片`} {...cellData} onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, false)} @@ -553,6 +606,9 @@ function ReferenceThumbnail({ node, theme, readOnly, rowId, columnIndex, columnI style={{ borderColor: isDropTarget || draggingColumnId === columnId ? theme.accent.primary : theme.node.stroke, opacity: isDraggingCell || draggingColumnId === columnId ? 0.55 : 1, ...dropStyle }} aria-label={readOnly ? node.title || "参考图" : `${node.title || "参考图"},点击替换素材`} onClick={(event) => { event.stopPropagation(); if (!readOnly && !draggingColumnId && !suppressClickRef.current) onReplace(node); }} + onMouseEnter={() => { hoveredCellRef.current = { rowId, columnIndex }; }} + onMouseLeave={() => { if (hoveredCellRef.current?.rowId === rowId && hoveredCellRef.current?.columnIndex === columnIndex) hoveredCellRef.current = null; }} + onContextMenu={onContextMenu} {...cellData} onPointerDown={(event) => onPointerDown(event, rowId, columnIndex, true)} onPointerMove={onPointerMove} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 5ddc7b8db..79b236e92 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -2174,6 +2174,15 @@ function InfiniteCanvasPage() { onMoveReferenceCell={(sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex) => moveBatchReferenceCell(contentNode.id, sourceRowId, sourceColumnIndex, targetRowId, targetColumnIndex)} onReplaceReference={replaceCanvasNodeMedia} onUploadReference={(rowId, columnIndex, file) => { void handleUploadBatchReference(contentNode.id, rowId, columnIndex, file); }} + onRemoveReference={(rowId, columnIndex) => { + const table = nodesRef.current.find((n) => n.id === contentNode.id)?.metadata?.batchTable; + if (!table) return; + const row = table.rows.find((r) => r.id === rowId); + if (!row) return; + const newInputNodeIds = [...row.inputNodeIds]; + newInputNodeIds[columnIndex] = ""; + updateBatchRow(contentNode.id, rowId, { inputNodeIds: newInputNodeIds }); + }} onConnectStart={(event, handleId) => handleConnectStart(event, contentNode.id, "target", handleId)} onConnectDrop={(event, handleId) => handleConnectDrop(event, contentNode.id, handleId)} /> diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index 77cfb9cbb..f0c3d869e 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -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; 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(null); - const viewportRef = useRef({ x: 0, y: 0, k: 1 }); - const dragRef = useRef(null); - const toolbarTimerRef = useRef | null>(null); - const [title, setTitle] = useState("共享画布"); - const [nodes, setNodes] = useState([]); - const [connections, setConnections] = useState>["project"]["connections"]>([]); - const [appearance, setAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); - const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines"); - const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); - const [selectedNodeId, setSelectedNodeId] = useState(null); - const [infoNodeId, setInfoNodeId] = useState(null); - const [toolbarNodeId, setToolbarNodeId] = useState(null); - const [dragOffset, setDragOffset] = useState(null); - const [contextMenu, setContextMenu] = useState(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(); - 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 ; - if (node.type === CanvasNodeType.BatchTable) return {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onAddTextColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onReplaceReference={() => {}} onUploadReference={() => {}} onConnectStart={() => {}} />; - return ; - }, [connections, nodes, theme, unauthorized]); - const toolbarNodeKey = selectedNodeId; - const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null; - - if (loading) return ; - if (loadError) return
} />
; - - return ( -
-
-
- - {title} - 只读分享 -
- -
- - { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}> - - {visibleConnections.map(({ connection, from, to }) => setInfoNodeId(to.id)} />)} - - {visibleNodes.map((node) => isFrameNode(node) ? { - 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)} /> : { - 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)} />)} - - - 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} /> - -
-
访客操作仅在当前页面临时生效
- - {contextMenu ? { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null} - setInfoNodeId(null)} readOnly onUnauthorized={unauthorized} /> -
- ); -} - -function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) { - const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; - return
event.stopPropagation()}> - {menu.nodeId ? <>} label="查看节点信息" onClick={onInfo} />} label="编辑或生成" onClick={onUnauthorized} /> : <> -
添加临时节点
- } label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} /> - } label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} /> - } label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} /> - } label="分镜脚本" onClick={() => onAdd(CanvasNodeType.Script)} /> - } -
; -} - -function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) { - return ; -} - -function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { - const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; - return
-
{node.title}
-
{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}
-
-
; -} - -function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { - const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; - const rows = node.metadata?.storyboard?.rows || []; - return
-
{node.title}{rows.length} 镜
-
event.stopPropagation()}>{rows.length ? rows.map((row) =>
#{row.shotNumber}{row.durationSeconds}s{row.plotDescription || "-"}{row.dialogue || "-"}
) :
暂无分镜
}
- {node.metadata?.composerContent ?
{node.metadata.composerContent}
: null} -
; -} +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; 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(null); + const viewportRef = useRef({ x: 0, y: 0, k: 1 }); + const dragRef = useRef(null); + const toolbarTimerRef = useRef | null>(null); + const [title, setTitle] = useState("共享画布"); + const [nodes, setNodes] = useState([]); + const [connections, setConnections] = useState>["project"]["connections"]>([]); + const [appearance, setAppearance] = useState(() => canvasAppearanceForTheme(colorTheme)); + const [backgroundMode, setBackgroundMode] = useState<"lines" | "dots" | "blank">("lines"); + const [viewport, setViewport] = useState({ x: 0, y: 0, k: 1 }); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [infoNodeId, setInfoNodeId] = useState(null); + const [toolbarNodeId, setToolbarNodeId] = useState(null); + const [dragOffset, setDragOffset] = useState(null); + const [contextMenu, setContextMenu] = useState(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(); + 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 ; + if (node.type === CanvasNodeType.BatchTable) return {}} onAddRow={() => {}} onRemoveRow={() => {}} onUpdateRow={() => {}} onFillRows={() => {}} onGenerate={() => {}} onRetryItem={() => {}} onAddReferenceColumn={() => {}} onAddTextColumn={() => {}} onReorderReferenceColumns={() => {}} onMoveReferenceCell={() => {}} onReplaceReference={() => {}} onUploadReference={() => {}} onRemoveReference={() => {}} onConnectStart={() => {}} />; + return ; + }, [connections, nodes, theme, unauthorized]); + const toolbarNodeKey = selectedNodeId; + const toolbarNode = toolbarNodeKey ? nodeById.get(toolbarNodeKey) || null : null; + + if (loading) return ; + if (loadError) return
} />
; + + return ( +
+
+
+ + {title} + 只读分享 +
+ +
+ + { viewportRef.current = next; }} onCanvasDeselect={() => { setSelectedNodeId(null); setContextMenu(null); }} onContextMenu={(event) => openContextMenu(event)} onDrop={(event) => { event.preventDefault(); unauthorized(); }}> + + {visibleConnections.map(({ connection, from, to }) => setInfoNodeId(to.id)} />)} + + {visibleNodes.map((node) => isFrameNode(node) ? { + 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)} /> : { + 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)} />)} + + + 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} /> + +
+
访客操作仅在当前页面临时生效
+ + {contextMenu ? { if (contextMenu.nodeId) setInfoNodeId(contextMenu.nodeId); setContextMenu(null); }} onUnauthorized={() => { setContextMenu(null); unauthorized(); }} /> : null} + setInfoNodeId(null)} readOnly onUnauthorized={unauthorized} /> +
+ ); +} + +function SharedContextMenu({ menu, onAdd, onInfo, onUnauthorized }: { menu: ContextMenu; onAdd: (type: CanvasNodeType) => void; onInfo: () => void; onUnauthorized: () => void }) { + const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; + return
event.stopPropagation()}> + {menu.nodeId ? <>} label="查看节点信息" onClick={onInfo} />} label="编辑或生成" onClick={onUnauthorized} /> : <> +
添加临时节点
+ } label="文本节点" onClick={() => onAdd(CanvasNodeType.Text)} /> + } label="图片节点" onClick={() => onAdd(CanvasNodeType.Image)} /> + } label="视频节点" onClick={() => onAdd(CanvasNodeType.Video)} /> + } label="分镜脚本" onClick={() => onAdd(CanvasNodeType.Script)} /> + } +
; +} + +function MenuButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) { + return ; +} + +function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { + const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; + return
+
{node.title}
+
{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}
+
+
; +} + +function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { + const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; + const rows = node.metadata?.storyboard?.rows || []; + return
+
{node.title}{rows.length} 镜
+
event.stopPropagation()}>{rows.length ? rows.map((row) =>
#{row.shotNumber}{row.durationSeconds}s{row.plotDescription || "-"}{row.dialogue || "-"}
) :
暂无分镜
}
+ {node.metadata?.composerContent ?
{node.metadata.composerContent}
: null} +
; +}