news 2026/10/6 12:32:44

JointJS 画布自由绘制实战:矩形、椭圆、直线与自由曲线绘图工具的实现指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JointJS 画布自由绘制实战:矩形、椭圆、直线与自由曲线绘图工具的实现指南
  • 前端
  • UI组件

【免费下载链接】joint

A proven SVG-based JavaScript diagramming library powering exceptional UIs

项目地址:https://gitcode.com/gh_mirrors/jo/joint
点击查看免费下载

本指南基于开源仓库 joint(JointJS)中的 shapes-drawing-js 示例,完整讲解如何为基于 JointJS 的图形编辑器增加"自由绘制"能力:让用户直接在画布上拖拽画出矩形、椭圆、直线乃至任意自由曲线。读完本文,你将掌握该示例的安装运行方式、五种绘图工具的状态机设计、拖拽实时预览与正式落图的完整实现,并理解@joint/core中V(SVG 矢量器)、g(几何库)与Paper事件体系在这一场景中的协同用法。

示例概览:一个可运行的绘图工具演示

examples/shapes-drawing-js是 monorepo 中的一个 Vite 驱动的纯前端演示,核心文件如下:

  • examples/shapes-drawing-js/index.html:页面骨架,包含画布容器与顶部工具栏(单选按钮);
  • examples/shapes-drawing-js/src/main.js:全部绘图逻辑,约 220 行;
  • examples/shapes-drawing-js/src/styles.scss:工具栏、预览形状与光标反馈样式;
  • examples/shapes-drawing-js/package.json:依赖@joint/core(工作区版本)与vite,提供dev/build/preview三个脚本。

其核心思路是:在 JointJS 的Paper上监听blank:pointerdown/element:pointerdown等指针事件,拖拽过程中先用V()向 SVG 视口追加一个"预览形状"实时跟随鼠标,松手后把预览几何数据换算为最终的 Cell(如standard.Rectangle、standard.Ellipse、standard.Path、standard.Polyline)通过graph.addCell()加入图模型,从而完成一次"绘制"。

快速上手:安装、开发与构建

示例的 README 给出了标准的 monorepo 工作流。首先从仓库根目录安装全部依赖并构建工作区包:

yarn install yarn run build

