From d9a62401d5b51a28fd5e5d0d246e4f04ebbdac0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Tue, 7 Jul 2026 00:18:47 +0200 Subject: [PATCH 1/4] [players] Account for eraser stroke bounds --- src/lib/players/eraserbrush.js | 19 ++++++++++++++++++- tests/unit/lib/eraserbrush.spec.js | 30 ++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/lib/players/eraserbrush.js b/src/lib/players/eraserbrush.js index 27aa24877d..fa75279cd4 100644 --- a/src/lib/players/eraserbrush.js +++ b/src/lib/players/eraserbrush.js @@ -13,6 +13,19 @@ import { // gesture is degenerate (a single point). Matches fabric's own internal check. const EMPTY_SVG_PATH = 'M 0 0 Q 0 0 0 0 L 0 0' +const inflateRect = (rect, amount) => ({ + left: rect.left - amount, + top: rect.top - amount, + width: rect.width + amount * 2, + height: rect.height + amount * 2 +}) + +const rectsOverlap = (a, b) => + a.left <= b.left + b.width && + a.left + a.width >= b.left && + a.top <= b.top + b.height && + a.top + a.height >= b.top + // An object's eraser: a group of paths in the object's LOCAL coordinates. // FixedLayout + center origin: its dimensions don't change when paths are // added; they're realigned onto the object by `_drawClipPath`. @@ -238,10 +251,14 @@ export class EraserBrush extends PencilBrush { path.setCoords && path.setCoords() this.canvas.fire('before:path:created', { path }) const context = { targets: [], subTargets: [] } + // Fabric's object intersection ignores stroke width, so include the + // eraser radius in the bounds used to decide which objects receive a mask. + const eraserRadius = (path.strokeWidth || this.width || 1) / 2 + const eraserBounds = inflateRect(path.getBoundingRect(), eraserRadius) const tasks = this.canvas._objects.map( obj => obj.erasable && - obj.intersectsWithObject(path, true, true) && + rectsOverlap(obj.getBoundingRect(), eraserBounds) && this._addPathToObjectEraser(obj, path, context) ) await Promise.all(tasks) diff --git a/tests/unit/lib/eraserbrush.spec.js b/tests/unit/lib/eraserbrush.spec.js index b243ee5933..3393d2728c 100644 --- a/tests/unit/lib/eraserbrush.spec.js +++ b/tests/unit/lib/eraserbrush.spec.js @@ -228,11 +228,14 @@ describe('EraserBrush._addPathToObjectEraser', () => { }) describe('EraserBrush._finalizeAndAddPath', () => { - const makeErasable = erasable => ({ + const makeErasable = ( + erasable, + bounds = { left: -5, top: -5, width: 20, height: 20 } + ) => ({ erasable, eraser: undefined, group: undefined, - intersectsWithObject: () => true, + getBoundingRect: () => bounds, calcTransformMatrix: () => [1, 0, 0, 1, 0, 0], set: vi.fn(), fire: vi.fn() @@ -267,6 +270,29 @@ describe('EraserBrush._finalizeAndAddPath', () => { expect(end.data.path).toBeDefined() }) + it('uses the eraser stroke width when detecting affected objects', async () => { + const nearStrokeEdge = makeErasable(true, { + left: -5, + top: 7, + width: 20, + height: 4 + }) + const outsideStroke = makeErasable(true, { + left: -5, + top: 100, + width: 20, + height: 4 + }) + const canvas = makeCanvas([nearStrokeEdge, outsideStroke]) + const brush = new EraserBrush(canvas) + brush.width = 20 + brush._points = [{ x: 0, y: 0 }, { x: 10, y: 0 }] + await brush._finalizeAndAddPath() + const end = canvas._fired.find(f => f.name === 'erasing:end') + expect(end.data.targets).toContain(nearStrokeEdge) + expect(end.data.targets).not.toContain(outsideStroke) + }) + it('fires a bare erasing:end and creates no path when there are too few points', async () => { const obj = makeErasable(true) const canvas = makeCanvas([obj]) From 79895f3a1f3e488e8f3ac61f5d33562cd1c25f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Tue, 7 Jul 2026 00:29:00 +0200 Subject: [PATCH 2/4] [players] Simplify eraser stroke bounds test --- tests/unit/lib/eraserbrush.spec.js | 36 +++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/unit/lib/eraserbrush.spec.js b/tests/unit/lib/eraserbrush.spec.js index 3393d2728c..d15226e974 100644 --- a/tests/unit/lib/eraserbrush.spec.js +++ b/tests/unit/lib/eraserbrush.spec.js @@ -271,26 +271,32 @@ describe('EraserBrush._finalizeAndAddPath', () => { }) it('uses the eraser stroke width when detecting affected objects', async () => { - const nearStrokeEdge = makeErasable(true, { - left: -5, - top: 7, - width: 20, - height: 4 + const radius = 10 + const touchingSeparation = radius * 1.5 + const outsideSeparation = radius * 4 + const parallelStrokeBounds = centerY => ({ + left: 0, + top: centerY - radius, + width: 10, + height: radius * 2 }) - const outsideStroke = makeErasable(true, { - left: -5, - top: 100, - width: 20, - height: 4 - }) - const canvas = makeCanvas([nearStrokeEdge, outsideStroke]) + + const touchingParallelStroke = makeErasable( + true, + parallelStrokeBounds(touchingSeparation) + ) + const outsideParallelStroke = makeErasable( + true, + parallelStrokeBounds(outsideSeparation) + ) + const canvas = makeCanvas([touchingParallelStroke, outsideParallelStroke]) const brush = new EraserBrush(canvas) - brush.width = 20 + brush.width = radius * 2 brush._points = [{ x: 0, y: 0 }, { x: 10, y: 0 }] await brush._finalizeAndAddPath() const end = canvas._fired.find(f => f.name === 'erasing:end') - expect(end.data.targets).toContain(nearStrokeEdge) - expect(end.data.targets).not.toContain(outsideStroke) + expect(end.data.targets).toContain(touchingParallelStroke) + expect(end.data.targets).not.toContain(outsideParallelStroke) }) it('fires a bare erasing:end and creates no path when there are too few points', async () => { From b748506783bd9418d3f7ad87785f1dcd19158723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Tue, 7 Jul 2026 00:35:02 +0200 Subject: [PATCH 3/4] [players] Mock eraser path bounds in tests --- tests/unit/lib/eraserbrush.spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/lib/eraserbrush.spec.js b/tests/unit/lib/eraserbrush.spec.js index d15226e974..4a66e59e5e 100644 --- a/tests/unit/lib/eraserbrush.spec.js +++ b/tests/unit/lib/eraserbrush.spec.js @@ -25,6 +25,7 @@ vi.mock('fabric', () => { set(props) { Object.assign(this, props); return this } calcTransformMatrix() { return [1, 0, 0, 1, 0, 0] } clone() { return Promise.resolve(new FakePath(this.path, { ...this })) } + getBoundingRect() { return { left: 0, top: 0, width: 10, height: 10 } } setCoords() {} } class FakePencilBrush { From b840a04e2661e6ca23f08a3eb0fb4d74a72e68b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Tue, 7 Jul 2026 00:40:23 +0200 Subject: [PATCH 4/4] [players] Tighten eraser stroke bounds test --- tests/unit/lib/eraserbrush.spec.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/lib/eraserbrush.spec.js b/tests/unit/lib/eraserbrush.spec.js index 4a66e59e5e..8b4fd08b36 100644 --- a/tests/unit/lib/eraserbrush.spec.js +++ b/tests/unit/lib/eraserbrush.spec.js @@ -25,7 +25,7 @@ vi.mock('fabric', () => { set(props) { Object.assign(this, props); return this } calcTransformMatrix() { return [1, 0, 0, 1, 0, 0] } clone() { return Promise.resolve(new FakePath(this.path, { ...this })) } - getBoundingRect() { return { left: 0, top: 0, width: 10, height: 10 } } + getBoundingRect() { return { left: 0, top: 0, width: 10, height: 0 } } setCoords() {} } class FakePencilBrush { @@ -237,6 +237,15 @@ describe('EraserBrush._finalizeAndAddPath', () => { eraser: undefined, group: undefined, getBoundingRect: () => bounds, + intersectsWithObject: path => { + const pathBounds = path.getBoundingRect() + return ( + bounds.left <= pathBounds.left + pathBounds.width && + bounds.left + bounds.width >= pathBounds.left && + bounds.top <= pathBounds.top + pathBounds.height && + bounds.top + bounds.height >= pathBounds.top + ) + }, calcTransformMatrix: () => [1, 0, 0, 1, 0, 0], set: vi.fn(), fire: vi.fn()