From f06fc87bb58bd8d9ea4310c149ea58e5dac2d8fb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 5 Sep 2026 17:41:03 +0800 Subject: [PATCH] feat(diagram): refine canvas layout and keyboard workflow --- .../src/components/DiagramEditorPane.test.mjs | 33 ++- apps/web/src/components/DiagramEditorPane.tsx | 205 +++++++++++++----- apps/web/src/lib/diagram-layout.test.mjs | 8 +- apps/web/src/lib/diagram-layout.ts | 12 +- packages/shared/src/diagram.ts | 6 +- packages/shared/src/i18n/en-US.ts | 2 + packages/shared/src/i18n/zh-CN.ts | 2 + 7 files changed, 202 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 0d54635f..baff7383 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -17,13 +17,38 @@ describe("diagram editor keyboard workflow", () => { expect(source).toContain('insertNodeRef.current("child"'); expect(source).toContain("graph.cleanSelection();\n graph.select(node);"); }); + + test("supports a complete flowchart keyboard workflow", () => { + expect(source).toContain('openFlowQuickCreateRef.current = openFlowQuickCreate'); + expect(source).toContain('graph.bindKey("tab"'); + expect(source).toContain('graph.bindKey(["meta+d", "ctrl+d"]'); + expect(source).toContain('graph.startBatch("duplicate")'); + expect(source).toContain('graph.bindKey(["up", "down", "left", "right"'); + expect(source).toContain('graph.bindKey("0"'); + expect(source).toContain('graph.bindKey("1"'); + expect(source).toContain('"1": "process", "2": "decision", "3": "terminator"'); + expect(source).toContain('t("diagram.quickCreateShortcuts")'); + }); + + test("continues from text editing into the flowchart node picker", () => { + expect(source).toContain('else openFlowQuickCreateRef.current(editedNode)'); + }); }); describe("diagram editor canvas surface", () => { - test("keeps alignment grids for flowcharts without adding visual noise to mind maps", () => { - expect(source).toContain('grid: document.kind === "flowchart" ? diagramGrid(documentTheme, appearance) : false'); - expect(source).toContain('if (kind === "flowchart") graph.drawGrid(diagramGrid(theme, appearance));'); - expect(source).toContain("else graph.clearGrid();"); + test("uses a clean grid-free canvas for both diagram types", () => { + expect(source).toContain("grid: false"); + expect(source).toContain("graph.clearGrid();"); + expect(source).not.toContain("diagramGrid"); + expect(source).not.toContain("graph.drawGrid"); + }); + + test("uses straight flowchart edges and presents the content smaller at the left", () => { + expect(source).toContain('connector: { name: kind === "mind-map" ? "smooth" : "normal" }'); + expect(source).toContain('router: "normal"'); + expect(source).not.toContain('name: "manhattan"'); + expect(source).toContain('maxScale: document.kind === "flowchart" ? 0.84 : 1'); + expect(source).toContain("fitDiagramContent(graph, document, containerRef.current);"); }); test("exposes flowchart-only connection handles with safe connection rules", () => { diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index cd8f30e5..68e7abb0 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -41,7 +41,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { ThemeToggle } from "@/components/ThemeToggle"; import { useAppearanceTheme } from "@/components/ThemeProvider"; import { api } from "@/lib/api"; -import { compactMindMapNodeSize, computeDiagramLayout } from "@/lib/diagram-layout"; +import { compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "@/lib/diagram-layout"; import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme"; import { isLocalMemoId } from "@/lib/local-mirror"; import { isBrowserOffline } from "@/lib/network-status"; @@ -98,37 +98,19 @@ const oppositeFlowPort = (port?: FlowPort): FlowPort | undefined => port ? ({ left: "right", } as const)[port] : undefined; -const flowNodeSize = (shape: DiagramNodeShape) => ( - shape === "decision" ? { width: 132, height: 84 } : { width: 140, height: 52 } -); - const removeFlowDraftEdge = (graph: Graph, edge: Edge, restoreHistory: boolean) => { if (graph.isHistoryEnabled()) graph.disableHistory(); graph.removeCell(edge); if (restoreHistory) graph.enableHistory(); }; -const diagramGrid = (theme: DiagramTheme, appearance: DiagramAppearance) => { - const palette = resolveDiagramPalette(theme, appearance); - return { - visible: true, - type: "doubleMesh" as const, - args: [ - { color: palette.grid, thickness: 1 }, - { color: palette.gridStrong, thickness: 1, factor: 5 }, - ], - }; -}; - const applyDiagramSurface = ( graph: Graph, theme: DiagramTheme, - kind: DiagramDocument["kind"], appearance: DiagramAppearance, ) => { graph.drawBackground({ color: resolveDiagramPalette(theme, appearance).canvas }); - if (kind === "flowchart") graph.drawGrid(diagramGrid(theme, appearance)); - else graph.clearGrid(); + graph.clearGrid(); }; const prepareExportSvg = (background: string) => (svg: SVGSVGElement) => { @@ -174,7 +156,7 @@ const nodeEditorState = ( top: topLeft.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y, - fontSize: 14 * graph.scale().sx, + fontSize: (data?.shape === "topic" ? 14 : 13) * graph.scale().sx, color: String(attrs.label.fill), background: String(attrs.body.fill), borderColor: String(attrs.body.stroke), @@ -199,7 +181,7 @@ const nodeAttrs = ( ry: isTerminator ? 24 : 11, ...(shape === "decision" ? { refPoints: "0,10 10,0 20,10 10,20" } : {}), }, - label: { fill: isAccent ? palette.topicText : palette.nodeText, fontSize: 14, fontWeight: isAccent ? 650 : 500 }, + label: { fill: isAccent ? palette.topicText : palette.nodeText, fontSize: shape === "topic" ? 14 : 13, fontWeight: isAccent ? 650 : 500 }, }; }; @@ -215,7 +197,7 @@ const nodeMetadata = ( const palette = resolveDiagramPalette(theme, appearance); const size = node.shape === "topic" ? compactMindMapNodeSize(node.label, isRootTopic) - : { width: node.width, height: node.height }; + : compactFlowchartNodeSize(node.shape); return { id: node.id, shape: isDecision ? "polygon" : "rect", @@ -251,8 +233,8 @@ const edgeMetadata = ( id: edge.id, source: { cell: edge.source }, target: { cell: edge.target }, - router: kind === "flowchart" ? { name: "manhattan", args: { padding: 20 } } : undefined, - connector: { name: kind === "mind-map" ? "smooth" : "rounded", args: { radius: 10 } }, + router: undefined, + connector: { name: kind === "mind-map" ? "smooth" : "normal" }, attrs: { line: { stroke: kind === "mind-map" ? palette.mindMapEdge : palette.flowEdge, @@ -314,19 +296,23 @@ const diagramEditorSnapshot = (title: string, document: DiagramDocument) => JSON theme: document.theme ?? "brand", nodes: document.nodes.map((node) => node.shape === "topic" ? { ...node, ...compactMindMapNodeSize(node.label, !node.parentId) } - : node), + : { ...node, ...compactFlowchartNodeSize(node.shape) }), }, }); const fitDiagramContent = (graph: Graph, document: DiagramDocument, container: HTMLElement | null, padding = 32) => { - graph.zoomToFit({ padding, maxScale: 1 }); - if (document.kind !== "mind-map" || !container) return; - const root = graph.getNodes().find((node) => !node.getData()?.parentId); - if (!root) return; - const rootLeft = graph.localToGraph(root.getBBox().topLeft).x; - const desiredLeft = Math.max(36, Math.min(96, container.clientWidth * 0.08)); + graph.zoomToFit({ padding, maxScale: document.kind === "flowchart" ? 0.84 : 1 }); + if (!container) return; + const anchor = document.kind === "mind-map" + ? graph.getNodes().find((node) => !node.getData()?.parentId) + : graph.getNodes().reduce((leftmost, node) => ( + !leftmost || node.getBBox().x < leftmost.getBBox().x ? node : leftmost + ), null); + if (!anchor) return; + const contentLeft = graph.localToGraph(anchor.getBBox().topLeft).x; + const desiredLeft = Math.max(32, Math.min(72, container.clientWidth * 0.055)); const translation = graph.translate(); - graph.translate(translation.tx + desiredLeft - rootLeft, translation.ty); + graph.translate(translation.tx + desiredLeft - contentLeft, translation.ty); }; const applyGraphPalette = ( @@ -361,7 +347,7 @@ const applyGraphPalette = ( edge.attr("body/stroke", palette.nodeStroke); } } - applyDiagramSurface(graph, theme, kind, appearance); + applyDiagramSurface(graph, theme, appearance); } finally { if (historyEnabled) graph.enableHistory(); } @@ -373,6 +359,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on const containerRef = useRef(null); const graphRef = useRef(null); const insertNodeRef = useRef<(relation: MindMapInsertRelation, baseNodeId?: string) => void>(() => undefined); + const openFlowQuickCreateRef = useRef<(node: Node) => void>(() => undefined); const memoRef = useRef(memo); const editSessionRef = useRef(null); const saveRef = useRef<() => void>(() => undefined); @@ -508,7 +495,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on autoResize: true, async: true, background: { color: palette.canvas }, - grid: document.kind === "flowchart" ? diagramGrid(documentTheme, appearance) : false, + grid: false, panning: { enabled: true, eventTypes: ["leftMouseDown", "mouseWheel"] }, mousewheel: { enabled: true, modifiers: ["ctrl", "meta"], minScale: 0.3, maxScale: 2.5 }, interacting: !readOnly, @@ -521,8 +508,8 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on allowMulti: false, highlight: document.kind === "flowchart", snap: { radius: 24 }, - router: document.kind === "flowchart" ? "manhattan" : "normal", - connector: document.kind === "mind-map" ? "smooth" : "rounded", + router: "normal", + connector: document.kind === "mind-map" ? "smooth" : "normal", validateConnection: ({ sourceCell, targetCell, sourcePort, targetPort }) => { if (document.kind !== "flowchart" || !sourceCell || !sourcePort) return false; if (!targetCell) return true; @@ -558,8 +545,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on graph.addNodes(document.nodes.map((node) => nodeMetadata(node, documentTheme, document.kind, appearance))); graph.addEdges(document.edges.map((edge) => edgeMetadata(edge, document.kind, documentTheme, appearance))); graph.cleanHistory(); - if (document.kind === "mind-map") fitDiagramContent(graph, document, containerRef.current); - else graph.centerContent(); + fitDiagramContent(graph, document, containerRef.current); const updateHistory = () => setHistoryState({ undo: graph.canUndo(), redo: graph.canRedo() }); const markDirty = () => { @@ -623,7 +609,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on if (!containerRef.current) return; const overlayPoint = graph.localToGraph(point); const popupWidth = 246; - const popupHeight = 112; + const popupHeight = 132; const nextQuickCreate: FlowQuickCreateState = { draftEdgeId: edge.id, restoreHistory, @@ -638,6 +624,31 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on setFlowQuickCreate(nextQuickCreate); if (restoreHistory) graph.enableHistory(); }; + const openFlowQuickCreate = (sourceNode: Node) => { + dismissFlowQuickCreate(); + const sourceBounds = sourceNode.getBBox(); + const nextSize = compactFlowchartNodeSize("process"); + const sourcePort: FlowPort = "right"; + const point = { + x: sourceBounds.x + sourceBounds.width + 96 + nextSize.width / 2, + y: sourceBounds.y + sourceBounds.height / 2, + }; + const restoreHistory = graph.isHistoryEnabled(); + if (restoreHistory) graph.disableHistory(); + const draftEdge = graph.addEdge({ + ...edgeMetadata( + { id: createId("edge"), source: sourceNode.id, target: "" }, + document.kind, + themeRef.current, + appearanceRef.current, + ), + source: { cell: sourceNode.id, port: sourcePort }, + target: point, + data: { quickConnectDraft: true, restoreHistory }, + }); + showFlowQuickCreate(draftEdge, sourceNode.id, sourcePort, point, restoreHistory); + }; + openFlowQuickCreateRef.current = openFlowQuickCreate; graph.on("edge:connected", ({ edge, isNew, type, currentCell, currentPort, currentPoint }) => { if (document.kind !== "flowchart" || !isNew || type !== "target") return; const sourceNodeId = edge.getSourceCellId(); @@ -751,13 +762,73 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on beginNodeEdit(selected); } }); - if (document.kind === "mind-map") { - graph.bindKey("tab", (event) => { - event.preventDefault(); - const selected = graph.getSelectedCells().find((cell) => cell.isNode()); + graph.bindKey("tab", (event) => { + event.preventDefault(); + const selected = graph.getSelectedCells().find((cell) => cell.isNode()); + if (document.kind === "mind-map") { insertNodeRef.current("child", selected?.id); + } else if (selected?.isNode()) { + openFlowQuickCreate(selected); + } + }); + if (document.kind === "flowchart") { + graph.bindKey(["meta+d", "ctrl+d"], (event) => { + event.preventDefault(); + const selectedNodes = graph.getSelectedCells().filter((cell): cell is Node => cell.isNode()); + if (selectedNodes.length === 0) return; + graph.startBatch("duplicate"); + const duplicates = selectedNodes.map((node) => { + const data = node.getData(); + const position = node.getPosition(); + return graph.addNode(nodeMetadata({ + id: createId("node"), + label: data?.label ?? t("diagram.newStep"), + x: position.x + 24, + y: position.y + 24, + width: node.getSize().width, + height: node.getSize().height, + shape: data?.shape ?? "process", + }, themeRef.current, document.kind, appearanceRef.current)); + }); + graph.stopBatch("duplicate"); + graph.cleanSelection(); + duplicates.forEach((node) => graph.select(node)); + const lastNode = duplicates.at(-1); + setSelectedNodeId(lastNode?.id ?? null); + setSelectedNodeLabel(lastNode?.getData()?.label ?? ""); + setHasSelection(duplicates.length > 0); }); } + const nudgeSelection = (event: KeyboardEvent) => { + const selectedNodes = graph.getSelectedCells().filter((cell): cell is Node => cell.isNode()); + if (selectedNodes.length === 0) return; + event.preventDefault(); + const distance = event.shiftKey ? 10 : 1; + const movement = { + ArrowUp: { dx: 0, dy: -distance }, + ArrowDown: { dx: 0, dy: distance }, + ArrowLeft: { dx: -distance, dy: 0 }, + ArrowRight: { dx: distance, dy: 0 }, + }[event.key]; + if (!movement) return; + graph.startBatch("nudge"); + selectedNodes.forEach((node) => node.translate(movement.dx, movement.dy)); + graph.stopBatch("nudge"); + }; + graph.bindKey(["up", "down", "left", "right", "shift+up", "shift+down", "shift+left", "shift+right"], nudgeSelection); + graph.bindKey("0", (event) => { + event.preventDefault(); + fitDiagramContent(graph, document, containerRef.current); + }); + graph.bindKey("1", (event) => { + event.preventDefault(); + graph.zoomTo(1); + }); + graph.bindKey("esc", (event) => { + if (!flowQuickCreateRef.current) return; + event.preventDefault(); + dismissFlowQuickCreate(); + }); graph.bindKey(["meta+z", "ctrl+z"], (event) => { event.preventDefault(); graph.undo(); @@ -772,7 +843,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on graphToDocument(graph, document.kind, themeRef.current), )); }); - graph.bindKey(["meta+shift+z", "ctrl+shift+z"], (event) => { + graph.bindKey(["meta+shift+z", "ctrl+shift+z", "ctrl+y"], (event) => { event.preventDefault(); graph.redo(); applyGraphPalette(graph, themeRef.current, document.kind, appearanceRef.current); @@ -791,6 +862,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on containerRef.current?.removeEventListener("pointerdown", handleFlowPointerDown, true); window.removeEventListener("pointerup", handleFlowPointerUp, true); flowPointerDragRef.current = null; + openFlowQuickCreateRef.current = () => undefined; nodeEditorRef.current = null; graphRef.current = null; graph.dispose(); @@ -918,7 +990,7 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on dismissFlowQuickCreate(); return; } - const size = flowNodeSize(shape); + const size = compactFlowchartNodeSize(shape); const id = createId("node"); const label = shape === "decision" ? t("diagram.newDecision") @@ -1156,7 +1228,12 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on ) : ( <> - + + + + + {t("diagram.flowchartShortcuts")} + {t("diagram.addDecision")} {t("diagram.addTerminator")} {t("diagram.connectHint")} @@ -1207,27 +1284,38 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on role="dialog" aria-label={t("diagram.quickCreateTitle")} onKeyDown={(event) => { - if (event.key !== "Escape") return; - event.preventDefault(); - dismissFlowQuickCreate(); - containerRef.current?.focus({ preventScroll: true }); + const shape = ({ "1": "process", "2": "decision", "3": "terminator" } as const)[event.key as "1" | "2" | "3"]; + if (shape) { + event.preventDefault(); + createConnectedFlowNode(shape); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + dismissFlowQuickCreate(); + containerRef.current?.focus({ preventScroll: true }); + } }} >
{t("diagram.quickCreateTitle")}
- - -
+
{t("diagram.quickCreateShortcuts")}
) : null} {nodeEditor ? ( @@ -1270,10 +1358,15 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on } return; } - if (event.key === "Tab" && document.kind === "mind-map") { + if (event.key === "Tab") { event.preventDefault(); const editedNode = finishNodeEdit(); - if (editedNode) requestAnimationFrame(() => insertNodeRef.current("child", editedNode.id)); + if (editedNode) { + requestAnimationFrame(() => { + if (document.kind === "mind-map") insertNodeRef.current("child", editedNode.id); + else openFlowQuickCreateRef.current(editedNode); + }); + } } }} /> diff --git a/apps/web/src/lib/diagram-layout.test.mjs b/apps/web/src/lib/diagram-layout.test.mjs index 7dd6adad..cdb1894d 100644 --- a/apps/web/src/lib/diagram-layout.test.mjs +++ b/apps/web/src/lib/diagram-layout.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createDefaultDiagramDocument } from "@edgeever/shared"; -import { compactMindMapNodeSize, computeDiagramLayout } from "./diagram-layout.ts"; +import { compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "./diagram-layout.ts"; describe("diagram auto layout", () => { test("places mind-map children to the right of their root", () => { @@ -58,4 +58,10 @@ describe("diagram auto layout", () => { expect(Number.isFinite(positions.detached.x)).toBeTrue(); expect(Number.isFinite(positions.detached.y)).toBeTrue(); }); + + test("uses compact flowchart nodes with aligned process and terminator centers", () => { + expect(compactFlowchartNodeSize("process")).toEqual({ width: 112, height: 40 }); + expect(compactFlowchartNodeSize("terminator")).toEqual({ width: 104, height: 40 }); + expect(compactFlowchartNodeSize("decision")).toEqual({ width: 104, height: 64 }); + }); }); diff --git a/apps/web/src/lib/diagram-layout.ts b/apps/web/src/lib/diagram-layout.ts index 6ac2fc68..bcc426d9 100644 --- a/apps/web/src/lib/diagram-layout.ts +++ b/apps/web/src/lib/diagram-layout.ts @@ -21,6 +21,14 @@ export const compactMindMapNodeSize = (label: string, isRoot: boolean) => ({ height: isRoot ? 42 : 36, }); +export const compactFlowchartNodeSize = (shape: DiagramDocument["nodes"][number]["shape"]) => ( + shape === "decision" + ? { width: 104, height: 64 } + : shape === "terminator" + ? { width: 104, height: 40 } + : { width: 112, height: 40 } +); + const computeMindMapLayout = ( document: DiagramDocument, options: DiagramLayoutOptions, @@ -118,8 +126,8 @@ export const computeDiagramLayout = ( const layoutGraph = new graphlib.Graph(); layoutGraph.setGraph({ rankdir: "LR", - ranksep: 130, - nodesep: 56, + ranksep: 96, + nodesep: 40, marginx: 32, marginy: 32, }); diff --git a/packages/shared/src/diagram.ts b/packages/shared/src/diagram.ts index da8e9e75..b06ebef3 100644 --- a/packages/shared/src/diagram.ts +++ b/packages/shared/src/diagram.ts @@ -150,9 +150,9 @@ export const createDefaultDiagramDocument = (kind: DiagramKind): DiagramDocument schemaVersion: DIAGRAM_SCHEMA_VERSION, kind, nodes: [ - { id: "flow-start", label: "开始", x: 100, y: 180, width: 120, height: 48, shape: "terminator" }, - { id: "flow-process", label: "处理步骤", x: 340, y: 180, width: 140, height: 56, shape: "process" }, - { id: "flow-end", label: "结束", x: 600, y: 180, width: 120, height: 48, shape: "terminator" }, + { id: "flow-start", label: "开始", x: 80, y: 180, width: 104, height: 40, shape: "terminator" }, + { id: "flow-process", label: "处理步骤", x: 256, y: 180, width: 112, height: 40, shape: "process" }, + { id: "flow-end", label: "结束", x: 440, y: 180, width: 104, height: 40, shape: "terminator" }, ], edges: [ { id: "flow-edge-1", source: "flow-start", target: "flow-process" }, diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index a0ab9c59..3831fe1a 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -250,6 +250,7 @@ export const enUS = { addTopic: "Add child topic", mindMapShortcuts: "Tab adds a child; Enter adds a sibling", addStep: "Add step", + flowchartShortcuts: "Tab connects the next node; Enter edits; Cmd/Ctrl+D duplicates; arrow keys nudge", addDecision: "Add decision", addTerminator: "Add start or end", connectHint: "Drag a handle to connect, or drop on canvas to create the next node", @@ -258,6 +259,7 @@ export const enUS = { newDecision: "Decision", newTerminator: "Start / end", quickCreateTitle: "Choose the next node type", + quickCreateShortcuts: "Press 1 / 2 / 3 to choose; Esc to cancel", nodeText: "Node text", editNode: "Edit node text", deleteSelection: "Delete selection", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index 568d8336..920e4569 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -250,6 +250,7 @@ export const zhCN = { addTopic: "添加子主题", mindMapShortcuts: "Tab 添加子主题,Enter 添加同级主题", addStep: "添加步骤", + flowchartShortcuts: "Tab 连接后续节点,Enter 编辑,⌘/Ctrl+D 复制,方向键微调", addDecision: "添加判断节点", addTerminator: "添加起止节点", connectHint: "拖动端点连线;松开在空白处可创建后续节点", @@ -258,6 +259,7 @@ export const zhCN = { newDecision: "判断条件", newTerminator: "开始/结束", quickCreateTitle: "选择后续节点类型", + quickCreateShortcuts: "按 1 / 2 / 3 选择,Esc 取消", nodeText: "节点文字", editNode: "编辑节点文字", deleteSelection: "删除所选内容",