当前位置:   article > 正文

fabric.js 实现元素拖拽、引入图片、标注交互_fabricjs

fabricjs

by:垃圾程序员

零、前言

特别鸣谢:拿只键盘出来绣花的德育处主任,他的系列文章给了我很大的帮助。该说不说,站在前人的肩膀上就是得劲。

德育处主任 - 知乎拿只键盘出来绣花 回答数 7,获得 143 次赞同icon-default.png?t=N7T8https://www.zhihu.com/people/rabbit-svip

fabric.js 是一个用于创建可交互式的 HTML5 canvas 应用程序的开源 JavaScript 库,它提供了一套简单、易用的 API,可以快速地实现各种图形操作和动画效果。使用 fabric.js,你可以轻松地创建文本、图像、形状、路径等多种元素,并对它们进行缩放、旋转、位移、剪切、合并等操作。

fabric.js 具有以下特点:

  1. 简单易用:fabric.js 的 API 非常简单、易于理解和使用,即使是初学者也能够快速上手。

  2. 功能强大:fabric.js 提供了各种基本图形元素的创建和操作方法,同时还支持高级功能,如复合对象、滤镜、选区、事件处理、动画等。

  3. 跨浏览器兼容:fabric.js 支持多种主流浏览器,包括 Chrome、Firefox、Safari、Edge 和 IE 等。

  4. 开源免费:fabric.js 是完全开源的,你可以自由地使用、修改和分发它。

最终实现效果:

一、引入 fabric.js 库

1.1npm 安装

npm install fabric --save

1.2全局引入

  1. import { fabric } from 'fabric'
  2. Vue.use(fabric);

二、页面布局

2.1创建左右结构的布局

  1. <template>
  2. <div class="mainDiv rowflex">
  3. <!-- 左侧元素区 -->
  4. <div class="leftFiv columnflex" style="justify-content: start;">
  5. <!-- 循环所有图标 -->
  6. <div class="element columnflex" v-for="(item, index) in elementList" :key="index">
  7. <img class="element_item" :src="item.address" @dragstart="handleDragStart(index)" />
  8. </div>
  9. </div>
  10. <!-- 画布区 -->
  11. <div id="rightDiv" class="rightDiv">
  12. <canvas id="fabric"></canvas>
  13. </div>
  14. <!-- 右键菜单 -->
  15. <div id="menu" class="menu-x" v-show="menuDisplay">
  16. <div class="menu-li" @click="bindingIdentifier()">绑定唯一标识</div>
  17. <div class="menu-li" @click="deleteElement()">删除</div>
  18. </div>
  19. </div>
  20. </template>

2.2画布初始化并加载底图

  1. //初始化画布
  2. initCanvas() {
  3. // 获取容器元素,得到父容器的宽和高
  4. const container = document.getElementById('rightDiv');
  5. // 创建一个与容器宽高一致的 canvas 对象
  6. this.canvas = new fabric.Canvas("fabric", {
  7. width: container.clientWidth,
  8. height: container.clientHeight,
  9. fireRightClick: true, // 启用右键,button的数字为3
  10. stopContextMenu: true, // 禁止默认右键菜单
  11. })
  12. // 设置画布的背景图片
  13. this.setBackground(container.clientWidth, container.clientHeight)
  14. //监听元素是否被下放到画布上
  15. this.elementPlacement()
  16. //监听画布拖拽,包含三个监听事件
  17. this.canvasDragAndDrop()
  18. //监听画布缩放
  19. this.canvasZoom()
  20. },
  21. //设置画布的背景图片
  22. setBackground(clientWidth, clientHeight) {
  23. fabric.Image.fromURL(this.canvasBackgroundImage, img => {
  24. // 缩放背景图片以适应画布
  25. // 如果背景图片宽度或高度大于画布宽度或高度
  26. if (img.width > clientWidth || img.height > clientHeight) {
  27. // 计算缩放比例
  28. let scaleX = clientWidth / img.width;
  29. let scaleY = clientHeight / img.height;
  30. let scale = Math.min(scaleX, scaleY);
  31. // 缩放背景图片
  32. img.scaleToWidth(img.width * scale);
  33. img.scaleToHeight(img.height * scale);
  34. } else { // 如果背景图片宽度和高度都小于或等于画布宽度和高度
  35. // 计算背景图片在画布中的位置
  36. img.left = (clientWidth - img.width) / 2;
  37. img.top = (clientHeight - img.height) / 2;
  38. }
  39. this.canvas.setBackgroundImage(img, this.canvas.renderAll.bind(this.canvas));
  40. });
  41. },

