Add LibTV canvas command interactions
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-18 14:51:07 +08:00
parent db657b3ea5
commit b24acff972

View File

@ -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<CanvasCommandState>(null);
const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextState>(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<HTMLDivElement>) => {
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<HTMLDivElement>, 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<CloneNode["inputType"]>;
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<string, Exclude<ActiveStarterScene, null>> = {
@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const drag = dragRef.current;
@ -776,6 +941,8 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
return (
<div
className="relative h-dvh w-full overflow-hidden bg-[#151515] text-white"
onContextMenu={openCanvasCommand}
onDoubleClick={openCanvasCommand}
onPointerDown={startCanvasDrag}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
@ -793,6 +960,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
key={node.id}
node={node}
selected={node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)}
onPointerDown={(event) => startNodeDrag(event, node)}
/>
))}
@ -804,7 +972,10 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
{activeStarterScene === "audio-to-video" ? <AudioToVideoScene /> : null}
{nodes.length === 0 && !activePanel && !activeStarterScene ? (
<div className="-translate-x-1/2 -translate-y-1/2 absolute left-1/2 top-[51%] z-20 w-[900px] max-w-[calc(100vw-180px)]">
<div
className="-translate-x-1/2 -translate-y-1/2 absolute left-1/2 top-[51%] z-20 w-[900px] max-w-[calc(100vw-180px)]"
data-canvas-overlay="true"
>
<div className="mb-6 flex items-center justify-center gap-2 text-[13px] leading-none text-white/58">
<PointerBadge />
<span className="font-bold text-white/60">双击画布</span>
@ -843,6 +1014,31 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
</div>
) : null}
{canvasCommand ? (
<CanvasCommandPalette
state={canvasCommand}
onClose={() => setCanvasCommand(null)}
onCreate={createFreeNode}
onOpenAdd={() => {
setActivePanel("add");
setCanvasCommand(null);
}}
/>
) : null}
{nodeContextMenu && contextNode ? (
<NodeContextMenu
node={contextNode}
state={nodeContextMenu}
onClose={() => 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 ? (
<>
<TopBar
@ -2080,6 +2276,160 @@ function ComposerControl({
);
}
function CanvasCommandPalette({
onClose,
onCreate,
onOpenAdd,
state,
}: {
onClose: () => void;
onCreate: (kind: CanvasCreateKind) => void;
onOpenAdd: () => void;
state: NonNullable<CanvasCommandState>;
}) {
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 (
<div
className="fixed z-[90] w-[270px] rounded-[8px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[10px]"
data-canvas-overlay="true"
onPointerDown={(event) => event.stopPropagation()}
style={{
left: `min(${state.screenX}px, calc(100vw - 286px))`,
top: `min(${state.screenY}px, calc(100vh - 384px))`,
}}
>
<div className="mb-2 flex items-center justify-between px-2 py-1">
<div>
<div className="flex items-center gap-2 text-[13px] font-semibold text-white/88">
<MousePointer2 className="h-4 w-4 text-white/58" />
自由生成节点
</div>
<div className="mt-1 text-[11px] text-white/36">
X {state.canvasX} / Y {state.canvasY}
</div>
</div>
<button className="flex h-7 w-7 items-center justify-center rounded-[8px] text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid gap-1">
{items.map((item) => {
const Icon = item.icon;
return (
<button
className="flex w-full items-center gap-3 rounded-[8px] px-2 py-2 text-left transition-colors hover:bg-white/8"
key={item.kind}
onClick={() => onCreate(item.kind)}
type="button"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border border-white/8 bg-white/7 text-white/72">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-white/86">{item.label}</span>
<span className="block truncate text-[11px] text-white/36">{item.note}</span>
</span>
</button>
);
})}
</div>
<div className="my-2 h-px bg-white/8" />
<button
className="flex h-9 w-full items-center justify-center gap-2 rounded-[8px] bg-white text-[13px] font-semibold text-black transition hover:bg-white/90"
onClick={onOpenAdd}
type="button"
>
<Search className="h-4 w-4" />
打开全部模块
</button>
</div>
);
}
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<NodeContextState>;
}) {
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 (
<div
className="fixed z-[92] w-[246px] rounded-[8px] border border-white/10 bg-[#202020]/98 p-2 text-white shadow-[0_28px_80px_rgba(0,0,0,0.56)] backdrop-blur-[10px]"
data-canvas-overlay="true"
onPointerDown={(event) => event.stopPropagation()}
style={{
left: `min(${state.screenX}px, calc(100vw - 262px))`,
top: `min(${state.screenY}px, calc(100vh - 310px))`,
}}
>
<div className="mb-2 flex items-start justify-between gap-3 px-2 py-1">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-white/88">{node.title}</div>
<div className="mt-1 truncate text-[11px] text-white/36">{node.subtitle || node.kind}</div>
</div>
<button className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-white/48 hover:bg-white/8 hover:text-white" onClick={onClose} type="button">
<X className="h-4 w-4" />
</button>
</div>
<div className="grid gap-1">
{actions.map((action) => {
const Icon = action.icon;
return (
<button
className={cn(
"flex w-full items-center gap-3 rounded-[8px] px-2 py-2 text-left transition-colors hover:bg-white/8",
action.danger && "text-[#ff8f9c] hover:bg-[#ff6b7a]/12"
)}
key={action.label}
onClick={action.onClick}
type="button"
>
<Icon className="h-4 w-4 shrink-0" />
<span className="min-w-0">
<span className="block text-[13px] font-medium">{action.label}</span>
<span className="block truncate text-[11px] text-white/36">{action.note}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
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<HTMLDivElement>) => void;
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => 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 }}
>