Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-video-prompt-tools.tsx
T

248 lines
11 KiB
TypeScript

import { type CSSProperties, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Image as ImageIcon } from "lucide-react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasNodeMetadata } from "@/types/canvas";
type VideoFrameOption = {
nodeId: string;
label: string;
title: string;
previewUrl?: string;
};
type CompactMenuItem = {
value: string;
label: string;
previewUrl?: string;
};
type CanvasVideoPromptToolsProps = {
metadata?: CanvasNodeMetadata;
frameOptions: VideoFrameOption[];
onMetadataChange: (patch: Partial<CanvasNodeMetadata>) => void;
referenceMode?: "frames" | "all";
referenceSummary?: { imageCount: number; videoCount: number; audioCount: number };
};
const EMPTY_FRAME_VALUE = "__none__";
const MENU_GAP = 6;
const MENU_MARGIN = 8;
const MENU_ITEM_HEIGHT = 28;
const CONTROL_TEXT_STYLE: CSSProperties = { fontFamily: "inherit", fontSize: 11, fontWeight: 400, letterSpacing: 0, lineHeight: 1 };
export function CanvasVideoPromptTools({ metadata, frameOptions, onMetadataChange, referenceMode = "frames", referenceSummary }: CanvasVideoPromptToolsProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const startFrame = metadata?.videoStartFrameNodeId || EMPTY_FRAME_VALUE;
const endFrame = metadata?.videoEndFrameNodeId || EMPTY_FRAME_VALUE;
const setFrame = (key: "videoStartFrameNodeId" | "videoEndFrameNodeId", value: string) => {
const next = value === EMPTY_FRAME_VALUE ? undefined : value;
onMetadataChange(key === "videoStartFrameNodeId" ? { videoStartFrameNodeId: next } : { videoEndFrameNodeId: next });
};
if (referenceMode === "all") {
const summary = referenceSummary
? [
referenceSummary.imageCount ? `${referenceSummary.imageCount} 图` : "",
referenceSummary.videoCount ? `${referenceSummary.videoCount} 视频` : "",
referenceSummary.audioCount ? `${referenceSummary.audioCount} 音频` : "",
].filter(Boolean).join(" · ")
: "";
return (
<div
className="flex min-h-8 min-w-0 items-center gap-2 rounded-md px-2 py-1.5"
data-canvas-no-zoom
style={{ background: theme.toolbar.itemHover, color: theme.node.text }}
title="已连接的图片、视频和音频会按工作流字段映射传入"
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<ImageIcon className="size-3.5 shrink-0 opacity-80" />
<span className="shrink-0 text-[var(--fs-tiny)] font-medium">工作流参考</span>
<span className="min-w-0 truncate text-[var(--fs-micro)] opacity-65">{summary || "尚未连接媒体"}</span>
</div>
);
}
if (!frameOptions.length) return null;
return (
<div
className="grid min-w-0 grid-cols-2 items-center gap-1"
data-canvas-no-zoom
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<FrameMenu label="首帧" value={startFrame} options={frameOptions} theme={theme} onChange={(value) => setFrame("videoStartFrameNodeId", value)} />
<FrameMenu label="尾帧" value={endFrame} options={frameOptions} theme={theme} onChange={(value) => setFrame("videoEndFrameNodeId", value)} />
</div>
);
}
function FrameMenu({ label, value, options, theme, onChange }: { label: string; value: string; options: VideoFrameOption[]; theme: CanvasTheme; onChange: (value: string) => void }) {
const selected = options.find((item) => item.nodeId === value);
const items = [{ value: EMPTY_FRAME_VALUE, label: "不指定" }, ...options.map((option) => ({ value: option.nodeId, label: `${option.label} · ${option.title}`, previewUrl: option.previewUrl }))];
return (
<CompactMenuButton
theme={theme}
title={label}
label={selected?.label || label}
icon={<ImageIcon className="size-3.5 shrink-0 opacity-90" />}
value={value}
items={items}
menuWidth={220}
maxMenuHeight={208}
onSelect={onChange}
/>
);
}
function CompactMenuButton({
theme,
title,
label,
icon,
value,
items,
menuWidth,
maxMenuHeight,
onSelect,
}: {
theme: CanvasTheme;
title: string;
label: string;
icon: ReactNode;
value?: string;
items: CompactMenuItem[];
menuWidth: number;
maxMenuHeight: number;
onSelect: (value: string) => void;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<{ left: number; top: number; width: number; maxHeight: number } | null>(null);
const updatePosition = () => {
const rect = triggerRef.current?.getBoundingClientRect();
if (!rect) return;
const menuHeight = Math.min(maxMenuHeight, items.length * MENU_ITEM_HEIGHT + 8);
const effectiveMenuWidth = Math.max(menuWidth, Math.round(rect.width));
const openAbove = rect.bottom + MENU_GAP + menuHeight > window.innerHeight && rect.top > menuHeight + MENU_GAP;
const maxLeft = Math.max(MENU_MARGIN, window.innerWidth - effectiveMenuWidth - MENU_MARGIN);
const maxTop = Math.max(MENU_MARGIN, window.innerHeight - menuHeight - MENU_MARGIN);
const left = Math.min(Math.max(MENU_MARGIN, rect.left), maxLeft);
const top = openAbove ? Math.max(MENU_MARGIN, rect.top - menuHeight - MENU_GAP) : Math.min(Math.max(MENU_MARGIN, rect.bottom + MENU_GAP), maxTop);
setPosition({ left, top, maxHeight: menuHeight, width: effectiveMenuWidth });
};
useLayoutEffect(() => {
if (open) updatePosition();
}, [open, items.length, maxMenuHeight, menuWidth]);
useEffect(() => {
if (!open) return;
const close = (event: PointerEvent) => {
const target = event.target instanceof Node ? event.target : null;
if (target && (triggerRef.current?.contains(target) || menuRef.current?.contains(target))) return;
setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const reposition = () => updatePosition();
document.addEventListener("pointerdown", close, true);
document.addEventListener("keydown", closeOnEscape, true);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
return () => {
document.removeEventListener("pointerdown", close, true);
document.removeEventListener("keydown", closeOnEscape, true);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
};
}, [open, items.length, maxMenuHeight, menuWidth]);
const buttonStyle: CSSProperties = { ...CONTROL_TEXT_STYLE, color: theme.node.text };
const menuStyle: CSSProperties | undefined = position
? {
left: position.left,
top: position.top,
width: position.width,
maxHeight: position.maxHeight,
background: theme.toolbar.panel,
borderColor: theme.toolbar.border,
color: theme.node.text,
boxShadow: "0 10px 26px rgba(15,23,42,.14)",
}
: undefined;
const listStyle: CSSProperties | undefined = position ? { maxHeight: Math.max(0, position.maxHeight - 8) } : undefined;
const menu =
open && position && typeof document !== "undefined"
? createPortal(
<div
ref={menuRef}
data-canvas-no-zoom
className="fixed z-[var(--z-toast)] overflow-hidden rounded-[var(--r-lg)] border p-1 backdrop-blur-xl"
style={menuStyle}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<div className="thin-scrollbar overflow-y-auto" style={listStyle}>
{items.map((item) => {
const selected = value === item.value;
return (
<button
key={item.value}
type="button"
className="flex h-7 w-full min-w-0 items-center gap-1.5 rounded-lg px-2 text-left transition"
style={{ ...CONTROL_TEXT_STYLE, background: selected ? theme.toolbar.itemHover : "transparent", color: selected ? theme.toolbar.activeText : theme.node.text }}
onMouseEnter={(event) => {
event.currentTarget.style.background = theme.toolbar.itemHover;
}}
onMouseLeave={(event) => {
event.currentTarget.style.background = selected ? theme.toolbar.itemHover : "transparent";
}}
onClick={() => {
onSelect(item.value);
setOpen(false);
}}
>
{item.previewUrl ? <img src={item.previewUrl} alt="" className="size-4 shrink-0 rounded object-cover" /> : null}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{selected ? <Check className="size-3.5 shrink-0" /> : null}
</button>
);
})}
</div>
</div>,
document.body,
)
: null;
return (
<>
<button
ref={triggerRef}
type="button"
className="inline-flex h-6 w-full min-w-0 items-center gap-1 rounded-[var(--dock-item-radius)] border-0 bg-transparent px-1.5 shadow-none transition hover:opacity-80 focus-visible:outline-none focus-visible:ring-1"
style={buttonStyle}
title={title}
onClick={() => setOpen((value) => !value)}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{icon}
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
<ChevronDown className="size-3 shrink-0 opacity-55" />
</button>
{menu}
</>
);
}