feat: add right-click and keyboard delete for batch table reference thumbnails

- Right-click on reference thumbnail shows context menu with 删除素材
- Delete/Backspace key removes the hovered reference cell
- Both actions only work within the batch table, on the specific thumbnail
This commit is contained in:
Codex
2026-09-19 14:36:20 +08:00
parent e921deac74
commit cf4d2ab10c
6 changed files with 596 additions and 333 deletions

20
fix_remove_ref.py Normal file
View File

@@ -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")

10
fix_shared.py Normal file
View File

@@ -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")

168
fix_thumbnail_delete.py Normal file
View File

@@ -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<ReferenceCell | null>(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 = """ <ReferenceThumbnail
key={column.id}
node={nodeById.get(row.inputNodeIds[columnIndex])}
theme={theme}
readOnly={readOnly}
rowId={row.id}
columnIndex={columnIndex}
columnId={column.id}
draggingColumnId={draggingColumnId}
isDraggingCell={draggingCell?.rowId === row.id && draggingCell.columnIndex === columnIndex}
isDropTarget={dropCell?.rowId === row.id && dropCell.columnIndex === columnIndex}
suppressClickRef={suppressClickRef}
onReplace={onReplaceReference}
onUpload={() => { uploadTargetRef.current = { rowId: row.id, columnIndex }; uploadInputRef.current?.click(); }}
onPointerDown={handleReferencePointerDown}
onPointerMove={handleReferencePointerMove}
onPointerUp={handleReferencePointerUp}
onPointerCancel={handleReferencePointerCancel}
onLostPointerCapture={handleReferencePointerCancel}
/>"""
new_thumb = """ <ReferenceThumbnail
key={column.id}
node={nodeById.get(row.inputNodeIds[columnIndex])}
theme={theme}
readOnly={readOnly}
rowId={row.id}
columnIndex={columnIndex}
columnId={column.id}
draggingColumnId={draggingColumnId}
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}
onPointerUp={handleReferencePointerUp}
onPointerCancel={handleReferencePointerCancel}
onLostPointerCapture={handleReferencePointerCancel}
/>"""
content = content.replace(old_thumb, new_thumb, 1)
# 6. Add context menu overlay before closing div
old_close = """ </div>
</div>
);
}
function ReferenceColumnHeader"""
new_close = """ </div>
{contextMenu ? (
<div
className="fixed z-50 min-w-[120px] rounded-lg border py-1 shadow-lg"
style={{ left: contextMenu.x, top: contextMenu.y, background: theme.node.panel, borderColor: theme.node.stroke }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:opacity-70"
style={{ color: "#ef4444" }}
onClick={() => { onRemoveReference(contextMenu.cell.rowId, contextMenu.cell.columnIndex); setContextMenu(null); }}
>
<Trash2 className="size-3.5" />
删除素材
</button>
</div>
) : null}
</div>
);
}
function ReferenceColumnHeader"""
content = content.replace(old_close, new_close, 1)
# 7. Update ReferenceThumbnail props type
old_thumb_props = " suppressClickRef: MutableRefObject<boolean>;\n onReplace: (node: CanvasNodeData) => void;\n onUpload: () => void;"
new_thumb_props = " suppressClickRef: MutableRefObject<boolean>;\n hoveredCellRef: MutableRefObject<ReferenceCell | null>;\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")

View File

@@ -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<ReferenceCell | null>(null);
const dragStartRef = useRef<{ x: number; y: number; pointerId: number; cell: ReferenceCell } | null>(null);
const lastPointerRef = useRef({ x: 0, y: 0 });
const hoveredCellRef = useRef<ReferenceCell | null>(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<HTMLInputElement>(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<HTMLButtonElement>, 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
<div className="grid h-40 place-items-center px-5 text-center" style={{ color: theme.node.muted }}>连接图片后会自动生成任务;点击缩略图可替换素材,拖动参考图列可调整顺序。</div>
)}
</div>
{contextMenu ? (
<div
className="fixed z-50 min-w-[120px] rounded-lg border py-1 shadow-lg"
style={{ left: contextMenu.x, top: contextMenu.y, background: theme.node.panel, borderColor: theme.node.stroke }}
onPointerDown={(e) => e.stopPropagation()}
>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:opacity-70"
style={{ color: "#ef4444" }}
onClick={() => { onRemoveReference(contextMenu.cell.rowId, contextMenu.cell.columnIndex); setContextMenu(null); }}
>
<Trash2 className="size-3.5" />
删除素材
</button>
</div>
) : null}
</div>
);
}
@@ -497,7 +544,7 @@ function TextNodeCell({ node, theme }: { node?: CanvasNodeData; theme: CanvasThe
return <div className="min-h-12 max-w-full overflow-hidden rounded border px-2 py-1.5 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, color: content ? theme.node.text : theme.node.placeholder }} title={content || "未连接文字节点"}>{content || "未连接文字"}</div>;
}
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<boolean>;
hoveredCellRef: MutableRefObject<ReferenceCell | null>;
onReplace: (node: CanvasNodeData) => void;
onRemove: () => void;
onContextMenu: (event: ReactMouseEvent) => void;
onUpload: () => void;
onPointerDown: (event: ReactPointerEvent<HTMLButtonElement>, rowId: string, columnIndex: number, hasMaterial: boolean) => void;
onPointerMove: (event: ReactPointerEvent<HTMLButtonElement>) => 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}

View File

@@ -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)}
/>

View File

@@ -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={() => {}} 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={() => {}} 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>;
}