Add video canvas double-click insert menu
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-05 03:14:33 +08:00
parent e60177c177
commit 584579e987
2 changed files with 307 additions and 3 deletions

View File

@ -6,19 +6,30 @@ import useSWR from "swr";
import {
AlertCircle,
ArrowLeft,
Bot,
Save,
Undo2,
Redo2,
ZoomIn,
ZoomOut,
Clapperboard,
MousePointer,
Hand,
GitBranch,
Plus,
ImagePlus,
FileImage,
FileText,
Eye,
Film,
Type,
Loader2,
MessageCircle,
Music2,
Palette,
Scissors,
Sparkles,
Table2,
Video,
Download,
Upload,
@ -32,6 +43,7 @@ import {
FolderOpen,
Crosshair,
Scan,
type LucideIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -58,6 +70,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@ -176,6 +191,7 @@ import type {
CanvasNode,
CanvasState,
CanvasWorkspaceType,
Position,
ToolType,
WorkflowNodeData,
} from "@/types/canvas";
@ -207,6 +223,26 @@ interface VideoCanvasProjectExportPayload {
canvasData: CanvasState;
}
interface VideoCanvasInsertMenuState {
screenPosition: Position;
canvasPosition: Position;
}
interface VideoCanvasInsertMenuChild {
id: string;
label: string;
templateType: VideoWorkflowTemplateType;
}
interface VideoCanvasInsertMenuItem {
id: string;
label: string;
icon: LucideIcon;
templateType?: VideoWorkflowTemplateType;
children?: VideoCanvasInsertMenuChild[];
accent?: "highlight";
}
const IMPORTABLE_CANVAS_NODE_TYPES = new Set<CanvasNode["type"]>([
"message",
"image",
@ -225,6 +261,66 @@ const IMPORTABLE_CANVAS_TOOL_TYPES = new Set<ToolType>([
"text",
]);
const VIDEO_CANVAS_INSERT_MENU_ITEMS: VideoCanvasInsertMenuItem[] = [
{ id: "image", label: "图片", icon: FileImage, templateType: "input-image" },
{ id: "text", label: "文字", icon: FileText, templateType: "text-node" },
{ id: "video-frame", label: "视频抽帧", icon: Film, templateType: "video-input" },
{ id: "video-analyze", label: "视频拆解", icon: Scissors, templateType: "video-analyze" },
{
id: "storyboard",
label: "分镜表",
icon: GitBranch,
children: [
{ id: "storyboard-node", label: "分镜节点", templateType: "storyboard-node" },
{ id: "storyboard-chart-node", label: "分镜图表", templateType: "storyboard-chart-node" },
],
},
{ id: "table", label: "表格编辑", icon: Table2, templateType: "table-editor-node" },
{ id: "doodle", label: "涂鸦画板", icon: PenTool, templateType: "doodle-canvas" },
{ id: "paint", label: "绘画", icon: Palette, templateType: "gen-image" },
{ id: "video", label: "视频", icon: Video, templateType: "gen-video" },
{ id: "music", label: "音乐", icon: Music2, templateType: "gen-music" },
{ id: "agent", label: "Agent", icon: Bot, templateType: "custom-agent" },
{
id: "comfy",
label: "Comfy UI",
icon: Box,
children: [
{ id: "rh-comfy", label: "Comfy 工作流", templateType: "rh-comfy" },
{ id: "rh-app", label: "RH 应用", templateType: "rh-app" },
],
},
{ id: "compare", label: "对比", icon: Layers, templateType: "image-compare" },
{
id: "inpaint",
label: "局部重绘",
icon: Scissors,
children: [
{ id: "inpaint-crop", label: "裁剪局部重绘", templateType: "inpaint-crop" },
{ id: "inpaint-stitch", label: "无缝拼接", templateType: "inpaint-stitch" },
],
},
{
id: "super-resolution",
label: "智能超清",
icon: Sparkles,
templateType: "jimeng-super-resolution",
},
{ id: "motion", label: "动作迁移", icon: Clapperboard, templateType: "motion-control" },
{ id: "preview", label: "预览", icon: Eye, templateType: "preview" },
{ id: "save", label: "保存", icon: Save, templateType: "local-save" },
{
id: "director",
label: "导演模式",
icon: Wand2,
accent: "highlight",
children: [
{ id: "professional-camera", label: "专业镜头", templateType: "professional-camera" },
{ id: "camera-movement", label: "镜头运动", templateType: "camera-movement" },
],
},
];
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@ -732,6 +828,8 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [editTitle, setEditTitle] = useState("");
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(() => !isVideoMode);
const [videoCanvasInsertMenu, setVideoCanvasInsertMenu] =
useState<VideoCanvasInsertMenuState | null>(null);
const [sceneFusionDialogOpen, setSceneFusionDialogOpen] = useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<'chat' | 'fileList'>('chat');
@ -752,6 +850,12 @@ export default function CanvasEditorPage({ params }: PageProps) {
const [workflowSelections, setWorkflowSelections] = useState<
Partial<Record<AgentWorkflowStepKey, string>>
>({});
useEffect(() => {
if (!isVideoMode) {
setVideoCanvasInsertMenu(null);
}
}, [isVideoMode]);
const [appearanceRecognitionEnabled, setAppearanceRecognitionEnabled] = useState(true);
const [selectedBrandModel, setSelectedBrandModel] =
useState<AgentWorkflowSelectedBrandModel | null>(null);
@ -4460,15 +4564,58 @@ export default function CanvasEditorPage({ params }: PageProps) {
);
const handleAddWorkflowNode = useCallback(
(templateType: VideoWorkflowTemplateType) => {
(
templateType: VideoWorkflowTemplateType,
options?: {
position?: Position;
connectFromSelection?: boolean;
selectNewNode?: boolean;
}
) => {
const definition = getVideoWorkflowNodeDefinition(templateType);
if (!definition) return;
const placement = getVideoWorkflowPlacement(definition.size);
createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement));
const placement = options?.position || getVideoWorkflowPlacement(definition.size);
createCanvasNode(buildVideoWorkflowCanvasNode(templateType, placement), {
connectFromSelection: options?.connectFromSelection ?? true,
selectNewNode: options?.selectNewNode ?? true,
});
},
[createCanvasNode, getVideoWorkflowPlacement]
);
const handleOpenVideoCanvasInsertMenu = useCallback(
(payload: VideoCanvasInsertMenuState) => {
if (!isVideoMode) return;
setVideoCanvasInsertMenu(payload);
},
[isVideoMode]
);
const handleInsertWorkflowNodeFromMenu = useCallback(
(templateType: VideoWorkflowTemplateType) => {
if (!videoCanvasInsertMenu) return;
const definition = getVideoWorkflowNodeDefinition(templateType);
const anchoredPosition = definition
? {
x:
videoCanvasInsertMenu.canvasPosition.x -
Math.min(Math.round(definition.size.width * 0.18), 84),
y:
videoCanvasInsertMenu.canvasPosition.y -
Math.min(Math.round(definition.size.height * 0.12), 42),
}
: videoCanvasInsertMenu.canvasPosition;
handleAddWorkflowNode(templateType, {
position: anchoredPosition,
connectFromSelection: false,
});
setVideoCanvasInsertMenu(null);
},
[handleAddWorkflowNode, videoCanvasInsertMenu]
);
const handleCreateSelectedAgentResultBranches = useCallback(async () => {
if (
!selectedVideoWorkflowNode ||
@ -7077,6 +7224,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
workspaceLabel={workspaceLabel}
workspaceVariant={isVideoMode ? "video" : "canvas"}
showEmptyState={!isVideoMode}
onBlankCanvasDoubleClick={handleOpenVideoCanvasInsertMenu}
emptyStateDescription={
isVideoMode
? "先在右侧选择文生视频、主体参考或首尾帧模式,再开始生成"
@ -7085,6 +7233,115 @@ export default function CanvasEditorPage({ params }: PageProps) {
/>
</div>
{isVideoMode && videoCanvasInsertMenu ? (
<DropdownMenu
open={Boolean(videoCanvasInsertMenu)}
onOpenChange={(open) => {
if (!open) {
setVideoCanvasInsertMenu(null);
}
}}
modal={false}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed h-px w-px opacity-0"
style={{
left: `${videoCanvasInsertMenu.screenPosition.x}px`,
top: `${videoCanvasInsertMenu.screenPosition.y}px`,
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
sideOffset={14}
onCloseAutoFocus={(event) => event.preventDefault()}
className="w-[312px] rounded-[28px] border border-slate-200 bg-white p-2.5 text-foreground shadow-[0_28px_80px_-32px_rgba(15,23,42,0.35)] dark:border-zinc-800 dark:bg-zinc-950"
>
<div className="px-4 py-3 text-[14px] font-semibold text-slate-500 dark:text-zinc-400">
@{siteSettingsData?.siteName || "视频画布"}
</div>
{VIDEO_CANVAS_INSERT_MENU_ITEMS.slice(0, -1).map((item) => {
const Icon = item.icon;
if (item.children?.length) {
return (
<DropdownMenuSub key={item.id}>
<DropdownMenuSubTrigger className="rounded-2xl px-4 py-3 text-[15px] font-medium text-slate-700 focus:bg-slate-50 data-[state=open]:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900 dark:data-[state=open]:bg-zinc-900">
<Icon className="h-5 w-5 text-slate-400 dark:text-zinc-500" />
<span>{item.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[220px] rounded-2xl border border-slate-200 bg-white p-1.5 shadow-[0_22px_60px_-28px_rgba(15,23,42,0.34)] dark:border-zinc-800 dark:bg-zinc-950">
{item.children.map((child) => (
<DropdownMenuItem
key={child.id}
onSelect={() => handleInsertWorkflowNodeFromMenu(child.templateType)}
className="rounded-xl px-3 py-2.5 text-sm font-medium text-slate-700 focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900"
>
{child.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem
key={item.id}
onSelect={() => item.templateType && handleInsertWorkflowNodeFromMenu(item.templateType)}
className="rounded-2xl px-4 py-3 text-[15px] font-medium text-slate-700 focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900"
>
<Icon className="h-5 w-5 text-slate-400 dark:text-zinc-500" />
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
<DropdownMenuSeparator className="mx-3 my-2 bg-slate-200 dark:bg-zinc-800" />
{(() => {
const highlightedItem = VIDEO_CANVAS_INSERT_MENU_ITEMS.at(-1);
if (!highlightedItem) return null;
const HighlightIcon = highlightedItem.icon;
return highlightedItem.children?.length ? (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="rounded-2xl px-4 py-3 text-[15px] font-semibold text-orange-600 focus:bg-orange-50 data-[state=open]:bg-orange-50 dark:text-orange-300 dark:focus:bg-orange-950/40 dark:data-[state=open]:bg-orange-950/40">
<HighlightIcon className="h-5 w-5 text-orange-500" />
<span>{highlightedItem.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[220px] rounded-2xl border border-slate-200 bg-white p-1.5 shadow-[0_22px_60px_-28px_rgba(15,23,42,0.34)] dark:border-zinc-800 dark:bg-zinc-950">
{highlightedItem.children.map((child) => (
<DropdownMenuItem
key={child.id}
onSelect={() => handleInsertWorkflowNodeFromMenu(child.templateType)}
className="rounded-xl px-3 py-2.5 text-sm font-medium text-slate-700 focus:bg-slate-50 dark:text-slate-100 dark:focus:bg-zinc-900"
>
{child.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
) : (
<DropdownMenuItem
onSelect={() =>
highlightedItem.templateType &&
handleInsertWorkflowNodeFromMenu(highlightedItem.templateType)
}
className="rounded-2xl px-4 py-3 text-[15px] font-semibold text-orange-600 focus:bg-orange-50 dark:text-orange-300 dark:focus:bg-orange-950/40"
>
<HighlightIcon className="h-5 w-5 text-orange-500" />
<span>{highlightedItem.label}</span>
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{isVideoMode &&
selectedVideoWorkflowDefinition &&
(selectedVideoWorkflowQuickConnects.length > 0 || selectedWorkflowIsActionable) ? (

View File

@ -45,6 +45,10 @@ interface CanvasWorkspaceProps {
emptyStateDescription?: string;
workspaceVariant?: "canvas" | "video";
showEmptyState?: boolean;
onBlankCanvasDoubleClick?: (payload: {
screenPosition: Position;
canvasPosition: Position;
}) => void;
}
export function CanvasWorkspace({
@ -53,6 +57,7 @@ export function CanvasWorkspace({
emptyStateDescription = '点击顶部"添加"按钮开始创作',
workspaceVariant = "canvas",
showEmptyState = true,
onBlankCanvasDoubleClick,
}: CanvasWorkspaceProps) {
const SNAP_THRESHOLD = 10;
const SNAP_GAP = 12;
@ -391,6 +396,47 @@ export function CanvasWorkspace({
selection.removeAllRanges();
}, []);
const handleBlankCanvasDoubleClick = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!isWorkflowCanvas || !onBlankCanvasDoubleClick) return;
if (hasBlockingDialogOpen()) return;
if (isPointOnNodeElement(event.target)) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
clearBrowserTextSelection();
const viewportPosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
onBlankCanvasDoubleClick({
screenPosition: {
x: event.clientX,
y: event.clientY,
},
canvasPosition: {
x: (viewportPosition.x - viewport.x) / viewport.zoom,
y: (viewportPosition.y - viewport.y) / viewport.zoom,
},
});
},
[
clearBrowserTextSelection,
hasBlockingDialogOpen,
isPointOnNodeElement,
isWorkflowCanvas,
onBlankCanvasDoubleClick,
viewport.x,
viewport.y,
viewport.zoom,
]
);
const fitAllImageNodesIntoViewport = useCallback(() => {
const imageNodes = nodes.filter(
(node) => node.type === "image" && Boolean((node.data as any)?.src)
@ -1293,6 +1339,7 @@ export function CanvasWorkspace({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDoubleClick={handleBlankCanvasDoubleClick}
onWheel={handleWheel}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}