fix: refine canvas toolbar layout
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 17:43:16 +08:00
parent d2199975a5
commit 3d091a665b
2 changed files with 115 additions and 13 deletions

View File

@ -11217,12 +11217,13 @@ export function CanvasChatPanel({
{/* Bottom Toolbar */}
<div
className={cn(
"absolute left-2 bottom-2 right-2 flex items-center gap-2",
"absolute left-2 bottom-2 right-2 flex items-center justify-between gap-3",
isVideoWorkspace &&
"rounded-[24px] border border-border/60 bg-background/88 px-2.5 py-2 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80"
"rounded-[24px] border border-border/60 bg-background/88 px-3 py-2 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/80",
!isVideoWorkspace && "px-1"
)}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-0.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
<input
ref={fileInputRef}
type="file"
@ -11404,7 +11405,7 @@ export function CanvasChatPanel({
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full text-muted-foreground transition-colors hover:text-foreground",
"h-8 w-8 rounded-full text-muted-foreground transition-colors hover:text-foreground mr-1",
isVideoWorkspace
? "border border-border/60 bg-background hover:bg-background/90"
: "hover:bg-muted"
@ -11418,7 +11419,7 @@ export function CanvasChatPanel({
{isVideoModel ? (
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
<div className="w-max pr-1">
<div className="w-max pr-1 pl-1">
<VideoParamsBar
modelName={currentModel?.displayName}
initialParams={videoParams || undefined}
@ -11430,7 +11431,7 @@ export function CanvasChatPanel({
</div>
</div>
) : (
<div className="min-w-0 shrink">
<div className="min-w-0 shrink pl-1 border-l border-border/40 dark:border-border/60 ml-1">
<ImageParamsBar
modelName={activeComposerMode === "agent" ? agentDisplayName : currentModel?.displayName}
initialParams={imageParams}
@ -11445,7 +11446,7 @@ export function CanvasChatPanel({
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2.5">
<div className="flex shrink-0 items-center gap-2 pl-2 border-l border-border/40 dark:border-border/60">
{!isWorkspacePresentation && activeComposerMode === "image" ? (
<Button
variant="ghost"
@ -11535,7 +11536,7 @@ export function CanvasChatPanel({
{estimatedPointsCost ? (
<div
className={cn(
"flex h-8 items-center gap-1.5 rounded-full px-2.5 text-xs",
"flex h-8 items-center gap-1.5 rounded-full px-2.5 text-xs ml-2",
isVideoWorkspace
? "border border-border/60 bg-background text-muted-foreground"
: "bg-muted text-muted-foreground"

View File

@ -164,6 +164,15 @@ type NodeContextState = {
screenY: number;
} | null;
type ConnectionSide = "source" | "target";
type ConnectionMenuState = {
nodeId: string;
screenX: number;
screenY: number;
side: ConnectionSide;
} | null;
type PresetControlAnchor = {
bottomY: number;
centerX: number;
@ -217,6 +226,18 @@ const ADD_NODE_MODULES: Array<{
{ kind: "script", label: "脚本", description: "创意脚本、生成故事板", icon: Layers, badge: "Beta" },
];
const CONNECTION_ADD_MODULES: Array<{
icon: LucideIcon;
kind: Exclude<AddModuleKind, "audio">;
label: string;
}> = [
{ kind: "text", label: "文本生成", icon: FileText },
{ kind: "image", label: "图片生成", icon: ImageIcon },
{ kind: "video", label: "视频生成", icon: Video },
{ kind: "video-clip", label: "视频合成", icon: Scissors },
{ kind: "script", label: "脚本生成", icon: Layers },
];
const TARGET_MODULE_CENTER_OFFSETS: Record<AddModuleKind, { x: number; y: number }> = {
audio: { x: 705, y: -257 },
image: { x: 53, y: -257 },
@ -1077,6 +1098,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const [publishPublic, setPublishPublic] = useState(false);
const [canvasCommand, setCanvasCommand] = useState<CanvasCommandState>(null);
const [nodeContextMenu, setNodeContextMenu] = useState<NodeContextState>(null);
const [connectionMenu, setConnectionMenu] = useState<ConnectionMenuState>(null);
const selectedNode = useMemo(
() => nodes.find((node) => node.id === selectedNodeId) || null,
@ -1088,6 +1110,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
[nodeContextMenu?.nodeId, nodes]
);
const connectionNode = useMemo(
() => nodes.find((node) => node.id === connectionMenu?.nodeId) || null,
[connectionMenu?.nodeId, nodes]
);
useEffect(() => {
let active = true;
@ -1199,6 +1226,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
addNodes([node], []);
setActivePanel(null);
setCanvasCommand(null);
setConnectionMenu(null);
toast.success(`已添加 ${node.title}`);
},
[addNodes, nodes.length, toCanvasPoint]
@ -1218,6 +1246,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
addNodes(newNodes, []);
setActivePanel(null);
setCanvasCommand(null);
setConnectionMenu(null);
toast.success(files.length === 1 ? `已添加 ${newNodes[0]?.title || "素材"}` : `已添加 ${newNodes.length} 个素材`);
},
[addNodes, nodes.length]
@ -1245,6 +1274,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const point = toCanvasPoint(event.clientX, event.clientY);
setSelectedNodeId(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
setCanvasCommand({
canvasX: point.x,
@ -1261,6 +1291,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
event.stopPropagation();
setSelectedNodeId(node.id);
setCanvasCommand(null);
setConnectionMenu(null);
setNodeContextMenu({
nodeId: node.id,
screenX: event.clientX,
@ -1268,6 +1299,53 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
});
}, []);
const openConnectionMenu = useCallback((event: ReactMouseEvent<HTMLButtonElement>, node: CloneNode, side: ConnectionSide) => {
event.preventDefault();
event.stopPropagation();
setSelectedNodeId(node.id);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
setConnectionMenu({
nodeId: node.id,
screenX: event.clientX,
screenY: event.clientY,
side,
});
}, []);
const addConnectedModule = useCallback(
(kind: Exclude<AddModuleKind, "audio">) => {
if (!connectionMenu) return;
const anchor = nodes.find((node) => node.id === connectionMenu.nodeId);
if (!anchor) return;
const created = createTargetModuleNode(kind, 0, 0, nodes.length + 1);
const gap = 120;
const node =
connectionMenu.side === "source"
? {
...created,
x: Math.round(anchor.x + anchor.width + gap),
y: Math.round(anchor.y + anchor.height / 2 - created.height / 2),
}
: {
...created,
x: Math.round(anchor.x - created.width - gap),
y: Math.round(anchor.y + anchor.height / 2 - created.height / 2),
};
const edge =
connectionMenu.side === "source"
? { id: makeId("edge"), source: anchor.id, target: node.id }
: { id: makeId("edge"), source: node.id, target: anchor.id };
addNodes([node], [edge]);
setConnectionMenu(null);
toast.success(`已添加并连接 ${node.title}`);
},
[addNodes, connectionMenu, nodes]
);
const createFreeNode = useCallback(
(kind: CanvasCreateKind) => {
if (!canvasCommand) return;
@ -1279,6 +1357,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
};
addNodes([node], []);
setCanvasCommand(null);
setConnectionMenu(null);
toast.success(`已添加 ${node.title}`);
},
[addNodes, canvasCommand, nodes.length]
@ -1287,6 +1366,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const configureNode = useCallback((nodeId: string) => {
setSelectedNodeId(nodeId);
setNodeContextMenu(null);
setConnectionMenu(null);
}, []);
const copyNode = useCallback(async (nodeId: string) => {
@ -1294,6 +1374,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (!node) return;
await navigator.clipboard?.writeText(node.title).catch(() => undefined);
setNodeContextMenu(null);
setConnectionMenu(null);
toast.success("节点名称已复制");
}, [nodes]);
@ -1309,6 +1390,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setNodes((current) => [...current, duplicate]);
setSelectedNodeId(duplicate.id);
setNodeContextMenu(null);
setConnectionMenu(null);
toast.success("已复制节点");
}, [nodes]);
@ -1317,6 +1399,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (!node) return;
setSelectedNodeId(nodeId);
setNodeContextMenu(null);
setConnectionMenu(null);
toast.message(`${node.title} 生成接口待接入。`);
}, [nodes]);
@ -1325,6 +1408,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setEdges((current) => current.filter((edge) => edge.source !== nodeId && edge.target !== nodeId));
setSelectedNodeId((current) => (current === nodeId ? null : current));
setNodeContextMenu(null);
setConnectionMenu(null);
toast.success("节点已删除");
}, []);
@ -1337,6 +1421,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setViewport(PRESET_VIEWPORT);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel(null);
toast.success(`已载入 ${starter.title} 工作流`);
return;
@ -1397,6 +1482,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
setSelectedNodeId(null);
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
dragRef.current = {
kind: "canvas",
startX: event.clientX,
@ -1417,6 +1503,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
if (event.key === "Escape") {
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setSelectedNodeId(null);
setActivePanel(null);
}
@ -1508,6 +1595,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const toggleTopPanel = useCallback((panel: Exclude<ActivePanel, null>) => {
setCanvasCommand(null);
setNodeContextMenu(null);
setConnectionMenu(null);
setActivePanel((current) => (current === panel ? null : panel));
}, []);
@ -1515,6 +1603,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
const selectedPresetControlNode =
selectedNode?.kind === "media" && selectedNode.inputType !== "audio" ? selectedNode : null;
const selectedPresetMode = getPresetMode(selectedPresetControlNode);
const selectedTargetModuleKind = selectedNode ? getTargetModuleKind(selectedNode) : null;
const selectedPresetAnchor = useMemo<PresetControlAnchor | null>(() => {
if (!selectedPresetControlNode) return null;
return {
@ -1548,6 +1637,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
node={node}
selected={node.id === selectedNodeId}
onContextMenu={(event) => openNodeContext(event, node)}
onOpenConnectionMenu={(event, side) => openConnectionMenu(event, node, side)}
onPointerDown={(event) => startNodeDrag(event, node)}
/>
))}
@ -1622,6 +1712,15 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
/>
) : null}
{connectionMenu && connectionNode ? (
<ConnectionAddMenu
node={connectionNode}
onClose={() => setConnectionMenu(null)}
onCreate={addConnectedModule}
state={connectionMenu}
/>
) : null}
{selectedPresetMode && selectedPresetAnchor ? (
<PresetWorkflowControls anchor={selectedPresetAnchor} mode={selectedPresetMode} nodes={nodes} />
) : null}
@ -1670,7 +1769,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
/>
) : null}
{selectedNode && !selectedPresetMode ? (
{selectedNode && !selectedPresetMode && !selectedTargetModuleKind ? (
<InspectorPanel
node={selectedNode}
onChangeProperty={updateNodeDraft}
@ -3401,18 +3500,20 @@ function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] }
function CloneCanvasNode({
node,
onContextMenu,
onOpenConnectionMenu,
onPointerDown,
selected,
}: {
node: CloneNode;
onContextMenu: (event: ReactMouseEvent<HTMLDivElement>) => void;
onOpenConnectionMenu: (event: ReactMouseEvent<HTMLButtonElement>, side: ConnectionSide) => void;
onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;
selected: boolean;
}) {
const targetModuleKind = getTargetModuleKind(node);
if (targetModuleKind) {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
<TargetModuleCanvasNode kind={targetModuleKind} node={node} selected={selected} />
</CanvasNodeShell>
);
@ -3420,7 +3521,7 @@ function CloneCanvasNode({
if (node.kind === "media") {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
<PresetMediaCanvasNode node={node} selected={selected} />
</CanvasNodeShell>
);
@ -3428,7 +3529,7 @@ function CloneCanvasNode({
if (node.kind === "script") {
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
<PresetScriptCanvasNode node={node} selected={selected} />
</CanvasNodeShell>
);
@ -3440,7 +3541,7 @@ function CloneCanvasNode({
const isWarm = node.kind === "text" || node.kind === "audio";
const previewUrl = getDraftString(node, "thumbnailUrl") || getDraftString(node, "resultImage");
return (
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onPointerDown={onPointerDown} selected={selected}>
<CanvasNodeShell node={node} onContextMenu={onContextMenu} onOpenConnectionMenu={onOpenConnectionMenu} onPointerDown={onPointerDown} selected={selected}>
{isInput ? (
<div
className={cn(