import { createShapeId } from '@tldraw/editor' import { vi } from 'vitest' import { defaultOverlayUtils } from '../../lib/defaultOverlayUtils' import { SelectionForegroundOverlayUtil } from '../../lib/overlays/SelectionForegroundOverlayUtil' import { TestEditor } from '../TestEditor' let editor: TestEditor const ids = { box1: createShapeId('box1'), image1: createShapeId('image1'), note1: createShapeId('note1'), note2: createShapeId('note2'), text1: createShapeId('text1'), } beforeEach(() => { editor = new TestEditor({ overlayUtils: defaultOverlayUtils }) }) describe('SelectionForegroundOverlayUtil', () => { describe('isActive', () => { it('returns false when nothing is selected', () => { const util = editor.overlays.getOverlayUtil('selection_foreground') expect(util.isActive()).toBe(false) }) it('returns true in select.idle with a selected shape', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') expect(util.isActive()).toBe(true) }) it('returns true in select.brushing', () => { expect(true).toBe(true) }) it('returns true in select.pointing_resize_handle', () => { expect(true).toBe(true) }) it('returns true in crop states', () => { expect(true).toBe(true) }) it('returns false when there are no selection bounds', () => { expect(true).toBe(true) }) }) describe('getOverlays', () => { it('returns empty array when nothing is selected', () => { const util = editor.overlays.getOverlayUtil('selection_foreground') expect(util.getOverlays()).toEqual([]) }) it('includes resize corner handles for a resizable shape', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const overlays = util.getOverlays() // Should include at least the top_left resize handle expect(overlays.some((o) => o.id === 'selection_fg:top_left')).toBe(true) }) it('includes resize edge handles', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect(idsSet.has('selection_fg:top')).toBe(true) expect(idsSet.has('selection_fg:right')).toBe(true) expect(idsSet.has('selection_fg:bottom')).toBe(true) expect(idsSet.has('selection_fg:left')).toBe(true) }) it('includes rotate corner handles for non-coarse pointer', () => { editor.updateInstanceState({ isCoarsePointer: false }) editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect(idsSet.has('selection_fg:top_left_rotate')).toBe(true) expect(idsSet.has('selection_fg:top_right_rotate')).toBe(true) expect(idsSet.has('selection_fg:bottom_left_rotate')).toBe(true) expect(idsSet.has('selection_fg:bottom_right_rotate')).toBe(true) }) it('includes mobile rotate handle for coarse pointer', () => { editor.updateInstanceState({ isCoarsePointer: true }) editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 60, h: 60 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect(idsSet.has('selection_fg:mobile_rotate')).toBe(true) }) it('hides alternate corners when selection is tiny', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 10, h: 20 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) // top_left present expect(idsSet.has('selection_fg:top_left')).toBe(true) // alternate corners hidden expect(idsSet.has('selection_fg:top_right')).toBe(false) expect(idsSet.has('selection_fg:bottom_left')).toBe(false) // bottom_right still present when only one axis is tiny expect(idsSet.has('selection_fg:bottom_right')).toBe(true) }) it('shows only one handle when selection is tiny in both axes', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 10, h: 10 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect(idsSet.has('selection_fg:top_left')).toBe(true) expect(idsSet.has('selection_fg:bottom_right')).toBe(false) expect(idsSet.has('selection_fg:top_right')).toBe(false) expect(idsSet.has('selection_fg:bottom_left')).toBe(false) }) it('hides rotate handles when shape hides them', () => { editor.createShapes([{ id: ids.box1, type: 'line', x: 0, y: 0 }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect([...idsSet].some((id) => id.includes('rotate'))).toBe(false) }) it('hides resize handles when shape hides them', () => { editor.createShapes([{ id: ids.box1, type: 'line', x: 0, y: 0 }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect( [...idsSet].some( (id) => id === 'selection_fg:top_left' || id === 'selection_fg:top_right' || id === 'selection_fg:bottom_left' || id === 'selection_fg:bottom_right' || id === 'selection_fg:top' || id === 'selection_fg:right' || id === 'selection_fg:bottom' || id === 'selection_fg:left' ) ).toBe(false) }) it('shows crop handles in crop mode', () => { // Enter crop mode by setting croppingShapeId editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 60 } }]) editor.select(ids.box1) editor.updateCurrentPageState({ croppingShapeId: ids.box1 }) const util = editor.overlays.getOverlayUtil('selection_foreground') const overlays = util.getOverlays() // In crop mode, still uses 'resize_handle' overlay type but draws crop marks in render; // ensure handles exist at corners at least expect(overlays.some((o) => o.id === 'selection_fg:top_left')).toBe(true) }) it('hides crop edge targets when alternate crop handles are hidden', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 36, h: 36 } }]) editor.select(ids.box1) editor.updateCurrentPageState({ croppingShapeId: ids.box1 }) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect(idsSet.has('selection_fg:top')).toBe(false) expect(idsSet.has('selection_fg:right')).toBe(false) expect(idsSet.has('selection_fg:bottom')).toBe(false) expect(idsSet.has('selection_fg:left')).toBe(false) expect(idsSet.has('selection_fg:top_left')).toBe(true) expect(idsSet.has('selection_fg:bottom_right')).toBe(true) }) it('hides edge handles on coarse pointer', () => { editor.updateInstanceState({ isCoarsePointer: true }) editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const idsSet = new Set(util.getOverlays().map((o) => o.id)) expect(idsSet.has('selection_fg:top')).toBe(false) expect(idsSet.has('selection_fg:right')).toBe(false) expect(idsSet.has('selection_fg:bottom')).toBe(false) expect(idsSet.has('selection_fg:left')).toBe(false) }) }) describe('getGeometry', () => { it('returns a geometry for handles', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') for (const o of util.getOverlays()) { const geom = util.getGeometry(o) if ( o.props.overlayType === 'resize_handle' || o.props.overlayType === 'rotate_handle' || o.props.overlayType === 'mobile_rotate' ) { expect(geom).toBeTruthy() } } }) it('returns a circle-like geometry for rotate handles', () => { editor.updateInstanceState({ isCoarsePointer: false }) editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const rot = util.getOverlays().find((o) => o.id === 'selection_fg:top_left_rotate')! const geom = util.getGeometry(rot)! // Circle geometry produces a square bounding box const b = geom.getBounds() expect(Math.abs(b.width - b.height)).toBeLessThan(0.001) }) it('returns a polygon for mobile rotate handle', () => { editor.updateInstanceState({ isCoarsePointer: true }) editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 60, h: 60 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const mob = util.getOverlays().find((o) => o.id === 'selection_fg:mobile_rotate')! const geom = util.getGeometry(mob)! // Polygon2d duck-typing: no radius, but has vertices/points via bounds defined expect((geom as any).radius).toBeUndefined() }) it('transforms geometry to page space with selection rotation', () => { expect(true).toBe(true) }) it('accounts for expanded selection outline', () => { expect(true).toBe(true) }) it('scales hit areas with zoom level', () => { expect(true).toBe(true) }) it('enlarges hit areas for coarse pointer', () => { expect(true).toBe(true) }) }) describe('render', () => { it('renders the selection box while brushing over multiple notes', () => { editor.createShapes([ { id: ids.note1, type: 'note', x: 200, y: 180 }, { id: ids.note2, type: 'note', x: 460, y: 180 }, ]) editor.pointerDown(120, 120) editor.pointerMove(710, 450) expect(editor.getPath()).toBe('select.brushing') expect(editor.getSelectedShapeIds()).toHaveLength(2) const util = editor.overlays.getOverlayUtil('selection_foreground') const ctx = { save: vi.fn(), restore: vi.fn(), rotate: vi.fn(), strokeRect: vi.fn(), translate: vi.fn(), } as unknown as CanvasRenderingContext2D util.render(ctx, []) expect(ctx.strokeRect).toHaveBeenCalledWith(0, 0, 460, 200) }) }) describe('getCursor', () => { it('returns correct cursors for handle types', () => { editor.createShapes([{ id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') const tl = util.getOverlays().find((o) => o.id === 'selection_fg:top_left')! expect(util.getCursor(tl)).toBe('nwse-resize') const top = util.getOverlays().find((o) => o.id === 'selection_fg:top')! expect(util.getCursor(top)).toBe('ns-resize') }) it('preserves selection rotation in cursor when pressing a handle on a rotated multi-selection', () => { editor.createShapes([ { id: ids.box1, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } }, { id: ids.image1, type: 'geo', x: 200, y: 0, props: { w: 100, h: 100 } }, ]) editor.select(ids.box1, ids.image1) editor.rotateSelection(Math.PI / 4) // Hover the top-left resize handle — cursor should reflect selection rotation const p = editor.getSelectionHandlePagePoint('top_left') editor.pointerMove(p.x, p.y) const hoverRotation = editor.getInstanceState().cursor.rotation expect(hoverRotation).toBeCloseTo(editor.getSelectionRotation()) // Press — cursor rotation should still match the selection rotation editor.pointerDown(p.x, p.y) const pressRotation = editor.getInstanceState().cursor.rotation expect(pressRotation).toBeCloseTo(editor.getSelectionRotation()) }) }) }) it('returns empty array for a locked shape', () => { editor.createShapes([ { id: ids.box1, type: 'geo', x: 0, y: 0, isLocked: true, props: { w: 100, h: 100 } as any, } as any, ]) editor.select(ids.box1) const util = editor.overlays.getOverlayUtil('selection_foreground') expect(util.getOverlays()).toEqual([]) })