From cf204dc411dbed29d692de01fd18ba93b9e327d8 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sun, 6 Sep 2026 22:15:47 +0800 Subject: [PATCH] refactor(diagram): share toolbar controls --- .../src/components/DiagramEditorPane.test.mjs | 29 +++- apps/web/src/components/DiagramEditorPane.tsx | 121 ++++++---------- apps/web/src/components/DiagramToolbar.tsx | 131 ++++++++++++++++++ packages/shared/src/i18n/en-US.ts | 1 + packages/shared/src/i18n/zh-CN.ts | 1 + 5 files changed, 196 insertions(+), 87 deletions(-) create mode 100644 apps/web/src/components/DiagramToolbar.tsx diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index b633e228..cc18906c 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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(''); - expect(source).toContain('{t("diagram.autoLayout")}'); - expect(source).not.toContain(' - + setOpen(true)} />
@@ -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 ( - - - + setOpen(true)} /> {items.map((item) => { const Icon = item.icon; @@ -2150,12 +2129,14 @@ export const DiagramEditorPane = ({
-
- {!readOnly && ( + addNode("topic", { relation: "child" }) }, { icon: ListTree, label: t("diagram.addSiblingTopic"), onSelect: () => addNode("topic", { relation: "sibling" }) }, @@ -2171,8 +2152,6 @@ export const DiagramEditorPane = ({ /> ) : ( 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 && ( +
+ {t("diagram.nodeText")} + updateSelectedLabel(event.target.value)} /> +
+ )} + {selectedEdgeId && !readOnly && ( +
+ {t("diagram.edgeText")} + updateSelectedEdgeLabel(event.target.value)} /> +
+ )} + )} - - {t("diagram.undo")} - {t("diagram.redo")} - {!readOnly && {t("diagram.deleteSelection")}} - {!readOnly && ( - - - - - {t("diagram.autoLayout")} - - )} - - {t("diagram.zoomOut")} - {t("diagram.zoomIn")} - {t("diagram.fit")} - - - - - - - exportDiagram("png")}>{t("diagram.exportPng")} - exportDiagram("svg")}>{t("diagram.exportSvg")} - - - {selectedNodeId && !readOnly && ( -
- {t("diagram.nodeText")} - updateSelectedLabel(event.target.value)} /> -
- )} - {selectedEdgeId && !readOnly && ( -
- {t("diagram.edgeText")} - updateSelectedEdgeLabel(event.target.value)} /> -
- )} -
+ theme={theme} + />
{flowQuickCreate ? ( diff --git a/apps/web/src/components/DiagramToolbar.tsx b/apps/web/src/components/DiagramToolbar.tsx new file mode 100644 index 00000000..58a071f1 --- /dev/null +++ b/apps/web/src/components/DiagramToolbar.tsx @@ -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 = () => ; + +export const DiagramToolbarAddTrigger = ({ + onPointerEnter, +}: { + onPointerEnter: PointerEventHandler; +}) => { + const { t } = useTranslation(); + return ( + + + + ); +}; + +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 ( +
+ {leading ? <>{leading} : null} + {t("diagram.undo")} + {t("diagram.redo")} + {!readOnly && {t("diagram.deleteSelection")}} + {!readOnly && ( + + + + + {t("diagram.autoLayout")} + + )} + + {t("diagram.zoomOut")} + {t("diagram.zoomIn")} + {t("diagram.fit")} + + + + + + + onExport("png")}>{t("diagram.exportPng")} + onExport("svg")}>{t("diagram.exportSvg")} + + + {selectionEditor} +
+ ); +}; diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index dbc73fbc..456a2ada 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index afb66446..62ebf344 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -351,6 +351,7 @@ export const zhCN = { zoomOut: "缩小", fit: "适应画布", autoLayout: "自动整理布局", + toolbar: "图形工具栏", theme: "图表主题", themeBrand: "森绿", themeOcean: "薄荷纸",