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>
一户炊烟煮黄昏