import { motion, useReducedMotion } from "motion/react"; import { useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject } from "react"; import { Clapperboard, Image as ImageIcon, List, Music2, Pencil, Table2, Video, WandSparkles, Workflow as WorkflowIcon } from "lucide-react"; import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer"; import { canvasThemes } from "@/lib/canvas-theme"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle, type Position, type ViewportTransform } from "@/types/canvas"; export type PendingConnectionCreate = { connection: ConnectionHandle; position: Position; quick?: boolean; batchSourceNodeIds?: string[]; }; export function CanvasSelectionToolbar({ anchorRef, containerRef, count, children }: { anchorRef: RefObject; containerRef: RefObject; count: number; children: ReactNode }) { const theme = canvasThemes[useActiveTheme()]; 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, panelHeight = 190, dragOffset, isDragging = false, allowOverflow = false, children, }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; allowOverflow?: boolean; children: ReactNode; }) { const panelRef = useRef(null); const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)"); const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth); const initialPosition = getNodePanelPosition(node, viewport, { width: containerRef.current?.clientWidth || 0, height: containerRef.current?.clientHeight || 0 }, initialWidth, panelHeight, dragOffset); useLayoutEffect(() => { bringToFront(); }, [bringToFront]); useLayoutEffect(() => { const container = containerRef.current; const panel = panelRef.current; if (!container || !panel) return; let liveViewport = viewport; let liveDragOffset = dragOffset; let viewportSize = { width: container.clientWidth, height: container.clientHeight }; const update = (nextViewport: ViewportTransform) => { liveViewport = nextViewport; const nextWidth = resolveNodePanelWidth(node, nextViewport, panelWidth); panel.style.width = `${nextWidth}px`; const nodeElement = container.querySelector(`[data-node-id="${CSS.escape(node.id)}"]`); const position = nodeElement ? getAttachedNodePanelPosition(nodeElement, container, nextWidth) : getNodePanelPosition(node, nextViewport, viewportSize, nextWidth, panelHeight, liveDragOffset); panel.style.transform = `translate3d(${position.left}px, ${position.top}px, 0)`; }; update(viewport); const resizeObserver = new ResizeObserver(() => { viewportSize = { width: container.clientWidth, height: container.clientHeight }; update(liveViewport); }); resizeObserver.observe(container); const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, update); const unsubscribeDrag = subscribeCanvasNodeDragPreview(container, (preview) => { liveDragOffset = preview?.nodeIds.has(node.id) ? { x: preview.x, y: preview.y } : null; update(liveViewport); }); return () => { resizeObserver.disconnect(); unsubscribeViewport(); unsubscribeDrag(); }; }, [containerRef, dragOffset?.x, dragOffset?.y, isDragging, node.height, node.id, node.position.x, node.position.y, node.width, panelHeight, panelWidth, viewport]); // 面板内任意位置按下 ⌘/Ctrl + Enter 时直接提交,避免先把手移回输入框或生成按钮。 // 只有面板自己声明了主按钮([data-canvas-panel-submit])才生效,且按钮禁用时不拦截按键。 const handleSubmitShortcut = (event: ReactKeyboardEvent) => { if (event.key !== "Enter" || event.shiftKey || event.altKey) return; if (!(event.ctrlKey || event.metaKey)) return; if (event.nativeEvent.isComposing) return; const submit = panelRef.current?.querySelector("[data-canvas-panel-submit]"); if (!submit || submit.disabled || submit.getAttribute("aria-disabled") === "true") return; event.preventDefault(); event.stopPropagation(); submit.click(); }; return (
event.stopPropagation()} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront} onPointerDown={(event) => event.stopPropagation()} > {children}
); } function resolveNodePanelWidth(node: CanvasNodeData, viewport: ViewportTransform, requestedWidth?: number) { if (requestedWidth) return requestedWidth; return clamp(Math.round(node.width * viewport.k * 1.5), 680, 920); } 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.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub", ) => string; onCreate: ( type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Script | CanvasNodeType.BatchTable | CanvasNodeType.Video | CanvasNodeType.Audio | CanvasNodeType.Drawing | CanvasNodeType.Config | CanvasNodeType.MediaConversion, provider?: "runninghub", ) => void; onClose: () => void; }) { const theme = canvasThemes[useActiveTheme()]; const reducedMotion = useReducedMotion(); const menuRef = useRef(null); const [activeOption, setActiveOption] = useState(null); const lastPointerRef = useRef(null); const { bringToFront, zIndex } = useCanvasOverlayLayer("connection-create-menu", "var(--z-modal-overlay)"); const menuWidth = Math.min(288, viewportSize.width - 24); const menuHeight = canCreateDrawing ? 448 : 404; const gap = 12; const initialPosition = getConnectionMenuPosition(pending.position, viewport, viewportSize, menuWidth, menuHeight, gap); useLayoutEffect(() => { bringToFront(); }, [bringToFront]); 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 ( { if (event.key === "Escape") { event.stopPropagation(); onClose(); } }} style={{ width: menuWidth, maxHeight: Math.max(120, viewportSize.height - 84), left: initialPosition.left, top: initialPosition.top, zIndex, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onPointerDownCapture={bringToFront} onFocusCapture={(event) => { bringToFront(); if (event.target.matches(":focus-visible")) setActiveOption(event.target.closest("[data-create-option]")?.dataset.createOption || null); }} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setActiveOption(null); }} onPointerMove={(event) => { if (event.pointerType === "touch") return; const previous = lastPointerRef.current; // Layout changes can retarget a stationary pointer; only real movement selects a new row. if (previous?.x === event.clientX && previous.y === event.clientY) return; lastPointerRef.current = { x: event.clientX, y: event.clientY }; const option = (event.target as Element).closest("[data-create-option]")?.dataset.createOption; if (option) setActiveOption(option); }} onPointerLeave={() => { lastPointerRef.current = null; setActiveOption(null); }} onPointerDown={(event) => event.stopPropagation()} >
} title="文本生成" description="引用当前内容,生成或改写文本" disabledReason={getDisabledReason(CanvasNodeType.Text)} onClick={() => onCreate(CanvasNodeType.Text)} /> } title="分镜脚本" description="根据剧情拆解镜头,编排分镜脚本" disabledReason={getDisabledReason(CanvasNodeType.Script)} onClick={() => onCreate(CanvasNodeType.Script)} /> } title="批量创作表" description="汇总多张图片,批量执行换装或创意生图" disabledReason={getDisabledReason(CanvasNodeType.BatchTable)} onClick={() => onCreate(CanvasNodeType.BatchTable)} /> } title="图片生成" description="结合提示词和参考图,生成新的画面" disabledReason={getDisabledReason(CanvasNodeType.Image)} onClick={() => onCreate(CanvasNodeType.Image)} /> } title="生成配置" description="选择模型,或使用已启用的工作流插件" disabledReason={getDisabledReason(CanvasNodeType.Config)} onClick={() => onCreate(CanvasNodeType.Config)} /> {canCreateDrawing ? ( } title="绘图" description="以参考图片为底图,自由绘制和标注" disabledReason={getDisabledReason(CanvasNodeType.Drawing)} onClick={() => onCreate(CanvasNodeType.Drawing)} /> ) : null} } title="视频生成" description="结合提示词与参考素材,生成动态视频" disabledReason={getDisabledReason(CanvasNodeType.Video)} onClick={() => onCreate(CanvasNodeType.Video)} /> } title="音频参考" description="连接文本或角色卡,创建音频生成节点" disabledReason={getDisabledReason(CanvasNodeType.Audio)} onClick={() => onCreate(CanvasNodeType.Audio)} /> } title="转换" description="本地处理图片或视频" disabledReason={getDisabledReason(CanvasNodeType.MediaConversion)} onClick={() => onCreate(CanvasNodeType.MediaConversion)} />
); } function ConnectionCreateOption({ expanded, motionEnabled, icon, title, description, disabledReason, onClick, }: { expanded: boolean; motionEnabled: boolean; icon: ReactNode; title: string; description: string; disabledReason?: string; onClick: () => void; }) { const theme = canvasThemes[useActiveTheme()]; return ( ); } 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 getAttachedNodePanelPosition(nodeElement: HTMLElement, container: HTMLElement, panelWidth: number) { const gap = 10; const nodeRect = nodeElement.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); return { left: nodeRect.left - containerRect.left + nodeRect.width / 2 - panelWidth / 2, top: nodeRect.bottom - containerRect.top + gap, placement: "below" as const, }; } export function getNodePanelPosition(node: CanvasNodeData, viewport: ViewportTransform, _viewportSize: { width: number; height: number }, panelWidth: number, _panelHeight: number, dragOffset?: Position | null) { const gap = 10; const offsetX = dragOffset?.x || 0; const offsetY = dragOffset?.y || 0; const nodeCenterX = viewport.x + (node.position.x + offsetX + node.width / 2) * viewport.k; const nodeBottom = viewport.y + (node.position.y + offsetY + node.height) * viewport.k; return { left: nodeCenterX - panelWidth / 2, top: nodeBottom + gap, placement: "below" as const, }; }