fabricjs图形拓展
1. 起因
在使用fabricjs的时候发现有几个问题,首先线段的操作只能伸缩、旋转,而没法像任意图表那样按住端点可以拖动一端;其次多边形也只能进行缩放、旋转,同样不能变更外框的形状。其实仔细分析之后可以发现上述两个问题都是一个问题 —— 多边形的端点拖拽问题(直线可以看作“二边形”)。于是我就开始尝试解决这个问题。
2. 方法1
首先想到的方法就是把多个图形组合起来视为一个可拖拽的多边形,也就是下面的写法:
let points: Ellipse[] = []; // 这里用Ellipse是因为Point无法设置大小
let lines: Line[] = [];
function drawMulti(posArr: number[][]) {
points = [];
lines = [];
if (posArr.length > 0) {
for (const index in posArr) {
// 取当前点和下一个点做连线
const pos = posArr[index];
const posNext = posArr[(+index + 1) % posArr.length];
const point = new fabric.Ellipse({
left: pos[0],
top: pos[1],
rx: 5,
ry: 5,
originX: "center",
originY: "center",
selectable: true,
opacity: 0.6,
strokeWidth: 1,
stroke: "#00aeff",
fill: "#78cdd1",
hasControls: false,
hasRotatingPoint: false,
data: {
objType: `point-${index}`,
},
});
points.push(point);
if (posNext) {
const line = new fabric.Line([...pos, ...posNext], {
left: Math.min(pos[0], posNext[0]) - 0.5,
top: Math.min(pos[1], posNext[1]) - 0.5,
selectable: false,
strokeWidth: 1,
stroke: "#00aeff",
opacity: 0.6,
});
lines.push(line);
}
}
const point0 = points[0];
// point0中保存对其余各个点各条线的信息引用,以便后续修改
for (const index in points) {
if (+index !== 0) point0.data[`point${index}`] = points[index];
point0.data[`line${index}`] = lines[index];
}
// 各线段保存对point0的引用
for (const index in points) {
if (+index !== 0) points[index].data.point0 = point0;
lines[index].data = { point0 };
}
// 添加图形
for (const point of points) canvas.add(point);
for (const line of lines) {
canvas.add(line);
line.sendToBack();
}
}
canvas.renderAll();
}
而后对这个拼接出来的图形做特殊处理:
// 拖动以point标记的端点后,特殊处理
canvas.on("object:modified", (opt: IEvent) => {
if (opt.target) {
const data = opt.target.data;
if (/point/.test(data.objType)) {
redrawLine();
}
}
});
canvas.on("object:moving", (opt: IEvent) => {
if (opt.target) {
const data = opt.target.data;
if (/point/.test(data.objType)) {
redrawLine();
}
}
});
function redrawLine() {
for (const index in lines) {
const point = points[index];
const pointNext = points[(+index + 1) % points.length];
// 重新设置线段位置
lines[index].set({
x1: point.left,
y1: point.top,
x2: pointNext.left,
y2: pointNext.top,
left: Math.min(point.left as number, pointNext.left as number) - 0.5,
top: Math.min(point.top as number, pointNext.top as number) - 0.5,
});
lines[index].sendToBack();
}
canvas.renderAll();
}
这个实现是我从以前的历史代码里找到的,copy了一下之后发现首先写起来很麻烦,其次这个拼出来的图形没法适用fabric的一些图形操作的方法,于是做了下面第二种实现。
3. 实现2
直接拼凑图形没法使用fabric原生方法,于是我想到了继承fabric某一类,那样不就可以用原生方法操作它了吗,而具体思路还是把多个元素拼起来得到一个图形,于是我想到了Group类。下面是实现(实现的Polyline,若要实现Polygon只需要在最底层再叠一个fabric.Polygon即可):
export class PolylineExtended extends fabric.Group {
private dragging = NaN; // 正在拖拽的点的index
private readonly lines: Line[] = []; // 线段数组
private readonly points: Ellipse[] = []; // 点数组
private readonly pointsPos: number[][] = []; // 点位置数组
constructor(
pointsArr: number[][],
lineOpts?: ILineOptions,
pointOpts?: IEllipseOptions,
groupOpts?: IGroupOptions
) {
// 初始化
const lines: Line[] = [];
const points: Ellipse[] = [];
const pointsPos: number[][] = [];
for (const index in pointsArr) {
const pos = pointsArr[index];
const next = (+index + 1) % pointsArr.length;
const posNext = pointsArr[next];
const line = new fabric.Line([...pos, ...posNext], {
...lineOpts,
data: {
objType: `line-${index}-${next}`,
...(lineOpts && lineOpts.data),
},
hasControls: false,
hasRotatingPoint: false,
});
lines.push(line);
const point = new fabric.Ellipse({
left: pos[0],
top: pos[1],
rx: 5,
ry: 5,
originX: "center",
originY: "center",
selectable: false,
opacity: 0.6,
strokeWidth: 1,
stroke: "#0000ff",
fill: "#0000ff",
...pointOpts,
data: {
objType: `point-${index}`,
...(pointOpts && pointOpts.data),
},
hasControls: false,
hasRotatingPoint: false,
});
points.push(point);
pointsPos.push(pos);
}
super([...lines, ...points], {
...groupOpts,
hasControls: false,
hasRotatingPoint: false,
});
this.lines = lines;
this.points = points;
this.pointsPos = pointsPos;
// 对Group本身的监听,用于进行拖拽
this.on("mousedown", (opt: IEvent) => {
if (opt.pointer && this.left !== undefined && this.top !== undefined) {
const left = opt.pointer.x - this.left;
const top = opt.pointer.y - this.top;
const hWidth = (this.width as number) / 2;
const hHeight = (this.height as number) / 2;
let flag = true;
for (const index in this.points) {
const point = this.points[index];
const x = point.left ?? 0;
const y = point.top ?? 0;
const offsetX = x - left + hWidth; // 圆心距鼠标点击位置的X轴距离
const offsetY = y - top + hHeight; // 圆心距鼠标点击位置的Y轴距离
// 根据偏移量判断是否点击在端点上
if (offsetX * offsetX + offsetY * offsetY <= 25) {
this.dragging = +index;
flag = false;
break;
}
}
if (flag) this.dragging = NaN;
}
});
this.on("mouseup", (opt: IEvent) => {
this.dragging = NaN;
});
this.on("moving", (opt: IEvent) => {
if (opt.pointer && this.left !== undefined && this.top !== undefined) {
if (!isNaN(this.dragging)) {
// 正拖拽点时
const left = opt.pointer.x;
const top = opt.pointer.y;
/**
** 这里用remove移除元素的原因是,如果用set单纯设置每一个内部图形的大小,这个group是无法自动适应内部大小的,
** 因此先移除后添加,这样group就能重新计算其自己的大小了
**/
const objects = this.getObjects();
for (const object of objects) {
this.remove(object);
}
// 重新设置各point, line的位置并重新添加到group中
for (const index in this.points) {
const point = this.points[index];
if (+index === this.dragging) {
// 是正在拖拽的点,直接设置其为当前鼠标位置
point.set({
left,
top,
});
this.pointsPos[index] = [left, top];
} else {
/**
** 此处重新设置位置的原因是,在group中和不在group中时,object.left/top的坐标系不同,
** 在group中时为相对group的偏移,而不在时是相对canvas的偏移,此时point已不在group中
**/
point.set({
left: this.pointsPos[index][0],
top: this.pointsPos[index][1],
});
}
let prevIndex = +index - 1;
let nextIndex = +index + 1;
if (+index === 0) prevIndex = this.points.length - 1;
if (+index === this.points.length - 1) nextIndex = 0;
const prevPoint = this.points[prevIndex];
const nextPoint = this.points[nextIndex];
const line1 = this.lines[prevIndex];
const line2 = this.lines[+index];
// 由于上方已经重设了point的位置,此处直接使用point的位置即可
line1.set({
x1: prevPoint.left,
y1: prevPoint.top,
x2: point.left,
y2: point.top,
});
line2.set({
x1: point.left,
y1: point.top,
x2: nextPoint.left,
y2: nextPoint.top,
});
}
for (const line of this.lines) this.addWithUpdate(line);
for (const point of this.points) this.addWithUpdate(point);
} else {
// 并未拖拽点时(对象平移),更新pointPos中的位置信息
const centerX = this.left + (this.width as number) / 2;
const centerY = this.top + (this.height as number) / 2;
for (const index in this.points) {
const point = this.points[index];
const x = (point.left as number) + centerX;
const y = (point.top as number) + centerY;
this.pointsPos[index] = [x, y];
}
}
}
});
}
/**
** 重写group.set方法,object.set有两种参数,
** 一种是<K extends keyof this>(key: K, value: this[K] | ((value: this[K]) => this[K])),
** 另一种是set(options: Partial<this>),
** 因此要分别处理
**/
set(...params: any) {
if (typeof params[0] === "string") {
// 第一种set方法
super.set(params[0] as keyof this, params[1]);
if (["strokeWidth", "stroke", "opacity"].includes(params[0])) {
for (const line of this.lines) {
line.set(params[0] as keyof Line, params[1]);
}
}
super.set(params);
} else if (typeof params[0] === "object") {
// 第二种set方法
for (const line of this.lines) {
line.set({
strokeWidth: params[0].strokeWidth ?? line.strokeWidth,
stroke: params[0].stroke ?? line.stroke,
opacity: params[0].opacity ?? line.opacity,
});
}
super.set(params[0]);
}
return this;
}
}
这个方法依然有一些缺点,首先是需要重写object.set()
方法,而且在后续功能补充是可能还要多次拓展set方法;其次是在canvas中有合并/拆分组操作时,这个多边形会被识别成一个Group,导致行为逻辑错误。因此我又做了第三种拓展。
3. 实现3
这个拓展方法是我在fabricjs官网的demo里找到的,是用object.controls自定义元素的控制器实现的,我把它封装成了一个类:
// 计算polygon新位置
function polygonPositionHandler(
this: any,
dim: any,
finalMatrix: any,
fabricObject: any
) {
const x = fabricObject.points[this.pointIndex].x - fabricObject.pathOffset.x,
y = fabricObject.points[this.pointIndex].y - fabricObject.pathOffset.y;
return fabric.util.transformPoint(
new fabric.Point(x, y),
fabric.util.multiplyTransformMatrices(
fabricObject.canvas.viewportTransform,
fabricObject.calcTransformMatrix()
)
);
}
// 计算整个polygon的右下位置
function getObjectSizeWithStroke(object: any) {
const stroke = new fabric.Point(
object.strokeUniform ? 1 / object.scaleX : 1,
object.strokeUniform ? 1 / object.scaleY : 1
).multiply(object.strokeWidth);
return new fabric.Point(object.width + stroke.x, object.height + stroke.y);
}
// 拖拽操作处理
function actionHandler(eventData: any, transform: any, x: number, y: number) {
const polygon = transform.target,
currentControl = polygon.controls[polygon.__corner],
mouseLocalPosition = polygon.toLocalPoint(
new fabric.Point(x, y),
"center",
"center"
),
polygonBaseSize = getObjectSizeWithStroke(polygon),
size = polygon._getTransformedDimensions(0, 0);
polygon.points[currentControl.pointIndex] = {
x:
(mouseLocalPosition.x * polygonBaseSize.x) / size.x +
polygon.pathOffset.x,
y:
(mouseLocalPosition.y * polygonBaseSize.y) / size.y +
polygon.pathOffset.y,
};
return true;
}
// 固定polygon的位置,避免默认的拖拽平移
function anchorWrapper(anchorIndex: number, fn: any) {
return function (eventData: any, transform: any, x: number, y: number) {
const fabricObject = transform.target,
absolutePoint = fabric.util.transformPoint(
new fabric.Point(
fabricObject.points[anchorIndex].x - fabricObject.pathOffset.x,
fabricObject.points[anchorIndex].y - fabricObject.pathOffset.y
),
fabricObject.calcTransformMatrix()
),
actionPerformed = fn(eventData, transform, x, y),
newDim = fabricObject._setPositionDimensions({}),
polygonBaseSize = getObjectSizeWithStroke(fabricObject),
newX =
(fabricObject.points[anchorIndex].x - fabricObject.pathOffset.x) /
polygonBaseSize.x,
newY =
(fabricObject.points[anchorIndex].y - fabricObject.pathOffset.y) /
polygonBaseSize.y;
fabricObject.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5);
return actionPerformed;
};
}
export class PolygonExtended extends fabric.Polygon {
constructor(points: { x: number; y: number }[], options?: IPolylineOptions) {
super(points, options);
const lastControl = (this.points?.length ?? 0) - 1;
this.controls = this.points?.reduce(
(acc: any, point: any, index: number) => {
acc["p" + index] = new fabric.Control({
positionHandler: polygonPositionHandler,
actionHandler: anchorWrapper(
index > 0 ? index - 1 : lastControl,
actionHandler
),
actionName: "modifyPolygon",
});
acc["p" + index].pointIndex = index;
return acc;
},
{}
);
}
}
这个方法有一个小问题,就是如果在创建polygon,将其克隆元素添加到canvas上的同时将其选中(设置为activeObject),那么controls就会出错,因此在这种情况下可能需要特殊处理;
4. 总结
上面三种方法里,第三种方法毫无疑问是适配最好的方法(这个故事教育我们要认真看文档),前两种方式都各自有一些问题,不过也算是自己思考的结果了,于是就这样记录一下自己的思路。