效果如下:

三、实现画布缩放和移动

3.1画布缩放

  1. //监听画布缩放
  2. canvasZoom() {
  3. this.canvas.on('mouse:wheel', opt => {
  4. const delta = opt.e.deltaY // 滚轮,向上滚一下是 -100,向下滚一下是 100
  5. let zoom = this.canvas.getZoom() // 获取画布当前缩放值
  6. zoom *= 0.999 ** delta
  7. if (zoom > 20) zoom = 20 // 限制最大缩放级别
  8. if (zoom < 0.01) zoom = 0.01 // 限制最小缩放级别
  9. // 以鼠标所在位置为原点缩放
  10. this.canvas.zoomToPoint({ // 关键点
  11. x: opt.e.offsetX,
  12. y: opt.e.offsetY
  13. },
  14. zoom // 传入修改后的缩放级别
  15. )
  16. })
  17. },

3.2画布移动

  1. // 监听画布拖拽,同时也监听了右键菜单
  2. canvasDragAndDrop() {
  3. // 按下鼠标事件
  4. this.canvas.on("mouse:down", opt => {
  5. var evt = opt.e
  6. // 判断:右键,且在元素上右键
  7. // opt.button: 1-左键;2-中键;3-右键
  8. // 在画布上点击:opt.target 为 null
  9. if (opt.button === 3 && opt.target) {
  10. this.lastMenu = opt.target
  11. let menu = document.getElementById('menu');
  12. // 禁止在菜单上的默认右键事件
  13. menu.oncontextmenu = function(e) {
  14. e.preventDefault()
  15. }
  16. // 显示菜单,设置右键菜单位置
  17. // 获取菜单组件的宽高
  18. //这个地方是我自己设置的宽和高计算的,如果你之后复制过去需要修改一下
  19. const menuWidth = 120
  20. const menuHeight = menu.childNodes.length * 40
  21. // 当前鼠标位置
  22. let pointX = opt.pointer.x
  23. let pointY = opt.pointer.y
  24. // 计算菜单出现的位置
  25. // 如果鼠标靠近画布底部,菜单就出现在鼠标指针上方
  26. if (this.canvas.height - pointY <= menuHeight) {
  27. pointY -= menuHeight
  28. }
  29. menu.style = `
  30. visibility: visible;
  31. left: ${pointX}px;
  32. top: ${pointY}px;
  33. z-index: 100;
  34. `
  35. // 将菜单展示
  36. this.menuDisplay = true
  37. } else {
  38. // 将菜单隐藏
  39. this.menuDisplay = false
  40. }
  41. //拖拽
  42. if (evt.shiftKey === true) {
  43. this.isDragging = true
  44. this.canvas.selection = false;
  45. }
  46. });
  47. // 移动鼠标事件
  48. this.canvas.on("mouse:move", opt => {
  49. if (this.isDragging && opt && opt.e) {
  50. var delta = new fabric.Point(opt.e.movementX, opt.e.movementY);
  51. this.canvas.relativePan(delta);
  52. }
  53. });
  54. // 松开鼠标事件
  55. this.canvas.on("mouse:up", opt => {
  56. this.isDragging = false;
  57. this.canvas.selection = true;
  58. });
  59. },

四、实现拖拽元素到画布

4.1监听拖到画布上

  1. //监听元素是否被下放到画布上
  2. elementPlacement() {
  3. this.canvas.on('drop', elt => {
  4. // 画布元素距离浏览器左侧和顶部的距离
  5. let offset = {
  6. left: this.canvas.getSelectionElement().getBoundingClientRect().left,
  7. top: this.canvas.getSelectionElement().getBoundingClientRect().top
  8. }
  9. // 鼠标坐标转换成画布的坐标(未经过缩放和平移的坐标)
  10. let point = {
  11. x: elt.e.x - offset.left,
  12. y: elt.e.y - offset.top,
  13. }
  14. // 转换后的坐标,restorePointerVpt 不受视窗变换的影响
  15. let pointerVpt = this.canvas.restorePointerVpt(point)
  16. //创建元素
  17. this.createElement(this.imageAddress, pointerVpt)
  18. });
  19. },

