From 8f72c4839d4d86f70abe395e7cbc69546fafe5ef Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 15 Apr 2026 19:42:25 +0200 Subject: [PATCH] fix: preserve zoomed block handle hover --- src/components/SingleEditorView.tsx | 2 + .../blockNoteSideMenuHoverGuard.test.ts | 109 ++++++++++++++++++ src/components/blockNoteSideMenuHoverGuard.ts | 81 +++++++++++++ tests/smoke/editor-block-handle-zoom.spec.ts | 94 +++++++++++++++ 4 files changed, 286 insertions(+) create mode 100644 src/components/blockNoteSideMenuHoverGuard.test.ts create mode 100644 src/components/blockNoteSideMenuHoverGuard.ts create mode 100644 tests/smoke/editor-block-handle-zoom.spec.ts diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 7a688b6f..22129853 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -12,6 +12,7 @@ import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionE import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu' import type { VaultEntry } from '../types' import { _wikilinkEntriesRef } from './editorSchema' +import { useBlockNoteSideMenuHoverGuard } from './blockNoteSideMenuHoverGuard' import { useEditorLinkActivation } from './useEditorLinkActivation' const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 | @@ -129,6 +130,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange const containerRef = useRef(null) const onImageUrl = useInsertImageCallback(editor) const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath }) + useBlockNoteSideMenuHoverGuard(containerRef) useEditorLinkActivation(containerRef, onNavigateWikilink) const handleContainerClick = useCallback((e: React.MouseEvent) => { diff --git a/src/components/blockNoteSideMenuHoverGuard.test.ts b/src/components/blockNoteSideMenuHoverGuard.test.ts new file mode 100644 index 00000000..0ccc4bc4 --- /dev/null +++ b/src/components/blockNoteSideMenuHoverGuard.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + isWithinBlockNoteHandleHoverBridge, + shouldSuppressBlockNoteHandleHoverUpdate, +} from './blockNoteSideMenuHoverGuard' + +function rect(left: number, top: number, width: number, height: number) { + return DOMRect.fromRect({ x: left, y: top, width, height }) +} + +function setRect(element: HTMLElement, nextRect: DOMRect) { + element.getBoundingClientRect = () => nextRect +} + +describe('blockNoteSideMenuHoverGuard', () => { + it('treats the side-menu gutter as part of the hover bridge', () => { + expect( + isWithinBlockNoteHandleHoverBridge( + { x: 228, y: 118 }, + rect(240, 90, 420, 32), + rect(190, 96, 32, 24), + ), + ).toBe(true) + }) + + it('ignores points outside the side-menu bridge band', () => { + expect( + isWithinBlockNoteHandleHoverBridge( + { x: 228, y: 150 }, + rect(240, 90, 420, 32), + rect(190, 96, 32, 24), + ), + ).toBe(false) + }) + + 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) + }) + + 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) + + 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 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) + }) +}) diff --git a/src/components/blockNoteSideMenuHoverGuard.ts b/src/components/blockNoteSideMenuHoverGuard.ts new file mode 100644 index 00000000..917ce785 --- /dev/null +++ b/src/components/blockNoteSideMenuHoverGuard.ts @@ -0,0 +1,81 @@ +import { useEffect, type RefObject } from 'react' + +type RectLike = Pick + +const HOVER_BRIDGE_PADDING_X = 8 +const HOVER_BRIDGE_PADDING_Y = 6 + +function isVisibleRect(rect: RectLike) { + return rect.right > rect.left && rect.bottom > rect.top +} + +export function isWithinBlockNoteHandleHoverBridge( + point: { x: number; y: number }, + editorRect: RectLike, + sideMenuRect: RectLike, +) { + if (!isVisibleRect(editorRect) || !isVisibleRect(sideMenuRect)) return false + + const left = Math.min(editorRect.left, sideMenuRect.left) - HOVER_BRIDGE_PADDING_X + const right = Math.max(editorRect.left, sideMenuRect.right) + HOVER_BRIDGE_PADDING_X + const top = sideMenuRect.top - HOVER_BRIDGE_PADDING_Y + const bottom = sideMenuRect.bottom + HOVER_BRIDGE_PADDING_Y + + return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom +} + +export function shouldSuppressBlockNoteHandleHoverUpdate({ + eventTarget, + point, + container, + doc, +}: { + eventTarget: EventTarget | null + point: { x: number; y: number } + container: HTMLElement | null + doc: Document +}) { + if (!container) return false + + const editor = container.querySelector('.bn-editor') + if (!(editor instanceof HTMLElement)) return false + + if (eventTarget instanceof Element && eventTarget.closest('.bn-side-menu')) { + return true + } + + const sideMenu = doc.querySelector('.bn-side-menu') + if (!(sideMenu instanceof HTMLElement)) return false + + return isWithinBlockNoteHandleHoverBridge( + point, + editor.getBoundingClientRect(), + sideMenu.getBoundingClientRect(), + ) +} + +export function useBlockNoteSideMenuHoverGuard( + containerRef: RefObject, +) { + useEffect(() => { + const doc = containerRef.current?.ownerDocument + const view = doc?.defaultView + if (!doc || !view) return + + const handleMouseMove = (event: MouseEvent) => { + if (!shouldSuppressBlockNoteHandleHoverUpdate({ + eventTarget: event.target, + point: { x: event.clientX, y: event.clientY }, + container: containerRef.current, + doc, + })) { + return + } + + event.stopPropagation() + } + + view.addEventListener('mousemove', handleMouseMove, true) + return () => view.removeEventListener('mousemove', handleMouseMove, true) + }, [containerRef]) +} diff --git a/tests/smoke/editor-block-handle-zoom.spec.ts b/tests/smoke/editor-block-handle-zoom.spec.ts new file mode 100644 index 00000000..4fd9435d --- /dev/null +++ b/tests/smoke/editor-block-handle-zoom.spec.ts @@ -0,0 +1,94 @@ +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 applyZoom(page: Page, percent: number) { + await page.evaluate((pct) => { + document.documentElement.style.setProperty('zoom', `${pct}%`) + window.dispatchEvent(new Event('laputa-zoom-change')) + }, percent) + await page.waitForTimeout(250) +} + +async function movePointerFromBlockToHandle( + page: Page, + block: Locator, + handle: Locator, +) { + const blockBox = await block.boundingBox() + const handleBox = await handle.boundingBox() + + expect(blockBox).not.toBeNull() + expect(handleBox).not.toBeNull() + + await page.mouse.move( + blockBox!.x + blockBox!.width / 2, + blockBox!.y + blockBox!.height / 2, + ) + await page.waitForTimeout(100) + await page.mouse.move( + handleBox!.x + handleBox!.width / 2, + handleBox!.y + handleBox!.height / 2, + { steps: 12 }, + ) +} + +async function expectZoomedHandleClickToWork( + page: Page, + block: Locator, +) { + await block.hover() + + const dragHandle = page.locator('.bn-side-menu [draggable="true"]').first() + await expect(dragHandle).toBeVisible({ timeout: 5_000 }) + + await movePointerFromBlockToHandle(page, block, dragHandle) + await expect(dragHandle).toBeVisible({ timeout: 2_000 }) + + const handleBox = await dragHandle.boundingBox() + expect(handleBox).not.toBeNull() + + await page.mouse.click( + handleBox!.x + handleBox!.width / 2, + handleBox!.y + handleBox!.height / 2, + ) + + const menu = page.locator('.mantine-Menu-dropdown').filter({ has: page.locator('.mantine-Menu-item') }).first() + await expect(menu).toBeVisible({ timeout: 5_000 }) + await page.keyboard.press('Escape') + await expect(menu).toBeHidden({ timeout: 5_000 }) +} + +test('block handles stay clickable while zoomed in', async ({ page }) => { + await page.getByText('Alpha Project', { exact: true }).first().click() + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 5_000 }) + + await applyZoom(page, 150) + + await expectZoomedHandleClickToWork( + page, + editor.getByRole('heading', { name: 'Alpha Project', level: 1 }), + ) + + await expectZoomedHandleClickToWork( + page, + editor.getByText('This is a test project that references other notes.', { exact: true }), + ) + + await expectZoomedHandleClickToWork( + page, + editor.getByRole('heading', { name: 'Notes', level: 2 }), + ) +})