做 diagram-design 这个项目,起因其实很朴素。我负责的某内部系统需要一整套可交互的技术图表绘制能力,当时市面上开源的在线绘图方案各有各的局限:要么太重量级,要么交互深度不够,要么在移动端拖拽体验很生涩。团队权衡之后,决定自研一个以通用图编辑为核心的组件,名字就叫 diagram-design。它不是某个业务系统,而是一个面向技术场景(架构图、流程图、时序图、ER图)的 Web 图表编辑器内核,核心功能包括节点拖拽、连线绘制、画布缩放平移、框选多选、撤销重做、JSON 序列化与图片导出。
这篇文章不讲虚的,直接把这套东西从数据模型到渲染层、再到交互层和工程化细节,按我当初实现的思路拆开讲。如果你正在做类似方向的前端项目,或者打算自研一套绘图工具,这篇应该能帮你节省大量试错时间。
1. 项目定位与整体拆分:为什么自研一款图表编辑器
1.1 先想清楚边界:不是画板,是图编辑器
在动手写第一行代码之前,最应该做的不是选技术栈,而是把"图编辑器"和"画板"这两件事分清。画板的核心是自由绘制,用户拿画笔去涂抹;图编辑器的核心是结构化编辑,用户操作的是"节点"和"连线"这两个原子对象。
diagram-design 的定位非常明确:处理结构化图数据。这意味着所有交互都围绕节点和边展开,而不是像素级绘制。用户拖过来一个矩形,它就是节点,双击它编辑文本,它还是节点。用户从一个节点底部拉出一条线到另一个节点的顶部,它在内部被记录为一条 edge,而不是一串路径数据。
这个定位直接决定了后续的架构设计:
- 数据必须可持久化、可 diff、可树状渲染;
- 每个图元都必须有唯一标识,这样才能支撑复杂的交互;
- 编辑器只关心"图结构",不关心图元内部长什么样——图元是外部注册进来的。
想清楚这一点之后,我再去看市面上的开源方案,感觉思路完全不一样了。它们大多偏重某一类图(比如流程图或思维导图),通用性和可扩展性都不够好。自研的核心价值在于,我们可以完全控制数据结构和交互模型,并且在业务系统里做到深度定制。
1.2 整体架构拆成四层
diagram-design 在代码层面拆成了四个清晰的分层:
- 数据层:负责定义 Node、Edge、Viewport、History 的数据结构,以及序列化、反序列化逻辑;
- 渲染层:负责把数据模型映射到 SVG 元素,包括节点外观、连线路径、锚点、选区等;
- 交互层:负责接收鼠标和触摸事件,将其翻译为对数据层的操作,例如拖拽节点、拉一条线、平移画布;
- 功能层:负责撤销重做、复制粘贴、对齐吸附、导入导出等更上层的功能。
这个分层的价值在项目后期体现得特别明显。比如我后来要给连线增加贝塞尔曲线控制点,只需要改渲染层和数据层的 edge 结构,交互层基本不受影响。再比如增加"网格对齐"功能时,交互层加一个吸附判断就完事,数据层和渲染层完全不用动。
如果你准备做同类项目,我建议你同样在前期就把边界划清楚,不要贪快把逻辑全部堆在一起。分层带来的重构成本,远低于不分层带来的维护成本。
2. 数据模型先行:图结构如何映射为可持久化的 JSON
2.1 节点与连线的数据结构设计
整个项目的核心无非两样东西:节点和连线。我在设计数据结构时踩过一些坑,最开始把节点写成宽泛的 Record<string, any>,后期发现很多通用逻辑没法写,每条节点却要重复判断字段。后来收敛成了这样:
// 节点数据模型 interface DiagramNode { id: string; // 全局唯一 type: string; // 图元类型:"rect" | "diamond" | "ellipse" | "text" | "custom-*" x: number; // 画布坐标,左上角 y: number; width: number; height: number; style: NodeStyle; // 颜色、圆角、边框、阴影、字体等 data: Record<string, unknown>; // 业务字段,例如文本内容、图标链接 zIndex: number; // 层级 } // 连线数据模型 interface DiagramEdge { id: string; source: string; // 源节点 id target: string; // 目标节点 id sourceHandle: string; // 源节点上连线的锚点方向:"bottom"、"right" 等 targetHandle: string; // 目标节点上连线的锚点方向:"top"、"left" 等 type: 'bezier' | 'straight' | 'step'; // 连线样式 waypoints: Array<{ x: number; y: number }>; // 手动添加的中间拐点 style: EdgeStyle; // 颜色、线宽、箭头、虚线等 }有人可能会问,为什么连线的 source 和 target 要保存"节点 id 加锚点方向"而不是直接保存一端端点的坐标?
因为一旦目标节点被拖动,连线必须跟着动。如果保存的是端点坐标,那节点移动后,连线不会自动更新,需要额外做一轮坐标同步;而保存锚点方向,渲染层每次都可以从节点的新位置重新计算端点,天然保持一致。这是图编辑器最基础的"关联"思想。
对于 waypoints 拐点,我一开始也是存全局坐标,后来发现节点移动时拐点不动,会显得很奇怪。最后的处理方式有两种:要么把拐点和两端节点做相对位置关联,要么干脆在移动节点时对拐点做同步平移。前者更完善但复杂度高,我为了先跑通主流程选了后者,实测够用。
2.2 序列化与版本兼容
图数据要保存到后端,就涉及序列化的问题。diagram-design 导出的是一个完整的 JSON 结构,大致长这样:
{ "version": 1, "viewport": { "x": -120, "y": -80, "scale": 1 }, "nodes": [ /* DiagramNode 数组 */ ], "edges": [ /* DiagramEdge 数组 */ ] }我强烈建议把 version 字段放进去。
为什么?因为数据模型一定会演化。比如后来我要增加"分组"功能,给节点加一个 groupId 字段,如果没有版本号,旧数据直接读取时缺字段,渲染逻辑要做一堆兼容判断。有了 version,可以在导入时做数据迁移,一句"旧格式升级到新格式",非常省心。
我实际做的迁移机制也很简单,就是一个按版本递增的迁移函数数组:
const migrations = [ (data: any) => { /* v1 -> v2:给所有 node 补充默认 style */ }, (data: any) => { /* v2 -> v3:给 edge 补充 waypoints 空数组 */ } ]; function migrate(rawData: any) { let data = rawData; for (let v = rawData.version; v < CURRENT_VERSION; v++) { data = migrations[v - 1](data); } return data; }这里的核心原则是:永远不要直接改用户的旧数据,而是通过迁移函数生成新数据,保证每次打开文件都是幂等操作。
2.3 历史记录不是快照,是操作日志
回到撤销重做这一块。最朴素的做法是每次操作完都存一个全量快照,但图一大(几千个节点)之后,内存和性能都会爆炸。我实际采用的是命令模式:每个操作都实现为包含 execute 和 undo 两个方法的对象,比如添加节点、移动节点、删除连线各自有对应的命令。
移动节点这种高频操作,如果每个 mousemove 都生成一条命令,撤销列表会塞满无意义的中间态。这里的处理技巧是"合并连续操作":拖动过程中不记录历史,只在 mouseup 时记录一条"从位置 A 移动到位置 B"的命令,撤销时直接回到 A,而不是一步一步倒退。
interface Command { label: string; // 用于显示操作名 execute(): void; undo(): void; }这个设计跑了一年多,稳定得让我很放心。它比快照方案的优势在于:撤销粒度完全可控、内存占用固定、可以单独对某个操作做定制(比如从特定节点创建连线时,自动附带对节点样式的修改)。
3. 渲染方案选型:SVG 还是 Canvas,我为什么做了组合
3.1 两条路线的优缺点对比
这是 diagram-design 技术选型中最关键的一个决定:用 SVG 渲染节点,还是用 Canvas 整体绘制?
我把两边的权衡列在下面,这也是我调研后真实感受到的差异。
| 维度 | SVG | Canvas |
|---|---|---|
| 节点数量 | 轻量场景表现优秀,几百个没问题 | 适合几千上万个节点,无 DOM 压力 |
| 交互命中检测 | 原生 DOM 事件,天然定位到元素 | 需要自己做坐标反查和图形算法 |
| 样式控制 | CSS 直接控制,动画容易 | 每次重绘要手动操作样式属性 |
| 导出与嵌入 | 直接拿到矢量图形,转 HTML 也很方便 | 需要额外序列化到图片或 SVG |
| 文本编辑 | 可以在节点内直接放输入框 | 文本块渲染和光标处理较麻烦 |
| 开发成本 | 低,几乎不用写图形绘制代码 | 高,需要自己实现很多底层逻辑 |
对于 diagram-design 这种以"编辑"为核心目标的工具,交互的精准度和开发效率是第一位的。我最终选择 SVG 作为主要渲染方案,理由很简单:节点和连线数量在大多数技术场景下是几百量级,这个区间内 SVG 性能完全能打,而它带来的"原生事件 + 天然矢量导出"优势在编辑器场景里太值钱了。
3.2 图元注册机制:渲染层与业务层解耦
SVG 方案有了,接下来要考虑的是:如何在不让渲染层变成大杂烩的前提下支持各种节点类型?
我实现了一个图元注册机制,本质就是一个从 type 字符串到渲染函数(或组件)的映射表:
// 图元注册表:把节点的 type 映射到具体的渲染实现 const shapeRenderers: Record<string, (node: DiagramNode) => string> = { rect: renderRect, diamond: renderDiamond, ellipse: renderEllipse, image: renderImage, text: renderText }; function registerShape(type: string, renderer: (node: DiagramNode) => string) { shapeRenderers[type] = renderer; }业务系统里自定义一个"数据库表节点"或者"服务图标节点",只需要调用 registerShape 注册一个渲染函数即可,核心编辑器代码一行都不用改。这样渲染层只负责遍历 nodes 数组,根据 node.type 查表拿渲染结果,再挂上统一的拖拽、选中事件。
这种解耦让我后面加功能非常爽。有一次业务方要一个折线型的节点,我写了一个 renderPolyline 组件,注册进去就完事,数据模型和交互模型完全没动。
3.3 连线的贝塞尔曲线怎么画的
连线不是简单画一条直线。diagram-design 默认的连线类型是贝塞尔曲线,因为曲线在架构图里看起来最柔和,不刺眼。
实现思路是这样的:已知起点和终点坐标,以及两个节点上的锚点方向。根据方向,计算出两个控制点,然后构造三次贝塞尔曲线路径:
// 根据锚点方向生成控制点偏移 function getControlOffset(handle: string, len = 40) { if (handle === 'bottom') return { x: 0, y: len }; if (handle === 'top') return { x: 0, y: -len }; if (handle === 'right') return { x: len, y: 0 }; if (handle === 'left') return { x: -len, y: 0 }; return { x: 0, y: -len }; // 默认按顶部方向 } // 构造三次贝塞尔路径 function buildBezierPath( start: Point, end: Point, startHandle: string, endHandle: string ) { const c1 = { x: start.x + getControlOffset(startHandle).x, y: start.y + getControlOffset(startHandle).y }; const c2 = { x: end.x + getControlOffset(endHandle).x, y: end.y + getControlOffset(endHandle).y }; return `M ${start.x} ${start.y} C ${c1.x} ${c1.y}, ${c2.x} ${c2.y}, ${end.x} ${end.y}`; }这种方案的优势是控制点数量少且规则稳定。后续如果想加"自动避让节点"或"正交折线",只需要替换 buildBezierPath 的实现,渲染层不需要改动。我是在做跨多个泳道的连线时,才发现固定长度控制点在长连线下曲线弧度不够自然。后来把 control offset 改成了按两端距离的比例动态计算,曲线才真正做到不管多远都优雅。
4. 交互层实操:拖拽、缩放、连线这些高频操作怎么落地
4.1 坐标系变换:缩放后拖拽不出错的数学公式
交互层最大的坑是坐标系混乱。SVG 的坐标是画布坐标,而鼠标事件给出的坐标是屏幕坐标。画布缩放之后,这两者之间就出现了差异,如果直接拿屏幕坐标设置节点位置,会出现"鼠标抓不住节点"的漂移现象。
我封装了一个视口(Viewport)对象,统一处理坐标换算:
interface Viewport { offsetX: number; // 画布在屏幕上的偏移 offsetY: number; scale: number; // 缩放比例 } // 屏幕坐标 -> 画布坐标 function clientToCanvas(clientX: number, clientY: number, vp: Viewport) { return { x: (clientX - vp.offsetX) / vp.scale, y: (clientY - vp.offsetY) / vp.scale }; } // 画布坐标 -> 屏幕坐标 function canvasToClient(canvasX: number, canvasY: number, vp: Viewport) { return { x: canvasX * vp.scale + vp.offsetX, y: canvasY * vp.scale + vp.offsetY }; }这两个函数是整个交互层的基石。节点拖拽时,每一次 mousemove 都执行 clientToCanvas,拿到画布坐标后再写回 node 的 x 和 y。这样就保证了"鼠标在节点上的落点,移动过程中始终不离开"。
缩放实现用了滚轮监听,以鼠标位置为缩放中心。这一步也有个细节:缩放时,要保证鼠标指向的那个画布坐标点在缩放前后都处于同一屏幕位置。公式是:
// 以鼠标位置 (mouseX, mouseY) 为中心缩放 function zoomAt(mouseX: number, mouseY: number, nextScale: number, vp: Viewport) { const before = clientToCanvas(mouseX, mouseY, vp); vp.scale = nextScale; vp.offsetX = mouseX - before.x * vp.scale; vp.offsetY = mouseY - before.y * vp.scale; }我最初省略了这一步,直接缩放 offset 和 scale,结果每次放大后鼠标附近的节点都会小幅度跑位,看着不严重但很影响手感。补上以鼠标为中心的计算后,体验立刻正常了。
4.2 连线交互:从锚点拖出,到目标节点校验
连线交互是最容易写崩的一部分。diagram-design 的交互流程是:
- 鼠标按下某个节点上的锚点(比如底部圆点);
- 拖动时,屏幕上出现一条临时连线,终点跟随鼠标;
- 鼠标悬停在另一个节点的锚点附近时,该锚点高亮,提示可以连接;
- 松开鼠标,如果命中了合法锚点,生成一条新连线,否则取消。
在这个过程中,最核心的是"命中检测"。因为 SVG 天然支持事件,我可以在每个锚点上挂 mouseenter 和 mouseleave 事件来高亮,但连接合法性判断必须放在数据层。我定义了一套规则:
- 不能连接自己到自己的同一锚点;
- 同一个 source 和 target 之间,如果已存在同方向连线,默认不允许重复建立;
- 某些业务场景下,不同类型的节点不允许互相连接(这个通过数据层的 validator 注册钩子实现)。
function validateConnection( sourceNode: DiagramNode, sourceHandle: string, targetNode: DiagramNode, targetHandle: string ): boolean { // 禁连自己 if (sourceNode.id === targetNode.id) return false; // 禁连已有的同向连线 // 注册的业务校验 // ... return true; }我把这些校验单独抽成 validateConnection 函数,而不是散落在各个事件回调里,原因很简单:后续测试、复用、以及做成表单时校验都会用到同一个入口。
关于锚点鼠标事件,有一个细节值得说:锚点的尺寸在视觉上很小,鼠标不容易点中。我把锚点的 SVG 热区做成了比视觉尺寸大一圈的透明圆形,这样用户稍微歪一点也能命中,交互友好度提升非常明显。
4.3 对齐吸附:让图看起来"专业"的一招
架构图或流程图如果节点随意摆放,看上去会非常业余。diagram-design 做了两层辅助:
- 网格吸附:节点拖拽时,按设定的网格大小(默认 10px)对齐坐标;
- 智能参考线:当拖动节点的边缘或中心点接近另一个节点的边缘或中心时,自动显示一条红色参考线,并在误差范围内吸附。
智能参考线的实现不算复杂,核心是实时计算"当前被拖动的节点的关键坐标集合"和"画布上其他节点的关键坐标集合",求差值,当差值小于吸附阈值时进行对齐修正。
坐标集合包括每个节点的:左边 x、水平中心 x、右边 x、顶部 y、垂直中心 y、底部 y。被拖动节点的每一条参考坐标,都和其余节点的每一条参考坐标做比较,找到最小差值的那个,如果差值小于 5px 就吸附。
注意这里只取可见范围内的节点做碰撞计算,否则节点上千后,每次拖动都要做几千次比较,会比较卡。我实测加入这一层优化后,性能开销可以忽略不计。
4.4 框选与多选:橡皮筋模式的实现
框选(也叫橡皮筋选择)是用一个矩形选择区域来批量选中节点。实现上是监听画布空白区域的 mousedown,记录起点,然后 mousemove 时绘制一个半透明蓝色矩形,最后 mouseup 时检测哪些节点的边界与选择区域相交,把它们加入选中集合。
判断相交我用的是最直接的矩形交集检测:
function rectsIntersect(r1: Rect, r2: Rect) { return !( r1.x + r1.width < r2.x || r2.x + r2.width < r1.x || r1.y + r1.height < r2.y || r2.y + r2.height < r1.y ); }框选矩形本身要用一个独立的 SVG 图层来渲染,这个图层只接收一次鼠标按下事件,绘制过程中不干扰节点。这里有一个很容易犯的错:在框选矩形上叠加了 pointer-events: none,否则框选矩形会拦截后续的鼠标事件,导致拖拽框选时无法继续和其他元素交互。
多选之后,移动节点时要保证所有被选中的节点都同步偏移。实现方式是对每个选中节点保存移动前的起点坐标,每次 mousemove 时计算相对起点的偏移量,再统一应用。
5. 工程化细节:状态管理、撤销重做与导出
5.1 状态管理:尽量保持数据层"纯净"
diagram-design 没有引入特别复杂的状态管理方案,因为图数据结构天然是统一根节点对象,适合集中管理。我用了类似"单 Store + 操作函数"的方式:所有图数据变更都通过 dispatch(action) 形式进入 Store,然后触发重新渲染。
最关键的设计约束是:数据层必须是纯数据操作,不直接操作 DOM,不持有任何渲染引用。这样能保证单测可以绕开 DOM 直接对图操作,比如"添加节点、移动节点、撤销、断言位置"这些测试跑得飞快。
class GraphStore { private nodes: DiagramNode[] = []; private edges: DiagramEdge[] = []; private viewport: Viewport = { offsetX: 0, offsetY: 0, scale: 1 }; addNode(node: DiagramNode): void; moveNode(id: string, deltaX: number, deltaY: number): void; removeNode(id: string): void; // ... }我也考虑过用发布订阅模式给视图层提供更新通知,做法是 store.subscribe(callback),每次数据变更后统一通知外部重新渲染。这套方案简单可靠,后续做协同编辑时还可以无缝把它接入 WebSocket 同步。
5.2 复制粘贴与跨画布操作
复制粘贴在图表编辑器里属于高频操作。实现时注意一个容易忽略的点:复制时要同时复制节点和它们之间的连线,而且复制出来的节点都要重新生成 id,否则和原节点会冲突。
function cloneSelection(selectedIds: Set<string>, nodes: DiagramNode[], edges: DiagramEdge[]) { const idMap = new Map<string, string>(); const newNodeList: DiagramNode[] = []; const newEdgeList: DiagramEdge[] = []; for (const node of nodes) { if (!selectedIds.has(node.id)) continue; const newId = generateId(); idMap.set(node.id, newId); newNodeList.push({ ...node, id: newId, x: node.x + 20, y: node.y + 20 }); } for (const edge of edges) { if (!selectedIds.has(edge.source) || !selectedIds.has(edge.target)) continue; newEdgeList.push({ ...edge, id: generateId(), source: idMap.get(edge.source)!, target: idMap.get(edge.target)! }); } return { nodes: newNodeList, edges: newEdgeList }; }粘贴时默认把新节点往右下角偏移 20px,这样用户能直观地看到粘贴结果。这个偏移值我测试过,太小了会叠在一起根本看不清,太大了又脱离鼠标摆放的直觉,20px 是折中经验值。
5.3 导出能力:SVG 原生导出与 PNG 兜底
因为渲染层选择的是 SVG,导出矢量图基本是白送的:直接拿 SVG DOM 序列化字符串,就能交给用户保存为 .svg 文件。
但实际环境中,用户更常要的是 PNG 图片。实现思路不复杂:把 SVG 塞进 Image,绘制到 Canvas 上,最后用 toDataURL 导出。
function exportPNG(svgElement: SVGElement, scale = 2) { const svgData = new XMLSerializer().serializeToString(svgElement); const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(svgBlob); const img = new Image(); img.onload = () => { const canvas = document.createElement('canvas'); canvas.width = svgElement.clientWidth * scale; canvas.height = svgElement.clientHeight * scale; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(url); // 触发下载 }; img.src = url; }这里有两个坑我必须提一下:第一个是 SVG 里的 CSS 样式(比如样式表控制的 fill、stroke),在转图片时可能丢失,所以导出前要把样式内联到每个元素上;第二个是字体问题,如果节点文本使用了浏览器特殊字体,导出到图片时会回退成默认字体,所以图编辑器最好只依赖少量 web 安全字体。
我后来还在导出前加了一步"缩放画布以适配所有节点",也就是自动计算所有节点的包围盒,然后设置一个合适的 viewport 范围,保证导出内容不缺失也不留白太多。这个细节用户非常买账,因为很多人导出图片后发现只导出一小部分内容,体验很差。
6. 高频问题实录与排查思路
6.1 高频问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 拖节点时鼠标"脱手",节点乱跑 | 坐标系没换算,直接用了屏幕坐标 | 检查 clientToCanvas 是否在 mousemove 中被调用,且不要修改源坐标 |
| 连线断开,节点移动后端点不跟 | edge 存了端点坐标而非锚点方向 | 改存 source/target + handle,每次渲染时重新计算端点 |
| 缩放后节点文字模糊 | SVG 在浏览器里做了位缩放,或导出的图片分辨率低 | 导出时提高 scale 参数,建议 2x 或 3x |
| 撤销回到中间态 | 拖动过程中记录过多历史 | 合并操作,只在 mouseup 时记录一条命令 |
| 框选后拖动选不中节点 | 框选矩形拦截了鼠标事件 | 给临时矩形设置 pointer-events: none |
| 锚点很难点中 | 热区太小 | 用透明的大圆作为热区绑定事件 |
| 数据导入后样式错乱 | 缺少版本迁移直接读取旧数据 | 增加 version 字段,按版本递增做迁移 |
我这个表是根据自己的踩坑记录整理的,每一行背后都有真实的排查过程,参考价值比单纯的架构描述要高得多。
6.2 SVG 事件流造成的一个隐蔽 bug
有一个 bug 极具隐蔽性,值得单独拿出来说。SVG 的事件会沿着嵌套结构向上冒泡,节点元素自身也设置了 mousedown 事件。当用户从节点内部拖动开始,mousedown 命中了节点内部的某个子元素(比如文本块),再冒泡到节点上,导致节点上的拖拽处理器被触发。
这本意是好的,但也引入了一个额外的联动问题:用户从空白处开始拖动框选时,如果起点正好落在某个节点下方,框选和节点拖拽会同时触发。我最后的处理方式是在空白处绑定 mousedown,并在内部检查 event.target 的所属层级,只有点到画布底层时才启动框选模式。
这个 20 行的判断加进去之后,用户误触的情况少了很多,交互干净了很多。
6.3 大数据量下 SVG 的优化手法
虽然 SVG 在几百节点的场景下很流畅,但一旦达到上千节点,会明显感觉到卡顿。我做过一轮专门的性能优化,效果比较明显的有三点:
- 只渲染视口范围内的节点和连线:通过视口坐标计算可见区域,渲染时过滤掉完全不可见的节点;
- 节流渲染:连续拖动时,用 requestAnimationFrame 合并渲染帧,避免同一帧里多次更新 DOM;
- 连线缓存路径字符串:同一个 edge 的路径属性只有在相关节点移动时才重新计算,否则复用缓存。
第一点需要数据层配合:渲染层遍历 nodes 数组时,通过 node 的 x/y/width/height 快速计算是否在视口内。这个过滤逻辑加在渲染层外层,数据层不动,也是我坚持分层之后得到的红利。
优化之后,即使画 2000 个节点,拖动缩放也能稳定在 40 帧以上,这在白板类工具里已经算比较可用的水平了。
6.4 一个关于"初始视口"的经验
最后讲一个容易忽略但体验影响很大的小点:新画布初始视口。我一开始默认视口偏移是 (0, 0),scale 是 1。但用户第一次打开时,如果画布上的节点都位于左上角,离可视区域边缘很近,视觉上很局促。后来我改成了"首次加载时自动计算所有节点的包围盒,居中显示",也就是通常所说的 fit to view。
function fitToView(nodes: DiagramNode[], svgWidth: number, svgHeight: number, padding = 40) { const bounds = getNodesBounds(nodes); // 计算包围盒 const scale = Math.min( (svgWidth - padding * 2) / bounds.width, (svgHeight - padding * 2) / bounds.height, 1 ); const offsetX = (svgWidth - bounds.width * scale) / 2 - bounds.x * scale; const offsetY = (svgHeight - bounds.height * scale) / 2 - bounds.y * scale; return { offsetX, offsetY, scale }; }这个功能不值得做的时候你可能觉得没必要,但每次打开都看到一片空白画布,还得手动拖过去找节点,那个体验差距是非常直观的。diagram-design 上线后,我第一轮收集用户反馈时,这个"自动居中显示已有内容"被频繁提到是最想要的体验改进之一。
模块拆到这里,整个 diagram-design 的骨架已经比较完整了。从最初的数据结构定义,到 SVG 渲染选型,再到拖拽连线的交互细节,以及最后那层状态管理、撤销重做和导出能力,每个阶段踩过的坑我基本都写到了。最后再分享一个小技巧:图编辑器这类项目最难的往往不是某个炫酷功能,而是把基础交互手感打磨好,尤其是拖拽不漂移、缩放以鼠标为中心、锚点热区足够宽容这三件事。这三件事做好了,用户给你的第一句反馈一定不是"功能好强",而是"操作好顺"——对我来说,这句话比任何设计模式都值钱。