4.2生成元素

  1. //在画布上生成拖拽过来的元素
  2. createElement(imageAddress, pointerVpt) {
  3. fabric.Image.fromURL(imageAddress, oImg => {
  4. //这个地方做了一下偏移,让鼠标位置为图标的中心,真实的位置信息要还原回去
  5. oImg.top = pointerVpt.y - 24
  6. oImg.left = pointerVpt.x - 24
  7. this.canvas.add(oImg)
  8. })
  9. },

五、实现元素右键交互

5.2删除画布元素

  1. //删除元素
  2. deleteElement() {
  3. this.canvas.remove(this.lastMenu)
  4. // 将菜单隐藏
  5. this.menuDisplay = false
  6. },

5.3弹窗输入编码

  1. //设定唯一标识
  2. bindingIdentifier() {
  3. this.$prompt('请输入唯一编码', '编辑', {
  4. confirmButtonText: '确定',
  5. cancelButtonText: '取消'
  6. }).then(({
  7. value
  8. }) => {
  9. this.$message({
  10. type: 'success',
  11. message: '你的唯一编码是: ' + value
  12. });
  13. }).catch(() => {
  14. this.$message({
  15. type: 'info',
  16. message: '取消输入'
  17. });
  18. });
  19. // 将菜单隐藏
  20. this.menuDisplay = false
  21. }

