by:垃圾程序员
零、前言
特别鸣谢:拿只键盘出来绣花的德育处主任,他的系列文章给了我很大的帮助。该说不说,站在前人的肩膀上就是得劲。
德育处主任 - 知乎拿只键盘出来绣花 回答数 7,获得 143 次赞同https://www.zhihu.com/people/rabbit-svip
fabric.js 是一个用于创建可交互式的 HTML5 canvas 应用程序的开源 JavaScript 库,它提供了一套简单、易用的 API,可以快速地实现各种图形操作和动画效果。使用 fabric.js,你可以轻松地创建文本、图像、形状、路径等多种元素,并对它们进行缩放、旋转、位移、剪切、合并等操作。
fabric.js 具有以下特点:
-
简单易用:fabric.js 的 API 非常简单、易于理解和使用,即使是初学者也能够快速上手。
-
功能强大:fabric.js 提供了各种基本图形元素的创建和操作方法,同时还支持高级功能,如复合对象、滤镜、选区、事件处理、动画等。
-
跨浏览器兼容:fabric.js 支持多种主流浏览器,包括 Chrome、Firefox、Safari、Edge 和 IE 等。
-
开源免费:fabric.js 是完全开源的,你可以自由地使用、修改和分发它。
最终实现效果:
一、引入 fabric.js 库
1.1npm 安装
npm install fabric --save
复制
1.2全局引入
import { fabric } from 'fabric' Vue.use(fabric);
复制
二、页面布局
2.1创建左右结构的布局
<template> <div class="mainDiv rowflex"> <!-- 左侧元素区 --> <div class="leftFiv columnflex" style="justify-content: start;"> <!-- 循环所有图标 --> <div class="element columnflex" v-for="(item, index) in elementList" :key="index"> <img class="element_item" :src="item.address" @dragstart="handleDragStart(index)" /> </div> </div> <!-- 画布区 --> <div id="rightDiv" class="rightDiv"> <canvas id="fabric"></canvas> </div> <!-- 右键菜单 --> <div id="menu" class="menu-x" v-show="menuDisplay"> <div class="menu-li" @click="bindingIdentifier()">绑定唯一标识</div> <div class="menu-li" @click="deleteElement()">删除</div> </div> </div> </template>
复制
2.2画布初始化并加载底图
//初始化画布 initCanvas() { // 获取容器元素,得到父容器的宽和高 const container = document.getElementById('rightDiv'); // 创建一个与容器宽高一致的 canvas 对象 this.canvas = new fabric.Canvas("fabric", { width: container.clientWidth, height: container.clientHeight, fireRightClick: true, // 启用右键,button的数字为3 stopContextMenu: true, // 禁止默认右键菜单 }) // 设置画布的背景图片 this.setBackground(container.clientWidth, container.clientHeight) //监听元素是否被下放到画布上 this.elementPlacement() //监听画布拖拽,包含三个监听事件 this.canvasDragAndDrop() //监听画布缩放 this.canvasZoom() }, //设置画布的背景图片 setBackground(clientWidth, clientHeight) { fabric.Image.fromURL(this.canvasBackgroundImage, img => { // 缩放背景图片以适应画布 // 如果背景图片宽度或高度大于画布宽度或高度 if (img.width > clientWidth || img.height > clientHeight) { // 计算缩放比例 let scaleX = clientWidth / img.width; let scaleY = clientHeight / img.height; let scale = Math.min(scaleX, scaleY); // 缩放背景图片 img.scaleToWidth(img.width * scale); img.scaleToHeight(img.height * scale); } else { // 如果背景图片宽度和高度都小于或等于画布宽度和高度 // 计算背景图片在画布中的位置 img.left = (clientWidth - img.width) / 2; img.top = (clientHeight - img.height) / 2; } this.canvas.setBackgroundImage(img, this.canvas.renderAll.bind(this.canvas)); }); },
复制
效果如下:
三、实现画布缩放和移动
3.1画布缩放
//监听画布缩放 canvasZoom() { this.canvas.on('mouse:wheel', opt => { const delta = opt.e.deltaY // 滚轮,向上滚一下是 -100,向下滚一下是 100 let zoom = this.canvas.getZoom() // 获取画布当前缩放值 zoom *= 0.999 ** delta if (zoom > 20) zoom = 20 // 限制最大缩放级别 if (zoom < 0.01) zoom = 0.01 // 限制最小缩放级别 // 以鼠标所在位置为原点缩放 this.canvas.zoomToPoint({ // 关键点 x: opt.e.offsetX, y: opt.e.offsetY }, zoom // 传入修改后的缩放级别 ) }) },
复制
3.2画布移动
// 监听画布拖拽,同时也监听了右键菜单 canvasDragAndDrop() { // 按下鼠标事件 this.canvas.on("mouse:down", opt => { var evt = opt.e // 判断:右键,且在元素上右键 // opt.button: 1-左键;2-中键;3-右键 // 在画布上点击:opt.target 为 null if (opt.button === 3 && opt.target) { this.lastMenu = opt.target let menu = document.getElementById('menu'); // 禁止在菜单上的默认右键事件 menu.oncontextmenu = function(e) { e.preventDefault() } // 显示菜单,设置右键菜单位置 // 获取菜单组件的宽高 //这个地方是我自己设置的宽和高计算的,如果你之后复制过去需要修改一下 const menuWidth = 120 const menuHeight = menu.childNodes.length * 40 // 当前鼠标位置 let pointX = opt.pointer.x let pointY = opt.pointer.y // 计算菜单出现的位置 // 如果鼠标靠近画布底部,菜单就出现在鼠标指针上方 if (this.canvas.height - pointY <= menuHeight) { pointY -= menuHeight } menu.style = ` visibility: visible; left: ${pointX}px; top: ${pointY}px; z-index: 100; ` // 将菜单展示 this.menuDisplay = true } else { // 将菜单隐藏 this.menuDisplay = false } //拖拽 if (evt.shiftKey === true) { this.isDragging = true this.canvas.selection = false; } }); // 移动鼠标事件 this.canvas.on("mouse:move", opt => { if (this.isDragging && opt && opt.e) { var delta = new fabric.Point(opt.e.movementX, opt.e.movementY); this.canvas.relativePan(delta); } }); // 松开鼠标事件 this.canvas.on("mouse:up", opt => { this.isDragging = false; this.canvas.selection = true; }); },
复制
四、实现拖拽元素到画布
4.1监听拖到画布上
//监听元素是否被下放到画布上 elementPlacement() { this.canvas.on('drop', elt => { // 画布元素距离浏览器左侧和顶部的距离 let offset = { left: this.canvas.getSelectionElement().getBoundingClientRect().left, top: this.canvas.getSelectionElement().getBoundingClientRect().top } // 鼠标坐标转换成画布的坐标(未经过缩放和平移的坐标) let point = { x: elt.e.x - offset.left, y: elt.e.y - offset.top, } // 转换后的坐标,restorePointerVpt 不受视窗变换的影响 let pointerVpt = this.canvas.restorePointerVpt(point) //创建元素 this.createElement(this.imageAddress, pointerVpt) }); },
复制
4.2生成元素
//在画布上生成拖拽过来的元素 createElement(imageAddress, pointerVpt) { fabric.Image.fromURL(imageAddress, oImg => { //这个地方做了一下偏移,让鼠标位置为图标的中心,真实的位置信息要还原回去 oImg.top = pointerVpt.y - 24 oImg.left = pointerVpt.x - 24 this.canvas.add(oImg) }) },
复制
五、实现元素右键交互
5.2删除画布元素
//删除元素 deleteElement() { this.canvas.remove(this.lastMenu) // 将菜单隐藏 this.menuDisplay = false },
复制
5.3弹窗输入编码
//设定唯一标识 bindingIdentifier() { this.$prompt('请输入唯一编码', '编辑', { confirmButtonText: '确定', cancelButtonText: '取消' }).then(({ value }) => { this.$message({ type: 'success', message: '你的唯一编码是: ' + value }); }).catch(() => { this.$message({ type: 'info', message: '取消输入' }); }); // 将菜单隐藏 this.menuDisplay = false }
复制
完整代码:
<template> <div class="mainDiv rowflex"> <!-- 左侧元素区 --> <div class="leftFiv columnflex" style="justify-content: start;"> <!-- 循环所有图标 --> <div class="element columnflex" v-for="(item, index) in elementList" :key="index"> <img class="element_item" :src="item.address" @dragstart="handleDragStart(index)" /> </div> </div> <!-- 画布区 --> <div id="rightDiv" class="rightDiv"> <canvas id="fabric"></canvas> </div> <!-- 右键菜单 --> <div id="menu" class="menu-x" v-show="menuDisplay"> <div class="menu-li" @click="bindingIdentifier()">绑定唯一标识</div> <div class="menu-li" @click="deleteElement()">删除</div> </div> </div> </template> <script> export default { data() { return { // 图标列表 elementList: [{ key: 'ancientTrees', address: require('@/assets/icons/fabric/ancientTrees.png') }, { key: 'camera', address: require('@/assets/icons/fabric/camera.png') }, { key: 'factory', address: require('@/assets/icons/fabric/factory.png') }, { key: 'fireFighting', address: require('@/assets/icons/fabric/fireFighting.png') }, { key: 'house', address: require('@/assets/icons/fabric/house.png') }, { key: 'hydrology', address: require('@/assets/icons/fabric/hydrology.png') }, ], // 画布背景图片 canvasBackgroundImage: require('@/assets/icons/fabric/canvasImg.jpg'), // 画布 canvas: null, //最后一次拖动的图片 imageAddress: '', //是否拖动中 isDragging: false, //最后的x轴方向的位置 lastPosX: 0, //最后的y轴方向的位置 lastPosY: 0, //菜单是否显示 menuDisplay: false, //最后一次右键选中的元素 lastMenu: null } }, mounted() { this.initCanvas(); }, methods: { //初始化画布 initCanvas() { // 获取容器元素,得到父容器的宽和高 const container = document.getElementById('rightDiv'); // 创建一个与容器宽高一致的 canvas 对象 this.canvas = new fabric.Canvas("fabric", { width: container.clientWidth, height: container.clientHeight, fireRightClick: true, // 启用右键,button的数字为3 stopContextMenu: true, // 禁止默认右键菜单 }) // 设置画布的背景图片 this.setBackground(container.clientWidth, container.clientHeight) //监听元素是否被下放到画布上 this.elementPlacement() //监听画布拖拽,包含三个监听事件 this.canvasDragAndDrop() //监听画布缩放 this.canvasZoom() }, //设置画布的背景图片 setBackground(clientWidth, clientHeight) { fabric.Image.fromURL(this.canvasBackgroundImage, img => { // 缩放背景图片以适应画布 // 如果背景图片宽度或高度大于画布宽度或高度 if (img.width > clientWidth || img.height > clientHeight) { // 计算缩放比例 let scaleX = clientWidth / img.width; let scaleY = clientHeight / img.height; let scale = Math.min(scaleX, scaleY); // 缩放背景图片 img.scaleToWidth(img.width * scale); img.scaleToHeight(img.height * scale); } else { // 如果背景图片宽度和高度都小于或等于画布宽度和高度 // 计算背景图片在画布中的位置 img.left = (clientWidth - img.width) / 2; img.top = (clientHeight - img.height) / 2; } this.canvas.setBackgroundImage(img, this.canvas.renderAll.bind(this.canvas)); }); }, //监听被拖动元素的图片 handleDragStart(event) { this.imageAddress = this.elementList[event].address }, //监听元素是否被下放到画布上 elementPlacement() { this.canvas.on('drop', elt => { // 画布元素距离浏览器左侧和顶部的距离 let offset = { left: this.canvas.getSelectionElement().getBoundingClientRect().left, top: this.canvas.getSelectionElement().getBoundingClientRect().top } // 鼠标坐标转换成画布的坐标(未经过缩放和平移的坐标) let point = { x: elt.e.x - offset.left, y: elt.e.y - offset.top, } // 转换后的坐标,restorePointerVpt 不受视窗变换的影响 let pointerVpt = this.canvas.restorePointerVpt(point) //创建元素 this.createElement(this.imageAddress, pointerVpt) }); }, //在画布上生成拖拽过来的元素 createElement(imageAddress, pointerVpt) { fabric.Image.fromURL(imageAddress, oImg => { //这个地方做了一下偏移,让鼠标位置为图标的中心,真实的位置信息要还原回去 oImg.top = pointerVpt.y - 24 oImg.left = pointerVpt.x - 24 this.canvas.add(oImg) }) }, //监听画布缩放 canvasZoom() { this.canvas.on('mouse:wheel', opt => { const delta = opt.e.deltaY // 滚轮,向上滚一下是 -100,向下滚一下是 100 let zoom = this.canvas.getZoom() // 获取画布当前缩放值 zoom *= 0.999 ** delta if (zoom > 20) zoom = 20 // 限制最大缩放级别 if (zoom < 0.01) zoom = 0.01 // 限制最小缩放级别 // 以鼠标所在位置为原点缩放 this.canvas.zoomToPoint({ // 关键点 x: opt.e.offsetX, y: opt.e.offsetY }, zoom // 传入修改后的缩放级别 ) }) }, // 监听画布拖拽,同时也监听了右键菜单 canvasDragAndDrop() { // 按下鼠标事件 this.canvas.on("mouse:down", opt => { var evt = opt.e // 判断:右键,且在元素上右键 // opt.button: 1-左键;2-中键;3-右键 // 在画布上点击:opt.target 为 null if (opt.button === 3 && opt.target) { this.lastMenu = opt.target let menu = document.getElementById('menu'); // 禁止在菜单上的默认右键事件 menu.oncontextmenu = function(e) { e.preventDefault() } // 显示菜单,设置右键菜单位置 // 获取菜单组件的宽高 //这个地方是我自己设置的宽和高计算的,如果你之后复制过去需要修改一下 const menuWidth = 120 const menuHeight = menu.childNodes.length * 40 // 当前鼠标位置 let pointX = opt.pointer.x let pointY = opt.pointer.y // 计算菜单出现的位置 // 如果鼠标靠近画布底部,菜单就出现在鼠标指针上方 if (this.canvas.height - pointY <= menuHeight) { pointY -= menuHeight } menu.style = ` visibility: visible; left: ${pointX}px; top: ${pointY}px; z-index: 100; ` // 将菜单展示 this.menuDisplay = true } else { // 将菜单隐藏 this.menuDisplay = false } //拖拽 if (evt.shiftKey === true) { this.isDragging = true this.canvas.selection = false; } }); // 移动鼠标事件 this.canvas.on("mouse:move", opt => { if (this.isDragging && opt && opt.e) { var delta = new fabric.Point(opt.e.movementX, opt.e.movementY); this.canvas.relativePan(delta); } }); // 松开鼠标事件 this.canvas.on("mouse:up", opt => { this.isDragging = false; this.canvas.selection = true; }); }, //删除元素 deleteElement() { this.canvas.remove(this.lastMenu) // 将菜单隐藏 this.menuDisplay = false }, //设定唯一标识 bindingIdentifier() { this.$prompt('请输入唯一编码', '编辑', { confirmButtonText: '确定', cancelButtonText: '取消' }).then(({ value }) => { this.$message({ type: 'success', message: '你的唯一编码是: ' + value }); }).catch(() => { this.$message({ type: 'info', message: '取消输入' }); }); // 将菜单隐藏 this.menuDisplay = false } } } </script> <style lang="scss"> // flex横向布局 .rowflex { display: flex; flex-direction: row; align-items: center; justify-content: center; } // flex纵向布局 .columnflex { display: flex; flex-direction: column; align-items: center; justify-content: center; } // 主div .mainDiv { height: calc(100vh - 50px) !important; width: 100%; min-width: 1000px; } /* 左侧元素区 */ .leftFiv { height: 100%; width: 6%; min-width: 60px; border-right: solid 1px #eee; // 元素块 .element { height: 6.5%; width: 60%; min-height: 48px; min-width: 48px; margin-top: 20px; .element_item { height: 90%; width: 90%; } } } /* 右侧画布区 */ .rightDiv { height: 100%; width: 94%; min-width: 940px; } .menu-x { z-index: -100; position: absolute; top: 0; left: 0; box-sizing: border-box; border-radius: 4px; box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); background-color: #fff; } /* 菜单每个选项 */ .menu-li { box-sizing: border-box; padding: 4px 8px; border-bottom: 1px solid #ccc; cursor: pointer; line-height: 30px; height: 40px; width: 120px; } /* 鼠标经过的选项,更改背景色 */ .menu-li:hover { background-color: antiquewhite; } /* 第一个选项,顶部两角是圆角 */ .menu-li:first-child { border-top-left-radius: 4px; border-top-right-radius: 4px; } /* 最后一个选项,底部两角是圆角,底部不需要边框 */ .menu-li:last-child { border-bottom: none; border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } </style>
复制
一户炊烟煮黄昏