fix: preserve zoomed block handle hover
This commit is contained in:
@@ -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>) => {
|
||||
|
||||
109
src/components/blockNoteSideMenuHoverGuard.test.ts
Normal file
109
src/components/blockNoteSideMenuHoverGuard.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
81
src/components/blockNoteSideMenuHoverGuard.ts
Normal file
81
src/components/blockNoteSideMenuHoverGuard.ts
Normal 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])
|
||||
}
|
||||
94
tests/smoke/editor-block-handle-zoom.spec.ts
Normal file
94
tests/smoke/editor-block-handle-zoom.spec.ts
Normal 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 }),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user