import { createShapeId } from '@tldraw/editor' import { vi } from 'vitest' import { createDrawSegments } from '../lib/utils/test-helpers' import { TestEditor } from './TestEditor' let editor: TestEditor vi.useFakeTimers() const ids = { box1: createShapeId('box1'), box2: createShapeId('box2'), box3: createShapeId('box3'), box4: createShapeId('box4'), draw1: createShapeId('draw1'), frame1: createShapeId('frame1'), group1: createShapeId('group1'), } beforeEach(() => { editor = new TestEditor() editor.createShapes([ { id: ids.box1, type: 'geo', x: 0, y: 0, props: { fill: 'none', }, }, { id: ids.box2, type: 'geo', x: 75, // overlapping box1 y: 75, props: { fill: 'solid', }, }, { id: ids.box3, type: 'geo', x: 300, y: 300, props: { fill: 'solid', }, }, { id: ids.frame1, type: 'frame', x: 300, y: 0, props: { w: 100, h: 100, }, }, { id: ids.box4, type: 'geo', parentId: ids.frame1, x: 50, y: 50, // clipped by frame props: { fill: 'solid', }, }, { id: ids.draw1, type: 'draw', x: 0, y: 300, props: { segments: createDrawSegments([ [ { x: 0, y: 0 }, { x: 2, y: 50 }, { x: 10, y: 100 }, { x: 48, y: 100 }, { x: 100, y: 100 }, ], ]), }, }, ]) }) afterEach(() => { editor?.dispose() }) describe('When clicking', () => { it('Selects the tool, adds the hovered shapes to the editor.erasingShapeIds array on pointer down, deletes them on pointer up, restores on undo and deletes again on redo', () => { editor.setCurrentTool('eraser') // Starts in idle editor.expectToBeIn('eraser.idle') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(0, 0) // near enough to box1 // Enters the pointing state editor.expectToBeIn('eraser.pointing') // Sets the erasingShapeIds array expect(editor.getErasingShapeIds()).toEqual([ids.box1]) editor.pointerUp() const shapesAfterCount = editor.getCurrentPageShapes().length // Deletes the erasing shapes expect(editor.getShape(ids.box1)).toBeUndefined() expect(shapesAfterCount).toBe(shapesBeforeCount - 1) // Also empties the erasingShapeIds array expect(editor.getErasingShapeIds()).toEqual([]) // Returns to idle editor.expectToBeIn('eraser.idle') editor.undo() expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getCurrentPageShapes().length).toBe(shapesBeforeCount) editor.redo() expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getCurrentPageShapes().length).toBe(shapesBeforeCount - 1) }) it('Erases all shapes under the cursor on click', () => { editor.setCurrentTool('eraser') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(99, 99) // next to box1 AND in box2 expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.box1, ids.box2])) editor.pointerUp() expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined() const shapesAfterCount = editor.getCurrentPageShapes().length expect(shapesAfterCount).toBe(shapesBeforeCount - 2) }) it("Erases a group when clicking on the group's child", () => { editor.groupShapes([ids.box2, ids.box3], { groupId: ids.group1 }) editor.setCurrentTool('eraser') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(350, 350) // in box3 expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.group1])) editor.pointerUp() const shapesAfterCount = editor.getCurrentPageShapes().length expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box3)).toBeUndefined() expect(editor.getShape(ids.group1)).toBeUndefined() expect(shapesAfterCount).toBe(shapesBeforeCount - 3) }) it('Does not erase a group when clicking on the group itself', () => { editor.groupShapes([ids.box2, ids.box3], { groupId: ids.group1 }) editor.setCurrentTool('eraser') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group editor.pointerUp() const shapesAfterCount = editor.getCurrentPageShapes().length expect(shapesAfterCount).toBe(shapesBeforeCount) }) it('Stops erasing when it reaches a frame when the frame was not was the top-most hovered shape', () => { editor.setCurrentTool('eraser') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(375, 75) // inside of the box4 shape inside of box3 editor.pointerUp() const shapesAfterCount = editor.getCurrentPageShapes().length expect(shapesAfterCount).toBe(shapesBeforeCount - 1) // Erases the child but does not erase the frame expect(editor.getShape(ids.box4)).toBeUndefined() expect(editor.getShape(ids.frame1)).toBeDefined() }) it('Erases a frame only when its clicked on the edge', () => { editor.setCurrentTool('eraser') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(325, 25) // directly on frame1, not its children editor.pointerUp() // without dragging! const shapesAfterCount = editor.getCurrentPageShapes().length expect(shapesAfterCount).toBe(shapesBeforeCount) // Erases BOTH the frame and its child expect(editor.getShape(ids.box4)).toBeDefined() expect(editor.getShape(ids.frame1)).toBeDefined() }) it('Only erases masked shapes when pointer is inside the mask', () => { editor.setCurrentTool('eraser') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(425, 125) // inside of box4's bounds, but outside of its parent's mask editor.pointerUp() // without dragging! const shapesAfterCount = editor.getCurrentPageShapes().length expect(shapesAfterCount).toBe(shapesBeforeCount) // Erases NEITHER the frame nor its child expect(editor.getShape(ids.box4)).toBeDefined() expect(editor.getShape(ids.frame1)).toBeDefined() }) it('Clears erasing ids and does not erase shapes on cancel', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(0, 0) // in box1 editor.expectToBeIn('eraser.pointing') expect(editor.getErasingShapeIds()).toEqual([ids.box1]) editor.cancel() editor.pointerUp() const shapesAfterCount = editor.getCurrentPageShapes().length editor.expectToBeIn('eraser.idle') // Does NOT erase the shape expect(editor.getErasingShapeIds()).toEqual([]) expect(editor.getShape(ids.box1)).toBeDefined() expect(shapesAfterCount).toBe(shapesBeforeCount) }) it('Clears erasing ids and does not erase shapes on interrupt', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.pointerDown(0, 0) // near to box1 editor.expectToBeIn('eraser.pointing') expect(editor.getErasingShapeIds()).toEqual([ids.box1]) editor.interrupt() editor.pointerUp() const shapesAfterCount = editor.getCurrentPageShapes().length editor.expectToBeIn('eraser.idle') // Does NOT erase the shape expect(editor.getErasingShapeIds()).toEqual([]) expect(editor.getShape(ids.box1)).toBeDefined() expect(shapesAfterCount).toBe(shapesBeforeCount) }) }) describe('When clicking and dragging', () => { it('Enters erasing state on pointer move, adds contacted shapes to the apps.erasingShapeIds array, deletes them and clears erasingShapeIds on pointer up, restores shapes on undo and deletes again on redo', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') editor.pointerDown(-100, -100) // outside of any shapes editor.expectToBeIn('eraser.pointing') expect(editor.getInstanceState().scribbles.length).toBe(0) editor.pointerMove(50, 50) // inside of box1 editor.expectToBeIn('eraser.erasing') vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(editor.getErasingShapeIds()).toEqual([ids.box1]) // editor.pointerUp() // editor.expectToBeIn('eraser.idle') // expect(editor.erasingShapeIds).toEqual([]) // expect(editor.getShape(ids.box1)).not.toBeDefined() // editor.undo() // expect(editor.erasingShapeIds).toEqual([]) // expect(editor.getShape(ids.box1)).toBeDefined() // editor.redo() // expect(editor.erasingShapeIds).toEqual([]) // expect(editor.getShape(ids.box1)).not.toBeDefined() }) it('Clears erasing ids and does not erase shapes on cancel', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') editor.pointerDown(-100, -100) // outside of any shapes editor.pointerMove(50, 50) // inside of box1 vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(editor.getErasingShapeIds()).toEqual([ids.box1]) editor.cancel() editor.expectToBeIn('eraser.idle') expect(editor.getErasingShapeIds()).toEqual([]) expect(editor.getShape(ids.box1)).toBeDefined() }) it('Excludes a group if it was hovered when the drag started, but erases its children', () => { editor.groupShapes([ids.box2, ids.box3], { groupId: ids.group1 }) editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') editor.pointerDown(275, 275) // in between box2 AND box3, so over of the new group editor.pointerMove(280, 280) // still in the gap between children vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(editor.getErasingShapeIds()).toEqual([]) // Moving to (0,0) crosses through box2 (child of the group) and box1 editor.pointerMove(0, 0) // box2 is now erased as a child shape (not resolved to the excluded group) expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.box1, ids.box2])) expect(editor.getShape(ids.box1)).toBeDefined() editor.pointerUp() // box1 and box2 are deleted; the group auto-dissolves since it only has one child left expect(editor.getShape(ids.box1)).not.toBeDefined() expect(editor.getShape(ids.box2)).not.toBeDefined() expect(editor.getShape(ids.group1)).not.toBeDefined() // box3 survives (it was reparented when the group dissolved) expect(editor.getShape(ids.box3)).toBeDefined() }) it('Erases child shapes when starting drag inside a group and dragging over them', () => { editor.groupShapes([ids.box2, ids.box3], { groupId: ids.group1 }) editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') // Start inside the group bounds (between box2 and box3) editor.pointerDown(275, 275) // Drag directly over box3 editor.pointerMove(350, 350) vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) // box3 should be marked for erasing as a child of the excluded group expect(editor.getErasingShapeIds()).toEqual([ids.box3]) editor.pointerUp() // box3 is deleted; the group auto-dissolves since it only has one child left expect(editor.getShape(ids.box3)).not.toBeDefined() expect(editor.getShape(ids.group1)).not.toBeDefined() // box2 survives (reparented when the group dissolved) expect(editor.getShape(ids.box2)).toBeDefined() }) it('Erases the whole group when starting drag outside of it', () => { editor.groupShapes([ids.box2, ids.box3], { groupId: ids.group1 }) editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') // Start outside the group (avoiding box1 which is at 0,0) editor.pointerDown(150, -100) // Drag over box2 (child of group) editor.pointerMove(150, 125) vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) // The whole group should be erased since we started outside expect(editor.getErasingShapeIds()).toEqual([ids.group1]) editor.pointerUp() expect(editor.getShape(ids.group1)).not.toBeDefined() expect(editor.getShape(ids.box2)).not.toBeDefined() expect(editor.getShape(ids.box3)).not.toBeDefined() }) it('Excludes a frame if it was hovered when the drag started', () => { editor.setCurrentTool('eraser') editor.pointerDown(325, 25) // directly on frame1, not its children editor.pointerMove(350, 375) // still in the frame, passing through box3 vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(editor.getErasingShapeIds()).toEqual([ids.box3]) editor.pointerUp() expect(editor.getShape(ids.frame1)).toBeDefined() expect(editor.getShape(ids.box3)).not.toBeDefined() }) it('Only erases masked shapes when pointer is inside the mask', () => { editor.setCurrentTool('eraser') editor.pointerMove(425, 0) editor.pointerDown() // Above the masked part of box3 expect(editor.getErasingShapeIds()).toEqual([]) editor.pointerMove(425, 500) // Through the masked part of box3 vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(editor.getErasingShapeIds()).toEqual([]) editor.pointerUp() expect(editor.getShape(ids.box3)).toBeDefined() editor.pointerMove(375, 0) editor.pointerDown() // Above the not-masked part of box3 editor.pointerMove(375, 500) // Through the masked part of box3 expect(editor.getInstanceState().scribbles.length).toBe(1) expect(editor.getErasingShapeIds()).toEqual([ids.box3]) editor.pointerUp() expect(editor.getShape(ids.box3)).not.toBeDefined() }) it('Does nothing on interrupt, allowing for a pinch during the erasing session', () => { editor.setCurrentTool('eraser') editor.pointerDown(-100, -100) editor.pointerMove(50, 50) editor.interrupt() editor.expectToBeIn('eraser.erasing') }) it('Starts a scribble on pointer down, updates it on pointer move, stops it on exit', () => { editor.setCurrentTool('eraser') editor.pointerDown(-100, -100) expect(editor.getInstanceState().scribbles.length).toBe(0) editor.pointerMove(50, 50) vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) editor.pointerMove(50, 50) editor.pointerMove(51, 50) editor.pointerMove(52, 50) editor.pointerMove(53, 50) editor.pointerUp() expect(editor.getInstanceState().scribbles.length).toBe(1) }) }) describe('Does not erase hollow shapes on click', () => { it('Returns to select on cancel', () => { editor.selectAll().deleteShapes(editor.getSelectedShapes()) expect(editor.getCurrentPageShapes().length).toBe(0) editor.createShape({ id: createShapeId(), type: 'geo', }) editor.setCurrentTool('eraser') editor.pointerMove(50, 50) editor.pointerDown() expect(editor.getErasingShapeIds()).toEqual([]) editor.pointerUp() expect(editor.getCurrentPageShapes().length).toBe(1) }) }) // Not yet implemented describe('When shift clicking', () => { it.todo('Erases a line between the previous clicked point and the current point') it.todo('Clears the previous clicked point when leaving / re-entering the eraser tool') }) describe('When holding meta/ctrl key (accel key)', () => { it('Only erases the top shape hit when clicking with accel key held', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') const shapesBeforeCount = editor.getCurrentPageShapes().length editor.keyDown('Meta') editor.pointerDown(99, 99) // next to box1 AND in box2 expect(editor.getErasingShapeIds()).toEqual([ids.box2]) editor.pointerUp() expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box2)).toBeUndefined() const shapesAfterCount = editor.getCurrentPageShapes().length expect(shapesAfterCount).toBe(shapesBeforeCount - 1) editor.keyUp('Meta') }) it('Erases all hit shapes once an accel pointer becomes a drag', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') editor.keyDown('Meta') editor.pointerDown(99, 99) // next to box1 AND in box2 expect(editor.getErasingShapeIds()).toEqual([ids.box2]) editor.pointerMove(350, 350) // in box3 editor.expectToBeIn('eraser.erasing') expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.box1, ids.box2, ids.box3])) vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.box1, ids.box2, ids.box3])) editor.pointerUp() expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box3)).toBeUndefined() editor.keyUp('Meta') }) it('Still erases normally when accel key is released during erasing', () => { editor.setCurrentTool('eraser') editor.expectToBeIn('eraser.idle') editor.pointerDown(-100, -100) // outside of any shapes editor.pointerMove(99, 99) // next to box1 AND in box2 vi.advanceTimersByTime(16) expect(editor.getInstanceState().scribbles.length).toBe(1) expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.box1, ids.box2])) editor.keyDown('Meta') editor.keyUp('Meta') editor.pointerMove(350, 350) // in box3 expect(new Set(editor.getErasingShapeIds())).toEqual(new Set([ids.box1, ids.box2, ids.box3])) editor.pointerUp() expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box3)).toBeUndefined() }) }) describe('Hold accel to temporarily erase from the draw / highlight tool', () => { for (const tool of ['draw', 'highlight'] as const) { describe(`from ${tool}`, () => { it(`holding accel alone does not switch tools`, () => { editor.setCurrentTool(tool) editor.expectToBeIn(`${tool}.idle`) editor.keyDown('Meta') // No tool switch on key down — the switch only happens on pointer down. editor.expectToBeIn(`${tool}.idle`) expect(editor.getCurrentToolId()).toBe(tool) editor.keyUp('Meta') editor.expectToBeIn(`${tool}.idle`) }) it(`accel + click in idle goes straight into eraser.pointing with ${tool} masked`, () => { editor.setCurrentTool(tool) editor.keyDown('Meta') editor.pointerDown(99, 99) // next to box1 AND in box2 editor.expectToBeIn('eraser.pointing') expect(editor.getCurrentTool().id).toBe('eraser') expect(editor.getCurrentToolId()).toBe(tool) expect(editor.getErasingShapeIds()).toEqual([ids.box2]) }) it(`accel + click + release with accel held returns to eraser.idle, masked as ${tool}`, () => { editor.setCurrentTool(tool) editor.keyDown('Meta') const shapesBefore = editor.getCurrentPageShapes().length editor.pointerDown(99, 99) editor.pointerUp() expect(editor.getCurrentPageShapes().length).toBe(shapesBefore - 1) expect(editor.getShape(ids.box1)).toBeDefined() expect(editor.getShape(ids.box2)).toBeUndefined() editor.expectToBeIn('eraser.idle') expect(editor.getCurrentTool().id).toBe('eraser') expect(editor.getCurrentToolId()).toBe(tool) }) it(`releasing accel after a transient erase returns to ${tool}`, () => { editor.setCurrentTool(tool) editor.keyDown('Meta') editor.pointerDown(99, 99) editor.pointerUp() editor.expectToBeIn('eraser.idle') editor.keyUp('Meta') editor.expectToBeIn(`${tool}.idle`) expect(editor.getCurrentToolId()).toBe(tool) }) it(`release event on transient click returns to ${tool}`, () => { editor.setCurrentTool(tool) editor.keyDown('Meta') editor.pointerDown(99, 99) editor.expectToBeIn('eraser.pointing') expect(editor.inputs.getAccelKey()).toBe(true) editor.pointerUp(99, 99, { accelKey: false, metaKey: false, ctrlKey: false }) editor.expectToBeIn(`${tool}.idle`) expect(editor.getCurrentToolId()).toBe(tool) }) it(`releasing accel mid-erase does not yank back; pointer up returns to ${tool}`, () => { editor.setCurrentTool(tool) editor.keyDown('Meta') editor.pointerDown(99, 99) editor.expectToBeIn('eraser.pointing') editor.pointerMove(350, 350) editor.expectToBeIn('eraser.erasing') expect(new Set(editor.getErasingShapeIds())).toEqual( new Set([ids.box1, ids.box2, ids.box3]) ) expect(editor.getCurrentTool().id).toBe('eraser') expect(editor.inputs.getAccelKey()).toBe(true) editor.pointerUp(350, 350, { accelKey: false, metaKey: false, ctrlKey: false }) editor.expectToBeIn(`${tool}.idle`) expect(editor.getCurrentToolId()).toBe(tool) expect(editor.getShape(ids.box1)).toBeUndefined() expect(editor.getShape(ids.box2)).toBeUndefined() expect(editor.getShape(ids.box3)).toBeUndefined() }) it(`accel pressed mid-stroke does not switch tools`, () => { editor.setCurrentTool(tool) editor.pointerDown(0, 0) editor.expectToBeIn(`${tool}.drawing`) editor.keyDown('Meta') editor.expectToBeIn(`${tool}.drawing`) editor.keyUp('Meta') editor.pointerUp() }) it(`shift + accel + click does not switch (preserves shift+cmd straight-line snap)`, () => { editor.setCurrentTool(tool) editor.keyDown('Shift') editor.keyDown('Meta') editor.pointerDown(99, 99) // Should be drawing (straight-line mode), not erasing. editor.expectToBeIn(`${tool}.drawing`) }) }) } it('explicit eraser (not from accel) does not get masked or auto-return on key up', () => { editor.setCurrentTool('eraser') expect(editor.getCurrentToolId()).toBe('eraser') editor.keyDown('Meta') editor.keyUp('Meta') // Still eraser; no transient return because there's no onInteractionEnd. expect(editor.getCurrentToolId()).toBe('eraser') expect(editor.getCurrentTool().id).toBe('eraser') }) })