mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-06 01:14:36 +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 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 { 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 { 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 { useThemeStore } from "@/stores/use-theme-store";
|
||||||
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function CanvasToolbar({
|
|||||||
onAddText,
|
onAddText,
|
||||||
onAddConfig,
|
onAddConfig,
|
||||||
onAddGroup,
|
onAddGroup,
|
||||||
|
onAddExtensionNode,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onUpload,
|
onUpload,
|
||||||
@@ -40,6 +42,7 @@ export function CanvasToolbar({
|
|||||||
onAddText: () => void;
|
onAddText: () => void;
|
||||||
onAddConfig: () => void;
|
onAddConfig: () => void;
|
||||||
onAddGroup: () => void;
|
onAddGroup: () => void;
|
||||||
|
onAddExtensionNode: (type: string) => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onUpload: () => void;
|
onUpload: () => void;
|
||||||
@@ -51,6 +54,7 @@ export function CanvasToolbar({
|
|||||||
onOpenMyAssets: () => void;
|
onOpenMyAssets: () => void;
|
||||||
}) {
|
}) {
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const colorTheme = useThemeStore((state) => state.theme);
|
const colorTheme = useThemeStore((state) => state.theme);
|
||||||
const setTheme = useThemeStore((state) => state.setTheme);
|
const setTheme = useThemeStore((state) => state.setTheme);
|
||||||
const theme = canvasThemes[colorTheme];
|
const theme = canvasThemes[colorTheme];
|
||||||
@@ -58,13 +62,31 @@ export function CanvasToolbar({
|
|||||||
const [tipX, setTipX] = useState(0);
|
const [tipX, setTipX] = useState(0);
|
||||||
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
const [appearanceOpen, setAppearanceOpen] = useState(false);
|
||||||
const [panelX, setPanelX] = useState(0);
|
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 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 hoverStyle = { background: theme.toolbar.itemHover, color: theme.toolbar.activeText };
|
||||||
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
const activeStyle = { background: theme.toolbar.activeBg, color: theme.toolbar.activeText };
|
||||||
const tip = hovered ? toolLabel(hovered) : "";
|
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 (
|
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}
|
{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}>
|
<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}>
|
<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}>
|
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
|
||||||
<Group className="size-4.5" />
|
<Group className="size-4.5" />
|
||||||
</ToolbarButton>
|
</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}>
|
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
|
||||||
<Upload className="size-4.5" />
|
<Upload className="size-4.5" />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
@@ -114,6 +156,7 @@ export function CanvasToolbar({
|
|||||||
onHover={setHovered}
|
onHover={setHovered}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
setPanelX(getTipX(wrapRef.current, event.currentTarget));
|
||||||
|
setExtensionsOpen(false);
|
||||||
setAppearanceOpen((value) => !value);
|
setAppearanceOpen((value) => !value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -133,6 +176,36 @@ export function CanvasToolbar({
|
|||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</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 ? (
|
{appearanceOpen ? (
|
||||||
<div
|
<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"
|
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-audio") return "音频";
|
||||||
if (id === "tool-config") return "生成配置";
|
if (id === "tool-config") return "生成配置";
|
||||||
if (id === "tool-group") return "组";
|
if (id === "tool-group") return "组";
|
||||||
|
if (id === "tool-extensions") return "扩展节点";
|
||||||
if (id === "tool-upload") return "上传素材";
|
if (id === "tool-upload") return "上传素材";
|
||||||
if (id === "tool-assets") return "我的素材";
|
if (id === "tool-assets") return "我的素材";
|
||||||
if (id === "tool-style") return "画布外观";
|
if (id === "tool-style") return "画布外观";
|
||||||
|
|||||||
@@ -167,7 +167,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
|||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
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 });
|
container.addEventListener("wheel", preventWheelScroll, { passive: false });
|
||||||
return () => container.removeEventListener("wheel", preventWheelScroll);
|
return () => container.removeEventListener("wheel", preventWheelScroll);
|
||||||
}, [containerRef]);
|
}, [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 }) {
|
function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
|
||||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||||
useNodeRegistryVersion();
|
useNodeRegistryVersion();
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
|
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 (
|
return (
|
||||||
<div
|
<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"
|
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
|
data-canvas-no-zoom
|
||||||
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
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)}
|
onAddText={() => createNode(CanvasNodeType.Text)}
|
||||||
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
onAddConfig={() => createNode(CanvasNodeType.Config)}
|
||||||
onAddGroup={() => createNode(CanvasNodeType.Group)}
|
onAddGroup={() => createNode(CanvasNodeType.Group)}
|
||||||
|
onAddExtensionNode={(type) => createNode(type)}
|
||||||
onUndo={undoCanvas}
|
onUndo={undoCanvas}
|
||||||
onRedo={redoCanvas}
|
onRedo={redoCanvas}
|
||||||
onUpload={() => handleUploadRequest()}
|
onUpload={() => handleUploadRequest()}
|
||||||
|
|||||||
Reference in New Issue
Block a user