import { motion, useReducedMotion } from "motion/react"; import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from "react"; import { ChevronRight, Clapperboard, Image as ImageIcon, List, Music2, Pencil, Video, WandSparkles, X } from "lucide-react"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { canvasThemes } from "@/lib/canvas-theme"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle, type Position, type ViewportTransform } from "@/types/canvas"; export type PendingConnectionCreate = { connection: ConnectionHandle; position: Position; quick?: boolean; }; export function CanvasSelectionToolbar({ anchorRef, containerRef, count, children }: { anchorRef: RefObject; containerRef: RefObject; count: number; children: ReactNode }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const reducedMotion = useReducedMotion(); const toolbarRef = useRef(null); const [anchor, setAnchor] = useState<{ left: number; top: number; placement: "above" | "below" } | null>(null); useLayoutEffect(() => { const element = anchorRef.current; const container = containerRef.current; if (!element || !container) { setAnchor(null); return; } const update = () => { const bounds = element.getBoundingClientRect(); const containerBounds = container.getBoundingClientRect(); const toolbarWidth = toolbarRef.current?.offsetWidth || 320; const toolbarHeight = toolbarRef.current?.offsetHeight || 38; const halfWidth = Math.min(toolbarWidth / 2, Math.max(0, containerBounds.width / 2 - 12)); const center = bounds.left - containerBounds.left + bounds.width / 2; const left = Math.min(Math.max(center, 12 + halfWidth), Math.max(12 + halfWidth, containerBounds.width - 12 - halfWidth)); const boundsTop = bounds.top - containerBounds.top; const boundsBottom = bounds.bottom - containerBounds.top; const placement = boundsTop - toolbarHeight - 8 >= 68 ? "above" : "below"; const top = placement === "above" ? boundsTop - 8 : Math.min(boundsBottom + 8, containerBounds.height - toolbarHeight - 12); if (toolbarRef.current) { toolbarRef.current.style.left = `${left}px`; toolbarRef.current.style.top = `${top}px`; toolbarRef.current.classList.toggle("-translate-y-full", placement === "above"); return; } setAnchor((current) => current?.left === left && current.top === top && current.placement === placement ? current : { left, top, placement }); }; update(); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(element); resizeObserver.observe(container); if (toolbarRef.current) resizeObserver.observe(toolbarRef.current); const viewportLayer = element.parentElement; const mutationObserver = new MutationObserver(update); if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] }); const unsubscribeViewport = subscribeCanvasViewportPreview(container, update); window.addEventListener("resize", update); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); unsubscribeViewport(); window.removeEventListener("resize", update); }; }, [anchorRef, containerRef, count]); if (!anchor) return null; return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > 已选 {count}
{children}
); } export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth = 520, panelHeight = 420, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; panelWidth?: number; panelHeight?: number; children: ReactNode }) { const panelRef = useRef(null); const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, panelWidth, panelHeight); useLayoutEffect(() => { const container = containerRef.current; const panel = panelRef.current; if (!container || !panel) return; const update = (nextViewport: ViewportTransform) => { const position = getNodePanelPosition(node, nextViewport, { width: container.clientWidth, height: container.clientHeight }, panel.offsetWidth || panelWidth, panel.offsetHeight || panelHeight); panel.style.left = `${position.left}px`; panel.style.top = `${position.top}px`; }; update(viewport); const resizeObserver = new ResizeObserver(() => update(viewport)); resizeObserver.observe(container); resizeObserver.observe(panel); const unsubscribeViewport = subscribeCanvasViewportPreview(container, update); return () => { resizeObserver.disconnect(); unsubscribeViewport(); }; }, [containerRef, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]); return (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > {children}
); } export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, containerRef, canCreateDrawing, getDisabledReason, onCreate, onClose }: { pending: PendingConnectionCreate; viewport: ViewportTransform; viewportSize: { width: number; height: number }; containerRef: RefObject; canCreateDrawing: boolean; getDisabledReason: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => string; onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing) => void; onClose: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const reducedMotion = useReducedMotion(); const menuRef = useRef(null); const menuWidth = 248; const menuHeight = canCreateDrawing ? 420 : 376; const gap = 12; const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap); useLayoutEffect(() => { const container = containerRef.current; const menu = menuRef.current; if (!container || !menu) return; const update = (nextViewport: ViewportTransform) => { const containerBounds = container.getBoundingClientRect(); const position = getConnectionMenuPosition(pending.position, nextViewport, { width: containerBounds.width, height: containerBounds.height }, menu.offsetWidth || menuWidth, menu.offsetHeight || menuHeight, gap); menu.style.left = `${position.left}px`; menu.style.top = `${position.top}px`; }; update(viewport); return subscribeCanvasViewportPreview(container, update); }, [containerRef, pending.position, viewport, viewportSize.height, viewportSize.width]); return ( event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} >
创建下一步引用当前节点
} title="文本生成" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> } title="分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> } title="图片生成" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> {canCreateDrawing ? } title="绘图" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> : null} } title="视频生成" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> } title="音频参考" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} />
); } function ConnectionCreateOption({ motionEnabled, icon, title, description, disabledReason, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; disabledReason?: string; onClick: () => void }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; return ( {icon} {title} {disabledReason || description ? {disabledReason || description} : null} ); } function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function getConnectionMenuPosition(position: Position, viewport: ViewportTransform, viewportSize: { width: number; height: number }, menuWidth: number, menuHeight: number, gap: number) { const screenX = viewport.x + position.x * viewport.k; const screenY = viewport.y + position.y * viewport.k; return { left: clamp(screenX, gap, Math.max(gap, viewportSize.width - menuWidth - gap)), top: clamp(screenY, 72, Math.max(72, viewportSize.height - menuHeight - gap)), }; } function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, viewportSize: { width: number; height: number }, panelWidth: number, panelHeight: number) { const gap = 10; const margin = 12; const topBoundary = 72; const nodeCenterX = viewport.x + (node.position.x + node.width / 2) * viewport.k; const nodeTop = viewport.y + node.position.y * viewport.k; const nodeBottom = viewport.y + (node.position.y + node.height) * viewport.k; const maxLeft = Math.max(margin, viewportSize.width - panelWidth - margin); const left = clamp(nodeCenterX - panelWidth / 2, margin, maxLeft); const belowTop = nodeBottom + gap; const aboveTop = nodeTop - panelHeight - gap; const preferredTop = belowTop + panelHeight <= viewportSize.height - margin ? belowTop : aboveTop; return { left, top: clamp(preferredTop, topBoundary, Math.max(topBoundary, viewportSize.height - panelHeight - margin)), }; }