Add video canvas double-click insert menu
This commit is contained in:
parent
e60177c177
commit
584579e987
@ -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) ? (
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user