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 (