Fabric
2024年01月17日
一、认识
Fabric.js 是一个功能强大的、易于使用的 JavaScript
库,它让开发者能够在网页上处理和操纵画布元素(Canvas Element
)。它提供一个简单的接口来创建和管理图形,处理事件,以及执行各种图形处理任务。
本文利用 Fabric.js
独立打造高度交互性的 HTML5
画板应用, 支持多层次图形编辑、无限撤销/重做、自定义素材库集成、图片处理、自由变换、多用户协作编辑及实时共享等高级功能的交互式在线画板应用,为用户提供了一个强大而直观的绘图和设计体验。
二、搭建环境
2.1 安装依赖
pnpm add fabric -S
pnpm add @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve rollup rollup-plugin-string -D
2.2 编译配置
rollup.config.js
配置如下:
import JSON from '@rollup/plugin-json';
import SVG from 'rollup-plugin-svg-import';
import CommonJS from '@rollup/plugin-commonjs';
import { string as String } from 'rollup-plugin-string';
import { nodeResolve as NodeResolve } from '@rollup/plugin-node-resolve';
export default {
input: 'src/index.js',
output: {
dir: 'dist',
format: 'es',
sourcemap: true,
entryFileNames: '[name].js'
},
plugins: [
SVG({
stringify: true
}),
JSON(),
String({
include: '**/*.html',
exclude: ['**/index.html']
}),
CommonJS(),
NodeResolve({
extensions: ['.js', '.node']
})
]
};
2.3 命令配置
package.json
增加配置如下:
"scripts": {
"start": "rollup -c -w"
}
三、代码实现
3.1 /fabric/src/index.js
import Toolbar from './toolbar';
import DrawingBoardTemplate from './template/drawing-board.html';
export const shapeMap = {
line: 'line', // 直线
text: 'text', // 文本
rect: 'rect', // 矩形
path: 'path', // 路径
brush: 'brush', // 画笔
circle: 'circle', // 圆形
eraser: 'eraser', // 橡皮擦
ellipse: 'ellipse' // 椭圆
};
// 操作工具
export const operationMap = {
undo: 'undo', // 撤销
redo: 'redo', // 重做
move: 'move', // 移动
save: 'save', // 保存
clear: 'clear', // 清空
zoomIn: 'zoomIn', // 放大
zoomOut: 'zoomOut' // 缩小
};
// 所有工具
export const toolMap = Object.assign({}, shapeMap, operationMap);
export default class DrawingBoard {
constructor(options) {
this.canvas = null; // 画布
this.id = '#canvas';
this.context = null; // 画布上下文
this.textObject = null; // 文本对象
this.activeShape = null; // 当前选中的图形
this.canvasHistoryIndex = 0; // 画布历史记录索引
this.canvasHistoryList = []; // 保存画布的历史记录
this.selectedTool = shapeMap.brush; // 当前选中的工具
this.drawingObject = null; /// 鼠标未松开时用户绘制的临时图像
this.freeDrawingObject = null; // 自由绘制对象(画笔、橡皮)
this.isFreeDraw = true; // 是否为自由绘制模式 (画笔、橡皮)
this.lineSize = 1; // 线条大小
this.fontSize = 18; // 字体大小
this.canvasScale = 1;
this.canvasScale = 1;
this.canvasMaxScale = 20;
this.canvasMinScale = 0.1;
this.canvasScaleFactor = 0.8;
this.downXY = {
x: 0,
y: 0
};
this.moveXY = {
x: 0,
y: 0
};
this.isDrawing = false; // 当前是否正在绘制图形(画笔,文本模式除外)
this.isRedoing = false; // 当前是否在执行撤销或重做操作
this.isShiftDown = false; // 当前是否按下了 shift 键
this.strokeColor = '#000000'; // 线框色
this.showStrokeColorPicker = false; // 是否显示 线框色选择器
this.fillColor = 'rgba(0,0,0,0)'; // 填充色
this.showFillColorPicker = false; // 是否显示 填充色选择器
this.bgColor = '#2F782C'; // 背景色
this.showBgColorPicker = false; // 是否显示 背景色选择器
this.container = options.container || document.body;
this.container.innerHTML = DrawingBoardTemplate;
this.initCanvasSize(options);
this.initCanvas();
this.initEvent();
this.initToolbar();
this.setTool(shapeMap.brush);
}
initCanvas() {
if (this.canvas) {
return;
}
this.canvas = new fabric.Canvas('canvas');
this.canvas.setBackgroundColor(this.bgColor, undefined, {
erasable: false
});
this.canvas.set('backgroundVpt', false);
this.canvas.selection = false;
this.canvas.defaultCursor = 'default';
this.canvas.renderAll();
this.canvasHistoryIndex = 0;
this.canvasHistoryList.push(JSON.stringify(this.canvas));
}
initToolbar() {
this.toolbar = new Toolbar({
drawingBoard: this
});
}
initCanvasSize(options) {
const { width, height } = options;
const canvasEl = document.querySelector(this.id);
canvasEl.width = this.width = width;
canvasEl.height = this.height = height;
}
getPointer(e) {
const { x, y } = this.canvas.getPointer(e);
return [x, y];
}
initEvent() {
console.log('this.canvas', this.canvas);
this.canvas.on('mouse:down', this.onDown.bind(this));
this.canvas.on('mouse:move', this.onMove.bind(this));
this.canvas.on('mouse:up', this.onUp.bind(this));
this.canvas.on('mouse:wheel', this.onScale.bind(this));
this.canvas.on('after:render', this.onRenderAfter.bind(this));
this.canvas.on('object:moving', this.onObjectMoving.bind(this));
document.addEventListener('keyup', this.onKeyup.bind(this));
document.addEventListener('keydown', this.onKeydown.bind(this));
}
onKeydown(e) {
if (e.key == 'Shift') {
this.isShiftDown = true;
}
}
onKeyup() {
this.isShiftDown = false;
}
setPoint(type, x, y) {
this[type].x = x;
this[type].y = y;
}
onDown(options) {
if(this.selectedTool != toolMap.text && this.textObject){
this.textObject.exitEditing();
this.textObject.set('backgroundColor', 'rgba(0,0,0,0)');
if (this.textObject.text == '') {
this.canvas.remove(this.textObject);
}
this.canvas.renderAll();
this.textObject = null;
}
this.setCursorStyle(this.selectedTool, 'down');
if(this.selectedTool == toolMap.move && options.target){
this.canvas.setActiveObject(options.target);
return;
}
const [x, y] = this.getPointer(options.e);
this.setPoint('downXY', x, y);
if (this.selectedTool === toolMap.text) {
this.drawText();
} else {
this.isDrawing = true;
}
}
onMove(options) {
if (!this.isDrawing) {
return;
}
const [x, y] = this.getPointer(options.e);
this.setPoint('moveXY', x, y);
this.setCursorStyle(this.selectedTool, 'move');
switch (this.selectedTool) {
case toolMap.move:
this.drawBoardMoving();
break;
case toolMap.line:
this.drawLine();
break;
case toolMap.circle:
this.drawCircle();
break;
case toolMap.rect:
this.drawRectangle();
break;
}
}
onUp() {
if (!this.isDrawing) {
return;
}
this.isDrawing = false;
this.drawingObject = null;
this.resetMove();
this.setCursorStyle(this.selectedTool);
if (this.selectedTool == toolMap.move) {
this.canvas.setViewportTransform(this.canvas.viewportTransform);
}
}
onScale(options) {
const { e } = options;
const delta = e.deltaY;
let zoom = this.canvas.getZoom();
zoom = Math.min(
this.canvasMaxScale,
Math.max(zoom * this.canvasScaleFactor ** delta, this.canvasMinScale)
);
this.canvas.zoomToPoint(
{
x: e.offsetX,
y: e.offsetY
},
zoom
);
}
onRenderAfter() {
if (this.isRedoing) {
this.isRedoing = false;
return;
}
if (this.recordTimer) {
clearTimeout(this.recordTimer);
this.recordTimer = null;
}
this.recordTimer = setTimeout(() => {
this.canvasHistoryList.push(JSON.stringify(this.canvas));
this.canvasHistoryIndex++;
}, 100);
}
onObjectMoving(options) {}
drawLine() {
const [x1, y1] = [this.downXY.x, this.downXY.y];
const [x2, y2] = [this.moveXY.x, this.moveXY.y];
const object = new fabric.Line([x1, y1, x2, y2], {
fill: this.fillColor,
stroke: this.strokeColor,
strokeWidth: this.lineSize
});
object.selectable = false;
if (this.drawingObject) {
this.canvas.remove(this.drawingObject);
}
this.canvas.add(object);
this.drawingObject = object;
}
drawText() {
if (this.textObject) {
this.textObject.exitEditing();
this.textObject.set('backgroundColor', 'rgba(0,0,0,0)');
if (this.textObject.text == '') {
this.canvas.remove(this.textObject);
}
this.canvas.renderAll();
this.textObject = null;
return;
}
const [x, y] = [this.downXY.x, this.downXY.y];
this.textObject = new fabric.Textbox('', {
left: x,
top: y,
fontSize: this.fontSize,
fill: this.strokeColor,
hasControls: false,
editable: true,
width: 30,
backgroundColor: '#fff',
selectable: false
});
this.canvas.add(this.textObject);
this.textObject.enterEditing(); // 文本打开编辑模式
this.textObject.hiddenTextarea.focus(); // 文本编辑框获取焦点
}
drawBrush() {
this.canvas.freeDrawingBrush = new fabric.PencilBrush(this.canvas);
this.canvas.isDrawingMode = true;
this.canvas.freeDrawingBrush.color = this.strokeColor;
this.canvas.freeDrawingBrush.width = parseInt(this.lineSize, 10);
}
drawEraser() {
this.canvas.freeDrawingBrush = new fabric.EraserBrush(this.canvas);
this.canvas.freeDrawingBrush.width = parseInt(this.lineSize, 10);
this.canvas.isDrawingMode = true;
}
drawCircle() {
let object = {};
const [x1, y1] = [this.downXY.x, this.downXY.y];
const [x2, y2] = [this.moveXY.x, this.moveXY.y];
if (this.isShiftDown) {
const radius =
Math.abs(x2 - x1) < Math.abs(y2 - y1)
? Math.abs(x2 - x1) / 2
: Math.abs(y2 - y1) / 2;
const top = y2 > y1 ? y1 : y1 - radius * 2;
const left = x2 > x1 ? x1 : x1 - radius * 2;
object = new fabric.Circle({
left,
top,
stroke: this.strokeColor,
fill: this.fillColor,
radius: radius,
strokeWidth: this.lineSize,
selectable: true
});
} else {
const longAxis = Math.abs(x2 - x1) / 2;
const shortAxis = Math.abs(y2 - y1) / 2;
const top = y2 > y1 ? y1 : y1 - shortAxis * 2;
const left = x2 > x1 ? x1 : x1 - longAxis * 2;
object = new fabric.Ellipse({
left,
top,
rx: longAxis,
ry: shortAxis,
stroke: this.strokeColor,
fill: this.fillColor,
strokeWidth: this.lineSize,
selectable: true
});
}
object.selectable = false;
if (this.drawingObject) {
this.canvas.remove(this.drawingObject);
}
this.canvas.add(object);
this.drawingObject = object;
}
drawRectangle() {
let object;
const [x1, y1] = [this.downXY.x, this.downXY.y];
const [x2, y2] = [this.moveXY.x, this.moveXY.y];
if (this.isShiftDown) {
const width =
Math.abs(x2 - x1) < Math.abs(y2 - y1)
? Math.abs(x2 - x1)
: Math.abs(y2 - y1);
object = new fabric.Rect({
left: x1,
top: y1,
width: width,
height: width,
stroke: this.strokeColor,
fill: this.fillColor,
strokeWidth: this.lineSize
});
} else {
const width = x2 - x1;
const height = y2 - y1;
object = new fabric.Rect({
left: x1,
top: y1,
width: width,
height: height,
stroke: this.strokeColor,
fill: this.fillColor,
strokeWidth: this.lineSize
});
}
object.selectable = false;
if (this.drawingObject) {
this.canvas.remove(this.drawingObject);
}
this.canvas.add(object);
this.drawingObject = object;
}
drawBoardMoving() {
const vpt = this.canvas.viewportTransform;
const [x1, y1] = [this.downXY.x, this.downXY.y];
const [x2, y2] = [this.moveXY.x, this.moveXY.y];
vpt[4] += x2 - x1;
vpt[5] += y2 - y1;
this.canvas.requestRenderAll();
}
save() {
this.canvas.clone(cvs => {
let top = 0;
let left = 0;
let width = this.canvas.width;
let height = this.canvas.height;
var objects = cvs.getObjects();
if (objects.length > 0) {
var rect = objects[0].getBoundingRect();
var minX = rect.left;
var minY = rect.top;
var maxX = rect.left + rect.width;
var maxY = rect.top + rect.height;
for (var i = 1; i < objects.length; i++) {
rect = objects[i].getBoundingRect();
minX = Math.min(minX, rect.left);
minY = Math.min(minY, rect.top);
maxX = Math.max(maxX, rect.left + rect.width);
maxY = Math.max(maxY, rect.top + rect.height);
}
top = minY - 100;
left = minX - 100;
width = maxX - minX + 200;
height = maxY - minY + 200;
cvs.sendToBack(
new fabric.Rect({
left,
top,
width,
height,
stroke: 'rgba(0,0,0,0)',
fill: this.bgColor,
strokeWidth: 0
})
);
}
const dataURL = cvs.toDataURL({
format: 'png',
multiplier: cvs.getZoom(),
left,
top,
width,
height
});
const link = document.createElement('a');
link.download = 'canvas.png';
link.href = dataURL;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
});
}
clear() {
let children = this.canvas.getObjects();
if (children.length > 0) {
this.canvas.remove(...children);
}
}
redo() {
this.isRedoing = true;
let index = this.canvasHistoryIndex - 1;
if (index >= this.canvasHistoryList.length) return;
if (this.canvasHistoryList[this.canvasHistoryIndex]) {
this.canvas.loadFromJSON(this.canvasHistoryList[this.canvasHistoryIndex]);
if (this.canvas.getObjects().length > 0) {
this.canvas.getObjects().forEach(item => {
item.set('selectable', false);
});
}
this.canvasHistoryIndex = index;
}
}
undo() {
this.isRedoing = true;
let index = this.canvasHistoryIndex - 1;
if (index < 0) return;
if (this.canvasHistoryList[this.canvasHistoryIndex]) {
this.canvas.loadFromJSON(this.canvasHistoryList[this.canvasHistoryIndex]);
if (this.canvas.getObjects().length > 0) {
this.canvas.getObjects().forEach(item => {
item.set('selectable', false);
});
}
this.canvasHistoryIndex = index;
}
}
zoomIn() {
let zoom = this.canvas.getZoom();
zoom *= 1.1;
this.canvas.setZoom(zoom);
}
zoomOut() {
let zoom = this.canvas.getZoom();
zoom *= 0.9;
this.canvas.setZoom(zoom);
}
setTool(tool) {
if (tool === toolMap.undo) {
this.undo();
return;
} else if (tool === toolMap.redo) {
this.redo();
return;
} else if (tool === toolMap.clear) {
this.clear();
return;
} else if (tool === toolMap.save) {
this.save();
return;
} else if (tool === toolMap.zoomIn) {
this.zoomIn();
return;
} else if (tool === toolMap.zoomOut) {
this.zoomOut();
return;
}
this.selectedTool = tool;
this.setCursorStyle(this.selectedTool);
this.canvas.isDrawingMode = false;
let drawObjects = this.canvas.getObjects();
if (drawObjects.length > 0) {
drawObjects.map(item => {
item.set('selectable', false);
});
}
if (this.selectedTool === toolMap.brush) {
this.drawBrush();
} else if (this.selectedTool === toolMap.eraser) {
this.drawEraser();
}
}
resetMove() {
this.downXY = { x: 0, y: 0 };
this.moveXY = { x: 0, y: 0 };
}
setLineSize(value) {
this.lineSize = value;
if(this.canvas.freeDrawingBrush){
this.canvas.freeDrawingBrush.width = parseInt(this.lineSize, 10);
}
}
setFontSize(value) {
this.fontSize = value;
}
setBgColor(value) {
this.bgColor = value;
this.canvas.setBackgroundColor(this.bgColor, undefined, {
erasable: false
});
this.canvas.renderAll();
}
setFillColor(value) {
this.fillColor = value;
}
setStrokeColor(value) {
this.strokeColor = value;
if(this.canvas.freeDrawingBrush){
this.canvas.freeDrawingBrush.color = this.strokeColor;
}
}
setCursorStyle(tool, status) {
switch (tool) {
case toolMap.brush:
this.canvas.defaultCursor = 'crosshair';
break;
case toolMap.circle:
this.canvas.defaultCursor = 'crosshair';
break;
case toolMap.rect:
this.canvas.defaultCursor = 'crosshair';
break;
case toolMap.line:
this.canvas.defaultCursor = 'crosshair';
break;
case toolMap.move:
if (status === 'down') {
this.canvas.defaultCursor = `grab`;
} else if (status === 'move') {
this.canvas.defaultCursor = `grabbing`;
} else {
this.canvas.defaultCursor = `all-scroll`;
}
break;
case toolMap.eraser:
this.canvas.defaultCursor = `grab`;
break;
default:
this.canvas.defaultCursor = 'default';
break;
}
}
}
3.2 /fabric/src/toolbar.js
export default class Toolbar {
constructor(options) {
this.options = options;
this.drawingBoard = options.drawingBoard;
this.initEvent();
this.initValue();
this.initToolbar();
}
initToolbar() {
this.container = document.querySelector('.toolbar-container');
this.container.style.width = `${this.drawingBoard.width}px`;
}
initValue() {
this.updateSelectedButton();
this.initValueOfSize('line-size');
this.initValueOfSize('font-size');
this.initValueOfColorPicker('bg-color-picker');
this.initValueOfColorPicker('fill-color-picker');
this.initValueOfColorPicker('stroke-color-picker');
}
updateSelectedButton() {
const buttonList = document.querySelectorAll(
'.toolbar-container .toolbar-item_button'
);
for (let i = 0; i < buttonList.length; i++) {
const button = buttonList[i];
button.classList.remove('active');
if (button.id === this.drawingBoard.selectedTool) {
button.classList.add('active');
}
}
}
initValueOfSize(type) {
const typeDom = document.getElementById(type);
switch (type) {
case 'line-size':
typeDom.value = this.drawingBoard.lineSize;
typeDom.previousElementSibling.innerHTML = this.drawingBoard.lineSize;
break;
case 'font-size':
typeDom.value = this.drawingBoard.fontSize;
typeDom.previousElementSibling.innerHTML = this.drawingBoard.fontSize;
break;
default:
break;
}
}
initValueOfColorPicker(type) {
const typeDom = document.getElementById(type);
switch (type) {
case 'bg-color-picker':
typeDom.value = this.drawingBoard.bgColor;
break;
case 'fill-color-picker':
typeDom.value = this.drawingBoard.fillColor;
break;
case 'stroke-color-picker':
typeDom.value = this.drawingBoard.strokeColor;
break;
default:
break;
}
}
initEvent() {
this.watchButton();
this.watchSize('line-size');
this.watchSize('font-size');
this.watchColorPicker('bg-color-picker');
this.watchColorPicker('fill-color-picker');
this.watchColorPicker('stroke-color-picker');
}
watchButton() {
const buttonList = document.querySelectorAll(
'.toolbar-container .toolbar-item_button'
);
for (let i = 0; i < buttonList.length; i++) {
buttonList[i].addEventListener('click', e => {
const type = e.target.id;
this.drawingBoard.selectedTool = type;
this.updateSelectedButton();
this.drawingBoard.setTool(type);
}),
false;
}
}
watchSize(type) {
const typeDom = document.getElementById(type);
typeDom.addEventListener('input', e => {
const { value } = e.target;
this.updateSize(e.target, type, value);
}),
false;
}
watchColorPicker(type) {
const typeDom = document.getElementById(type);
typeDom.addEventListener('input', e => {
const { value } = e.target;
this.updateColorPicker(e.target, type, value);
}),
false;
}
updateSize(node, type, value) {
node.previousElementSibling.innerHTML = value;
switch (type) {
case 'line-size':
this.drawingBoard.setLineSize(value);
break;
case 'font-size':
this.drawingBoard.setFontSize(value);
break;
default:
break;
}
}
updateColorPicker(node, type, value) {
switch (type) {
case 'bg-color-picker':
this.drawingBoard.setBgColor(value);
break;
case 'fill-color-picker':
this.drawingBoard.setFillColor(value);
break;
case 'stroke-color-picker':
this.drawingBoard.setStrokeColor(value);
break;
default:
break;
}
}
}
3.3 /src/template/drawing-board.html
<style>
canvas {
user-select: none;
touch-action: none; /* 防止绘制时浏览器滑动 */
}
.toolbar-container {
width: 600px;
gap: 24px;
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 24px;
justify-content: flex-start;
}
.toolbar-item {
flex: none;
gap: 12px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.toolbar-item_button.active {
color: #fff;
background-color: #384bc8;
}
</style>
<div class="drawing-board">
<div class="toolbar-container">
<div class="toolbar-item toolbar-item_stroke-color-picker">
<div class="label">stroke color</div>
<div class="color-picker">
<input id="stroke-color-picker" type="color" />
</div>
</div>
<div class="toolbar-item toolbar-item_fill-color-picker">
<div class="label">fill color</div>
<div class="color-picker">
<input id="fill-color-picker" type="color" />
</div>
</div>
<div class="toolbar-item toolbar-item_bg-color-picker">
<div class="label">bg color</div>
<div class="color-picker">
<input id="bg-color-picker" type="color" />
</div>
</div>
<div class="toolbar-item toolbar-item_line-size">
<div class="label">line size:</div>
<div class="value">1</div>
<input id="line-size" type="range" min="1" max="100" />
</div>
<div class="toolbar-item toolbar-item_font-size">
<div class="label">font size:</div>
<div class="value">1</div>
<input id="font-size" type="range" min="1" max="100" />
</div>
<button class="toolbar-item toolbar-item_button" id="text">text</button>
<button class="toolbar-item toolbar-item_button" id="brush">brush</button>
<button class="toolbar-item toolbar-item_button" id="line">line</button>
<button class="toolbar-item toolbar-item_button" id="rect">
rect
</button>
<button class="toolbar-item toolbar-item_button" id="circle">circle</button>
<button class="toolbar-item toolbar-item_button" id="eraser">eraser</button>
<button class="toolbar-item toolbar-item_button" id="move">move</button>
<button class="toolbar-item toolbar-item_button" id="undo">undo</button>
<button class="toolbar-item toolbar-item_button" id="redo">redo</button>
<button class="toolbar-item toolbar-item_button" id="zoomOut">zoomOut</button>
<button class="toolbar-item toolbar-item_button" id="zoomIn">zoomIn</button>
<button class="toolbar-item toolbar-item_button" id="clear">clear</button>
<button class="toolbar-item toolbar-item_button" id="save">save</button>
</div>
<canvas id="canvas"></canvas>
</div>
四、效果测试
4.1 /fabric/test/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fabric 画布</title>
</head>
<body>
<script src="./fabric.min.js"></script>
<script type="module" src="./index.js"></script>
</body>
</html>
fabric.min.js
从官网下即可。
4.2 /fabric/test/index.js
import DrawingBoard from '../dist/index.js';
new DrawingBoard({
width: 600,
height: 600,
container: document.getElementById('container')
});
然后执行 pnpm start
, 通过服务打开 index.html
即可看到效果。