Add LibTV canvas command interactions
This commit is contained in:
parent
db657b3ea5
commit
b24acff972
@ -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 }}
|
||||
>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user