feat(toolbar): add extension node management and close on outside click

This commit is contained in:
HouYunFei
2026-07-15 13:10:53 +08:00
parent 88510223eb
commit 8d3f244524
3 changed files with 94 additions and 4 deletions
+77 -3
View File
@@ -1,9 +1,10 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
@@ -19,6 +20,7 @@ export function CanvasToolbar({
onAddText,
onAddConfig,
onAddGroup,
onAddExtensionNode,
onUndo,
onRedo,
onUpload,
@@ -40,6 +42,7 @@ export function CanvasToolbar({
onAddText: () => void;
onAddConfig: () => void;
onAddGroup: () => void;
onAddExtensionNode: (type: string) => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
@@ -51,6 +54,7 @@ export function CanvasToolbar({
onOpenMyAssets: () => void;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const theme = canvasThemes[colorTheme];
@@ -58,13 +62,31 @@ export function CanvasToolbar({
const [tipX, setTipX] = useState(0);
const [appearanceOpen, setAppearanceOpen] = useState(false);
const [panelX, setPanelX] = useState(0);
const [extensionsOpen, setExtensionsOpen] = useState(false);
const [extPanelX, setExtPanelX] = useState(0);
// 扩展(插件)节点,随注册表变化实时更新
useNodeRegistryVersion();
const extensionDefs = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false && getNodePluginId(def.type) !== "builtin");
const dockStyle = { background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item, boxShadow: colorTheme === "dark" ? "0 18px 45px rgba(0,0,0,.32)" : "0 16px 40px rgba(28,25,23,.12)" };
const hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
const tip = hovered ? toolLabel(hovered) : "";
// 点击工具栏(含弹出面板)以外的地方,关闭弹出的扩展节点/画布外观面板
useEffect(() => {
if (!extensionsOpen && !appearanceOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setExtensionsOpen(false);
setAppearanceOpen(false);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [extensionsOpen, appearanceOpen]);
return (
<div className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
<ToolbarButton id="tool-hand" label="移动/选择" active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
@@ -95,6 +117,26 @@ export function CanvasToolbar({
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
<Group className="size-4.5" />
</ToolbarButton>
{extensionDefs.length ? (
<ToolbarButton
id="tool-extensions"
label="扩展节点"
active={extensionsOpen}
hovered={hovered}
activeStyle={activeStyle}
hoverStyle={hoverStyle}
wrapRef={wrapRef}
onTipX={setTipX}
onHover={setHovered}
onClick={(event) => {
setExtPanelX(getTipX(wrapRef.current, event.currentTarget));
setAppearanceOpen(false);
setExtensionsOpen((value) => !value);
}}
>
<Puzzle className="size-4.5" />
</ToolbarButton>
) : null}
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
@@ -114,6 +156,7 @@ export function CanvasToolbar({
onHover={setHovered}
onClick={(event) => {
setPanelX(getTipX(wrapRef.current, event.currentTarget));
setExtensionsOpen(false);
setAppearanceOpen((value) => !value);
}}
>
@@ -133,6 +176,36 @@ export function CanvasToolbar({
</ToolbarButton>
</div>
{extensionsOpen && extensionDefs.length ? (
<div
className="thin-scrollbar pointer-events-auto absolute bottom-[72px] z-30 max-h-[50vh] w-[240px] -translate-x-1/2 overflow-y-auto rounded-xl border p-2 shadow-xl backdrop-blur"
style={{ left: extPanelX || "50%", background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.toolbar.item }}
>
<div className="px-1.5 pb-1.5 text-[11px] font-medium opacity-50"></div>
<div className="grid gap-0.5">
{extensionDefs.map((def) => (
<button
key={def.type}
type="button"
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left text-sm transition"
style={{ color: theme.toolbar.item }}
onMouseEnter={(event) => (event.currentTarget.style.background = theme.toolbar.itemHover)}
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
onClick={() => {
onAddExtensionNode(def.type);
setExtensionsOpen(false);
}}
>
<span className="grid size-7 shrink-0 place-items-center rounded-md text-base" style={{ background: theme.toolbar.itemHover }}>
{def.icon}
</span>
<span className="min-w-0 flex-1 truncate">{def.title}</span>
</button>
))}
</div>
</div>
) : null}
{appearanceOpen ? (
<div
className="pointer-events-auto absolute bottom-[72px] z-30 w-[248px] -translate-x-1/2 rounded-xl border p-2.5 shadow-xl backdrop-blur"
@@ -287,6 +360,7 @@ function toolLabel(id: string) {
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
if (id === "tool-group") return "组";
if (id === "tool-extensions") return "扩展节点";
if (id === "tool-upload") return "上传素材";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
@@ -167,7 +167,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
const container = containerRef.current;
if (!container) return;
const preventWheelScroll = (event: WheelEvent) => event.preventDefault();
// 阻止画布滚动导致页面滚动;但浮层(创建菜单/弹窗等)内允许原生滚动
const preventWheelScroll = (event: WheelEvent) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown")) return;
event.preventDefault();
};
container.addEventListener("wheel", preventWheelScroll, { passive: false });
return () => container.removeEventListener("wheel", preventWheelScroll);
}, [containerRef]);
+11
View File
@@ -225,9 +225,19 @@ function ConnectionCreateOption({ theme, icon, title, description, onClick }: {
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
useNodeRegistryVersion();
const menuRef = useRef<HTMLDivElement>(null);
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
// 点击菜单外的空白处自动关闭
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
}, [onClose]);
return (
<div
ref={menuRef}
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
data-canvas-no-zoom
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
@@ -2777,6 +2787,7 @@ function InfiniteCanvasPage() {
onAddText={() => createNode(CanvasNodeType.Text)}
onAddConfig={() => createNode(CanvasNodeType.Config)}
onAddGroup={() => createNode(CanvasNodeType.Group)}
onAddExtensionNode={(type) => createNode(type)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onUpload={() => handleUploadRequest()}