仓库根 package.json 中workspaces将./packages/*与./examples/*一并纳入管理,packageManager指定为yarn@4.18.0;yarn run build会通过yarn workspaces foreach依次构建所有工作区(包括@joint/core与各示例),确保示例依赖的@joint/core已就绪。

随后进入示例目录启动开发服务器:

yarn dev

示例的 package.json 中"dev": "vite",启动后终端会打印本地地址(通常为http://localhost:5173)。打开页面即可在工具栏切换工具、在画布上拖拽绘制。

如需生成生产构建:

yarn build

该脚本为tsc && vite build,输出生成在dist/目录。若想本地预览生产产物,运行:

yarn preview

同样会启动一个静态服务器供体验dist/中的成品。

界面入口:工具栏与画布容器

index.html 定义了极简的界面骨架:一个占满页面的#paper-container容器,以及一组type="radio"的工具按钮:

<div id="tools"> <input type="radio" name="tools" id="Pointer"> <label for="Pointer">Pointer</label> <input type="radio" name="tools" id="Line"> <label for="Line">Line</label> <input type="radio" name="tools" id="Rectangle" checked> <label for="Rectangle">Rectangle</label> <input type="radio" name="tools" id="Ellipse"> <label for="Ellipse">Ellipse</label> <input type="radio" name="tools" id="FreeDraw"> <label for="FreeDraw">Free Draw</label> </div>

默认选中Rectangle。main.js末尾通过setTool(document.querySelector('[checked]')?.id ?? 'Pointer')读取初始选中项,并给#tools挂上change事件监听,实现运行时切换工具。工具栏的视觉样式(选中态高亮、圆角分段、焦点轮廓等)在 styles.scss 中以 SCSS 变量与 mixin 实现。

图纸与纸张的初始化

main.js 首先创建图模型与纸张:

const graph = new dia.Graph({}, { cellNamespace: shapes }); const paper = new dia.Paper({ model: graph, cellViewNamespace: shapes, width: '100%', height: '100%', async: true, sorting: dia.Paper.sorting.APPROX, background: { color: '#F3F7F6' } }); paperContainer.appendChild(paper.el);

几个值得注意的配置:

  • cellNamespace: shapes/cellViewNamespace: shapes:把@joint/core内置的standard.*形状命名空间注入图与视图,使后续graph.addCell({ type: 'standard.Rectangle' })能正确解析出对应的 Cell 与 View;
  • async: true:启用异步渲染,画布元素较多时拖拽预览与绘制仍能保持流畅;
  • sorting: dia.Paper.sorting.APPROX:使用近似排序策略渲染视图层级,属于性能优化选项;
  • background: { color: '#F3F7F6' }:设置画布底色,与后面预览形状的绿色描边形成视觉对比。

工具状态机:五种模式

绘图行为由一组枚举驱动(main.js):

const Tool = { Pointer: 0, Line: 1, Rectangle: 2, Ellipse: 3, FreeDraw: 4 }; let tool = Tool.Pointer;

Pointer模式是普通的选择/交互模式;其余四种是绘图模式。setTool()(main.js)负责在切换时同步 Paper 的交互能力:

function setTool(toolId) { tool = Tool[toolId]; paper.setInteractivity(tool === Tool.Pointer); paper.el.classList.toggle('paper-active-tools', tool !== Tool.Pointer); }

Paper.prototype.setInteractivity(见 packages/joint-core/src/dia/Paper.mjs#L4105-L4110)会把值写入this.options.interactive,并通过invoke(this._views, 'setInteractivity', value)广播给所有视图;各 CellView 的setInteractivity则仅更新自己的options.interactive(见 packages/joint-core/src/dia/CellView.mjs#L1379-L1382)。因此进入绘图模式时画布上既有元素会失去拖拽/交互能力,避免与绘制手势冲突;同时给paper.el加上paper-active-tools类,由 SCSS 统一将光标设为十字准星(crosshair),给用户明确的"正在画图"反馈。

拖拽中的实时预览:dragStart 与 drag

所有绘图工具共用同一套"按下 → 移动 → 松开"流程。dragStart(main.js)在指针按下时记录起点坐标,并用V()(@joint/core的 SVG 矢量器)直接向paper.viewport追加一个临时预览元素:

case Tool.Line: { evt.data.x1 = x; evt.data.y1 = y; evt.data.x2 = x; evt.data.y2 = y; data.vel = V('line', { x1: x, y1: y, x2: x, y2: y }); break; } case Tool.Rectangle: { data.vel = V('rect', { x, y, width: 1, height: 1 }); break; } case Tool.Ellipse: { data.vel = V('ellipse', { cx: x, cy: y, rx: 1, ry: 1 }); break; } case Tool.FreeDraw: { data.vel = V('polyline'); evt.data.points = [[x, y]]; break; }

data.vel.appendTo(paper.viewport)把预览元素放入画布 SVG 视口;addClass('preview-shape')套用预览样式。Pointer模式直接返回,不做任何预览。

drag(main.js)在指针移动时用几何库g计算起点到当前点的包围盒:

const bbox = new g.Rect(ox, oy, x - ox, y - oy); if (bbox.width === 0) bbox.width = 1; if (bbox.height === 0) bbox.height = 1; bbox.normalize();

normalize()保证向左/向上拖拽时宽度、高度为正,1的兜底值避免出现 0 尺寸的退化几何。随后按工具类型更新预览元素属性:直线更新x2/y2;矩形直接套用bbox.toJSON();椭圆换算rx/ry/cx/cy;自由绘制则把每个采样点压入points数组并序列化为polyline的points字符串。

落笔成图:dragEnd 中四种形状的正式生成

松手后dragEnd(main.js)先vel.remove()移除预览元素,再依据bbox生成正式 Cell。这里体现了 JointJS 常见的最佳实践:预览阶段只用轻量 SVG 元素,最终结果才进入图模型。

直线:旋转对齐的 standard.Path

const line = new g.Line({ x: x1, y: y1 }, { x: x2, y: y2 }); const angle = line.angle(); const { start } = line.clone().rotate(line.midpoint(), angle); graph.addCell({ type: 'standard.Path', angle, position: { x: start.x, y: start.y }, size: { width: line.length(), height: 1 }, attrs: { body: { d: 'M 0 calc(0.5 * h) H calc(w)' } } });

其技巧在于:standard.Path的size为水平走向的width: line.length(),通过g.Line计算倾斜角angle并让 Cell 整体旋转对齐;路径数据'M 0 calc(0.5 * h) H calc(w)'利用calc()表达式画一条从左上角垂直中点(0.5 * h)到右上角的水平线,旋转后即得到斜线效果。calc()是 JointJS 路径属性中基于元素宽高计算的表达式,无需手算具体坐标。

矩形与椭圆:标准形状的直接落图

case Tool.Rectangle: graph.addCell({ type: 'standard.Rectangle', position: { x, y }, size: { width, height } }); break; case Tool.Ellipse: graph.addCell({ type: 'standard.Ellipse', position: { x, y }, size: { width, height } }); break;

这里position/size直接取自bbox,而预览阶段椭圆需手动换算cx/cy/rx/ry——正式 Cell 则交给standard.Ellipse以 position + size 语义托管,体现了预览(纯 SVG 几何)与模型(Cell 语义)两种表达方式的差异。

自由绘制:折线的简化与序列化

const geometry = new g.Polyline(points.join(' ')); geometry.simplify({ threshold: 0.8 }); const geometryBBox = geometry.bbox(); graph.addCell({ type: 'standard.Polyline', position: { x: geometryBBox.x, y: geometryBBox.y }, size: { width: geometryBBox.width, height: geometryBBox.height }, attrs: { body: { refPoints: geometry.serialize() } } });

鼠标轨迹的原始采样点可能成百上千,直接入图会带来多余的顶点。这里用g.Polyline.simplify({ threshold: 0.8 })做抽稀:threshold为容差阈值,数值越大顶点越少、曲线越平滑。simplify()在@joint/core内部同样被用于路径计算——例如 packages/joint-core/src/dia/LinkView.mjs#L538 中连线求交后对折线做simplify({ threshold: SIMPLIFY_THRESHOLD })化简,packages/joint-core/src/routers/rightAngle.mjs#L40 的直角路由也在收尾时以threshold: 1简化路径点,可见这是库内通用的几何精简手段。

落图时以简化后几何的包围盒作为 Cell 的position/size,再用refPoints引用坐标系内的相对点集,geometry.serialize()把g.Polyline序列化为"x1,y1 x2,y2 ..."的字符串。这样无论图形画在画布哪个位置,其路径数据都相对自身包围盒定义,便于后续移动、缩放与编辑。

事件绑定与指针语义

预览与落图的完整闭环由 main.js 的四组 Paper 事件驱动:

paper.on('blank:pointerdown', (evt, x, y) => dragStart(evt, x, y)); paper.on('element:pointerdown', (_, evt, x, y) => dragStart(evt, x, y)); paper.on('blank:pointermove', (evt, x, y) => drag(evt, x, y)); paper.on('element:pointermove', (_, evt, x, y) => drag(evt, x, y)); paper.on('blank:pointerup', (evt) => dragEnd(evt)); paper.on('element:pointerup', (_, evt) => dragEnd(evt));

注意blank:*与element:*两套事件都绑定了相同的处理函数:前者处理空白处起笔,后者让用户在已有元素之上也能继续绘制新形状(回调首参被忽略)。Paper的指针事件统一携带(evt, x, y)形式的本地坐标,处理函数直接消费即可,无需自行做坐标系换算——这是把绘制逻辑做在Paper事件层而非原生 DOM 事件上的关键收益。拖拽的中间状态存放在evt.data这个共享对象上,dragStart写入、drag更新、dragEnd读取,结构清晰且天然与单个手势生命周期绑定。

预览样式与光标反馈

styles.scss 为绘制体验提供了视觉反馈:

.preview-shape { stroke: $green2; // #08b081 fill: none; stroke-width: 2; } .paper-active-tools * { cursor: crosshair; }

预览形状使用绿色描边、无填充、2px 线宽,与浅色画布背景(#F3F7F6)形成对比;进入任何绘图工具时整张画布显示十字准星光标。二者配合setTool中的paper-active-tools类切换,用户能随时感知当前处于绘制模式。工具栏按钮选中态($green2背景 + 绿色光晕0 0 10px rgba($green1, 0.5))与预览描边同色系,保持了视觉语言的一致。

扩展思路

在理解本示例的实现骨架后,可以自然地扩展出更多能力:

  • 新增工具:在Tool枚举中追加类型,并在dragStart/drag/dragEnd三个 switch 中补齐对应分支,例如多边形(V('polygon'))、圆角矩形(V('rect', { rx, ry }))、自由贝塞尔曲线等;
  • 撤销/重做:graph.addCell()是模型层的变更,可结合@joint/core的graph事件(如add/remove/change)自行记录操作栈,实现绘图历史;
  • 约束与吸附:drag中可在更新bbox前对坐标取整或吸附到网格,配合g.Rect的几何运算即可;
  • 辅助键与修饰:可读取evt上的修饰键(如 Shift)强制正方形/正圆,只需在生成bbox后做宽高等比化。

小结

shapes-drawing-js演示了在 JointJS 中实现"用户自由绘图"的完整范式:Paper指针事件提供统一坐标、V()提供轻量 SVG 预览、g几何库负责包围盒与折线化简、standard.*形状负责正式落图,配合setInteractivity在绘图模式与选择模式间切换。这套代码量不大但信息密度很高的示例,是任何想在 JointJS 之上构建"可画图"编辑器(如拓扑图、示意图、批注工具)的开发者的理想起点。

  • 前端
  • UI组件

【免费下载链接】joint

A proven SVG-based JavaScript diagramming library powering exceptional UIs

项目地址:https://gitcode.com/gh_mirrors/jo/joint
点击查看免费下载

相关推荐

上一篇:Gatsby GraphQL 数据层实战:以 graphql-reference 示例中的 "Break with a Banshee" 为样本,读懂 Markdown 内容如何变成可查询节点
下一篇:RxJS `includes` 操作符完全指南:在 Observable 流中检索元素与 fromIndex 起始索引解析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 12:30:23

TIM定时中断(第一节)

目录 TIM简介定时器类型 一句话理解三档对比&#xff08;从强到弱&#xff09;几个名词翻译一下跟我们手上那块芯片的关系实际写代码时怎么选例 2&#xff1a;PWM 输出 —— 做 LED 呼吸灯例 3&#xff1a;输入捕获 —— 读红外遥控器的按键值三个例子串起来看*底层逻辑&#…

作者头像 李华
网站建设 2026/10/6 12:23:01

鸣潮后台自动战斗完全指南:ok-ww 一键日常与 4C 声骸刷取配置

鸣潮后台自动战斗完全指南&#xff1a;ok-ww 一键日常与 4C 声骸刷取配置 【免费下载链接】ok-wuthering-waves 鸣潮 后台自动战斗 自动刷声骸 一键日常 Automation for Wuthering Waves 项目地址: https://gitcode.com/GitHub_Trending/ok/ok-wuthering-waves 深夜想上…

作者头像 李华
网站建设 2026/10/6 12:07:00

CLIP改进工作串讲(上)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华