From b24acff972338613223cf6792b937f67f2c36689 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 18 Apr 2026 14:51:07 +0800 Subject: [PATCH] Add LibTV canvas command interactions --- .../canvas/LiblibVideoCanvasReplica.tsx | 355 +++++++++++++++++- 1 file changed, 354 insertions(+), 1 deletion(-) diff --git a/components/canvas/LiblibVideoCanvasReplica.tsx b/components/canvas/LiblibVideoCanvasReplica.tsx index b1ac018cd..c433ec579 100644 --- a/components/canvas/LiblibVideoCanvasReplica.tsx +++ b/components/canvas/LiblibVideoCanvasReplica.tsx @@ -13,12 +13,16 @@ import { Image as ImageIcon, Layers, Loader2, + MousePointer2, Music2, Plus, + Play, Save, Search, + Settings2, Share2, Sparkles, + Trash2, UserRound, Video, X, @@ -26,6 +30,7 @@ import { } from "lucide-react"; import { useRouter } from "next/navigation"; import { + type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, useCallback, useEffect, @@ -141,6 +146,29 @@ type DragState = viewportY: number; }; +type CanvasCommandState = { + canvasX: number; + canvasY: number; + screenX: number; + screenY: number; +} | null; + +type NodeContextState = { + nodeId: string; + screenX: number; + screenY: number; +} | null; + +type CanvasCreateKind = + | "image-input" + | "video-input" + | "audio-input" + | "text-input" + | "image-model" + | "video-model" + | "audio-model" + | "text-model"; + type LiblibVideoCanvasReplicaProps = { canvasId: string; isLoading?: boolean; @@ -511,12 +539,19 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib const [isSaving, setIsSaving] = useState(false); const [shareEnabled, setShareEnabled] = useState(false); const [publishPublic, setPublishPublic] = useState(false); + const [canvasCommand, setCanvasCommand] = useState(null); + const [nodeContextMenu, setNodeContextMenu] = useState(null); const selectedNode = useMemo( () => nodes.find((node) => node.id === selectedNodeId) || null, [nodes, selectedNodeId] ); + const contextNode = useMemo( + () => nodes.find((node) => node.id === nodeContextMenu?.nodeId) || null, + [nodeContextMenu?.nodeId, nodes] + ); + const filteredTools = useMemo(() => { const search = toolSearch.trim().toLowerCase(); return capturedTools.filter((tool) => { @@ -647,6 +682,114 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib [addNodes] ); + const toCanvasPoint = useCallback( + (clientX: number, clientY: number) => ({ + x: Math.round((clientX - viewport.x) / viewport.zoom), + y: Math.round((clientY - viewport.y) / viewport.zoom), + }), + [viewport.x, viewport.y, viewport.zoom] + ); + + const openCanvasCommand = useCallback( + (event: ReactMouseEvent) => { + if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; + event.preventDefault(); + const point = toCanvasPoint(event.clientX, event.clientY); + setSelectedNodeId(null); + setNodeContextMenu(null); + setActivePanel(null); + setCanvasCommand({ + canvasX: point.x, + canvasY: point.y, + screenX: event.clientX, + screenY: event.clientY, + }); + }, + [toCanvasPoint] + ); + + const openNodeContext = useCallback((event: ReactMouseEvent, node: CloneNode) => { + event.preventDefault(); + event.stopPropagation(); + setSelectedNodeId(node.id); + setCanvasCommand(null); + setNodeContextMenu({ + nodeId: node.id, + screenX: event.clientX, + screenY: event.clientY, + }); + }, []); + + const createFreeNode = useCallback( + (kind: CanvasCreateKind) => { + if (!canvasCommand) return; + const createAtPoint = (node: CloneNode) => ({ + ...node, + x: Math.round(canvasCommand.canvasX - node.width / 2), + y: Math.round(canvasCommand.canvasY - node.height / 2), + }); + const type = kind.split("-")[0] as NonNullable; + const tool = capturedTools.find((item) => getToolType(item) === type); + const node = + kind.endsWith("input") + ? createAtPoint(createInputNode(type, canvasCommand.canvasX, canvasCommand.canvasY)) + : createAtPoint( + tool + ? createToolNode(tool, canvasCommand.canvasX, canvasCommand.canvasY) + : createSimpleNode(`${TOOL_TYPE_META[type]?.label || "工具"}生成`, type === "text" ? "text" : "model", canvasCommand.canvasX, canvasCommand.canvasY) + ); + addNodes([node], []); + setActiveStarterScene(null); + setCanvasCommand(null); + toast.success(`已添加 ${node.title}`); + }, + [addNodes, canvasCommand] + ); + + const configureNode = useCallback((nodeId: string) => { + setSelectedNodeId(nodeId); + setNodeContextMenu(null); + }, []); + + const copyNode = useCallback(async (nodeId: string) => { + const node = nodes.find((item) => item.id === nodeId); + if (!node) return; + await navigator.clipboard?.writeText(node.title).catch(() => undefined); + setNodeContextMenu(null); + toast.success("节点名称已复制"); + }, [nodes]); + + const duplicateNode = useCallback((nodeId: string) => { + const node = nodes.find((item) => item.id === nodeId); + if (!node) return; + const duplicate = { + ...node, + id: makeId("copy"), + x: node.x + 32, + y: node.y + 32, + }; + setNodes((current) => [...current, duplicate]); + setSelectedNodeId(duplicate.id); + setNodeContextMenu(null); + toast.success("已复制节点"); + }, [nodes]); + + const runNode = useCallback((nodeId: string) => { + const node = nodes.find((item) => item.id === nodeId); + if (!node) return; + setSelectedNodeId(nodeId); + setNodeContextMenu(null); + toast.message(`${node.title} 生成接口待接入。`); + }, [nodes]); + + const deleteNode = useCallback((nodeId: string) => { + setNodes((current) => current.filter((node) => node.id !== nodeId)); + setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); + setSelectedNodeId((current) => (current === nodeId ? null : current)); + setNodeContextMenu(null); + toast.success("节点已删除"); + }, []); + const createStarter = useCallback( (starter: (typeof STARTER_CARDS)[number]) => { const starterScenes: Record> = { @@ -703,6 +846,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId); setSelectedNodeId(node.id); + setCanvasCommand(null); + setNodeContextMenu(null); dragRef.current = { kind: "node", id: node.id, @@ -714,8 +859,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib }, []); const startCanvasDrag = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) return; if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return; setSelectedNodeId(null); + setCanvasCommand(null); + setNodeContextMenu(null); dragRef.current = { kind: "canvas", startX: event.clientX, @@ -725,6 +873,23 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib }; }, [viewport.x, viewport.y]); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, select, [contenteditable='true']")) return; + if ((event.key === "Delete" || event.key === "Backspace") && selectedNodeId && !activeStarterScene) { + event.preventDefault(); + deleteNode(selectedNodeId); + } + if (event.key === "Escape") { + setCanvasCommand(null); + setNodeContextMenu(null); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [activeStarterScene, deleteNode, selectedNodeId]); + const onPointerMove = useCallback( (event: ReactPointerEvent) => { const drag = dragRef.current; @@ -776,6 +941,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib return (
openNodeContext(event, node)} onPointerDown={(event) => startNodeDrag(event, node)} /> ))} @@ -804,7 +972,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib {activeStarterScene === "audio-to-video" ? : null} {nodes.length === 0 && !activePanel && !activeStarterScene ? ( -
+
双击画布 @@ -843,6 +1014,31 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
) : null} + {canvasCommand ? ( + setCanvasCommand(null)} + onCreate={createFreeNode} + onOpenAdd={() => { + setActivePanel("add"); + setCanvasCommand(null); + }} + /> + ) : null} + + {nodeContextMenu && contextNode ? ( + setNodeContextMenu(null)} + onConfigure={() => configureNode(contextNode.id)} + onCopy={() => void copyNode(contextNode.id)} + onDelete={() => deleteNode(contextNode.id)} + onDuplicate={() => duplicateNode(contextNode.id)} + onRun={() => runNode(contextNode.id)} + /> + ) : null} + {!activeStarterScene ? ( <> void; + onCreate: (kind: CanvasCreateKind) => void; + onOpenAdd: () => void; + state: NonNullable; +}) { + const items: Array<{ kind: CanvasCreateKind; icon: LucideIcon; label: string; note: string }> = [ + { kind: "image-input", icon: ImageIcon, label: "图片输入", note: "上传参考图/角色图" }, + { kind: "video-input", icon: Video, label: "视频输入", note: "上传视频素材" }, + { kind: "audio-input", icon: Music2, label: "音频输入", note: "上传音乐/语音" }, + { kind: "text-input", icon: FileText, label: "文本输入", note: "脚本/提示词" }, + { kind: "image-model", icon: Sparkles, label: "图片生成", note: "调用图片模型" }, + { kind: "video-model", icon: Play, label: "视频生成", note: "调用视频模型" }, + { kind: "audio-model", icon: Music2, label: "音频生成", note: "调用音频模型" }, + { kind: "text-model", icon: FileText, label: "脚本生成", note: "调用文本模型" }, + ]; + + return ( +
event.stopPropagation()} + style={{ + left: `min(${state.screenX}px, calc(100vw - 286px))`, + top: `min(${state.screenY}px, calc(100vh - 384px))`, + }} + > +
+
+
+ + 自由生成节点 +
+
+ X {state.canvasX} / Y {state.canvasY} +
+
+ +
+ +
+ {items.map((item) => { + const Icon = item.icon; + return ( + + ); + })} +
+ +
+ +
+ ); +} + +function NodeContextMenu({ + node, + onClose, + onConfigure, + onCopy, + onDelete, + onDuplicate, + onRun, + state, +}: { + node: CloneNode; + onClose: () => void; + onConfigure: () => void; + onCopy: () => void; + onDelete: () => void; + onDuplicate: () => void; + onRun: () => void; + state: NonNullable; +}) { + const actions: Array<{ icon: LucideIcon; label: string; note: string; onClick: () => void; danger?: boolean }> = [ + { icon: Settings2, label: "配置参数", note: "打开右侧参数面板", onClick: onConfigure }, + { icon: Play, label: "立即生成", note: "执行当前节点", onClick: onRun }, + { icon: Plus, label: "复制节点", note: "在旁边创建副本", onClick: onDuplicate }, + { icon: Copy, label: "复制到剪贴板", note: "复制节点名称", onClick: onCopy }, + { icon: Trash2, label: "删除", note: "移除节点与连接", onClick: onDelete, danger: true }, + ]; + + return ( +
event.stopPropagation()} + style={{ + left: `min(${state.screenX}px, calc(100vw - 262px))`, + top: `min(${state.screenY}px, calc(100vh - 310px))`, + }} + > +
+
+
{node.title}
+
{node.subtitle || node.kind}
+
+ +
+
+ {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
+
+ ); +} + function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] }) { const nodeMap = new Map(nodes.map((node) => [node.id, node])); return ( @@ -2111,10 +2461,12 @@ function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] } function CloneCanvasNode({ node, + onContextMenu, onPointerDown, selected, }: { node: CloneNode; + onContextMenu: (event: ReactMouseEvent) => void; onPointerDown: (event: ReactPointerEvent) => void; selected: boolean; }) { @@ -2128,6 +2480,7 @@ function CloneCanvasNode({ selected && "z-20" )} data-canvas-overlay="true" + onContextMenu={onContextMenu} onPointerDown={onPointerDown} style={{ height: node.height, left: node.x, top: node.y, width: node.width }} >