import { AssetRecordType, BaseBoxShapeUtil, Geometry2d, PageRecordType, Rectangle2d, TLGeoShapeProps, TLShape, TldrawEditorProps, atom, createShapeId, debounce, getSnapshot, loadSnapshot, react, } from '@tldraw/editor' import { vi } from 'vitest' import { TestEditor } from './TestEditor' let editor: TestEditor const ids = { box1: createShapeId('box1'), box2: createShapeId('box2'), box3: createShapeId('box3'), frame1: createShapeId('frame1'), group1: createShapeId('group1'), page2: PageRecordType.createId('page2'), } beforeEach(() => { editor = new TestEditor({}) editor.createShapes([ // on its own { id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }, // in a frame { id: ids.frame1, type: 'frame', x: 100, y: 100, props: { w: 100, h: 100 } }, { id: ids.box2, type: 'geo', x: 700, y: 700, props: { w: 100, h: 100 }, parentId: ids.frame1 }, { id: ids.group1, type: 'group', x: 100, y: 100, props: {} }, { id: ids.box3, type: 'geo', x: 500, y: 500, props: { w: 100, h: 100 }, parentId: ids.group1 }, ]) const page1 = editor.getCurrentPageId() editor.createPage({ name: 'page 2', id: ids.page2 }) editor.setCurrentPage(page1) }) const moveShapesToPage2 = () => { // directly manipulate parentId like would happen in multiplayer situations editor.updateShapes([ { id: ids.box1, type: 'geo', parentId: ids.page2 }, { id: ids.box2, type: 'geo', parentId: ids.page2 }, { id: ids.group1, type: 'group', parentId: ids.page2 }, ]) } describe('shapes that are moved to another page', () => { it("should be excluded from the previous page's focusedGroupId", () => { editor.setFocusedGroup(ids.group1) expect(editor.getFocusedGroupId()).toBe(ids.group1) moveShapesToPage2() expect(editor.getFocusedGroupId()).toBe(editor.getCurrentPageId()) }) describe("should be excluded from the previous page's hintingShapeIds", () => { test('[boxes]', () => { editor.setHintingShapes([ids.box1, ids.box2, ids.box3]) expect(editor.getHintingShapeIds()).toEqual([ids.box1, ids.box2, ids.box3]) moveShapesToPage2() expect(editor.getHintingShapeIds()).toEqual([]) }) test('[frame that does not move]', () => { editor.setHintingShapes([ids.frame1]) expect(editor.getHintingShapeIds()).toEqual([ids.frame1]) moveShapesToPage2() expect(editor.getHintingShapeIds()).toEqual([ids.frame1]) }) }) describe("should be excluded from the previous page's editingShapeId", () => { test('[root shape]', () => { editor.setEditingShape(ids.box1) expect(editor.getEditingShapeId()).toBe(ids.box1) moveShapesToPage2() expect(editor.getEditingShapeId()).toBe(null) }) test('[child of frame]', () => { editor.setEditingShape(ids.box2) expect(editor.getEditingShapeId()).toBe(ids.box2) moveShapesToPage2() expect(editor.getEditingShapeId()).toBe(null) }) test('[child of group]', () => { editor.setEditingShape(ids.box3) expect(editor.getEditingShapeId()).toBe(ids.box3) moveShapesToPage2() expect(editor.getEditingShapeId()).toBe(null) }) test('[frame that doesnt move]', () => { editor.setEditingShape(ids.frame1) expect(editor.getEditingShapeId()).toBe(ids.frame1) moveShapesToPage2() expect(editor.getEditingShapeId()).toBe(ids.frame1) }) }) describe("should be excluded from the previous page's erasingShapeIds", () => { test('[boxes]', () => { editor.setErasingShapes([ids.box1, ids.box2, ids.box3]) expect(editor.getErasingShapeIds()).toEqual([ids.box1, ids.box2, ids.box3]) moveShapesToPage2() expect(editor.getErasingShapeIds()).toEqual([]) }) test('[frame that does not move]', () => { editor.setErasingShapes([ids.frame1]) expect(editor.getErasingShapeIds()).toEqual([ids.frame1]) moveShapesToPage2() expect(editor.getErasingShapeIds()).toEqual([ids.frame1]) }) }) describe("should be excluded from the previous page's selectedShapeIds", () => { test('[boxes]', () => { editor.setSelectedShapes([ids.box1, ids.box2, ids.box3]) expect(editor.getSelectedShapeIds()).toEqual([ids.box1, ids.box2, ids.box3]) moveShapesToPage2() expect(editor.getSelectedShapeIds()).toEqual([]) }) test('[frame that does not move]', () => { editor.setSelectedShapes([ids.frame1]) expect(editor.getSelectedShapeIds()).toEqual([ids.frame1]) moveShapesToPage2() expect(editor.getSelectedShapeIds()).toEqual([ids.frame1]) }) }) }) it('Begins dragging from pointer move', () => { editor.pointerDown(0, 0) editor.pointerMove(2, 2) expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(10, 10) expect(editor.inputs.getIsDragging()).toBe(true) }) it('Begins dragging from wheel', () => { editor.pointerDown(0, 0) editor.wheel(2, 2) expect(editor.inputs.getIsDragging()).toBe(false) editor.wheel(10, 10) expect(editor.inputs.getIsDragging()).toBe(true) }) it('Does not create an undo stack item when first clicking on an empty canvas', () => { editor = new TestEditor() editor.pointerMove(50, 50) editor.click(0, 0) expect(editor.getCanUndo()).toBe(false) }) describe('Editor.sharedOpacity', () => { it('should return the current opacity', () => { expect(editor.getSharedOpacity()).toStrictEqual({ type: 'shared', value: 1 }) editor.setOpacityForSelectedShapes(0.5) editor.setOpacityForNextShapes(0.5) expect(editor.getSharedOpacity()).toStrictEqual({ type: 'shared', value: 0.5 }) }) it('should return opacity for a single selected shape', () => { const A = createShapeId('A') editor.createShapes([{ id: A, type: 'geo', x: 0, y: 0, opacity: 0.3, props: {} }]) editor.setSelectedShapes([A]) expect(editor.getSharedOpacity()).toStrictEqual({ type: 'shared', value: 0.3 }) }) it('should return opacity for multiple selected shapes', () => { const A = createShapeId('A') const B = createShapeId('B') editor.createShapes([ { id: A, type: 'geo', x: 0, y: 0, opacity: 0.3, props: {} }, { id: B, type: 'geo', x: 0, y: 0, opacity: 0.3, props: {} }, ]) editor.setSelectedShapes([A, B]) expect(editor.getSharedOpacity()).toStrictEqual({ type: 'shared', value: 0.3 }) }) it('should return mixed when multiple selected shapes have different opacity', () => { const A = createShapeId('A') const B = createShapeId('B') editor.createShapes([ { id: A, type: 'geo', x: 0, y: 0, opacity: 0.3, props: {} }, { id: B, type: 'geo', x: 0, y: 0, opacity: 0.5, props: {} }, ]) editor.setSelectedShapes([A, B]) expect(editor.getSharedOpacity()).toStrictEqual({ type: 'mixed' }) }) it('ignores the opacity of groups and returns the opacity of their children', () => { const ids = { group: createShapeId('group'), A: createShapeId('A'), } editor.createShapes([ { id: ids.group, type: 'group', x: 0, y: 0, props: {} }, { id: ids.A, type: 'geo', x: 0, y: 0, opacity: 0.3, parentId: ids.group, props: {} }, ]) editor.setSelectedShapes([ids.group]) expect(editor.getSharedOpacity()).toStrictEqual({ type: 'shared', value: 0.3 }) }) }) describe('Editor.setOpacity', () => { it('should set opacity for selected shapes', () => { const ids = { A: createShapeId('A'), B: createShapeId('B'), } editor.createShapes([ { id: ids.A, type: 'geo', x: 0, y: 0, opacity: 0.3, props: {} }, { id: ids.B, type: 'geo', x: 0, y: 0, opacity: 0.4, props: {} }, ]) editor.setSelectedShapes([ids.A, ids.B]) editor.setOpacityForSelectedShapes(0.5) editor.setOpacityForNextShapes(0.5) expect(editor.getShape(ids.A)!.opacity).toBe(0.5) expect(editor.getShape(ids.B)!.opacity).toBe(0.5) }) it('should traverse into groups and set opacity in their children', () => { const ids = { boxA: createShapeId('boxA'), groupA: createShapeId('groupA'), boxB: createShapeId('boxB'), groupB: createShapeId('groupB'), boxC: createShapeId('boxC'), boxD: createShapeId('boxD'), } editor.createShapes([ { id: ids.boxA, type: 'geo', x: 0, y: 0, props: {} }, { id: ids.groupA, type: 'group', x: 0, y: 0, props: {} }, { id: ids.boxB, type: 'geo', x: 0, y: 0, parentId: ids.groupA, props: {} }, { id: ids.groupB, type: 'group', x: 0, y: 0, parentId: ids.groupA, props: {} }, { id: ids.boxC, type: 'geo', x: 0, y: 0, parentId: ids.groupB, props: {} }, { id: ids.boxD, type: 'geo', x: 0, y: 0, parentId: ids.groupB, props: {} }, ]) editor.setSelectedShapes([ids.groupA]) editor.setOpacityForSelectedShapes(0.5) editor.setOpacityForNextShapes(0.5) // a wasn't selected... expect(editor.getShape(ids.boxA)!.opacity).toBe(1) // b, c, & d were within a selected group... expect(editor.getShape(ids.boxB)!.opacity).toBe(0.5) expect(editor.getShape(ids.boxC)!.opacity).toBe(0.5) expect(editor.getShape(ids.boxD)!.opacity).toBe(0.5) // groups get skipped expect(editor.getShape(ids.groupA)!.opacity).toBe(1) expect(editor.getShape(ids.groupB)!.opacity).toBe(1) }) it('stores opacity on opacityForNextShape', () => { editor.setOpacityForSelectedShapes(0.5) editor.setOpacityForNextShapes(0.5) expect(editor.getInstanceState().opacityForNextShape).toBe(0.5) editor.setOpacityForSelectedShapes(0.6) editor.setOpacityForNextShapes(0.6) expect(editor.getInstanceState().opacityForNextShape).toBe(0.6) }) }) describe('Editor.TickManager', () => { it('Does not produce NaN values when elapsed is 0', () => { // a helper that calls update pointer velocity with a given elapsed time. // usually this is called by the app's tick manager, using the elapsed time // between two animation frames, but we're calling it directly here. const tick = (ms: number) => { editor.inputs.updatePointerVelocity(ms) } // 1. pointer velocity should be 0 when there is no movement expect(editor.inputs.getPointerVelocity().toJson()).toCloselyMatchObject({ x: 0, y: 0 }) editor.pointerMove(10, 10) // 2. moving is not enough, we also need to wait a frame before the velocity is updated expect(editor.inputs.getPointerVelocity().toJson()).toCloselyMatchObject({ x: 0, y: 0 }) // 3. once time passes, the pointer velocity should be updated tick(16) expect(editor.inputs.getPointerVelocity().toJson()).toCloselyMatchObject({ x: 0.3125, y: 0.3125, }) // 4. let's do it again, it should be updated again. move, tick, measure editor.pointerMove(20, 20) tick(16) expect(editor.inputs.getPointerVelocity().toJson()).toCloselyMatchObject({ x: 0.46875, y: 0.46875, }) // 5. if we tick again without movement, the velocity should decay tick(16) expect(editor.inputs.getPointerVelocity().toJson()).toCloselyMatchObject({ x: 0.23437, y: 0.23437, }) // 6. if updatePointerVelocity is (for whatever reason) called with an elapsed time of zero milliseconds, it should be ignored tick(0) expect(editor.inputs.getPointerVelocity().toJson()).toCloselyMatchObject({ x: 0.23437, y: 0.23437, }) }) it('smooths pointer velocity consistently across frame rates', () => { const getVelocityAfterMoving = (elapsed: number, frameCount: number) => { const testEditor = new TestEditor({}) try { for (let i = 1; i <= frameCount; i++) { testEditor.pointerMove((10 * i) / frameCount, (10 * i) / frameCount) testEditor.inputs.updatePointerVelocity(elapsed) } return testEditor.inputs.getPointerVelocity().toJson() } finally { testEditor.dispose() } } expect(getVelocityAfterMoving(16, 1)).toCloselyMatchObject({ x: 0.3125, y: 0.3125, }) expect(getVelocityAfterMoving(8, 2)).toCloselyMatchObject({ x: 0.3125, y: 0.3125, }) }) }) describe("App's default tool", () => { it('Is select for regular app', () => { editor = new TestEditor() expect(editor.getCurrentToolId()).toBe('select') }) it('Is hand for readonly mode', () => { editor = new TestEditor() editor.updateInstanceState({ isReadonly: true }) editor.setCurrentTool('hand') expect(editor.getCurrentToolId()).toBe('hand') }) }) describe('currentToolId', () => { it('is select by default', () => { expect(editor.getCurrentToolId()).toBe('select') }) it('is set to the last used tool', () => { editor.setCurrentTool('draw') expect(editor.getCurrentToolId()).toBe('draw') editor.setCurrentTool('geo') expect(editor.getCurrentToolId()).toBe('geo') }) it('stays the selected tool during shape creation interactions that technically use the select tool', () => { expect(editor.getCurrentToolId()).toBe('select') editor.setCurrentTool('geo') editor.pointerDown(0, 0) editor.pointerMove(100, 100) expect(editor.getCurrentToolId()).toBe('geo') editor.expectToBeIn('select.resizing') }) it('reverts back to select if we finish the interaction', () => { expect(editor.getCurrentToolId()).toBe('select') editor.setCurrentTool('geo') editor.pointerDown(0, 0) editor.pointerMove(100, 100) expect(editor.getCurrentToolId()).toBe('geo') editor.expectToBeIn('select.resizing') editor.pointerUp(100, 100) expect(editor.getCurrentToolId()).toBe('select') }) it('stays on the selected tool if we cancel the interaction', () => { expect(editor.getCurrentToolId()).toBe('select') editor.setCurrentTool('geo') editor.pointerDown(0, 0) editor.pointerMove(100, 100) expect(editor.getCurrentToolId()).toBe('geo') editor.expectToBeIn('select.resizing') editor.cancel() expect(editor.getCurrentToolId()).toBe('geo') }) }) describe('isFocused', () => { beforeEach(() => { // lame but duplicated here since this was moved into a hook const container = editor.getContainer() const updateFocus = debounce(() => { const { activeElement } = document const { isFocused: wasFocused } = editor.getInstanceState() const isFocused = document.hasFocus() && (container === activeElement || container.contains(activeElement)) if (wasFocused !== isFocused) { editor.updateInstanceState({ isFocused }) if (!isFocused) { // When losing focus, run complete() to ensure that any interacts end editor.complete() } } }, 32) container.addEventListener('focusin', updateFocus) container.addEventListener('focus', updateFocus) container.addEventListener('focusout', updateFocus) container.addEventListener('blur', updateFocus) }) it('is false by default', () => { expect(editor.getInstanceState().isFocused).toBe(false) }) it('becomes true when you call .focus()', () => { editor.updateInstanceState({ isFocused: true }) expect(editor.getInstanceState().isFocused).toBe(true) }) it('becomes false when you call .blur()', () => { editor.updateInstanceState({ isFocused: true }) expect(editor.getInstanceState().isFocused).toBe(true) editor.updateInstanceState({ isFocused: false }) expect(editor.getInstanceState().isFocused).toBe(false) }) it('remains false when you call .blur()', () => { expect(editor.getInstanceState().isFocused).toBe(false) editor.updateInstanceState({ isFocused: false }) expect(editor.getInstanceState().isFocused).toBe(false) }) it('becomes true when the container div receives a focus event', () => { vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(false) editor.elm.focus() vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(true) }) it('becomes false when the container div receives a blur event', () => { editor.elm.focus() vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(true) editor.elm.blur() vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(false) }) it.skip('becomes true when a child of the app container div receives a focusin event', () => { // We need to skip this one because it's not actually true: the focusin event will bubble // to the document.body, resulting in that being the active element. In reality, the editor's // container would also have received a focus event, and after the editor's debounce ends, // the container (or one of its descendants) will be the focused element. editor.elm.blur() const child = document.createElement('div') editor.elm.appendChild(child) vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(false) child.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(true) child.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(false) }) it.skip('becomes false when a child of the app container div receives a focusout event', () => { // This used to be true, but the focusout event doesn't actually bubble up anymore // after we reworked to have the focus manager handle things. const child = document.createElement('div') editor.elm.appendChild(child) editor.updateInstanceState({ isFocused: true }) expect(editor.getInstanceState().isFocused).toBe(true) child.dispatchEvent(new FocusEvent('focusout', { bubbles: true })) vi.advanceTimersByTime(100) expect(editor.getInstanceState().isFocused).toBe(false) }) }) const BLORG_TYPE = 'blorg' declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { [BLORG_TYPE]: { w: number; h: number } } } describe('getShapeUtil', () => { let myUtil: any beforeEach(() => { class _MyFakeShapeUtil extends BaseBoxShapeUtil { static override type = BLORG_TYPE getDefaultProps() { return { w: 100, h: 100, } } component() { throw new Error('Method not implemented.') } getIndicatorPath(): undefined { throw new Error('Method not implemented.') } } myUtil = _MyFakeShapeUtil editor = new TestEditor({ shapeUtils: [_MyFakeShapeUtil], }) editor.createShapes([ { id: ids.box1, type: 'blorg', x: 100, y: 100, props: { w: 100, h: 100 } }, ]) const page1 = editor.getCurrentPageId() editor.createPage({ name: 'page 2', id: ids.page2 }) editor.setCurrentPage(page1) }) it('accepts shapes', () => { const shape = editor.getShape(ids.box1)! const util = editor.getShapeUtil(shape) expect(util).toBeInstanceOf(myUtil) }) it('accepts shape types', () => { const util = editor.getShapeUtil('blorg') expect(util).toBeInstanceOf(myUtil) }) it('throws if that shape type isnt registered', () => { const myMissingShape = { type: 'missing' } expect(() => editor.getShapeUtil( // @ts-expect-error myMissingShape ) ).toThrowErrorMatchingInlineSnapshot(`[Error: No shape util found for type "missing"]`) }) it('throws if that type isnt registered', () => { expect(() => editor.getShapeUtil( // @ts-expect-error 'missing' ) ).toThrowErrorMatchingInlineSnapshot(`[Error: No shape util found for type "missing"]`) }) }) describe('snapshots', () => { it('creates and loads a snapshot', () => { const ids = { imageA: createShapeId('imageA'), boxA: createShapeId('boxA'), imageAssetA: AssetRecordType.createId('imageAssetA'), } editor.createAssets([ { type: 'image', id: ids.imageAssetA, typeName: 'asset', props: { w: 1200, h: 800, name: '', isAnimated: false, mimeType: 'png', src: '', }, meta: {}, }, ]) editor.createShapes([ { type: 'geo', x: 0, y: 0 }, { type: 'geo', x: 100, y: 0 }, { id: ids.imageA, type: 'image', props: { playing: false, url: '', w: 1200, h: 800, assetId: ids.imageAssetA, }, x: 0, y: 1200, }, ]) const page2Id = PageRecordType.createId('page2') editor.createPage({ id: page2Id, }) editor.setCurrentPage(page2Id) editor.createShapes([ { type: 'geo', x: 0, y: 0 }, { type: 'geo', x: 100, y: 0 }, ]) editor.selectAll() // now serialize const snapshot = getSnapshot(editor.store) const newEditor = new TestEditor() loadSnapshot(newEditor.store, snapshot) expect(editor.store.serialize()).toEqual(newEditor.store.serialize()) }) }) describe('when the user prefers dark UI', () => { beforeEach(() => { window.matchMedia = vi.fn().mockImplementation((query) => { return { matches: query === '(prefers-color-scheme: dark)', media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), } }) }) it('isDarkMode should be false by default', () => { editor = new TestEditor({}) expect(editor.user.getIsDarkMode()).toBe(false) }) }) describe('when the user prefers light UI', () => { beforeEach(() => { window.matchMedia = vi.fn().mockImplementation((query) => { return { matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), } }) }) it('isDarkMode should be false by default', () => { editor = new TestEditor({}) expect(editor.user.getIsDarkMode()).toBe(false) }) }) describe('middle-click panning', () => { it('clears the isPanning state on mouse up', () => { editor.pointerDown(0, 0, { // middle mouse button button: 1, }) editor.pointerMove(100, 100) expect(editor.inputs.getIsPanning()).toBe(true) editor.pointerUp(100, 100) expect(editor.inputs.getIsPanning()).toBe(false) }) it('does not clear thee isPanning state if the space bar is down', () => { editor.pointerDown(0, 0, { // middle mouse button button: 1, }) editor.pointerMove(100, 100) expect(editor.inputs.getIsPanning()).toBe(true) editor.keyDown(' ') editor.pointerUp(100, 100, { button: 1, }) expect(editor.inputs.getIsPanning()).toBe(true) editor.keyUp(' ') expect(editor.inputs.getIsPanning()).toBe(false) }) }) describe('dragging', () => { it('drags correctly at 100% zoom', () => { expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 0).pointerDown() expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 1) expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 5) expect(editor.inputs.getIsDragging()).toBe(true) }) it('drags correctly at 150% zoom', () => { editor.setCamera({ x: 0, y: 0, z: 8 }).forceTick() expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 0).pointerDown() expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 2) expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 5) expect(editor.inputs.getIsDragging()).toBe(true) }) it('drags correctly at 50% zoom', () => { editor.setCamera({ x: 0, y: 0, z: 0.1 }).forceTick() expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 0).pointerDown() expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 2) expect(editor.inputs.getIsDragging()).toBe(false) editor.pointerMove(0, 5) expect(editor.inputs.getIsDragging()).toBe(true) }) }) describe('getShapeVisibility', () => { const getShapeVisibility = vi.fn(((shape: TLShape) => { return shape.meta.visibility as any }) satisfies TldrawEditorProps['getShapeVisibility']) beforeEach(() => { getShapeVisibility.mockClear() editor = new TestEditor({ getShapeVisibility }) editor.createShapes([ { id: ids.box1, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, fill: 'solid' } satisfies Partial, }, { id: ids.box2, type: 'geo', x: 200, y: 200, props: { w: 100, h: 100, fill: 'solid' } satisfies Partial, }, { id: ids.box3, type: 'geo', x: 300, y: 300, props: { w: 100, h: 100, fill: 'solid' } satisfies Partial, }, ]) }) it('can be directly used via editor.isShapeHidden', () => { expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(false) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(true) }) it('excludes hidden shapes from the rendering shapes array', () => { expect(editor.getRenderingShapes().length).toBe(3) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) expect(editor.getRenderingShapes().length).toBe(2) editor.updateShape({ id: ids.box2, type: 'geo', meta: { visibility: 'hidden' } }) expect(editor.getRenderingShapes().length).toBe(1) }) it('excludes hidden shapes from hit testing', () => { expect(editor.getShapeAtPoint({ x: 150, y: 150 })).toBeDefined() expect(editor.getShapesAtPoint({ x: 150, y: 150 }).length).toBe(1) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) expect(editor.getShapeAtPoint({ x: 150, y: 150 })).not.toBeDefined() expect(editor.getShapesAtPoint({ x: 150, y: 150 }).length).toBe(0) }) it('uses the callback reactively', () => { const isFilteringEnabled = atom('', true) getShapeVisibility.mockImplementation((shape: TLShape) => { if (!isFilteringEnabled.get()) return 'inherit' return shape.meta.visibility }) let renderingShapes = editor.getRenderingShapes() react('setRenderingShapes', () => { renderingShapes = editor.getRenderingShapes() }) expect(renderingShapes.length).toBe(3) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) expect(renderingShapes.length).toBe(2) isFilteringEnabled.set(false) expect(renderingShapes.length).toBe(3) isFilteringEnabled.set(true) expect(renderingShapes.length).toBe(2) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'inherit' } }) expect(renderingShapes.length).toBe(3) }) it('applies recursively to children', () => { const groupId = createShapeId('group') editor.groupShapes([ids.box1, ids.box2], { groupId }) expect(editor.isShapeHidden(editor.getShape(groupId)!)).toBe(false) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(false) editor.updateShape({ id: groupId, type: 'group', meta: { visibility: 'hidden' } }) expect(editor.isShapeHidden(editor.getShape(groupId)!)).toBe(true) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(true) }) it('still allows hidden shapes to be selected', () => { editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) editor.select(ids.box1) expect(editor.getSelectedShapeIds()).toEqual([ids.box1]) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(true) }) it('applies to getCurrentPageRenderingShapesSorted', () => { expect(editor.getCurrentPageRenderingShapesSorted().length).toBe(3) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) expect(editor.getCurrentPageRenderingShapesSorted().length).toBe(2) }) it('does not apply to getCurrentPageShapesSorted', () => { expect(editor.getCurrentPageShapesSorted().length).toBe(3) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'hidden' } }) expect(editor.getCurrentPageShapesSorted().length).toBe(3) }) it('allows overriding hidden parents with "visible" value', () => { const groupId = createShapeId('group') editor.groupShapes([ids.box1, ids.box2], { groupId }) expect(editor.isShapeHidden(editor.getShape(groupId)!)).toBe(false) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(false) editor.updateShape({ id: groupId, type: 'group', meta: { visibility: 'hidden' } }) expect(editor.isShapeHidden(editor.getShape(groupId)!)).toBe(true) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(true) editor.updateShape({ id: ids.box1, type: 'geo', meta: { visibility: 'visible' } }) expect(editor.isShapeHidden(editor.getShape(groupId)!)).toBe(true) expect(editor.isShapeHidden(editor.getShape(ids.box1)!)).toBe(false) }) }) describe('instance.isReadonly', () => { it('updates in accordance with collaboration.mode', () => { const mode = atom<'readonly' | 'readwrite'>('', 'readonly') const editor = new TestEditor( {}, { collaboration: { mode, status: atom('', 'online'), }, } ) expect(editor.getIsReadonly()).toBe(true) mode.set('readwrite') expect(editor.getIsReadonly()).toBe(false) mode.set('readonly') expect(editor.getIsReadonly()).toBe(true) }) }) const MY_CUSTOM_SHAPE_TYPE = 'myCustomShape' type MyCustomShape = TLShape declare module '@tldraw/tlschema' { export interface TLGlobalShapePropsMap { [MY_CUSTOM_SHAPE_TYPE]: { w: number; h: number } } } describe('the geometry cache', () => { class CustomShapeUtil extends BaseBoxShapeUtil { static override type = MY_CUSTOM_SHAPE_TYPE getDefaultProps() { return { w: 100, h: 100, } } override getGeometry(shape: any): Geometry2d { return new Rectangle2d({ width: shape.meta.double ? shape.props.w * 2 : shape.props.w, height: shape.meta.double ? shape.props.h * 2 : shape.props.h, isFilled: true, }) } component() { throw new Error('Method not implemented.') } getIndicatorPath(): undefined { throw new Error('Method not implemented.') } } it('should be busted when meta changes', () => { editor = new TestEditor({ shapeUtils: [CustomShapeUtil], }) const A = createShapeId('A') editor.createShapes([{ id: A, type: 'myCustomShape', x: 0, y: 0, props: { w: 100, h: 100 } }]) expect(editor.getShapePageBounds(A)!.width).toBe(100) editor.updateShape({ id: A, type: 'myCustomShape', meta: { double: true } }) expect(editor.getShapePageBounds(A)!.width).toBe(200) }) }) describe('editor.getShapePageBounds', () => { it('calculates axis aligned bounds correctly', () => { editor.createShape({ type: 'geo', x: 99, y: 88, props: { w: 199, h: 188, }, }) const shape = editor.getLastCreatedShape() expect(editor.getShapePageBounds(shape)!).toMatchInlineSnapshot(` Box { "h": 188, "w": 199, "x": 99, "y": 88, } `) }) it('calculates rotated bounds correctly', () => { editor.createShape({ type: 'geo', x: 99, y: 88, rotation: Math.PI / 4, props: { w: 199, h: 188, }, }) const shape = editor.getLastCreatedShape() expect(editor.getShapePageBounds(shape)!).toMatchInlineSnapshot(` Box { "h": 273.65032431919394, "w": 273.6503243191939, "x": -33.93607486307093, "y": 88, } `) }) it('calculates bounds based on vertices, not corners', () => { editor.createShape({ type: 'geo', x: 99, y: 88, rotation: Math.PI / 4, props: { geo: 'ellipse', w: 199, h: 188, }, }) const shape = editor.getLastCreatedShape() expect(editor.getShapePageBounds(shape)!).toMatchInlineSnapshot(` Box { "h": 193.49999999999997, "w": 193.50000000000003, "x": 6.139087296526014, "y": 128.07516215959694, } `) }) })