From cca39587c20067030af70d6a2d2dc030760cc1bf Mon Sep 17 00:00:00 2001 From: maurice_kindermann Date: Thu, 15 Jan 2026 11:04:35 +1000 Subject: [PATCH] feat(extensions): Migrate centering_guidelines from lib/ to extension Co-Authored-By: Claude Opus 4.5 --- extensions/centering_guidelines/README.MD | 34 +++++ extensions/centering_guidelines/demo.html | 26 ++++ extensions/centering_guidelines/index.spec.ts | 74 +++++++++ extensions/centering_guidelines/index.ts | 140 ++++++++++++++++++ extensions/index.ts | 3 + lib/centering_guidelines.js | 90 ----------- 6 files changed, 277 insertions(+), 90 deletions(-) create mode 100644 extensions/centering_guidelines/README.MD create mode 100644 extensions/centering_guidelines/demo.html create mode 100644 extensions/centering_guidelines/index.spec.ts create mode 100644 extensions/centering_guidelines/index.ts delete mode 100644 lib/centering_guidelines.js diff --git a/extensions/centering_guidelines/README.MD b/extensions/centering_guidelines/README.MD new file mode 100644 index 00000000000..fa81aa0518e --- /dev/null +++ b/extensions/centering_guidelines/README.MD @@ -0,0 +1,34 @@ +# Centering Guidelines + +Shows guidelines and snaps objects when dragged to the canvas center. + +## How to use it + +```ts +import { CenteringGuidelines } from 'fabric/extensions'; + +const centeringGuidelines = new CenteringGuidelines(canvas); + +// To disable later: +centeringGuidelines.dispose(); +``` + +## Options + +```ts +new CenteringGuidelines(canvas, { + margin: 4, // Snap distance in pixels + color: 'rgba(255,0,241,0.5)', // Line color + width: 1, // Line width +}); +``` + +## Using with script tags + +```html + + + +``` diff --git a/extensions/centering_guidelines/demo.html b/extensions/centering_guidelines/demo.html new file mode 100644 index 00000000000..6461b0b6341 --- /dev/null +++ b/extensions/centering_guidelines/demo.html @@ -0,0 +1,26 @@ + + + + Centering Guidelines + + + +

Drag objects to canvas center to see snap guidelines

