fix: preserve zoomed block handle hover

This commit is contained in:
lucaronin
2026-04-15 19:42:25 +02:00
parent a1331453ad
commit 8f72c4839d
4 changed files with 286 additions and 0 deletions

View File

@@ -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<HTMLDivElement>(null)
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
useBlockNoteSideMenuHoverGuard(containerRef)
useEditorLinkActivation(containerRef, onNavigateWikilink)
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {

View File

@@ -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)
})
})

View File

@@ -0,0 +1,81 @@
import { useEffect, type RefObject } from 'react'
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
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<HTMLElement | null>,
) {
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])
}

View File

@@ -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 }),
)
})