完整代码:

  1. <template>
  2. <div class="mainDiv rowflex">
  3. <!-- 左侧元素区 -->
  4. <div class="leftFiv columnflex" style="justify-content: start;">
  5. <!-- 循环所有图标 -->
  6. <div class="element columnflex" v-for="(item, index) in elementList" :key="index">
  7. <img class="element_item" :src="item.address" @dragstart="handleDragStart(index)" />
  8. </div>
  9. </div>
  10. <!-- 画布区 -->
  11. <div id="rightDiv" class="rightDiv">
  12. <canvas id="fabric"></canvas>
  13. </div>
  14. <!-- 右键菜单 -->
  15. <div id="menu" class="menu-x" v-show="menuDisplay">
  16. <div class="menu-li" @click="bindingIdentifier()">绑定唯一标识</div>
  17. <div class="menu-li" @click="deleteElement()">删除</div>
  18. </div>
  19. </div>
  20. </template>
  21. <script>
  22. export default {
  23. data() {
  24. return {
  25. // 图标列表
  26. elementList: [{
  27. key: 'ancientTrees',
  28. address: require('@/assets/icons/fabric/ancientTrees.png')
  29. },
  30. {
  31. key: 'camera',
  32. address: require('@/assets/icons/fabric/camera.png')
  33. },
  34. {
  35. key: 'factory',
  36. address: require('@/assets/icons/fabric/factory.png')
  37. },
  38. {
  39. key: 'fireFighting',
  40. address: require('@/assets/icons/fabric/fireFighting.png')
  41. },
  42. {
  43. key: 'house',
  44. address: require('@/assets/icons/fabric/house.png')
  45. },
  46. {
  47. key: 'hydrology',
  48. address: require('@/assets/icons/fabric/hydrology.png')
  49. },
  50. ],
  51. // 画布背景图片
  52. canvasBackgroundImage: require('@/assets/icons/fabric/canvasImg.jpg'),
  53. // 画布
  54. canvas: null,
  55. //最后一次拖动的图片
  56. imageAddress: '',
  57. //是否拖动中
  58. isDragging: false,
  59. //最后的x轴方向的位置
  60. lastPosX: 0,
  61. //最后的y轴方向的位置
  62. lastPosY: 0,
  63. //菜单是否显示
  64. menuDisplay: false,
  65. //最后一次右键选中的元素
  66. lastMenu: null
  67. }
  68. },
  69. mounted() {
  70. this.initCanvas();
  71. },
  72. methods: {
  73. //初始化画布
  74. initCanvas() {
  75. // 获取容器元素,得到父容器的宽和高
  76. const container = document.getElementById('rightDiv');
  77. // 创建一个与容器宽高一致的 canvas 对象
  78. this.canvas = new fabric.Canvas("fabric", {
  79. width: container.clientWidth,
  80. height: container.clientHeight,
  81. fireRightClick: true, // 启用右键,button的数字为3
  82. stopContextMenu: true, // 禁止默认右键菜单
  83. })
  84. // 设置画布的背景图片
  85. this.setBackground(container.clientWidth, container.clientHeight)
  86. //监听元素是否被下放到画布上
  87. this.elementPlacement()
  88. //监听画布拖拽,包含三个监听事件
  89. this.canvasDragAndDrop()
  90. //监听画布缩放
  91. this.canvasZoom()
  92. },
  93. //设置画布的背景图片
  94. setBackground(clientWidth, clientHeight) {
  95. fabric.Image.fromURL(this.canvasBackgroundImage, img => {
  96. // 缩放背景图片以适应画布
  97. // 如果背景图片宽度或高度大于画布宽度或高度
  98. if (img.width > clientWidth || img.height > clientHeight) {
  99. // 计算缩放比例
  100. let scaleX = clientWidth / img.width;
  101. let scaleY = clientHeight / img.height;
  102. let scale = Math.min(scaleX, scaleY);
  103. // 缩放背景图片
  104. img.scaleToWidth(img.width * scale);
  105. img.scaleToHeight(img.height * scale);
  106. } else { // 如果背景图片宽度和高度都小于或等于画布宽度和高度
  107. // 计算背景图片在画布中的位置
  108. img.left = (clientWidth - img.width) / 2;
  109. img.top = (clientHeight - img.height) / 2;
  110. }
  111. this.canvas.setBackgroundImage(img, this.canvas.renderAll.bind(this.canvas));
  112. });
  113. },
  114. //监听被拖动元素的图片
  115. handleDragStart(event) {
  116. this.imageAddress = this.elementList[event].address
  117. },
  118. //监听元素是否被下放到画布上
  119. elementPlacement() {
  120. this.canvas.on('drop', elt => {
  121. // 画布元素距离浏览器左侧和顶部的距离
  122. let offset = {
  123. left: this.canvas.getSelectionElement().getBoundingClientRect().left,
  124. top: this.canvas.getSelectionElement().getBoundingClientRect().top
  125. }
  126. // 鼠标坐标转换成画布的坐标(未经过缩放和平移的坐标)
  127. let point = {
  128. x: elt.e.x - offset.left,
  129. y: elt.e.y - offset.top,
  130. }
  131. // 转换后的坐标,restorePointerVpt 不受视窗变换的影响
  132. let pointerVpt = this.canvas.restorePointerVpt(point)
  133. //创建元素
  134. this.createElement(this.imageAddress, pointerVpt)
  135. });
  136. },
  137. //在画布上生成拖拽过来的元素
  138. createElement(imageAddress, pointerVpt) {
  139. fabric.Image.fromURL(imageAddress, oImg => {
  140. //这个地方做了一下偏移,让鼠标位置为图标的中心,真实的位置信息要还原回去
  141. oImg.top = pointerVpt.y - 24
  142. oImg.left = pointerVpt.x - 24
  143. this.canvas.add(oImg)
  144. })
  145. },
  146. //监听画布缩放
  147. canvasZoom() {
  148. this.canvas.on('mouse:wheel', opt => {
  149. const delta = opt.e.deltaY // 滚轮,向上滚一下是 -100,向下滚一下是 100
  150. let zoom = this.canvas.getZoom() // 获取画布当前缩放值
  151. zoom *= 0.999 ** delta
  152. if (zoom > 20) zoom = 20 // 限制最大缩放级别
  153. if (zoom < 0.01) zoom = 0.01 // 限制最小缩放级别
  154. // 以鼠标所在位置为原点缩放
  155. this.canvas.zoomToPoint({ // 关键点
  156. x: opt.e.offsetX,
  157. y: opt.e.offsetY
  158. },
  159. zoom // 传入修改后的缩放级别
  160. )
  161. })
  162. },
  163. // 监听画布拖拽,同时也监听了右键菜单
  164. canvasDragAndDrop() {
  165. // 按下鼠标事件
  166. this.canvas.on("mouse:down", opt => {
  167. var evt = opt.e
  168. // 判断:右键,且在元素上右键
  169. // opt.button: 1-左键;2-中键;3-右键
  170. // 在画布上点击:opt.target 为 null
  171. if (opt.button === 3 && opt.target) {
  172. this.lastMenu = opt.target
  173. let menu = document.getElementById('menu');
  174. // 禁止在菜单上的默认右键事件
  175. menu.oncontextmenu = function(e) {
  176. e.preventDefault()
  177. }
  178. // 显示菜单,设置右键菜单位置
  179. // 获取菜单组件的宽高
  180. //这个地方是我自己设置的宽和高计算的,如果你之后复制过去需要修改一下
  181. const menuWidth = 120
  182. const menuHeight = menu.childNodes.length * 40
  183. // 当前鼠标位置
  184. let pointX = opt.pointer.x
  185. let pointY = opt.pointer.y
  186. // 计算菜单出现的位置
  187. // 如果鼠标靠近画布底部,菜单就出现在鼠标指针上方
  188. if (this.canvas.height - pointY <= menuHeight) {
  189. pointY -= menuHeight
  190. }
  191. menu.style = `
  192. visibility: visible;
  193. left: ${pointX}px;
  194. top: ${pointY}px;
  195. z-index: 100;
  196. `
  197. // 将菜单展示
  198. this.menuDisplay = true
  199. } else {
  200. // 将菜单隐藏
  201. this.menuDisplay = false
  202. }
  203. //拖拽
  204. if (evt.shiftKey === true) {
  205. this.isDragging = true
  206. this.canvas.selection = false;
  207. }
  208. });
  209. // 移动鼠标事件
  210. this.canvas.on("mouse:move", opt => {
  211. if (this.isDragging && opt && opt.e) {
  212. var delta = new fabric.Point(opt.e.movementX, opt.e.movementY);
  213. this.canvas.relativePan(delta);
  214. }
  215. });
  216. // 松开鼠标事件
  217. this.canvas.on("mouse:up", opt => {
  218. this.isDragging = false;
  219. this.canvas.selection = true;
  220. });
  221. },
  222. //删除元素
  223. deleteElement() {
  224. this.canvas.remove(this.lastMenu)
  225. // 将菜单隐藏
  226. this.menuDisplay = false
  227. },
  228. //设定唯一标识
  229. bindingIdentifier() {
  230. this.$prompt('请输入唯一编码', '编辑', {
  231. confirmButtonText: '确定',
  232. cancelButtonText: '取消'
  233. }).then(({
  234. value
  235. }) => {
  236. this.$message({
  237. type: 'success',
  238. message: '你的唯一编码是: ' + value
  239. });
  240. }).catch(() => {
  241. this.$message({
  242. type: 'info',
  243. message: '取消输入'
  244. });
  245. });
  246. // 将菜单隐藏
  247. this.menuDisplay = false
  248. }
  249. }
  250. }
  251. </script>
  252. <style lang="scss">
  253. // flex横向布局
  254. .rowflex {
  255. display: flex;
  256. flex-direction: row;
  257. align-items: center;
  258. justify-content: center;
  259. }
  260. // flex纵向布局
  261. .columnflex {
  262. display: flex;
  263. flex-direction: column;
  264. align-items: center;
  265. justify-content: center;
  266. }
  267. // 主div
  268. .mainDiv {
  269. height: calc(100vh - 50px) !important;
  270. width: 100%;
  271. min-width: 1000px;
  272. }
  273. /* 左侧元素区 */
  274. .leftFiv {
  275. height: 100%;
  276. width: 6%;
  277. min-width: 60px;
  278. border-right: solid 1px #eee;
  279. // 元素块
  280. .element {
  281. height: 6.5%;
  282. width: 60%;
  283. min-height: 48px;
  284. min-width: 48px;
  285. margin-top: 20px;
  286. .element_item {
  287. height: 90%;
  288. width: 90%;
  289. }
  290. }
  291. }
  292. /* 右侧画布区 */
  293. .rightDiv {
  294. height: 100%;
  295. width: 94%;
  296. min-width: 940px;
  297. }
  298. .menu-x {
  299. z-index: -100;
  300. position: absolute;
  301. top: 0;
  302. left: 0;
  303. box-sizing: border-box;
  304. border-radius: 4px;
  305. box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
  306. background-color: #fff;
  307. }
  308. /* 菜单每个选项 */
  309. .menu-li {
  310. box-sizing: border-box;
  311. padding: 4px 8px;
  312. border-bottom: 1px solid #ccc;
  313. cursor: pointer;
  314. line-height: 30px;
  315. height: 40px;
  316. width: 120px;
  317. }
  318. /* 鼠标经过的选项,更改背景色 */
  319. .menu-li:hover {
  320. background-color: antiquewhite;
  321. }
  322. /* 第一个选项,顶部两角是圆角 */
  323. .menu-li:first-child {
  324. border-top-left-radius: 4px;
  325. border-top-right-radius: 4px;
  326. }
  327. /* 最后一个选项,底部两角是圆角,底部不需要边框 */
  328. .menu-li:last-child {
  329. border-bottom: none;
  330. border-bottom-left-radius: 4px;
  331. border-bottom-right-radius: 4px;
  332. }
  333. </style>

一户炊烟煮黄昏

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/繁依Fanyi0/article/detail/566684
推荐阅读
相关标签
  

闽ICP备14008679号