import { act, fireEvent, screen } from '@testing-library/react' import { Editor } from '@tldraw/editor' import { Tldraw } from '../../lib/Tldraw' import { renderTldrawComponentWithEditor } from '../testutils/renderTldrawComponent' let editor: Editor beforeEach(async () => { // jsdom has no Element.scrollTo, which the page menu calls to reveal the current page. Element.prototype.scrollTo = vi.fn() const result = await renderTldrawComponentWithEditor((onMount) => , { waitForPatterns: false, }) editor = result.editor act(() => { for (let i = 2; i <= 5; i++) { editor.createPage({ name: `Page ${i}` }) } }) }) afterEach(() => { editor?.dispose() }) const ORIGINAL_ORDER = ['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5'] const RESTING_OFFSETS = [0, 36, 72, 108, 144] // jsdom's default pointerType is '', which tldraw treats as a coarse pointer, and the page menu // only lets fine pointers drag rows directly. const MOUSE = { pointerId: 1, pointerType: 'mouse' } function getPageNames() { return editor.getPages().map((p) => p.name) } function getListContent() { return document.querySelector('.tlui-page-menu__list__content') } // Each row's vertical offset, in page order. function getRowOffsets() { return editor.getPages().map((p) => { const row = document.querySelector(`[data-pageid="${p.id}"]`)! return Number(row.style.transform.match(/translateY\((-?[\d.]+)px\)/)![1]) }) } function getRowButton(name: string) { const page = editor.getPages().find((p) => p.name === name)! return document.querySelector( `[data-pageid="${page.id}"] .tlui-page-menu__item__button` )! } async function openPageMenu() { await act(async () => { fireEvent.click(screen.getByTestId('page-menu.button')) }) expect(getListContent()).not.toBeNull() } // Rows are 36px tall, so a 72px drag carries "Page 2" down two rows, over "Page 4". function dragPage2DownTwoRows() { const button = getRowButton('Page 2') fireEvent.pointerDown(button, { ...MOUSE, button: 0, clientY: 100 }) fireEvent.pointerMove(button, { ...MOUSE, clientY: 172 }) expect(getListContent()?.getAttribute('data-dragging')).toBe('true') expect(getRowOffsets()).toEqual([0, 108, 36, 72, 144]) return button } describe('pressing Escape while drag-reordering pages (#11112)', () => { it('cancels only the drag and keeps the menu open', async () => { await openPageMenu() const button = dragPage2DownTwoRows() fireEvent.keyDown(button, { key: 'Escape' }) expect(getListContent()).not.toBeNull() expect(getListContent()?.getAttribute('data-dragging')).toBe('false') expect(getRowOffsets()).toEqual(RESTING_OFFSETS) // The row still holds pointer capture, so the release lands on it. fireEvent.pointerUp(button, { ...MOUSE, clientY: 172 }) fireEvent.click(button) expect(getPageNames()).toEqual(ORIGINAL_ORDER) expect(editor.getCurrentPage().name).toBe('Page 1') expect(getListContent()).not.toBeNull() // With no drag in progress, Escape closes the menu as usual. fireEvent.keyDown(button, { key: 'Escape' }) expect(getListContent()).toBeNull() }) it('cancels only the drag when the body has focus', async () => { await openPageMenu() const button = dragPage2DownTwoRows() ;(document.activeElement as HTMLElement | null)?.blur() expect(document.activeElement).toBe(document.body) fireEvent.keyDown(document.body, { key: 'Escape' }) expect(getListContent()).not.toBeNull() expect(getRowOffsets()).toEqual(RESTING_OFFSETS) fireEvent.pointerUp(button, { ...MOUSE, clientY: 172 }) expect(getPageNames()).toEqual(ORIGINAL_ORDER) }) it('still lets the next click switch pages when the release lands on another row', async () => { await openPageMenu() const button = dragPage2DownTwoRows() fireEvent.keyDown(button, { key: 'Escape' }) // No click follows a release over a different row. fireEvent.pointerUp(button, { ...MOUSE, clientY: 172 }) const page5Button = getRowButton('Page 5') fireEvent.pointerDown(page5Button, { ...MOUSE, button: 0, clientY: 250 }) fireEvent.pointerUp(page5Button, { ...MOUSE, clientY: 250 }) fireEvent.click(page5Button) expect(editor.getCurrentPage().name).toBe('Page 5') expect(getPageNames()).toEqual(ORIGINAL_ORDER) }) it('closes the menu when the row is pressed but not yet dragged', async () => { await openPageMenu() const button = getRowButton('Page 2') fireEvent.pointerDown(button, { ...MOUSE, button: 0, clientY: 100 }) // Safari doesn't focus buttons on click, so the row may not see the key. ;(document.activeElement as HTMLElement | null)?.blur() fireEvent.keyDown(document.body, { key: 'Escape' }) expect(getListContent()).toBeNull() // Hovering after reopening must not turn the abandoned press into a drag. await openPageMenu() fireEvent.pointerMove(getRowButton('Page 2'), { ...MOUSE, clientY: 250 }) expect(getListContent()?.getAttribute('data-dragging')).toBe('false') expect(getRowOffsets()).toEqual(RESTING_OFFSETS) }) }) it('does not leave a drag behind when the menu closes mid-drag', async () => { await openPageMenu() dragPage2DownTwoRows() act(() => { editor.menus.clearOpenMenus() }) expect(getListContent()).toBeNull() await openPageMenu() expect(getListContent()?.getAttribute('data-dragging')).toBe('false') expect(getRowOffsets()).toEqual(RESTING_OFFSETS) // Hovering with no button held must not pick the row back up. fireEvent.pointerMove(getRowButton('Page 2'), { ...MOUSE, clientY: 250 }) expect(getListContent()?.getAttribute('data-dragging')).toBe('false') expect(getPageNames()).toEqual(ORIGINAL_ORDER) })