diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index 5b926de8..6cc18033 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -29,6 +29,10 @@ gap: 0 !important; } +.editor__blocknote-container .bn-side-menu [draggable="true"] * { + pointer-events: none; +} + /* More horizontal space between drag handle and content */ .editor__blocknote-container .bn-side-menu ~ * , .editor__blocknote-container [class*="bn-side-menu"] { diff --git a/src/components/blockNoteSideMenuHoverGuard.test.ts b/src/components/blockNoteSideMenuHoverGuard.test.ts index 0ccc4bc4..504121c4 100644 --- a/src/components/blockNoteSideMenuHoverGuard.test.ts +++ b/src/components/blockNoteSideMenuHoverGuard.test.ts @@ -12,6 +12,43 @@ function setRect(element: HTMLElement, nextRect: DOMRect) { element.getBoundingClientRect = () => nextRect } +function blockNoteHoverFixture() { + const container = document.createElement('div') + const editor = document.createElement('div') + editor.className = 'bn-editor' + container.appendChild(editor) + document.body.appendChild(container) + + const sideMenu = document.createElement('div') + sideMenu.className = 'bn-side-menu' + const sideMenuButton = document.createElement('button') + sideMenu.appendChild(sideMenuButton) + document.body.appendChild(sideMenu) + + setRect(editor, rect(240, 90, 420, 32)) + setRect(sideMenu, rect(190, 96, 32, 24)) + + return { container, sideMenuButton } +} + +function expectHoverSuppression(options: { + eventTarget?: EventTarget + point: { x: number; y: number } + hasPressedButton?: boolean + expected: boolean +}) { + const { container, sideMenuButton } = blockNoteHoverFixture() + expect( + shouldSuppressBlockNoteHandleHoverUpdate({ + eventTarget: options.eventTarget ?? sideMenuButton, + point: options.point, + container, + doc: document, + hasPressedButton: options.hasPressedButton, + }), + ).toBe(options.expected) +} + describe('blockNoteSideMenuHoverGuard', () => { it('treats the side-menu gutter as part of the hover bridge', () => { expect( @@ -34,76 +71,30 @@ describe('blockNoteSideMenuHoverGuard', () => { }) it('suppresses hover updates when the pointer is already over the side menu', () => { - const container = document.createElement('div') - const editor = document.createElement('div') - editor.className = 'bn-editor' - container.appendChild(editor) - document.body.appendChild(container) - - const sideMenu = document.createElement('div') - sideMenu.className = 'bn-side-menu' - const sideMenuButton = document.createElement('button') - sideMenu.appendChild(sideMenuButton) - document.body.appendChild(sideMenu) - - setRect(editor, rect(240, 90, 420, 32)) - setRect(sideMenu, rect(190, 96, 32, 24)) - - expect( - shouldSuppressBlockNoteHandleHoverUpdate({ - eventTarget: sideMenuButton, - point: { x: 200, y: 110 }, - container, - doc: document, - }), - ).toBe(true) + expectHoverSuppression({ point: { x: 200, y: 110 }, expected: true }) }) it('suppresses hover updates while the pointer crosses the handle bridge', () => { - const container = document.createElement('div') - const editor = document.createElement('div') - editor.className = 'bn-editor' - container.appendChild(editor) - document.body.appendChild(container) + expectHoverSuppression({ + eventTarget: document.body, + point: { x: 226, y: 116 }, + expected: true, + }) + }) - const sideMenu = document.createElement('div') - sideMenu.className = 'bn-side-menu' - document.body.appendChild(sideMenu) - - setRect(editor, rect(240, 90, 420, 32)) - setRect(sideMenu, rect(190, 96, 32, 24)) - - expect( - shouldSuppressBlockNoteHandleHoverUpdate({ - eventTarget: document.body, - point: { x: 226, y: 116 }, - container, - doc: document, - }), - ).toBe(true) + it('leaves block handle drag movement alone', () => { + expectHoverSuppression({ + point: { x: 200, y: 110 }, + hasPressedButton: true, + expected: false, + }) }) it('leaves unrelated pointer movement alone', () => { - const container = document.createElement('div') - const editor = document.createElement('div') - editor.className = 'bn-editor' - container.appendChild(editor) - document.body.appendChild(container) - - const sideMenu = document.createElement('div') - sideMenu.className = 'bn-side-menu' - document.body.appendChild(sideMenu) - - setRect(editor, rect(240, 90, 420, 32)) - setRect(sideMenu, rect(190, 96, 32, 24)) - - expect( - shouldSuppressBlockNoteHandleHoverUpdate({ - eventTarget: document.body, - point: { x: 360, y: 160 }, - container, - doc: document, - }), - ).toBe(false) + expectHoverSuppression({ + eventTarget: document.body, + point: { x: 360, y: 160 }, + expected: false, + }) }) }) diff --git a/src/components/blockNoteSideMenuHoverGuard.ts b/src/components/blockNoteSideMenuHoverGuard.ts index 917ce785..23dfb7b0 100644 --- a/src/components/blockNoteSideMenuHoverGuard.ts +++ b/src/components/blockNoteSideMenuHoverGuard.ts @@ -29,12 +29,15 @@ export function shouldSuppressBlockNoteHandleHoverUpdate({ point, container, doc, + hasPressedButton = false, }: { eventTarget: EventTarget | null point: { x: number; y: number } container: HTMLElement | null doc: Document + hasPressedButton?: boolean }) { + if (hasPressedButton) return false if (!container) return false const editor = container.querySelector('.bn-editor') @@ -68,6 +71,7 @@ export function useBlockNoteSideMenuHoverGuard( point: { x: event.clientX, y: event.clientY }, container: containerRef.current, doc, + hasPressedButton: event.buttons !== 0, })) { return } diff --git a/tests/smoke/editor-block-reorder.spec.ts b/tests/smoke/editor-block-reorder.spec.ts new file mode 100644 index 00000000..d32420b2 --- /dev/null +++ b/tests/smoke/editor-block-reorder.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, type Locator, type Page } from '@playwright/test' +import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' + +let tempVaultDir: string + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +async function blockOuterForText(page: Page, text: string): Promise { + const textNode = page.locator('.bn-editor').getByText(text, { exact: true }).first() + await expect(textNode).toBeVisible({ timeout: 5_000 }) + return textNode.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " bn-block-outer ")][1]') +} + +async function visibleDragHandle(page: Page, block: Locator): Promise { + await block.hover() + const handle = page.locator('.bn-side-menu [draggable="true"]').first() + await expect(handle).toBeVisible({ timeout: 5_000 }) + return handle +} + +async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locator): Promise { + const handleBox = await handle.boundingBox() + const targetBox = await targetBlock.boundingBox() + + expect(handleBox).not.toBeNull() + expect(targetBox).not.toBeNull() + + const start = { + x: handleBox!.x + handleBox!.width / 2, + y: handleBox!.y + handleBox!.height / 2, + } + await page.mouse.move(start.x, start.y) + await page.mouse.down() + await page.mouse.move(start.x + 4, start.y + 4, { steps: 4 }) + await page.mouse.move(start.x + 16, start.y + 16, { steps: 8 }) + await page.mouse.move( + targetBox!.x + targetBox!.width / 2, + targetBox!.y + 2, + { steps: 24 }, + ) + await page.mouse.up() +} + +test('dragging the left block handle reorders editor blocks', async ({ page }) => { + await page.getByText('Alpha Project', { exact: true }).first().click() + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 5_000 }) + + const paragraph = await blockOuterForText(page, 'This is a test project that references other notes.') + const notesHeading = await blockOuterForText(page, 'Notes') + + await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/) + + const handle = await visibleDragHandle(page, notesHeading) + await dragHandleToBlock(page, handle, paragraph) + + await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/) +})