+ + + + + + + diff --git a/extensions/centering_guidelines/index.spec.ts b/extensions/centering_guidelines/index.spec.ts new file mode 100644 index 00000000000..958bee9529f --- /dev/null +++ b/extensions/centering_guidelines/index.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Canvas } from '../../src/canvas/Canvas'; +import { Rect } from '../../src/shapes/Rect'; +import { CenteringGuidelines } from './index'; + +describe('CenteringGuidelines', () => { + let canvas: Canvas; + + beforeEach(() => { + canvas = new Canvas(); + canvas.setDimensions({ width: 800, height: 600 }); + }); + + it('creates with default options', () => { + const guidelines = new CenteringGuidelines(canvas); + expect(guidelines.margin).toBe(4); + expect(guidelines.color).toBe('rgba(255,0,241,0.5)'); + expect(guidelines.width).toBe(1); + guidelines.dispose(); + }); + + it('accepts custom options', () => { + const guidelines = new CenteringGuidelines(canvas, { + margin: 10, + color: 'red', + width: 2, + }); + expect(guidelines.margin).toBe(10); + expect(guidelines.color).toBe('red'); + expect(guidelines.width).toBe(2); + guidelines.dispose(); + }); + + it('snaps object to horizontal center', () => { + const guidelines = new CenteringGuidelines(canvas); + const rect = new Rect({ width: 100, height: 100, left: 100, top: 299 }); + canvas.add(rect); + + rect.set({ top: 299 }); + rect.setCoords(); + (canvas as any).fire('object:moving', { target: rect }); + + const center = rect.getCenterPoint(); + expect(center.y).toBe(300); + guidelines.dispose(); + }); + + it('snaps object to vertical center', () => { + const guidelines = new CenteringGuidelines(canvas); + const rect = new Rect({ width: 100, height: 100, left: 399, top: 100 }); + canvas.add(rect); + + rect.set({ left: 399 }); + rect.setCoords(); + (canvas as any).fire('object:moving', { target: rect }); + + const center = rect.getCenterPoint(); + expect(center.x).toBe(400); + guidelines.dispose(); + }); + + it('disposes event listeners', () => { + const guidelines = new CenteringGuidelines(canvas); + const offSpy = vi.spyOn(canvas, 'off'); + + guidelines.dispose(); + + expect(offSpy).toHaveBeenCalledWith('mouse:down', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('mouse:up', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('object:moving', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('before:render', expect.any(Function)); + expect(offSpy).toHaveBeenCalledWith('after:render', expect.any(Function)); + }); +}); diff --git a/extensions/centering_guidelines/index.ts b/extensions/centering_guidelines/index.ts new file mode 100644 index 00000000000..cc211c85ec8 --- /dev/null +++ b/extensions/centering_guidelines/index.ts @@ -0,0 +1,140 @@ +import { type Canvas, type FabricObject, Point } from 'fabric'; + +type MovingEvent = { target: FabricObject }; + +export type CenteringGuidelinesConfig = { + /** Margin in pixels for snap detection */ + margin: number; + /** Line color */ + color: string; + /** Line width */ + width: number; +}; + +export class CenteringGuidelines { + canvas: Canvas; + margin = 4; + color = 'rgba(255,0,241,0.5)'; + width = 1; + + private isInVerticalCenter = false; + private isInHorizontalCenter = false; + private viewportTransform: number[] = [1, 0, 0, 1, 0, 0]; + + constructor(canvas: Canvas, options: Partial = {}) { + this.canvas = canvas; + Object.assign(this, options); + + this.mouseDown = this.mouseDown.bind(this); + this.mouseUp = this.mouseUp.bind(this); + this.objectMoving = this.objectMoving.bind(this); + this.beforeRender = this.beforeRender.bind(this); + this.afterRender = this.afterRender.bind(this); + + this.canvas.on('mouse:down', this.mouseDown); + this.canvas.on('mouse:up', this.mouseUp); + this.canvas.on('object:moving', this.objectMoving); + this.canvas.on('before:render', this.beforeRender); + this.canvas.on('after:render', this.afterRender); + } + + private get canvasWidthCenter() { + return this.canvas.getWidth() / 2; + } + + private get canvasHeightCenter() { + return this.canvas.getHeight() / 2; + } + + private isNearCenter(value: number, center: number): boolean { + return Math.abs(Math.round(value) - Math.round(center)) <= this.margin; + } + + private mouseDown() { + this.viewportTransform = this.canvas.viewportTransform; + } + + private mouseUp() { + this.isInVerticalCenter = false; + this.isInHorizontalCenter = false; + this.canvas.renderAll(); + } + + private objectMoving(e: MovingEvent) { + const object = e.target; + const objectCenter = object.getCenterPoint(); + + this.isInVerticalCenter = this.isNearCenter( + objectCenter.x, + this.canvasWidthCenter, + ); + this.isInHorizontalCenter = this.isNearCenter( + objectCenter.y, + this.canvasHeightCenter, + ); + + if (this.isInVerticalCenter || this.isInHorizontalCenter) { + object.setPositionByOrigin( + new Point( + this.isInVerticalCenter ? this.canvasWidthCenter : objectCenter.x, + this.isInHorizontalCenter ? this.canvasHeightCenter : objectCenter.y, + ), + 'center', + 'center', + ); + } + } + + private beforeRender() { + if (this.canvas.contextTop) { + this.canvas.clearContext(this.canvas.contextTop); + } + } + + private afterRender() { + if (this.isInVerticalCenter) { + this.drawVerticalCenterLine(); + } + if (this.isInHorizontalCenter) { + this.drawHorizontalCenterLine(); + } + } + + private drawVerticalCenterLine() { + this.drawLine( + this.canvasWidthCenter + 0.5, + 0, + this.canvasWidthCenter + 0.5, + this.canvas.getHeight(), + ); + } + + private drawHorizontalCenterLine() { + this.drawLine( + 0, + this.canvasHeightCenter + 0.5, + this.canvas.getWidth(), + this.canvasHeightCenter + 0.5, + ); + } + + private drawLine(x1: number, y1: number, x2: number, y2: number) { + const ctx = this.canvas.getSelectionContext(); + ctx.save(); + ctx.strokeStyle = this.color; + ctx.lineWidth = this.width; + ctx.beginPath(); + ctx.moveTo(x1 * this.viewportTransform[0], y1 * this.viewportTransform[3]); + ctx.lineTo(x2 * this.viewportTransform[0], y2 * this.viewportTransform[3]); + ctx.stroke(); + ctx.restore(); + } + + dispose() { + this.canvas.off('mouse:down', this.mouseDown); + this.canvas.off('mouse:up', this.mouseUp); + this.canvas.off('object:moving', this.objectMoving); + this.canvas.off('before:render', this.beforeRender); + this.canvas.off('after:render', this.afterRender); + } +} diff --git a/extensions/index.ts b/extensions/index.ts index 18f39ab975e..4ddb926b665 100644 --- a/extensions/index.ts +++ b/extensions/index.ts @@ -1,6 +1,9 @@ export { AligningGuidelines } from './aligning_guidelines'; export type * from './aligning_guidelines/typedefs'; +export { CenteringGuidelines } from './centering_guidelines'; +export type { CenteringGuidelinesConfig } from './centering_guidelines'; + export { originUpdaterWrapper, installOriginWrapperUpdater, diff --git a/lib/centering_guidelines.js b/lib/centering_guidelines.js deleted file mode 100644 index 761e781a4b4..00000000000 --- a/lib/centering_guidelines.js +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Augments canvas by assigning to `onObjectMove` and `onAfterRender`. - * This kind of sucks because other code using those methods will stop functioning. - * Need to fix it by replacing callbacks with pub/sub kind of subscription model. - * (or maybe use existing fabric.util.fire/observe (if it won't be too slow)) - */ -function initCenteringGuidelines(canvas) { - - var canvasWidth = canvas.getWidth(), - canvasHeight = canvas.getHeight(), - canvasWidthCenter = canvasWidth / 2, - canvasHeightCenter = canvasHeight / 2, - canvasWidthCenterMap = { }, - canvasHeightCenterMap = { }, - centerLineMargin = 4, - centerLineColor = 'rgba(255,0,241,0.5)', - centerLineWidth = 1, - ctx = canvas.getSelectionContext(), - viewportTransform; - - for (var i = canvasWidthCenter - centerLineMargin, len = canvasWidthCenter + centerLineMargin; i <= len; i++) { - canvasWidthCenterMap[Math.round(i)] = true; - } - for (var i = canvasHeightCenter - centerLineMargin, len = canvasHeightCenter + centerLineMargin; i <= len; i++) { - canvasHeightCenterMap[Math.round(i)] = true; - } - - function showVerticalCenterLine() { - showCenterLine(canvasWidthCenter + 0.5, 0, canvasWidthCenter + 0.5, canvasHeight); - } - - function showHorizontalCenterLine() { - showCenterLine(0, canvasHeightCenter + 0.5, canvasWidth, canvasHeightCenter + 0.5); - } - - function showCenterLine(x1, y1, x2, y2) { - ctx.save(); - ctx.strokeStyle = centerLineColor; - ctx.lineWidth = centerLineWidth; - ctx.beginPath(); - ctx.moveTo(x1 * viewportTransform[0], y1 * viewportTransform[3]); - ctx.lineTo(x2 * viewportTransform[0], y2 * viewportTransform[3]); - ctx.stroke(); - ctx.restore(); - } - - var afterRenderActions = [], - isInVerticalCenter, - isInHorizontalCenter; - - canvas.on('mouse:down', function () { - viewportTransform = canvas.viewportTransform; - }); - - canvas.on('object:moving', function(e) { - var object = e.target, - objectCenter = object.getCenterPoint(), - transform = canvas._currentTransform; - - if (!transform) return; - - isInVerticalCenter = Math.round(objectCenter.x) in canvasWidthCenterMap, - isInHorizontalCenter = Math.round(objectCenter.y) in canvasHeightCenterMap; - - if (isInHorizontalCenter || isInVerticalCenter) { - object.setPositionByOrigin(new fabric.Point((isInVerticalCenter ? canvasWidthCenter : objectCenter.x), (isInHorizontalCenter ? canvasHeightCenter : objectCenter.y)), 'center', 'center'); - } - }); - - canvas.on('before:render', function() { - if (canvas.contextTop) { - canvas.clearContext(canvas.contextTop); - } - }); - - canvas.on('after:render', function() { - if (isInVerticalCenter) { - showVerticalCenterLine(); - } - if (isInHorizontalCenter) { - showHorizontalCenterLine(); - } - }); - - canvas.on('mouse:up', function() { - // clear these values, to stop drawing guidelines once mouse is up - isInVerticalCenter = isInHorizontalCenter = null; - canvas.renderAll(); - }); -}