diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b21f4f881b..a3d28410c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [next] +- perf(): optimize `perPixelTargetFind` [#8770](https://github.com/fabricjs/fabric.js/pull/8770) - BREAKING fix(): reflect NUM_FRACTION_DIGITS to SVG path data [#8782] (https://github.com/fabricjs/fabric.js/pull/8782) - fix(IText): layout change regression caused by #8663 (`text` was changed but layout was skipped) [#8711](https://github.com/fabricjs/fabric.js/pull/8711) - fix(IText, Textbox): fix broken text input [#8775](https://github.com/fabricjs/fabric.js/pull/8775) diff --git a/src/canvas/SelectableCanvas.ts b/src/canvas/SelectableCanvas.ts index d8967654a50..de71157562e 100644 --- a/src/canvas/SelectableCanvas.ts +++ b/src/canvas/SelectableCanvas.ts @@ -16,7 +16,7 @@ import { saveObjectTransform, } from '../util/misc/objectTransforms'; import { StaticCanvas, TCanvasSizeOptions } from './StaticCanvas'; -import { isCollection, isFabricObjectCached } from '../util/types'; +import { isCollection } from '../util/types'; import { invertTransform, transformPoint } from '../util/misc/matrix'; import { isTransparent } from '../util/misc/isTransparent'; import { AssertKeys, TMat2D, TOriginX, TOriginY, TSize } from '../typedefs'; @@ -39,10 +39,10 @@ type TDestroyed = { export type TDestroyedCanvas = TDestroyed< T, | 'contextTop' - | 'contextCache' + | 'pixelFindContext' | 'lowerCanvasEl' | 'upperCanvasEl' - | 'cacheCanvasEl' + | 'pixelFindCanvasEl' | 'wrapperEl' | '_activeSelection' >; @@ -480,15 +480,6 @@ export class SelectableCanvas< */ contextTopDirty = false; - /** - * a reference to the context of an additional canvas that is used for scratch operations - * @TODOL This is created automatically when needed, while it shouldn't. is probably not even often needed - * and is a memory waste. We should either have one that gets added/deleted - * @type CanvasRenderingContext2D - * @private - */ - declare contextCache: CanvasRenderingContext2D; - /** * During a mouse event we may need the pointer multiple times in multiple functions. * _absolutePointer holds a reference to the pointer in fabricCanvas/design coordinates that is valid for the event @@ -524,7 +515,9 @@ export class SelectableCanvas< declare upperCanvasEl: HTMLCanvasElement; declare contextTop: CanvasRenderingContext2D; declare wrapperEl: HTMLDivElement; - declare cacheCanvasEl: HTMLCanvasElement; + private declare pixelFindCanvasEl: HTMLCanvasElement; + private declare pixelFindContext: CanvasRenderingContext2D; + protected declare _isCurrentlyDrawing: boolean; declare freeDrawingBrush?: BaseBrush; declare _activeObject?: FabricObject; @@ -646,7 +639,7 @@ export class SelectableCanvas< /** * Given a pointer on the canvas with a viewport applied, - * find out the opinter in + * find out the pointer in object coordinates * @private */ _normalizePointer(object: FabricObject, pointer: Point): Point { @@ -656,6 +649,20 @@ export class SelectableCanvas< ); } + /** + * Set the canvas tolerance value for pixel taret find. + * Use only integer numbers. + * @private + */ + setTargetFindTolerance(value: number) { + value = Math.round(value); + this.targetFindTolerance = value; + const retina = this.getRetinaScaling(); + const size = Math.ceil((value * 2 + 1) * retina); + this.pixelFindCanvasEl.width = this.pixelFindCanvasEl.height = size; + this.pixelFindContext.scale(retina, retina); + } + /** * Returns true if object is transparent at a certain location * Clarification: this is `is target transparent at location X or are controls there` @@ -667,44 +674,26 @@ export class SelectableCanvas< * @return {Boolean} */ isTargetTransparent(target: FabricObject, x: number, y: number): boolean { - // in case the target is the activeObject, we cannot execute this optimization - // because we need to draw controls too. - if (isFabricObjectCached(target) && target !== this._activeObject) { - // optimizatio: we can reuse the cache - const normalizedPointer = this._normalizePointer(target, new Point(x, y)), - targetRelativeX = Math.max( - target.cacheTranslationX + normalizedPointer.x * target.zoomX, - 0 - ), - targetRelativeY = Math.max( - target.cacheTranslationY + normalizedPointer.y * target.zoomY, - 0 - ); - - return isTransparent( - target._cacheContext, - Math.round(targetRelativeX), - Math.round(targetRelativeY), - this.targetFindTolerance - ); - } - - const ctx = this.contextCache, - originalColor = target.selectionBackgroundColor, - v = this.viewportTransform; - - target.selectionBackgroundColor = ''; - + const tolerance = this.targetFindTolerance; + const ctx = this.pixelFindContext; this.clearContext(ctx); - ctx.save(); - ctx.transform(v[0], v[1], v[2], v[3], v[4], v[5]); + ctx.translate(-x + tolerance, -y + tolerance); + ctx.transform(...this.viewportTransform); + const selectionBgc = target.selectionBackgroundColor; + target.selectionBackgroundColor = ''; target.render(ctx); + target.selectionBackgroundColor = selectionBgc; ctx.restore(); - - target.selectionBackgroundColor = originalColor; - - return isTransparent(ctx, x, y, this.targetFindTolerance); + // our canvas is square, and made around tolerance. + // so tolerance in this case also represent the center of the canvas. + const enhancedTolerance = Math.round(tolerance * this.getRetinaScaling()); + return isTransparent( + ctx, + enhancedTolerance, + enhancedTolerance, + enhancedTolerance + ); } /** @@ -1195,7 +1184,6 @@ export class SelectableCanvas< _setBackstoreDimension(prop: keyof TSize, value: number) { super._setBackstoreDimension(prop, value); this.upperCanvasEl[prop] = value; - this.cacheCanvasEl[prop] = value; } /** @@ -1237,8 +1225,11 @@ export class SelectableCanvas< } protected _createCacheCanvas() { - this.cacheCanvasEl = this._createCanvasElement(); - this.contextCache = this.cacheCanvasEl.getContext('2d')!; + this.pixelFindCanvasEl = this._createCanvasElement(); + this.pixelFindContext = this.pixelFindCanvasEl.getContext('2d', { + willReadFrequently: true, + })!; + this.setTargetFindTolerance(this.targetFindTolerance); } protected _initWrapperElement() { @@ -1518,7 +1509,6 @@ export class SelectableCanvas< const wrapperEl = this.wrapperEl as HTMLDivElement, lowerCanvasEl = this.lowerCanvasEl!, upperCanvasEl = this.upperCanvasEl!, - cacheCanvasEl = this.cacheCanvasEl!, activeSelection = this._activeSelection!; // dispose of active selection activeSelection.removeAll(); @@ -1527,13 +1517,13 @@ export class SelectableCanvas< super.destroy(); wrapperEl.removeChild(upperCanvasEl); wrapperEl.removeChild(lowerCanvasEl); - this.contextCache = null; + this.pixelFindContext = null; this.contextTop = null; // TODO: interactive canvas should NOT be used in node, therefore there is no reason to cleanup node canvas getEnv().dispose(upperCanvasEl); this.upperCanvasEl = undefined; - getEnv().dispose(cacheCanvasEl); - this.cacheCanvasEl = undefined; + getEnv().dispose(this.pixelFindCanvasEl!); + this.pixelFindCanvasEl = undefined; if (wrapperEl.parentNode) { wrapperEl.parentNode.replaceChild(lowerCanvasEl, wrapperEl); } diff --git a/src/util/misc/isTransparent.ts b/src/util/misc/isTransparent.ts index cb4bbd2f226..d1c5a541464 100644 --- a/src/util/misc/isTransparent.ts +++ b/src/util/misc/isTransparent.ts @@ -2,9 +2,9 @@ * Returns true if context has transparent pixel * at specified location (taking tolerance into account) * @param {CanvasRenderingContext2D} ctx context - * @param {Number} x x coordinate in canvasElementCoordinate, not fabric space - * @param {Number} y y coordinate in canvasElementCoordinate, not fabric space - * @param {Number} tolerance Tolerance pixels around the point, not alpha tolerance + * @param {Number} x x coordinate in canvasElementCoordinate, not fabric space. integer + * @param {Number} y y coordinate in canvasElementCoordinate, not fabric space. integer + * @param {Number} tolerance Tolerance pixels around the point, not alpha tolerance, integer * @return {boolean} true if transparent */ export const isTransparent = ( @@ -13,39 +13,16 @@ export const isTransparent = ( y: number, tolerance: number ): boolean => { - // If tolerance is > 0 adjust start coords to take into account. - // If moves off Canvas fix to 0 - if (tolerance > 0) { - if (x > tolerance) { - x -= tolerance; - } else { - x = 0; - } - if (y > tolerance) { - y -= tolerance; - } else { - y = 0; - } - } - - let _isTransparent = true; - const { data } = ctx.getImageData( - x, - y, - tolerance * 2 || 1, - tolerance * 2 || 1 - ); - const l = data.length; + tolerance = Math.round(tolerance); + const size = tolerance * 2 + 1; + const { data } = ctx.getImageData(x - tolerance, y - tolerance, size, size); // Split image data - for tolerance > 1, pixelDataSize = 4; - for (let i = 3; i < l; i += 4) { + for (let i = 3; i < data.length; i += 4) { const alphaChannel = data[i]; if (alphaChannel > 0) { - // Stop if colour found - _isTransparent = false; - break; + return false; } } - - return _isTransparent; + return true; }; diff --git a/test/unit/canvas.js b/test/unit/canvas.js index 2c43a395249..e8213e2df7e 100644 --- a/test/unit/canvas.js +++ b/test/unit/canvas.js @@ -2222,113 +2222,95 @@ // }, 1000); // }); - QUnit.test('isTargetTransparent', function(assert) { - var rect = new fabric.Rect({ - width: 10, - height: 10, - strokeWidth: 4, - stroke: 'red', - fill: '', - top: 0, - left: 0, - objectCaching: true, - }); - canvas.add(rect); - assert.equal(canvas.isTargetTransparent(rect, 0, 0), false, 'opaque on 0,0'); - assert.equal(canvas.isTargetTransparent(rect, 1, 1), false, 'opaque on 1,1'); - assert.equal(canvas.isTargetTransparent(rect, 2, 2), false, 'opaque on 2,2'); - assert.equal(canvas.isTargetTransparent(rect, 3, 3), false, 'opaque on 3,3'); - assert.equal(canvas.isTargetTransparent(rect, 4, 4), true, 'transparent on 4,4'); - assert.equal(canvas.isTargetTransparent(rect, 5, 5), true, 'transparent on 5, 5'); - assert.equal(canvas.isTargetTransparent(rect, 6, 6), true, 'transparent on 6, 6'); - assert.equal(canvas.isTargetTransparent(rect, 7, 7), true, 'transparent on 7, 7'); - assert.equal(canvas.isTargetTransparent(rect, 8, 8), true, 'transparent on 8, 8'); - // disabled this pixel because firefox 110 updates - // assert.equal(canvas.isTargetTransparent(rect, 9, 9), true, 'transparent on 9, 9'); - assert.equal(canvas.isTargetTransparent(rect, 10, 10), false, 'opaque on 10, 10'); - assert.equal(canvas.isTargetTransparent(rect, 11, 11), false, 'opaque on 11, 11'); - assert.equal(canvas.isTargetTransparent(rect, 12, 12), false, 'opaque on 12, 12'); - assert.equal(canvas.isTargetTransparent(rect, 13, 13), false, 'opaque on 13, 13'); - assert.equal(canvas.isTargetTransparent(rect, 14, 14), true, 'transparent on 14, 14'); - }); - - QUnit.test('isTargetTransparent without objectCaching', function(assert) { - var rect = new fabric.Rect({ - width: 10, - height: 10, - strokeWidth: 4, - stroke: 'red', - fill: '', - top: 0, - left: 0, - objectCaching: false, - }); - canvas.add(rect); - assert.equal(canvas.isTargetTransparent(rect, 0, 0), false, 'opaque on 0,0'); - assert.equal(canvas.isTargetTransparent(rect, 1, 1), false, 'opaque on 1,1'); - assert.equal(canvas.isTargetTransparent(rect, 2, 2), false, 'opaque on 2,2'); - assert.equal(canvas.isTargetTransparent(rect, 3, 3), false, 'opaque on 3,3'); - assert.equal(canvas.isTargetTransparent(rect, 4, 4), true, 'transparent on 4,4'); - assert.equal(canvas.isTargetTransparent(rect, 5, 5), true, 'transparent on 5, 5'); - assert.equal(canvas.isTargetTransparent(rect, 6, 6), true, 'transparent on 6, 6'); - assert.equal(canvas.isTargetTransparent(rect, 7, 7), true, 'transparent on 7, 7'); - assert.equal(canvas.isTargetTransparent(rect, 8, 8), true, 'transparent on 8, 8'); - // disabled this pixel because firefox 110 updates - // assert.equal(canvas.isTargetTransparent(rect, 9, 9), true, 'transparent on 9, 9'); - assert.equal(canvas.isTargetTransparent(rect, 10, 10), false, 'opaque on 10, 10'); - assert.equal(canvas.isTargetTransparent(rect, 11, 11), false, 'opaque on 11, 11'); - assert.equal(canvas.isTargetTransparent(rect, 12, 12), false, 'opaque on 12, 12'); - assert.equal(canvas.isTargetTransparent(rect, 13, 13), false, 'opaque on 13, 13'); - assert.equal(canvas.isTargetTransparent(rect, 14, 14), true, 'transparent on 14, 14'); - }); - - QUnit.test('isTargetTransparent as active object', function(assert) { - var rect = new fabric.Rect({ - width: 20, - height: 20, - strokeWidth: 4, - stroke: 'red', - fill: '', - top: 0, - left: 0, - objectCaching: true, - }); - canvas.add(rect); - canvas.setActiveObject(rect); - assert.equal(canvas.isTargetTransparent(rect, 0, 0), false, 'opaque on 0,0'); - assert.equal(canvas.isTargetTransparent(rect, 1, 1), false, 'opaque on 1,1'); - assert.equal(canvas.isTargetTransparent(rect, 2, 2), false, 'opaque on 2,2'); - assert.equal(canvas.isTargetTransparent(rect, 3, 3), false, 'opaque on 3,3'); - assert.equal(canvas.isTargetTransparent(rect, 4, 4), true, 'transparent on 4,4'); - assert.equal(canvas.isTargetTransparent(rect, 5, 5), true, 'transparent on 5, 5'); - assert.equal(canvas.isTargetTransparent(rect, 6, 6), true, 'transparent on 6, 6'); - assert.equal(canvas.isTargetTransparent(rect, 7, 7), true, 'transparent on 7, 7'); - assert.equal(canvas.isTargetTransparent(rect, 8, 8), true, 'transparent on 8, 8'); - assert.equal(canvas.isTargetTransparent(rect, 9, 9), true, 'transparent on 9, 9'); - assert.equal(canvas.isTargetTransparent(rect, 10, 10), true, 'transparent 10, 10'); - assert.equal(canvas.isTargetTransparent(rect, 11, 11), true, 'transparent 11, 11'); - assert.equal(canvas.isTargetTransparent(rect, 12, 12), true, 'transparent 12, 12'); - assert.equal(canvas.isTargetTransparent(rect, 13, 13), true, 'transparent 13, 13'); - assert.equal(canvas.isTargetTransparent(rect, 14, 14), true, 'transparent 14, 14'); - assert.equal(canvas.isTargetTransparent(rect, 15, 15), true, 'transparent 15, 15'); - assert.equal(canvas.isTargetTransparent(rect, 16, 16), true, 'transparent 16, 16'); - assert.equal(canvas.isTargetTransparent(rect, 17, 17), true, 'transparent 17, 17'); - assert.equal(canvas.isTargetTransparent(rect, 18, 18), true, 'transparent 18, 18'); - // disabled this pixel because firefox 110 updates - // assert.equal(canvas.isTargetTransparent(rect, 19, 19), true, 'transparent 19, 19'); - assert.equal(canvas.isTargetTransparent(rect, 20, 20), false, 'opaque 20, 20'); - assert.equal(canvas.isTargetTransparent(rect, 21, 21), false, 'opaque 21, 21'); - assert.equal(canvas.isTargetTransparent(rect, 22, 22), false, 'opaque 22, 22'); - assert.equal(canvas.isTargetTransparent(rect, 23, 23), false, 'opaque 23, 23'); - assert.equal(canvas.isTargetTransparent(rect, 24, 24), true, 'transparent 24, 24'); - assert.equal(canvas.isTargetTransparent(rect, 25, 25), true, 'transparent 25, 25'); - assert.equal(canvas.isTargetTransparent(rect, 26, 26), true, 'transparent 26, 26'); - assert.equal(canvas.isTargetTransparent(rect, 27, 27), true, 'transparent 27, 27'); - assert.equal(canvas.isTargetTransparent(rect, 28, 28), true, 'transparent 28, 28'); - assert.equal(canvas.isTargetTransparent(rect, 29, 29), true, 'transparent 29, 29'); - assert.equal(canvas.isTargetTransparent(rect, 30, 30), true, 'transparent 30, 30'); - assert.equal(canvas.isTargetTransparent(rect, 31, 31), true, 'transparent 31, 31'); + [true, false].forEach(objectCaching => { + function testPixelDetection(assert, canvas, target, expectedHits) { + function execute(context = '') { + expectedHits.forEach(({ start, end, message, transparent }) => { + // make less sensitive by skipping edges for firefox 110 + const round = 0; + for (let index = start + round; index < end - round; index++) { + assert.equal( + canvas.isTargetTransparent(target, index, index), + transparent, + `checking transparency of (${index}, ${index}), expected to be ${transparent}, ${message}, ${context}` + ); + } + }); + } + execute(); + canvas.setActiveObject(target); + execute('target is selected'); + } + QUnit.test(`isTargetTransparent, objectCaching ${objectCaching}`, function (assert) { + var rect = new fabric.Rect({ + width: 10, + height: 10, + strokeWidth: 4, + stroke: 'red', + fill: '', + top: 0, + left: 0, + objectCaching, + }); + canvas.add(rect); + testPixelDetection(assert, canvas, rect, [ + { start: -5, end: 0, message: 'outside', transparent: true }, + { start: 0, end: 4, message: 'stroke', transparent: false }, + { start: 4, end: 10, message: 'fill', transparent: true }, + { start: 10, end: 14, message: 'stroke', transparent: false }, + { start: 14, end: 20, message: 'outside', transparent: true }, + ]); + }); + + QUnit.test(`isTargetTransparent, vpt, objectCaching ${objectCaching}`, function (assert) { + var rect = new fabric.Rect({ + width: 10, + height: 10, + strokeWidth: 4, + stroke: 'red', + fill: '', + top: 0, + left: 0, + objectCaching, + }); + canvas.add(rect); + canvas.setViewportTransform([2, 0, 0, 2, 0, 0]); + testPixelDetection(assert, canvas, rect, [ + { start: -5, end: 0, message: 'outside', transparent: true }, + { start: 0, end: 8, message: 'stroke', transparent: false }, + { start: 8, end: 20, message: 'fill', transparent: true }, + { start: 20, end: 28, message: 'stroke', transparent: false }, + { start: 28, end: 40, message: 'outside', transparent: true }, + ]); + }); + + QUnit.test(`isTargetTransparent, vpt, tolerance, objectCaching ${objectCaching}`, function (assert) { + var rect = new fabric.Rect({ + width: 10, + height: 10, + strokeWidth: 4, + stroke: 'red', + fill: '', + top: 0, + left: 0, + objectCaching, + }); + canvas.add(rect); + canvas.setTargetFindTolerance(5); + canvas.setViewportTransform([2, 0, 0, 2, 0, 0]); + testPixelDetection(assert, canvas, rect, [ + { start: -10, end: -5, message: 'outside', transparent: true }, + { start: -5, end: 0, message: 'stroke tolerance not affected by vpt', transparent: false }, + { start: 0, end: 8, message: 'stroke', transparent: false }, + { start: 8, end: 13, message: 'stroke tolerance not affected by vpt', transparent: false }, + { start: 13, end: 15, message: 'fill', transparent: true }, + { start: 15, end: 20, message: 'stroke tolerance not affected by vpt', transparent: false }, + { start: 20, end: 28, message: 'stroke', transparent: false }, + { start: 28, end: 33, message: 'stroke tolerance not affected by vpt', transparent: false }, + { start: 33, end: 40, message: 'outside', transparent: true }, + ]); + }); }); QUnit.test('canvas getTopContext', function(assert) { diff --git a/test/unit/canvas_dispose.js b/test/unit/canvas_dispose.js index c65405e0537..dfa0b1b34da 100644 --- a/test/unit/canvas_dispose.js +++ b/test/unit/canvas_dispose.js @@ -225,9 +225,9 @@ function testCanvasDisposing() { assert.equal(canvas.wrapperEl, null, 'wrapperEl should be deleted'); assert.equal(canvas.upperCanvasEl, null, 'upperCanvas should be deleted'); assert.equal(canvas.lowerCanvasEl, null, 'lowerCanvasEl should be deleted'); - assert.equal(canvas.cacheCanvasEl, null, 'cacheCanvasEl should be deleted'); + assert.equal(canvas.pixelFindCanvasEl, null, 'pixelFindCanvasEl should be deleted'); assert.equal(canvas.contextTop, null, 'contextTop should be deleted'); - assert.equal(canvas.contextCache, null, 'contextCache should be deleted'); + assert.equal(canvas.pixelFindContext, null, 'pixelFindContext should be deleted'); assert.equal(canvas._originalCanvasStyle, undefined, 'removed original canvas style'); assert.equal(el.style.cssText, elStyle, 'restored original canvas style'); assert.equal(el.width, 200, 'restored width');