import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent } from "react"; import { ChevronDown, ChevronRight, Video } from "lucide-react"; import { CometCard } from "@/components/ui/aceternity/comet-card"; import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview"; import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame"; import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview"; import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type Position } from "@/types/canvas"; type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; export const CanvasFrameNode = React.memo(function CanvasFrameNode({ data, dragOffset, childNodes, scale, isSelected, isDropTarget, onMouseDown, onResize, onToggleCollapsed, onFolderStyleChange, onFolderThemeChange = () => undefined, onTitleChange, onContextMenu, readOnly = false, onHoverStart, onHoverEnd, }: { data: CanvasNodeData; dragOffset?: Position; childNodes: CanvasNodeData[]; scale: number; isSelected: boolean; isDropTarget: boolean; onMouseDown: (event: ReactMouseEvent, nodeId: string) => void; onResize: (nodeId: string, width: number, height: number, position?: Position) => void; onToggleCollapsed: (nodeId: string) => void; onFolderStyleChange: (nodeId: string, style: CanvasFolderStyle) => void; onFolderThemeChange?: (nodeId: string, theme: CanvasFolderTheme) => void; onTitleChange: (nodeId: string, title: string) => void; onContextMenu: (event: ReactMouseEvent, nodeId: string) => void; readOnly?: boolean; onHoverStart?: (nodeId: string) => void; onHoverEnd?: (nodeId: string) => void; }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const collapsed = Boolean(data.metadata?.frame?.collapsed); const folder = isCanvasFolderNode(data); const [editing, setEditing] = useState(false); const [title, setTitle] = useState(data.title); const resizeRef = useRef({ active: false, corner: "bottom-right" as ResizeCorner, startX: 0, startY: 0, startLeft: 0, startTop: 0, startWidth: 0, startHeight: 0, nodeId: data.id, scale, childBounds: null as { left: number; top: number; right: number; bottom: number } | null, onResize, }); useEffect(() => setTitle(data.title), [data.title]); const commitTitle = () => { const next = title.trim() || (folder ? "未命名文件夹" : "未命名背板"); setTitle(next); setEditing(false); onTitleChange(data.id, next); }; const childBounds = useMemo(() => { if (!childNodes.length) return null; const left = Math.min(...childNodes.map((node) => node.position.x)); const top = Math.min(...childNodes.map((node) => node.position.y)); const right = Math.max(...childNodes.map((node) => node.position.x + node.width)); const bottom = Math.max(...childNodes.map((node) => node.position.y + node.height)); return { left, top, right, bottom }; }, [childNodes]); const handleResizeMove = useCallback( (event: MouseEvent) => { const state = resizeRef.current; if (!state.active) return; const dx = (event.clientX - state.startX) / state.scale; const dy = (event.clientY - state.startY) / state.scale; const fromLeft = state.corner.includes("left"); const fromTop = state.corner.includes("top"); const startRight = state.startLeft + state.startWidth; const startBottom = state.startTop + state.startHeight; let left = fromLeft ? Math.min(state.startLeft + dx, startRight - 360) : state.startLeft; let top = fromTop ? Math.min(state.startTop + dy, startBottom - 240) : state.startTop; let right = fromLeft ? startRight : Math.max(state.startLeft + 360, startRight + dx); let bottom = fromTop ? startBottom : Math.max(state.startTop + 240, startBottom + dy); if (state.childBounds) { if (fromLeft) left = Math.min(left, state.childBounds.left - FRAME_PADDING); else right = Math.max(right, state.childBounds.right + FRAME_PADDING); if (fromTop) top = Math.min(top, state.childBounds.top - FRAME_HEADER_HEIGHT - FRAME_PADDING); else bottom = Math.max(bottom, state.childBounds.bottom + FRAME_PADDING); } state.onResize(state.nodeId, right - left, bottom - top, { x: left, y: top }); }, [], ); const handleResizeUp = useCallback(() => { resizeRef.current.active = false; window.removeEventListener("mousemove", handleResizeMove); window.removeEventListener("mouseup", handleResizeUp); }, [handleResizeMove]); useEffect( () => () => { window.removeEventListener("mousemove", handleResizeMove); window.removeEventListener("mouseup", handleResizeUp); }, [handleResizeMove, handleResizeUp], ); const startResize = (event: ReactMouseEvent, corner: ResizeCorner) => { event.preventDefault(); event.stopPropagation(); resizeRef.current = { active: true, corner, startX: event.clientX, startY: event.clientY, startLeft: data.position.x, startTop: data.position.y, startWidth: data.width, startHeight: data.height, nodeId: data.id, scale, childBounds, onResize, }; window.addEventListener("mousemove", handleResizeMove); window.addEventListener("mouseup", handleResizeUp); }; const active = isSelected || isDropTarget; const linkedFolder = Boolean(data.metadata?.folder?.assetFolderId); return (
onMouseDown(event, data.id)} onDoubleClick={(event) => { if (!collapsed || (event.target instanceof Element && event.target.closest("button,input"))) return; event.stopPropagation(); onToggleCollapsed(data.id); }} onContextMenu={(event) => onContextMenu(event, data.id)} onKeyDown={(event) => { if (!folder || !collapsed || (event.target instanceof Element && event.target.closest("button,input"))) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); onToggleCollapsed(data.id); }} onMouseEnter={() => onHoverStart?.(data.id)} onMouseLeave={() => onHoverEnd?.(data.id)} > {/* 帧节点同样禁用指针跟随 3D 位移,hover 使用 CSS 静态抬升 */} {folder && collapsed ? ( // 素材库目录是单一事实源;画布节点只负责浏览和调用,避免本地改名后被同步结果覆盖。 ) : ( <>
{editing ? ( setTitle(event.target.value)} onBlur={commitTitle} onMouseDown={(event) => event.stopPropagation()} onKeyDown={(event) => { if (event.key === "Enter") commitTitle(); if (event.key === "Escape") { setTitle(data.title); setEditing(false); } }} /> ) : ( )} {childNodes.length}
{collapsed ? : null} )}
{!readOnly && !collapsed && isSelected && !data.metadata?.locked ? ( <> ) : null}
); }); function FramePreview({ nodes, frame, theme }: { nodes: CanvasNodeData[]; frame: CanvasNodeData; theme: CanvasTheme }) { const layout = useMemo(() => { if (!nodes.length) return []; const previewNodes = nodes.slice(0, 24); const left = Math.min(...previewNodes.map((node) => node.position.x)); const top = Math.min(...previewNodes.map((node) => node.position.y)); const right = Math.max(...previewNodes.map((node) => node.position.x + node.width)); const bottom = Math.max(...previewNodes.map((node) => node.position.y + node.height)); const width = Math.max(right - left, 1); const height = Math.max(bottom - top, 1); const previewWidth = Math.max(frame.width - 16, 1); const previewHeight = Math.max(frame.height - FRAME_HEADER_HEIGHT - 8, 1); const scale = Math.min(previewWidth / width, previewHeight / height); const offsetX = (previewWidth - width * scale) / 2; const offsetY = (previewHeight - height * scale) / 2; return previewNodes.map((node) => ({ node, left: offsetX + (node.position.x - left) * scale, top: offsetY + (node.position.y - top) * scale, width: Math.max(node.width * scale, 12), height: Math.max(node.height * scale, 10), })); }, [frame.height, frame.width, nodes]); return (
{layout.length ? ( layout.map(({ node, ...style }) => { const videoPreview = canvasNodeVideoPreviewUrl(node); return
{node.type === CanvasNodeType.Image && node.metadata?.content ? : null} {videoPreview ? : null} {node.type === CanvasNodeType.Video && !videoPreview ?
; }) ) : (
空背板
)}
); } function ResizeHandle({ corner, scale, theme, onMouseDown }: { corner: ResizeCorner; scale: number; theme: CanvasTheme; onMouseDown: (event: ReactMouseEvent, corner: ResizeCorner) => void }) { const inverseScale = 1 / Math.max(scale, 0.05); const size = 14 * inverseScale; const offset = -size / 2; const fromTop = corner.includes("top"); const fromLeft = corner.includes("left"); const cursor = corner === "top-left" || corner === "bottom-right" ? "nwse-resize" : "nesw-resize"; return (