mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat(toolbar): add extension node management and close on outside click
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user