refactor(diagram): share toolbar controls

This commit is contained in:
tianma-if committed 2026-09-06 22:15:47 +08:00
1 parent 2bac88b110
commit cf204dc411
5 files changed
+196 -87

No files matched your search

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const source = readFileSync(new URL("./DiagramEditorPane.tsx", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("./DiagramToolbar.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../styles/globals.css", import.meta.url), "utf8");
describe("diagram editor keyboard workflow", () => {
@@ -84,9 +85,26 @@ describe("diagram editor canvas surface", () => {
});
test("labels auto layout directly instead of relying on an ambiguous icon", () => {
expect(source).toContain('<WandSparkles className="h-4 w-4" />');
expect(source).toContain('<span>{t("diagram.autoLayout")}</span>');
expect(source).not.toContain('<Button size="icon" variant="ghost" aria-label={t("diagram.autoLayout")}');
expect(toolbarSource).toContain('<WandSparkles className="h-4 w-4" />');
expect(toolbarSource).toContain('<span>{t("diagram.autoLayout")}</span>');
expect(toolbarSource).not.toContain('<Button size="icon" variant="ghost" aria-label={t("diagram.autoLayout")}');
});
test("delegates every diagram kind to one shared toolbar shell", () => {
expect(source).toContain("<DiagramToolbar");
expect(source).toContain("leading={!readOnly ? (");
expect(toolbarSource).toContain('role="toolbar"');
expect(toolbarSource).toContain("{leading ? <>{leading}<ToolbarDivider /></> : null}");
expect(toolbarSource).toContain("{selectionEditor}");
});
test("reuses the architecture shape-library trigger across every diagram kind", () => {
expect(toolbarSource).toContain("export const DiagramToolbarAddTrigger");
expect(toolbarSource).toContain("<DropdownMenuTrigger asChild>");
expect(toolbarSource).toContain('<Button size="sm" variant="soft" onPointerEnter={onPointerEnter}>');
expect(toolbarSource).toContain('<Boxes className="h-4 w-4" />');
expect(toolbarSource).toContain('{t("diagram.componentLibrary")}');
expect(source.match(/<DiagramToolbarAddTrigger onPointerEnter=/g)).toHaveLength(2);
});
test("exposes connection handles on flowcharts and architecture components with safe connection rules", () => {
@@ -129,7 +147,6 @@ describe("diagram editor canvas surface", () => {
test("organizes architecture resources in a searchable category library", () => {
expect(source).toContain("ARCHITECTURE_LIBRARY_CATEGORIES");
expect(source).toContain('t("diagram.componentLibrary")');
expect(source).toContain('onPointerEnter={() => setOpen(true)}');
expect(source).toContain('t("diagram.componentSearch")');
expect(source).toContain('labelKey: "diagram.componentCategoryExperience"');
@@ -160,9 +177,9 @@ describe("diagram editor canvas surface", () => {
test("opens every diagram insertion library immediately on pointer hover", () => {
expect(source).toContain("const DiagramInsertMenu = ({");
expect(source.match(/onPointerEnter=\{\(\) => setOpen\(true\)\}/g)?.length).toBe(2);
expect(source).toContain('label={t("diagram.addTopic")}');
expect(source).toContain('label: t("diagram.addTopic")');
expect(source).toContain('label: t("diagram.addSiblingTopic")');
expect(source).toContain('label={t("diagram.addStep")}');
expect(source).toContain('label: t("diagram.addStep")');
expect(source).toContain('label: t("diagram.addDecision")');
expect(source).toContain('label: t("diagram.addTerminator")');
});
+40 -81
View File
@@ -6,7 +6,6 @@ import {
AppWindow,
Blocks,
Box,
Boxes,
BrickWall,
Cable,
Check,
@@ -26,7 +25,6 @@ import {
Diamond,
Database,
DatabaseZap,
Download,
EthernetPort,
FileCode2,
FileClock,
@@ -51,7 +49,6 @@ import {
Network,
Pencil,
RadioTower,
Redo2,
RefreshCw,
RotateCcw,
Router,
@@ -62,13 +59,9 @@ import {
Smartphone,
SquareFunction,
Trash2,
Undo2,
WandSparkles,
Webhook,
Workflow,
Zap,
ZoomIn,
ZoomOut,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -93,9 +86,9 @@ import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { RevisionHistoryDialog } from "@/components/dialogs/RevisionHistoryDialog";
import { ShareMemoDialog } from "@/components/dialogs/ShareMemoDialog";
import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
import { DiagramToolbar, DiagramToolbarAddTrigger } from "@/components/DiagramToolbar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useAppearanceTheme } from "@/components/ThemeProvider";
@@ -331,11 +324,7 @@ const ArchitectureComponentLibrary = ({
setOpen(nextOpen);
if (!nextOpen) setQuery("");
}}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" onPointerEnter={() => setOpen(true)}>
<Boxes className="h-4 w-4" />{t("diagram.componentLibrary")}
</Button>
</DropdownMenuTrigger>
<DiagramToolbarAddTrigger onPointerEnter={() => setOpen(true)} />
<DropdownMenuContent align="start" className="max-h-[min(36rem,calc(100vh-8rem))] w-[min(30rem,calc(100vw-2rem))] overflow-y-auto p-0">
<div className="sticky top-0 z-10 border-b border-slate-200 bg-white p-2.5">
<div className="relative">
@@ -398,24 +387,14 @@ const ArchitectureComponentLibrary = ({
};
const DiagramInsertMenu = ({
icon: TriggerIcon,
items,
label,
}: {
icon: LucideIcon;
items: Array<{ icon: LucideIcon; label: string; onSelect: () => void }>;
label: string;
}) => {
const [open, setOpen] = useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="soft" onPointerEnter={() => setOpen(true)}>
<TriggerIcon className="h-4 w-4" />
{label}
<ChevronDown className="h-3.5 w-3.5 text-slate-400" />
</Button>
</DropdownMenuTrigger>
<DiagramToolbarAddTrigger onPointerEnter={() => setOpen(true)} />
<DropdownMenuContent align="start">
{items.map((item) => {
const Icon = item.icon;
@@ -2150,12 +2129,14 @@ export const DiagramEditorPane = ({
</header>
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-slate-200 bg-white px-3 py-2">
{!readOnly && (
<DiagramToolbar
appearance={resolvedTheme}
canRedo={historyState.redo}
canUndo={historyState.undo}
hasSelection={hasSelection}
leading={!readOnly ? (
document.kind === "mind-map" ? (
<DiagramInsertMenu
icon={GitBranch}
label={t("diagram.addTopic")}
items={[
{ icon: GitBranch, label: t("diagram.addTopic"), onSelect: () => addNode("topic", { relation: "child" }) },
{ icon: ListTree, label: t("diagram.addSiblingTopic"), onSelect: () => addNode("topic", { relation: "sibling" }) },
@@ -2171,8 +2152,6 @@ export const DiagramEditorPane = ({
/>
) : (
<DiagramInsertMenu
icon={Box}
label={t("diagram.addStep")}
items={[
{ icon: Box, label: t("diagram.addStep"), onSelect: () => addNode("process") },
{ icon: Diamond, label: t("diagram.addDecision"), onSelect: () => addNode("decision") },
@@ -2180,58 +2159,38 @@ export const DiagramEditorPane = ({
]}
/>
)
) : undefined}
onAutoLayout={applyAutoLayout}
onDeleteSelection={removeSelected}
onExport={exportDiagram}
onFit={() => {
const graph = graphRef.current;
if (graph) fitDiagramContent(graph, document, containerRef.current);
}}
onRedo={() => runHistoryAction("redo")}
onThemeChange={applyTheme}
onUndo={() => runHistoryAction("undo")}
onZoomIn={() => graphRef.current?.zoom(0.1)}
onZoomOut={() => graphRef.current?.zoom(-0.1)}
readOnly={readOnly}
selectionEditor={(
<>
{selectedNodeId && !readOnly && (
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.nodeText")}</span>
<Input value={selectedNodeLabel} maxLength={120} onChange={(event) => updateSelectedLabel(event.target.value)} />
</div>
)}
{selectedEdgeId && !readOnly && (
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.edgeText")}</span>
<Input value={selectedEdgeLabel} maxLength={80} onChange={(event) => updateSelectedEdgeLabel(event.target.value)} />
</div>
)}
</>
)}
<span className="mx-1 h-5 w-px bg-slate-200" />
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.undo")} disabled={!historyState.undo || readOnly} onClick={() => runHistoryAction("undo")}><Undo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.undo")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.redo")} disabled={!historyState.redo || readOnly} onClick={() => runHistoryAction("redo")}><Redo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.redo")}</TooltipContent></Tooltip>
{!readOnly && <Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.deleteSelection")} disabled={!hasSelection} onClick={removeSelected}><Trash2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.deleteSelection")}</TooltipContent></Tooltip>}
{!readOnly && (
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="ghost" className="gap-1.5 px-2.5" aria-label={t("diagram.autoLayout")} onClick={applyAutoLayout}>
<WandSparkles className="h-4 w-4" />
<span>{t("diagram.autoLayout")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("diagram.autoLayout")}</TooltipContent>
</Tooltip>
)}
<span className="mx-1 h-5 w-px bg-slate-200" />
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.zoomOut")} onClick={() => graphRef.current?.zoom(-0.1)}><ZoomOut className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.zoomOut")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.zoomIn")} onClick={() => graphRef.current?.zoom(0.1)}><ZoomIn className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.zoomIn")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.fit")} onClick={() => { const graph = graphRef.current; if (graph) fitDiagramContent(graph, document, containerRef.current); }}><Maximize2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.fit")}</TooltipContent></Tooltip>
<Select value={theme} disabled={readOnly} onValueChange={(value) => applyTheme(value as DiagramTheme)}>
<SelectTrigger className="h-8 w-[8.5rem] gap-2" aria-label={t("diagram.theme")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brand" textValue={t("diagram.themeBrand")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("brand", resolvedTheme).topicFill }} />{t("diagram.themeBrand")}</span></SelectItem>
<SelectItem value="ocean" textValue={t("diagram.themeOcean")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ocean", resolvedTheme).topicFill }} />{t("diagram.themeOcean")}</span></SelectItem>
<SelectItem value="ink" textValue={t("diagram.themeInk")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ink", resolvedTheme).nodeFill }} />{t("diagram.themeInk")}</span></SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline"><Download className="h-4 w-4" />{t("diagram.export")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => exportDiagram("png")}><FileImage className="h-4 w-4" />{t("diagram.exportPng")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportDiagram("svg")}><FileCode2 className="h-4 w-4" />{t("diagram.exportSvg")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{selectedNodeId && !readOnly && (
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.nodeText")}</span>
<Input value={selectedNodeLabel} maxLength={120} onChange={(event) => updateSelectedLabel(event.target.value)} />
</div>
)}
{selectedEdgeId && !readOnly && (
<div className="ml-auto flex min-w-[220px] flex-1 items-center gap-2 sm:max-w-sm">
<span className="shrink-0 text-xs font-medium text-slate-500">{t("diagram.edgeText")}</span>
<Input value={selectedEdgeLabel} maxLength={80} onChange={(event) => updateSelectedEdgeLabel(event.target.value)} />
</div>
)}
</div>
theme={theme}
/>
<div className="relative min-h-0 flex-1">
<div ref={containerRef} className="edgeever-diagram-canvas absolute inset-0 touch-none outline-none" data-diagram-appearance={resolvedTheme} data-diagram-kind={document.kind} data-diagram-theme={theme} tabIndex={0} aria-label={t("diagram.canvas", { type: kindLabel })} />
{flowQuickCreate ? (
+131
View File
@@ -0,0 +1,131 @@
import type { PointerEventHandler, ReactNode } from "react";
import {
Boxes,
ChevronDown,
Download,
FileCode2,
FileImage,
Maximize2,
Redo2,
Trash2,
Undo2,
WandSparkles,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DiagramTheme } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme";
type DiagramToolbarProps = {
appearance: DiagramAppearance;
canRedo: boolean;
canUndo: boolean;
hasSelection: boolean;
leading?: ReactNode;
onAutoLayout: () => void;
onDeleteSelection: () => void;
onExport: (format: "png" | "svg") => void;
onFit: () => void;
onRedo: () => void;
onThemeChange: (theme: DiagramTheme) => void;
onUndo: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
readOnly: boolean;
selectionEditor?: ReactNode;
theme: DiagramTheme;
};
const ToolbarDivider = () => <span className="mx-1 h-5 w-px shrink-0 bg-slate-200" />;
export const DiagramToolbarAddTrigger = ({
onPointerEnter,
}: {
onPointerEnter: PointerEventHandler<HTMLButtonElement>;
}) => {
const { t } = useTranslation();
return (
<DropdownMenuTrigger asChild>
<Button size="sm" variant="soft" onPointerEnter={onPointerEnter}>
<Boxes className="h-4 w-4" />
{t("diagram.componentLibrary")}
<ChevronDown className="h-3.5 w-3.5 text-slate-400" />
</Button>
</DropdownMenuTrigger>
);
};
export const DiagramToolbar = ({
appearance,
canRedo,
canUndo,
hasSelection,
leading,
onAutoLayout,
onDeleteSelection,
onExport,
onFit,
onRedo,
onThemeChange,
onUndo,
onZoomIn,
onZoomOut,
readOnly,
selectionEditor,
theme,
}: DiagramToolbarProps) => {
const { t } = useTranslation();
return (
<div className="flex shrink-0 flex-wrap items-center gap-1 border-b border-slate-200 bg-white px-3 py-2" role="toolbar" aria-label={t("diagram.toolbar")}>
{leading ? <>{leading}<ToolbarDivider /></> : null}
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.undo")} disabled={!canUndo || readOnly} onClick={onUndo}><Undo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.undo")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.redo")} disabled={!canRedo || readOnly} onClick={onRedo}><Redo2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.redo")}</TooltipContent></Tooltip>
{!readOnly && <Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.deleteSelection")} disabled={!hasSelection} onClick={onDeleteSelection}><Trash2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.deleteSelection")}</TooltipContent></Tooltip>}
{!readOnly && (
<Tooltip>
<TooltipTrigger asChild>
<Button size="sm" variant="ghost" className="gap-1.5 px-2.5" aria-label={t("diagram.autoLayout")} onClick={onAutoLayout}>
<WandSparkles className="h-4 w-4" />
<span>{t("diagram.autoLayout")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("diagram.autoLayout")}</TooltipContent>
</Tooltip>
)}
<ToolbarDivider />
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.zoomOut")} onClick={onZoomOut}><ZoomOut className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.zoomOut")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.zoomIn")} onClick={onZoomIn}><ZoomIn className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.zoomIn")}</TooltipContent></Tooltip>
<Tooltip><TooltipTrigger asChild><Button size="icon" variant="ghost" aria-label={t("diagram.fit")} onClick={onFit}><Maximize2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent>{t("diagram.fit")}</TooltipContent></Tooltip>
<Select value={theme} disabled={readOnly} onValueChange={(value) => onThemeChange(value as DiagramTheme)}>
<SelectTrigger className="h-8 w-[8.5rem] gap-2" aria-label={t("diagram.theme")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brand" textValue={t("diagram.themeBrand")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("brand", appearance).topicFill }} />{t("diagram.themeBrand")}</span></SelectItem>
<SelectItem value="ocean" textValue={t("diagram.themeOcean")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ocean", appearance).topicFill }} />{t("diagram.themeOcean")}</span></SelectItem>
<SelectItem value="ink" textValue={t("diagram.themeInk")}><span className="flex items-center gap-2"><span className="h-3 w-3 rounded-full border border-black/10" style={{ background: resolveDiagramPalette("ink", appearance).nodeFill }} />{t("diagram.themeInk")}</span></SelectItem>
</SelectContent>
</Select>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline"><Download className="h-4 w-4" />{t("diagram.export")}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onExport("png")}><FileImage className="h-4 w-4" />{t("diagram.exportPng")}</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onExport("svg")}><FileCode2 className="h-4 w-4" />{t("diagram.exportSvg")}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{selectionEditor}
</div>
);
};
+1
View File
@@ -351,6 +351,7 @@ export const enUS = {
zoomOut: "Zoom out",
fit: "Fit diagram",
autoLayout: "Auto-arrange",
toolbar: "Diagram toolbar",
theme: "Diagram theme",
themeBrand: "Forest Green",
themeOcean: "Mint paper",
+1
View File
@@ -351,6 +351,7 @@ export const zhCN = {
zoomOut: "缩小",
fit: "适应画布",
autoLayout: "自动整理布局",
toolbar: "图形工具栏",
theme: "图表主题",
themeBrand: "森绿",
themeOcean: "薄荷纸",