diff --git a/CHANGELOG.md b/CHANGELOG.md index bbe630d9c8f..ac119629661 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [next] +- fix(AligningGuidelines): Guidelines features updates [#10120] (https://github.com/fabricjs/fabric.js/pull/10120) - chore(deps-dev): bump inquirer from 12.9.6 to 12.10.0 [#10789](https://github.com/fabricjs/fabric.js/pull/10789) - chore(deps-dev): bump @types/micromatch from 4.0.9 to 4.0.10 [#10788](https://github.com/fabricjs/fabric.js/pull/10788) - chore(): update major version of vitest [#10786](https://github.com/fabricjs/fabric.js/pull/10786) diff --git a/e2e/tests/guidelines/align-between-objects/index.ts b/e2e/tests/guidelines/align-between-objects/index.ts index d6ff2760ff2..380dbe505dc 100644 --- a/e2e/tests/guidelines/align-between-objects/index.ts +++ b/e2e/tests/guidelines/align-between-objects/index.ts @@ -4,7 +4,7 @@ */ import { Rect } from 'fabric'; -import { initAligningGuidelines } from 'fabric/extensions'; +import { AligningGuidelines } from 'fabric/extensions'; import { beforeAll } from '../../test'; import type { AligningLineConfig } from 'fabric/extensions'; @@ -28,7 +28,7 @@ beforeAll(async (canvas) => { fill: 'yellow', }); - initAligningGuidelines(canvas, { + new AligningGuidelines(canvas, { width: 2, margin: 60, } as AligningLineConfig); diff --git a/extensions/aligning_guidelines/README.MD b/extensions/aligning_guidelines/README.MD index eb9886973c2..33f708bf34c 100644 --- a/extensions/aligning_guidelines/README.MD +++ b/extensions/aligning_guidelines/README.MD @@ -3,7 +3,7 @@ ## How to use it ```ts -import { initAligningGuidelines } from 'fabric/extensions'; +import { AligningGuidelines } from 'fabric/extensions'; const config = { /** At what distance from the shape does alignment begin? */ @@ -11,12 +11,168 @@ const config = { /** Aligning line dimensions */ width: 1, /** Aligning line color */ - color: 'rgb(255,0,0,0.9)', + color: 'rgba(255,0,0,0.9)', + /** Close Vertical line, default false. */ + closeVLine: false, + /** Close horizontal line, default false. */ + closeHLine: false, }; -const deactivate = initAligningGuidelines(myCanvas, options); +const aligningGuidelines = new AligningGuidelines(myCanvas, options); // in order to disable alignment guidelines later: -deactivate(); +aligningGuidelines.dispose(); +``` + +### custom function + +```ts +import { AligningGuidelines } from 'fabric/extensions'; +import { FabricObject } from 'fabric'; + +// You can customize the return graphic, and the example will only compare it with sibling elements +new AligningGuidelines(myCanvas, { + getObjectsByTarget: function (target) { + const set = new Set(); + const p = target.parent ?? target.canvas; + p?.getObjects().forEach((o) => { + set.add(o); + }); + // Please remember to exclude yourself, or you will always align with yourself. + set.delete(target); + return set; + }, +}); +``` + +```ts +import { AligningGuidelines } from 'fabric/extensions'; + +// You can customize the alignment point, the example only aligns the TL control point +new AligningGuidelines(myCanvas, { + getPointMap: function (target) { + const tl = target.getCoords().tl; + return { tl }; + }, +}); +``` + +```ts +import { AligningGuidelines } from 'fabric/extensions'; +import { InteractiveFabricObject } from 'fabric'; + +// deactivate constructor control assignment +InteractiveFabricObject.createControls = function () { + return {}; +}; +// custom controllers +InteractiveFabricObject.ownDefaults.controls = { + abc: new Control({}), +}; + +// You can set control points for custom controllers +new AligningGuidelines(myCanvas, { + getPointMap: function (target) { + const abc = target.getCoords().tl; + return { abc }; + }, + getContraryMap: function (target) { + const abc = target.aCoords.br; + return { abc }; + }, + contraryOriginMap: { + // If abc is the top-left point, then the reference point is the bottom-right. + abc: ['right', 'bottom'], + }, +}); +``` + +```ts +import { AligningGuidelines } from 'fabric/extensions'; + +// You can close all +new AligningGuidelines(myCanvas, { + closeVLine: true, + closeHLine: true, + getPointMap: function (_) { + return {}; + }, +}); +``` + +```ts +import { AligningGuidelines } from 'fabric/extensions'; + +// You can set dashed lines. +// You can adjust the size of endpoint x. +new AligningGuidelines(myCanvas, { + lineDash: [2, 2], + xSize: 10, +}); +``` + +```ts +import { AligningGuidelines } from 'fabric/extensions'; + +// You can customize drawing line segments. What if you want to draw a Bézier curve? +new AligningGuidelines(myCanvas, { + drawLine(origin, target) { + const ctx = this.canvas.getTopContext(); + const viewportTransform = this.canvas.viewportTransform; + const zoom = this.canvas.getZoom(); + + ctx.save(); + ctx.transform(...viewportTransform); + ctx.lineWidth = this.width / zoom; + if (this.lineDash) ctx.setLineDash(this.lineDash); + ctx.strokeStyle = this.color; + + ctx.beginPath(); + ctx.moveTo(origin.x, origin.y); + const controlPoint1 = { x: (origin.x + target.x) / 3, y: origin.y - 50 }; // 控制点1 + const controlPoint2 = { x: (origin.x + target.x) / 3, y: target.y + 50 }; // 控制点2 + ctx.bezierCurveTo( + controlPoint1.x, + controlPoint1.y, + controlPoint2.x, + controlPoint2.y, + target.x, + target.y, + ); + ctx.stroke(); + + if (this.lineDash) ctx.setLineDash([]); + + this.drawX(origin, -1); + this.drawX(target, 1); + + ctx.restore(); + }, +}); +``` + +```ts +import { AligningGuidelines } from 'fabric/extensions'; + +// If you don't like the endpoints being "X," you can customize the endpoints. For example, the start point can be a solid circle, and the end point can be a hollow circle. +new AligningGuidelines(myCanvas, { + drawX(point: Point, dir: number) { + const ctx = this.canvas.getTopContext(); + const zoom = this.canvas.getZoom(); + const size = this.xSize / zoom; + + ctx.save(); + ctx.translate(point.x, point.y); + ctx.beginPath(); + ctx.arc(0, 0, size, 0, Math.PI * 2); + if (dir == -1) { + ctx.fillStyle = this.color; + ctx.fill(); + } else { + ctx.stroke(); + } + ctx.restore(); + }, +}); ``` diff --git a/extensions/aligning_guidelines/constant.ts b/extensions/aligning_guidelines/constant.ts deleted file mode 100644 index 94dce539cb7..00000000000 --- a/extensions/aligning_guidelines/constant.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AligningLineConfig } from './typedefs'; - -export const aligningLineConfig: AligningLineConfig = { - /** At what distance from the shape does alignment begin? */ - margin: 4, - /** Aligning line dimensions */ - width: 1, - /** Aligning line color */ - color: 'rgb(255,0,0,0.9)', -}; diff --git a/extensions/aligning_guidelines/index.ts b/extensions/aligning_guidelines/index.ts index 2c868bca89f..feadbb1604f 100644 --- a/extensions/aligning_guidelines/index.ts +++ b/extensions/aligning_guidelines/index.ts @@ -1,166 +1,264 @@ -import type { - BasicTransformEvent, - Canvas, - FabricObject, - TBBox, - TPointerEvent, +import { + type BasicTransformEvent, + type Canvas, + type FabricObject, + type TPointerEvent, + type Point, + util, } from 'fabric'; -import { Point, util } from 'fabric'; import { collectHorizontalPoint, collectVerticalPoint, } from './util/collect-point'; import { drawHorizontalLine, + drawLine, drawPointList, drawVerticalLine, + drawX, } from './util/draw'; -import { getObjectsByTarget } from './util/get-objects-by-target'; import { collectLine } from './util/collect-line'; -import type { - AligningLineConfig, - HorizontalLine, - VerticalLine, -} from './typedefs'; -import { aligningLineConfig } from './constant'; +import type { AligningLineConfig, OriginMap } from './typedefs'; +import { getObjectsByTarget } from './util/get-objects-by-target'; +import { getContraryMap, getPointMap } from './util/basic'; type TransformEvent = BasicTransformEvent & { target: FabricObject; }; -export type { AligningLineConfig } from './typedefs'; +export class AligningGuidelines { + canvas: Canvas; + horizontalLines = new Set(); + verticalLines = new Set(); + cacheMap = new Map(); + /** + * When we drag to resize using center points like mt, ml, mb, and mr, + * we do not need to draw line segments; we only need to draw the target points. + */ + onlyDrawPoint = false; + /** Alignment method is required when customizing. */ + contraryOriginMap: OriginMap = { + tl: ['right', 'bottom'], + tr: ['left', 'bottom'], + br: ['left', 'top'], + bl: ['right', 'top'], + mt: ['center', 'bottom'], + mr: ['left', 'center'], + mb: ['center', 'top'], + ml: ['right', 'center'], + }; + xSize = 2.4; + lineDash: number[] | undefined; + /** At what distance from the shape does alignment begin? */ + margin = 4; + /** Aligning line dimensions */ + width = 1; + /** Aligning line color */ + color = 'rgba(255,0,0,0.9)'; + /** Close Vertical line, default false. */ + closeVLine = false; + /** Close horizontal line, default false. */ + closeHLine = false; -export function initAligningGuidelines( - canvas: Canvas, - options: Partial = {}, -) { - Object.assign(aligningLineConfig, options); + constructor(canvas: Canvas, options: Partial = {}) { + this.canvas = canvas; + Object.assign(this, options); - const horizontalLines = new Set(); - const verticalLines = new Set(); - let onlyDrawPoint = false; - const cacheMap = new Map(); + this.mouseUp = this.mouseUp.bind(this); + this.scalingOrResizing = this.scalingOrResizing.bind(this); + this.moving = this.moving.bind(this); + this.beforeRender = this.beforeRender.bind(this); + this.afterRender = this.afterRender.bind(this); - const getCaCheMapValue = (object: FabricObject) => { + this.initBehavior(); + } + initBehavior() { + this.canvas.on('mouse:up', this.mouseUp); + this.canvas.on('object:resizing', this.scalingOrResizing); + this.canvas.on('object:scaling', this.scalingOrResizing); + this.canvas.on('object:moving', this.moving); + this.canvas.on('before:render', this.beforeRender); + this.canvas.on('after:render', this.afterRender); + } + /** Returns shapes that can draw aligning lines, default returns all shapes on the canvas excluding groups. */ + getObjectsByTarget(target: FabricObject) { + return getObjectsByTarget(target); + } + /** When the user customizes the controller, this property is set to enable or disable automatic alignment through point scaling/resizing. */ + getPointMap(target: FabricObject) { + return getPointMap(target); + } + /** When the user customizes the controller, this property is used to enable or disable alignment positioning through points. */ + getContraryMap(target: FabricObject) { + return getContraryMap(target); + } + /** Users can customize. */ + getCaCheMapValue(object: FabricObject) { const cacheKey = [ object.calcTransformMatrix().toString(), object.width, object.height, ].join(); - const cacheValue = cacheMap.get(cacheKey); + const cacheValue = this.cacheMap.get(cacheKey); if (cacheValue) return cacheValue; - const coords = object.getCoords(); - const rect = util.makeBoundingBoxFromPoints(coords); - const value: [TBBox, Point[]] = [rect, coords]; - cacheMap.set(cacheKey, value); + const value = object.getCoords(); + value.push(object.getCenterPoint()); + this.cacheMap.set(cacheKey, value); return value; - }; - - function moving(e: TransformEvent) { - const activeObject = e.target; - activeObject.setCoords(); - onlyDrawPoint = false; - verticalLines.clear(); - horizontalLines.clear(); - - const objects = getObjectsByTarget(activeObject); - const activeObjectRect = activeObject.getBoundingRect(); - - for (const object of objects) { - const objectRect = getCaCheMapValue(object)[0]; - const { vLines, hLines } = collectLine({ - activeObject, - activeObjectRect, - objectRect, - }); - vLines.forEach((o) => { - verticalLines.add(JSON.stringify(o)); - }); - hLines.forEach((o) => { - horizontalLines.add(JSON.stringify(o)); - }); - } + } + drawLine(origin: Point, target: Point) { + drawLine.call(this, origin, target); + } + drawX(point: Point, dir: number) { + drawX.call(this, point, dir); + } + mouseUp() { + this.verticalLines.clear(); + this.horizontalLines.clear(); + this.cacheMap.clear(); + this.canvas.requestRenderAll(); } - function scalingOrResizing(e: TransformEvent) { - // br bl tr tl mb ml mt mr - const activeObject = e.target; - activeObject.setCoords(); + scalingOrResizing(e: TransformEvent) { + const target = e.target; + // We need to obtain the real-time coordinates of the current object, so we need to update them in real-time + target.setCoords(); + // The value of action can be scaleX, scaleY, scale, resize, etc. + // If it does not start with "scale," it is considered a modification of size. const isScale = String(e.transform.action).startsWith('scale'); - verticalLines.clear(); - horizontalLines.clear(); + this.verticalLines.clear(); + this.horizontalLines.clear(); - const objects = getObjectsByTarget(activeObject); + const objects = this.getObjectsByTarget(target); + // When the shape is flipped, the tl obtained through getCoords is actually tr, + // and tl is actually tr. We need to make correction adjustments. + // tr <-> tl、 bl <-> br、 mb <-> mt、 ml <-> mr let corner = e.transform.corner; - if (activeObject.flipX) corner = corner.replace('l', 'r').replace('r', 'l'); - if (activeObject.flipY) corner = corner.replace('t', 'b').replace('b', 't'); - let index = ['tl', 'tr', 'br', 'bl', 'mt', 'mr', 'mb', 'ml'].indexOf( - corner, - ); - if (index == -1) return; - onlyDrawPoint = index > 3; - if (onlyDrawPoint) { - const angle = activeObject.getTotalAngle(); + if (target.flipX) { + if (corner.includes('l')) corner = corner.replace('l', 'r'); + else if (corner.includes('r')) corner = corner.replace('r', 'l'); + } + if (target.flipY) { + if (corner.includes('t')) corner = corner.replace('t', 'b'); + else if (corner.includes('b')) corner = corner.replace('b', 't'); + } + + // Obtain the coordinates of the current operation point through the value of corner. + // users can be allowed to customize and pass in custom corners. + const pointMap = this.getPointMap(target); + if (!(corner in pointMap)) return; + this.onlyDrawPoint = corner.includes('m'); + if (this.onlyDrawPoint) { + const angle = target.getTotalAngle(); + // When the shape is rotated, it is meaningless to draw points using the center point. if (angle % 90 != 0) return; - index -= 4; } - let point = activeObject.getCoords()[index]; + // If manipulating tl, then when the shape changes size, it should be positioned by br, + // and the same applies to others. + // users can be allowed to customize and pass in custom corners. + const contraryMap = this.getContraryMap(target); + const point = pointMap[corner]; + let diagonalPoint = contraryMap[corner]; + // When holding the centerKey (default is altKey), the shape will scale based on the center point, with the reference point being the center. + const isCenter = + e.transform.original.originX == 'center' && + e.transform.original.originY == 'center'; + if (isCenter) { + const p = target.group + ? point.transform( + util.invertTransform(target.group.calcTransformMatrix()), + ) + : point; + diagonalPoint = diagonalPoint.add(p).scalarDivide(2); + } + const uniformIsToggled = e.e[this.canvas.uniScaleKey!]; + let isUniform = + (this.canvas.uniformScaling && !uniformIsToggled) || + (!this.canvas.uniformScaling && uniformIsToggled); + // When controlling through the center point, + // if isUniform is true, it actually changes the skew, so it is meaningless. + if (this.onlyDrawPoint) isUniform = false; + + const list: Point[] = []; for (const object of objects) { - const [rect, coords] = getCaCheMapValue(object); - const center = new Point( - rect.left + rect.width / 2, - rect.top + rect.height / 2, - ); - const list = [...coords, center]; - const props = { activeObject, point, list, isScale, index }; - const vLines = collectVerticalPoint(props); - const hLines = collectHorizontalPoint(props); - vLines.forEach((o) => { - verticalLines.add(JSON.stringify(o)); - }); - hLines.forEach((o) => { - horizontalLines.add(JSON.stringify(o)); - }); - if (vLines.length || hLines.length) - point = activeObject.getCoords()[index]; + const d = this.getCaCheMapValue(object); + list.push(...d); } + + const props = { + target, + point, + diagonalPoint, + corner, + list, + isScale, + isUniform, + isCenter, + }; + + // Obtain horizontal and vertical reference lines. + const noNeedToCollectV = + this.onlyDrawPoint && (corner.includes('t') || corner.includes('b')); + const noNeedToCollectH = + this.onlyDrawPoint && (corner.includes('l') || corner.includes('r')); + const vList = noNeedToCollectV + ? [] + : collectVerticalPoint.call(this, props); + const hList = noNeedToCollectH + ? [] + : collectHorizontalPoint.call(this, props); + vList.forEach((o) => { + // Objects cannot be deduplicated; convert them to strings for deduplication. + this.verticalLines.add(JSON.stringify(o)); + }); + hList.forEach((o) => { + // Objects cannot be deduplicated; convert them to strings for deduplication. + this.horizontalLines.add(JSON.stringify(o)); + }); } + moving(e: TransformEvent) { + const target = e.target; + // We need to obtain the real-time coordinates of the current object, so we need to update them in real-time + target.setCoords(); + this.onlyDrawPoint = false; + this.verticalLines.clear(); + this.horizontalLines.clear(); - function beforeRender() { - canvas.clearContext(canvas.contextTop); + // Find the shapes associated with the current graphic to draw reference lines for it. + const objects = this.getObjectsByTarget(target); + const points: Point[] = []; + // Collect all the points to draw reference lines. + for (const object of objects) points.push(...this.getCaCheMapValue(object)); + + // Obtain horizontal and vertical reference lines. + const { vLines, hLines } = collectLine.call(this, target, points); + vLines.forEach((o) => { + // Objects cannot be deduplicated; convert them to strings for deduplication. + this.verticalLines.add(JSON.stringify(o)); + }); + hLines.forEach((o) => { + // Objects cannot be deduplicated; convert them to strings for deduplication. + this.horizontalLines.add(JSON.stringify(o)); + }); + } + beforeRender() { + this.canvas.clearContext(this.canvas.contextTop); } - function afterRender() { - if (onlyDrawPoint) { - const list: Array = []; - for (const v of verticalLines) list.push(JSON.parse(v)); - for (const h of horizontalLines) list.push(JSON.parse(h)); - drawPointList(canvas, list); + afterRender() { + if (this.onlyDrawPoint) { + drawPointList.call(this); } else { - for (const v of verticalLines) drawVerticalLine(canvas, JSON.parse(v)); - for (const h of horizontalLines) - drawHorizontalLine(canvas, JSON.parse(h)); + drawVerticalLine.call(this); + drawHorizontalLine.call(this); } } - function mouseUp() { - verticalLines.clear(); - horizontalLines.clear(); - cacheMap.clear(); - canvas.requestRenderAll(); - } - - canvas.on('object:resizing', scalingOrResizing); - canvas.on('object:scaling', scalingOrResizing); - canvas.on('object:moving', moving); - canvas.on('before:render', beforeRender); - canvas.on('after:render', afterRender); - canvas.on('mouse:up', mouseUp); - return () => { - canvas.off('object:resizing', scalingOrResizing); - canvas.off('object:scaling', scalingOrResizing); - canvas.off('object:moving', moving); - canvas.off('before:render', beforeRender); - canvas.off('after:render', afterRender); - canvas.off('mouse:up', mouseUp); - }; + dispose() { + this.canvas.off('mouse:up', this.mouseUp); + this.canvas.off('object:resizing', this.scalingOrResizing); + this.canvas.off('object:scaling', this.scalingOrResizing); + this.canvas.off('object:moving', this.moving); + this.canvas.off('before:render', this.beforeRender); + this.canvas.off('after:render', this.afterRender); + } } diff --git a/extensions/aligning_guidelines/typedefs.ts b/extensions/aligning_guidelines/typedefs.ts index 7db906de427..0c1b973079f 100644 --- a/extensions/aligning_guidelines/typedefs.ts +++ b/extensions/aligning_guidelines/typedefs.ts @@ -1,30 +1,13 @@ -export type VerticalLine = { - x: number; - y1: number; - y2: number; -}; +import type { FabricObject, Point, TOriginX, TOriginY } from 'fabric'; -export type HorizontalLine = { - y: number; - x1: number; - x2: number; +export type LineProps = { + origin: Point; + target: Point; }; -export type VerticalLineProps = { - x: number; - objectY: number; - objectHeight: number; - activeObjectY: number; - activeObjectHeight: number; -}; +export type PointMap = { [props: string]: Point }; -export type HorizontalLineProps = { - y: number; - objectX: number; - objectWidth: number; - activeObjectX: number; - activeObjectWidth: number; -}; +export type OriginMap = { [props: string]: [TOriginX, TOriginY] }; export type AligningLineConfig = { /** At what distance from the shape does alignment begin? */ @@ -33,4 +16,26 @@ export type AligningLineConfig = { width: number; /** Aligning line color */ color: string; + /** The size of endpoint x, default is 2.4 */ + xSize: number; + /** Dashed Line Style */ + lineDash: number[] | undefined; + /** Close Vertical line, default false. */ + closeVLine: boolean; + /** Close horizontal line, default false. */ + closeHLine: boolean; + /** Returns shapes that can draw aligning lines, default returns all shapes on the canvas excluding groups. */ + getObjectsByTarget?: (target: FabricObject) => Set; + /** When the user customizes the controller, this property is set to enable or disable automatic alignment through point scaling/resizing. */ + getPointMap?: (target: FabricObject) => PointMap; + /** When the user customizes the controller, this property is used to enable or disable alignment positioning through points. */ + getContraryMap?: (target: FabricObject) => PointMap; + /** Alignment method is required when customizing. */ + contraryOriginMap?: OriginMap; + /** Custom Line Drawing */ + drawLine?: (origin: Point, target: Point) => void; + /** Custom Endpoint Drawing */ + drawX?: (point: Point, dir: number) => void; + /** When moving a shape, the coordinates of other shapes are calculated only once. You can customize how to cache or clear them */ + getCaCheMapValue?: Point[]; }; diff --git a/extensions/aligning_guidelines/util/basic.spec.ts b/extensions/aligning_guidelines/util/basic.spec.ts index 7f9aec5dce6..c7573b40f5b 100644 --- a/extensions/aligning_guidelines/util/basic.spec.ts +++ b/extensions/aligning_guidelines/util/basic.spec.ts @@ -1,10 +1,15 @@ -import { getDistance, setPositionDir } from './basic'; -import { Rect } from '../../../src/shapes/Rect'; -import { Point } from '../../../src/Point'; import { describe, expect, it } from 'vitest'; +import { Point } from '../../../src/Point'; +import { Rect } from '../../../src/shapes/Rect'; +import { + getContraryMap, + getDistance, + getDistanceList, + getPointMap, +} from './basic'; describe('getDistance', () => { - it('returns the distabnce between the 2 numbers', () => { + it('returns the distance between the 2 numbers', () => { expect(getDistance(4, 6)).toBe(2); expect(getDistance(6, 4)).toBe(2); expect(getDistance(-6, -4)).toBe(2); @@ -12,21 +17,74 @@ describe('getDistance', () => { }); }); -describe('setPositionDir', () => { - it('set the position of the object', () => { - const rect = new Rect({ - width: 100, - height: 50, - originX: 'left', - originY: 'top', - left: 100, - top: 100, +describe('getDistanceList', () => { + it('returns the distance list', () => { + // getDistanceList point: Point, list: Point[], type: 'x' | 'y' + const point = new Point(0, 0); + const list = [ + new Point(2, 3), + new Point(-2, -3), + new Point(3, 3), + new Point(4, 4), + ]; + const xList = getDistanceList(point, list, 'x'); + expect(xList.dis).toBe(2); + expect(xList.arr).toEqual([list[0], list[1]]); + + const yList = getDistanceList(point, list, 'y'); + expect(yList.dis).toBe(3); + expect(yList.arr).toEqual(list.slice(0, 3)); + }); +}); + +describe('getPointMap', () => { + it('returns the pointMap', () => { + const target = new Rect({ + left: rnd(-100, 100), + top: rnd(-100, 100), + width: rnd(100), + height: rnd(100), + }); + const coords = target.getCoords(); + const pointMap = getPointMap(target); + expect([pointMap.tl, pointMap.tr, pointMap.br, pointMap.bl]).toEqual( + coords, + ); + const arr = []; + const p = coords.concat(coords[0]); + for (let i = 0; i < coords.length; i++) { + arr.push(p[i].add(p[i + 1]).scalarDivide(2)); + } + expect([pointMap.mt, pointMap.mr, pointMap.mb, pointMap.ml]).toEqual(arr); + }); +}); + +describe('getContraryMap', () => { + it('returns the contraryMap', () => { + const target = new Rect({ + left: rnd(-100, 100), + top: rnd(-100, 100), + width: rnd(100), + height: rnd(100), }); - setPositionDir(rect, new Point(10, 15), 'x'); - expect(rect.left).toEqual(-40.5); - expect(rect.top).toEqual(100); - setPositionDir(rect, new Point(10, 15), 'y'); - expect(rect.left).toEqual(-40.5); - expect(rect.top).toEqual(-10.5); + const aCoords = target.calcACoords(); + const pointMap = getContraryMap(target); + const { tl, tr, br, bl, mt, mr, ml, mb } = pointMap; + expect(tl).toEqual(aCoords.br); + expect(tr).toEqual(aCoords.bl); + expect(br).toEqual(aCoords.tl); + expect(bl).toEqual(aCoords.tr); + expect(mt).toEqual(aCoords.br.add(aCoords.bl).scalarDivide(2)); + expect(mr).toEqual(aCoords.bl.add(aCoords.tl).scalarDivide(2)); + expect(mb).toEqual(aCoords.tl.add(aCoords.tr).scalarDivide(2)); + expect(ml).toEqual(aCoords.tr.add(aCoords.br).scalarDivide(2)); }); }); + +function rnd(start: number, end?: number) { + if (end == undefined) { + end = start; + start = 0; + } + return Math.floor(Math.random() * (end - start)) + start; +} diff --git a/extensions/aligning_guidelines/util/basic.ts b/extensions/aligning_guidelines/util/basic.ts index d3431d93dc7..9f0ca68de7f 100644 --- a/extensions/aligning_guidelines/util/basic.ts +++ b/extensions/aligning_guidelines/util/basic.ts @@ -1,20 +1,50 @@ import type { FabricObject, Point } from 'fabric'; +import type { PointMap } from '../typedefs'; export function getDistance(a: number, b: number) { return Math.abs(a - b); } -export function setPositionDir( - target: FabricObject, - pos: Point, - dir: 'x' | 'y', -) { - const center = target.translateToCenterPoint(pos, 'center', 'center'); - const position = target.translateToOriginPoint( - center, - target.originX, - target.originY, - ); - if (dir == 'x') target.setX(position.x); - else target.setY(position.y); +export function getDistanceList(point: Point, list: Point[], type: 'x' | 'y') { + let dis = Infinity; + let arr: Point[] = []; + for (const item of list) { + const v = getDistance(point[type], item[type]); + if (dis > v) { + arr = []; + dis = v; + } + if (dis == v) { + arr.push(item); + } + } + return { dis, arr }; +} + +export function getPointMap(target: FabricObject): PointMap { + const coords = target.getCoords(); + return { + tl: coords[0], + tr: coords[1], + br: coords[2], + bl: coords[3], + mt: coords[0].add(coords[1]).scalarDivide(2), + mr: coords[1].add(coords[2]).scalarDivide(2), + mb: coords[2].add(coords[3]).scalarDivide(2), + ml: coords[3].add(coords[0]).scalarDivide(2), + }; +} + +export function getContraryMap(target: FabricObject): PointMap { + const aCoords = target.aCoords ?? target.calcACoords(); + return { + tl: aCoords.br, + tr: aCoords.bl, + br: aCoords.tl, + bl: aCoords.tr, + mt: aCoords.br.add(aCoords.bl).scalarDivide(2), + mr: aCoords.bl.add(aCoords.tl).scalarDivide(2), + mb: aCoords.tl.add(aCoords.tr).scalarDivide(2), + ml: aCoords.tr.add(aCoords.br).scalarDivide(2), + }; } diff --git a/extensions/aligning_guidelines/util/collect-line.ts b/extensions/aligning_guidelines/util/collect-line.ts index 9ccfd8f99d6..ef3f9d011bf 100644 --- a/extensions/aligning_guidelines/util/collect-line.ts +++ b/extensions/aligning_guidelines/util/collect-line.ts @@ -1,172 +1,65 @@ -import type { FabricObject, TBBox } from 'fabric'; -import { Point } from 'fabric'; -import type { HorizontalLine, VerticalLine } from '../typedefs'; -import { aligningLineConfig } from '../constant'; -import { getDistance, setPositionDir } from './basic'; +import type { FabricObject, Point, TOriginX, TOriginY } from 'fabric'; +import type { AligningGuidelines } from '..'; +import type { LineProps } from '../typedefs'; +import { getDistanceList } from './basic'; -type CollectLineProps = { - activeObject: FabricObject; - activeObjectRect: TBBox; - objectRect: TBBox; -}; - -export function collectLine(props: CollectLineProps) { - const aligningLineMargin = aligningLineConfig.margin; - const { activeObject, activeObjectRect, objectRect } = props; - const list = makeLineByRect(objectRect); - const aList = makeLineByRect(activeObjectRect); - const margin = aligningLineMargin / (activeObject.canvas?.getZoom() ?? 1); - const opts = { target: activeObject, list, aList, margin }; - const vLines = collectVerticalLine(opts); - const hLines = collectHorizontalLine(opts); +export function collectLine( + this: AligningGuidelines, + target: FabricObject, + points: Point[], +) { + const list = target.getCoords(); + list.push(target.getCenterPoint()); + const margin = this.margin / this.canvas.getZoom(); + const opts = { target, list, points, margin }; + const vLines = collectPoints({ ...opts, type: 'x' }); + const hLines = collectPoints({ ...opts, type: 'y' }); return { vLines, hLines }; } type CollectItemLineProps = { target: FabricObject; - list: LineProps[]; - aList: LineProps[]; + list: Point[]; + points: Point[]; margin: number; + type: 'x' | 'y'; }; -function collectVerticalLine(props: CollectItemLineProps) { - const { target, list, aList, margin } = props; - - const arr = aList.map((x) => getDistanceLine(x, list, 'x')); - const min = Math.min(...arr.map((x) => x.dis)); - if (min > margin) return []; - const lines: VerticalLine[] = []; - const width = aList[0].x2 - aList[0].x; - const height = aList[0].y2 - aList[0].y; - let b = false; - for (let i = 0; i < arr.length; i++) { - const item = arr[i]; - if (min == item.dis) { - const line = list[item.index]; - const aLine = aList[item.index]; - const x = line.x; - const y = aLine.y; - - const y1 = Math.min(line.y, line.y2, y, aLine.y2); - const y2 = Math.max(line.y, line.y2, y, aLine.y2); - // Multiple reference lines can be drawn - lines.push({ x, y1, y2 }); - if (b) continue; - b = true; - // Alignment is performed only once - setPos({ - target, - x, - y, - centerX: i - 1, - centerY: item.index - 1, - width, - height, - dir: 'x', - }); - const dis = min * item.dir; - aList.forEach((x) => (x.x -= dis)); - } +const originArr: [TOriginX, TOriginY][] = [ + ['left', 'top'], + ['right', 'top'], + ['right', 'bottom'], + ['left', 'bottom'], + ['center', 'center'], +]; +function collectPoints(props: CollectItemLineProps) { + const { target, list, points, margin, type } = props; + const res: LineProps[] = []; + const arr: ReturnType[] = []; + let min = Infinity; + for (const item of list) { + const o = getDistanceList(item, points, type); + arr.push(o); + if (min > o.dis) min = o.dis; } - return lines; -} - -function collectHorizontalLine(props: CollectItemLineProps) { - const { target, list, aList, margin } = props; - - const arr = aList.map((x) => getDistanceLine(x, list, 'y')); - const min = Math.min(...arr.map((x) => x.dis)); - if (min > margin) return []; - const lines: HorizontalLine[] = []; - const width = aList[0].x2 - aList[0].x; - const height = aList[0].y2 - aList[0].y; + if (min > margin) return res; let b = false; - for (let i = 0; i < arr.length; i++) { - const item = arr[i]; - if (min == item.dis) { - const line = list[item.index]; - const aLine = aList[item.index]; - const y = line.y; - const x = aLine.x; - - const x1 = Math.min(line.x, line.x2, x, aLine.x2); - const x2 = Math.max(line.x, line.x2, x, aLine.x2); - // Multiple reference lines can be drawn - lines.push({ y, x1, x2 }); - if (b) continue; - b = true; - // Alignment is performed only once - setPos({ - target, - x, - y, - centerX: item.index - 1, - centerY: i - 1, - width, - height, - dir: 'y', - }); - const dis = min * item.dir; - aList.forEach((x) => (x.y -= dis)); - } - } - return lines; -} - -type LineProps = { - x: number; - y: number; - x2: number; - y2: number; -}; -function getDistanceLine( - target: LineProps, - list: LineProps[], - type: 'x' | 'y', -) { - let dis = Infinity; - let index = -1; - /** 1 for positive value, -1 for negative value */ - let dir = 1; for (let i = 0; i < list.length; i++) { - const v = getDistance(target[type], list[i][type]); - if (dis > v) { - index = i; - dis = v; - dir = target[type] > list[i][type] ? 1 : -1; + if (arr[i].dis != min) continue; + for (const item of arr[i].arr) { + res.push({ origin: list[i], target: item }); } - } - return { dis, index, dir }; -} - -function makeLineByRect(rect: TBBox) { - const { left, top, width, height } = rect; - const a = { x: left, y: top, x2: left + width, y2: top + height }; - const x = left + width / 2; - const y = top + height / 2; - const b = { x, y, x2: x, y2: y }; - const c = { x: left + width, x2: left, y: top + height, y2: top }; - return [a, b, c]; -} + if (b) continue; + b = true; + const d = arr[i].arr[0][type] - list[i][type]; + // It will change the original data, and the next time we collect y, use the modified data. + list.forEach((item) => { + item[type] += d; + }); + target.setXY(list[i], ...originArr[i]); + target.setCoords(); + } -type SnapToPixelProps = { - target: FabricObject; - x: number; - y: number; - /** -1 0 1 */ - centerX: number; - /** -1 0 1 */ - centerY: number; - width: number; - height: number; - dir: 'x' | 'y'; -}; -function setPos(props: SnapToPixelProps) { - const { target, centerX, centerY, width, height, dir } = props; - let { x, y } = props; - x -= (centerX * width) / 2; - y -= (centerY * height) / 2; - setPositionDir(target, new Point(x, y), dir); - target.setCoords(); + return res; } diff --git a/extensions/aligning_guidelines/util/collect-point.ts b/extensions/aligning_guidelines/util/collect-point.ts index a60236dafc6..0fd02e39344 100644 --- a/extensions/aligning_guidelines/util/collect-point.ts +++ b/extensions/aligning_guidelines/util/collect-point.ts @@ -1,83 +1,116 @@ -import type { FabricObject, Point, TOriginX, TOriginY } from 'fabric'; -import { aligningLineConfig } from '../constant'; -import { getDistance } from './basic'; +import type { FabricObject, Point } from 'fabric'; +import type { AligningGuidelines } from '..'; +import type { LineProps } from '../typedefs'; +import { getDistanceList } from './basic'; type CollectPointProps = { - activeObject: FabricObject; + target: FabricObject; + /** Operation points of the target element: top-left, bottom-left, top-right, bottom-right */ point: Point; + /** Position using diagonal points when resizing/scaling. */ + diagonalPoint: Point; + /** Set of points to consider for alignment: [tl, tr, br, bl, center] */ list: Point[]; + /** Change the zoom or change the size, determine by whether e.transform.action starts with the string "scale" */ isScale: boolean; - index: number; + /** Whether to change uniformly is determined by canvas.uniformScaling and canvas.uniScaleKey. */ + isUniform: boolean; + /** When holding the centerKey (default is altKey), the shape will scale based on the center point, with the reference point being the center. */ + isCenter: boolean; + /** tl、tr、br、bl、mt、mr、mb、ml */ + corner: string; }; -const originXArr: TOriginX[] = ['left', 'center', 'right']; -const originYArr: TOriginY[] = ['top', 'center', 'bottom']; -export function collectVerticalPoint(props: CollectPointProps) { - const aligningLineMargin = aligningLineConfig.margin; - const { activeObject, isScale, index, point, list } = props; +export function collectVerticalPoint( + this: AligningGuidelines, + props: CollectPointProps, +): LineProps[] { + const { + target, + isScale, + isUniform, + corner, + point, + diagonalPoint, + list, + isCenter, + } = props; const { dis, arr } = getDistanceList(point, list, 'x'); - const margin = aligningLineMargin / (activeObject.canvas?.getZoom() ?? 1); + const margin = this.margin / this.canvas.getZoom(); if (dis > margin) return []; let v = arr[arr.length - 1].x - point.x; - const dir = index == 0 || index == 3 ? -1 : 1; - v *= dir; + // tl bl ml + // If modifying on the left side, the size decreases; conversely, it increases. + const dirX = corner.includes('l') ? -1 : 1; + v *= dirX; - const { width, scaleX, left } = activeObject; - const dim = activeObject._getTransformedDimensions(); - const sx = (v + dim.x) / dim.x; - if (isScale) activeObject.set('scaleX', scaleX * sx); - else activeObject.set('width', width * sx); - const dArr = [0, (v / 2) * dir, v * dir]; - if (dir < 0) dArr.reverse(); - const d = dArr[originXArr.indexOf(activeObject.originX)]; - activeObject.set('left', left + d); - activeObject.setCoords(); - return arr.map((item) => ({ - x: item.x, - y1: item.y, - y2: point.y, - })); + const { width, height, scaleX, scaleY } = target; + // Because when modifying through the center point, isUniform is always false, so skew does not need to be considered. + const dStrokeWidth = target.strokeUniform ? 0 : target.strokeWidth; + const scaleWidth = scaleX * width + dStrokeWidth; + const sx = (v + scaleWidth) / scaleWidth; + // When v equals -scaleWidth, sx equals 0. + if (sx == 0) return []; + if (isScale) { + target.set('scaleX', scaleX * sx); + if (isUniform) target.set('scaleY', scaleY * sx); + } else { + target.set('width', width * sx); + if (isUniform) target.set('height', height * sx); + } + if (isCenter) { + target.setRelativeXY(diagonalPoint, 'center', 'center'); + } else { + const originArr = this.contraryOriginMap; + target.setRelativeXY(diagonalPoint, ...originArr[corner]); + } + target.setCoords(); + return arr.map((target) => ({ origin: point, target })); } -export function collectHorizontalPoint(props: CollectPointProps) { - const aligningLineMargin = aligningLineConfig.margin; - const { activeObject, isScale, index, point, list } = props; +export function collectHorizontalPoint( + this: AligningGuidelines, + props: CollectPointProps, +): LineProps[] { + const { + target, + isScale, + isUniform, + corner, + point, + diagonalPoint, + list, + isCenter, + } = props; const { dis, arr } = getDistanceList(point, list, 'y'); - const margin = aligningLineMargin / (activeObject.canvas?.getZoom() ?? 1); + const margin = this.margin / this.canvas.getZoom(); if (dis > margin) return []; let v = arr[arr.length - 1].y - point.y; - const dir = index < 2 ? -1 : 1; - v *= dir; + // tl mt tr + // If modifying on the top side, the size decreases; conversely, it increases. + const dirY = corner.includes('t') ? -1 : 1; + v *= dirY; - const { height, scaleY, top } = activeObject; - const dim = activeObject._getTransformedDimensions(); - const sy = (v + dim.y) / dim.y; - if (isScale) activeObject.set('scaleY', scaleY * sy); - else activeObject.set('height', height * sy); - const dArr = [0, (v / 2) * dir, v * dir]; - if (dir < 0) dArr.reverse(); - const d = dArr[originYArr.indexOf(activeObject.originY)]; - activeObject.set('top', top + d); - activeObject.setCoords(); - return arr.map((item) => ({ - y: item.y, - x1: item.x, - x2: point.x, - })); -} - -function getDistanceList(point: Point, list: Point[], type: 'x' | 'y') { - let dis = Infinity; - let arr: Point[] = []; - for (const item of list) { - const v = getDistance(point[type], item[type]); - if (dis > v) { - arr = []; - dis = v; - } - if (dis == v) { - arr.push(item); - } + const { width, height, scaleX, scaleY } = target; + // Because when modifying through the center point, isUniform is always false, so skew does not need to be considered. + const dStrokeWidth = target.strokeUniform ? 0 : target.strokeWidth; + const scaleHeight = scaleY * height + dStrokeWidth; + const sy = (v + scaleHeight) / scaleHeight; + // When v equals -scaleHeight, sy equals 0. + if (sy == 0) return []; + if (isScale) { + target.set('scaleY', scaleY * sy); + if (isUniform) target.set('scaleX', scaleX * sy); + } else { + target.set('height', height * sy); + if (isUniform) target.set('width', width * sy); + } + if (isCenter) { + target.setRelativeXY(diagonalPoint, 'center', 'center'); + } else { + const originArr = this.contraryOriginMap; + target.setRelativeXY(diagonalPoint, ...originArr[corner]); } - return { dis, arr }; + target.setCoords(); + return arr.map((target) => ({ origin: point, target })); } diff --git a/extensions/aligning_guidelines/util/draw.ts b/extensions/aligning_guidelines/util/draw.ts index dcac5f0f555..61e473abc9a 100644 --- a/extensions/aligning_guidelines/util/draw.ts +++ b/extensions/aligning_guidelines/util/draw.ts @@ -1,29 +1,34 @@ -import type { Canvas } from 'fabric'; import { Point } from 'fabric'; -import type { HorizontalLine, VerticalLine } from '../typedefs'; -import { aligningLineConfig } from '../constant'; +import type { AligningGuidelines } from '..'; -function drawLine(canvas: Canvas, origin: Point, target: Point) { - const { width, color } = aligningLineConfig; - const ctx = canvas.getSelectionContext(); - const viewportTransform = canvas.viewportTransform; - const zoom = canvas.getZoom(); +export function drawLine( + this: AligningGuidelines, + origin: Point, + target: Point, +) { + const ctx = this.canvas.getTopContext(); + const viewportTransform = this.canvas.viewportTransform; + const zoom = this.canvas.getZoom(); ctx.save(); ctx.transform(...viewportTransform); - ctx.lineWidth = width / zoom; - ctx.strokeStyle = color; + ctx.lineWidth = this.width / zoom; + if (this.lineDash) ctx.setLineDash(this.lineDash); + ctx.strokeStyle = this.color; ctx.beginPath(); ctx.moveTo(origin.x, origin.y); ctx.lineTo(target.x, target.y); ctx.stroke(); - drawX(ctx, zoom, origin); - drawX(ctx, zoom, target); + if (this.lineDash) ctx.setLineDash([]); + + this.drawX(origin, -1); + this.drawX(target, 1); ctx.restore(); } -const xSize = 2.4; -function drawX(ctx: CanvasRenderingContext2D, zoom: number, point: Point) { - const size = xSize / zoom; +export function drawX(this: AligningGuidelines, point: Point, _: number) { + const ctx = this.canvas.getTopContext(); + const zoom = this.canvas.getZoom(); + const size = this.xSize / zoom; ctx.save(); ctx.translate(point.x, point.y); ctx.beginPath(); @@ -34,41 +39,46 @@ function drawX(ctx: CanvasRenderingContext2D, zoom: number, point: Point) { ctx.stroke(); ctx.restore(); } -function drawPoint(canvas: Canvas, arr: Point[]) { - const { width, color } = aligningLineConfig; - const ctx = canvas.getSelectionContext(); - const viewportTransform = canvas.viewportTransform; - const zoom = canvas.getZoom(); +function drawPoint(this: AligningGuidelines, arr: Point[]) { + const ctx = this.canvas.getTopContext(); + const viewportTransform = this.canvas.viewportTransform; + const zoom = this.canvas.getZoom(); ctx.save(); ctx.transform(...viewportTransform); - ctx.lineWidth = width / zoom; - ctx.strokeStyle = color; - for (const item of arr) drawX(ctx, zoom, item); + ctx.lineWidth = this.width / zoom; + ctx.strokeStyle = this.color; + for (const item of arr) this.drawX(item, 0); ctx.restore(); } -export function drawPointList( - canvas: Canvas, - list: Array, -) { - const arr = list.map((item) => { - const isVertical = 'y2' in item; - const x = isVertical ? item.x : item.x1; - const y = isVertical ? item.y1 : item.y; - return new Point(x, y); - }); - drawPoint(canvas, arr); + +export function drawPointList(this: AligningGuidelines) { + const list = []; + if (!this.closeVLine) { + for (const v of this.verticalLines) list.push(JSON.parse(v)); + } + if (!this.closeHLine) { + for (const h of this.horizontalLines) list.push(JSON.parse(h)); + } + const arr = list.map((item) => item.target); + drawPoint.call(this, arr); } -export function drawVerticalLine(canvas: Canvas, coords: VerticalLine) { - const x = coords.x; - const origin = new Point(x, coords.y1); - const target = new Point(x, coords.y2); - drawLine(canvas, origin, target); +export function drawVerticalLine(this: AligningGuidelines) { + if (this.closeVLine) return; + + for (const v of this.verticalLines) { + const { origin, target } = JSON.parse(v); + const o = new Point(target.x, origin.y); + this.drawLine(o, target); + } } -export function drawHorizontalLine(canvas: Canvas, coords: HorizontalLine) { - const y = coords.y; - const origin = new Point(coords.x1, y); - const target = new Point(coords.x2, y); - drawLine(canvas, origin, target); +export function drawHorizontalLine(this: AligningGuidelines) { + if (this.closeHLine) return; + + for (const v of this.horizontalLines) { + const { origin, target } = JSON.parse(v); + const o = new Point(origin.x, target.y); + this.drawLine(o, target); + } } diff --git a/extensions/index.ts b/extensions/index.ts index e3ac83f98cd..f921d017778 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -1,7 +1,5 @@ -export { - initAligningGuidelines, - type AligningLineConfig, -} from './aligning_guidelines'; +export { AligningGuidelines } from './aligning_guidelines'; +export type * from './aligning_guidelines/typedefs'; export { originUpdaterWrapper,