Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-frame-node.tsx

309 lines
15 KiB
TypeScript

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 { FRAME_HEADER_HEIGHT, FRAME_PADDING } from "@/lib/canvas/canvas-frame";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, 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,
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;
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 [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() || "未命名背板";
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;
return (
<div
data-node-id={data.id}
className={`absolute z-0 select-none ${dragOffset ? "cursor-grabbing" : "cursor-default"}`}
style={{ transform: `translate(${data.position.x + (dragOffset?.x || 0)}px, ${data.position.y + (dragOffset?.y || 0)}px)`, width: data.width, height: data.height, contain: "layout style" }}
onMouseDown={(event) => 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)}
onMouseEnter={() => onHoverStart?.(data.id)}
onMouseLeave={() => onHoverEnd?.(data.id)}
>
<CometCard
containerClassName="h-full w-full"
className="canvas-frame-shell overflow-hidden rounded-[14px] border"
rotateDepth={2.4}
translateDepth={2}
disabled={Boolean(dragOffset) || !collapsed || editing || scale < 0.32}
glare={collapsed}
style={{
background: active ? theme.frame.activeFill : theme.frame.fill,
borderColor: active ? theme.frame.activeStroke : theme.frame.stroke,
borderWidth: 1 / Math.max(scale, 0.05),
boxShadow: isSelected ? `0 0 0 ${1 / Math.max(scale, 0.05)}px ${theme.frame.activeStroke}33, 0 24px 72px ${theme.spatial.shadow}` : `0 18px 54px ${theme.spatial.shadow}`,
transition: "background-color 120ms ease-out, border-color 120ms ease-out",
}}
>
<div className="pointer-events-auto absolute inset-x-0 top-0 z-10 flex items-center gap-1.5 px-1.5" style={{ height: FRAME_HEADER_HEIGHT, color: theme.node.text }}>
<button
type="button"
className="grid size-8 shrink-0 place-items-center rounded-md transition-colors hover:bg-black/5 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 dark:hover:bg-white/10"
style={{ outlineColor: theme.frame.activeStroke }}
aria-label={collapsed ? "展开背板" : "折叠背板"}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onToggleCollapsed(data.id);
}}
>
{collapsed ? <ChevronRight className="size-4" /> : <ChevronDown className="size-4" />}
</button>
{editing ? (
<input
autoFocus
className="h-7 min-w-0 flex-1 rounded border bg-transparent px-1.5 text-sm font-semibold outline-none"
style={{ borderColor: theme.frame.activeStroke, color: theme.node.text }}
value={title}
onChange={(event) => 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);
}
}}
/>
) : (
<button
type="button"
className="min-w-0 truncate text-left text-sm font-semibold"
title={data.title}
onDoubleClick={(event) => {
event.stopPropagation();
if (readOnly) return;
setEditing(true);
}}
>
{data.title}
</button>
)}
<span className="ml-auto shrink-0 pr-1 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>
{childNodes.length}
</span>
</div>
{collapsed ? <FramePreview nodes={childNodes} frame={data} theme={theme} /> : null}
</CometCard>
{!readOnly && !collapsed && isSelected && !data.metadata?.locked ? (
<>
<ResizeHandle corner="top-left" scale={scale} theme={theme} onMouseDown={startResize} />
<ResizeHandle corner="top-right" scale={scale} theme={theme} onMouseDown={startResize} />
<ResizeHandle corner="bottom-left" scale={scale} theme={theme} onMouseDown={startResize} />
<ResizeHandle corner="bottom-right" scale={scale} theme={theme} onMouseDown={startResize} />
</>
) : null}
</div>
);
});
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 (
<div className="pointer-events-none absolute inset-x-2 bottom-2 overflow-hidden rounded-md" style={{ top: FRAME_HEADER_HEIGHT, background: theme.frame.preview }}>
{layout.length ? (
layout.map(({ node, ...style }) => (
<div key={node.id} className="absolute overflow-hidden rounded-[3px] border" style={{ ...style, background: theme.node.fill, borderColor: theme.node.stroke }}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" draggable={false} /> : null}
{node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="h-full w-full object-cover" muted playsInline preload="metadata" /> : null}
{node.type === CanvasNodeType.Video && !node.metadata?.content ? <Video className="m-auto size-4 h-full opacity-40" /> : null}
{node.type === CanvasNodeType.Text ? <div className="line-clamp-3 p-1 text-[7px] leading-[9px]" style={{ color: theme.node.text }}>{node.metadata?.content || node.title}</div> : null}
{node.type === CanvasNodeType.Script ? <div className="p-1 text-[7px] leading-[9px]" style={{ color: theme.node.text }}>分镜脚本 · {node.metadata?.storyboard?.rows.length || 0} 镜</div> : null}
</div>
))
) : (
<div className="grid h-full place-items-center text-[11px]" style={{ color: theme.node.faint }}>空背板</div>
)}
</div>
);
}
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 (
<button
type="button"
aria-label="调整背板尺寸"
className="pointer-events-auto absolute z-20 rounded-sm shadow-sm"
style={{
top: fromTop ? offset : undefined,
bottom: fromTop ? undefined : offset,
left: fromLeft ? offset : undefined,
right: fromLeft ? undefined : offset,
width: size,
height: size,
cursor,
background: theme.frame.activeStroke,
border: `${2 * inverseScale}px solid ${theme.canvas.background}`,
}}
onMouseDown={(event) => onMouseDown(event, corner)}
/>
);
}