feat(platform): 开源发布 - 初始化 Open AI Canvas
This commit is contained in:
308
web/src/components/canvas/canvas-frame-node.tsx
Normal file
308
web/src/components/canvas/canvas-frame-node.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user