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 { 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 { 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 { getPublicCanvasShare } from "@/services/api/canvas-share"; import { useThemeStore } from "@/stores/use-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() { const { token = "" } = useParams(); const { message } = App.useApp(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; 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 [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; setLoading(true); getPublicCanvasShare(token).then(({ project }) => { if (!active) return; setTitle(project.title || "共享画布"); setNodes((project.nodes || []).map(ensureMediaNodeMinimumSize)); setConnections(project.connections || []); setBackgroundMode(project.backgroundMode || "lines"); 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; }; }, [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 => node.type === CanvasNodeType.Script ? : , [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} onGenerateImage={unauthorized} onUpload={unauthorized} onDownload={unauthorized} onSaveAsset={unauthorized} onMaskEdit={unauthorized} onEmotion={unauthorized} onPortraitTexture={unauthorized} onCrop={unauthorized} onSplit={unauthorized} onUpscale={unauthorized} onSuperResolve={unauthorized} onAngle={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[useThemeStore((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[useThemeStore((state) => state.theme)]; return
{node.title}
{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}
; } function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUnauthorized: () => void }) { const theme = canvasThemes[useThemeStore((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}
; }