Compare commits

...

1 Commits

Author SHA1 Message Date
lucaronin
cec1627a81 fix: preserve image toolbar hover bridge 2026-04-18 15:12:39 +02:00
4 changed files with 361 additions and 12 deletions

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import {
isWithinFormattingToolbarHoverBridge,
shouldSuppressFormattingToolbarHoverUpdate,
} from './blockNoteFormattingToolbarHoverGuard'
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('blockNoteFormattingToolbarHoverGuard', () => {
it('treats the gap between the selected image block and toolbar as part of the hover bridge', () => {
expect(
isWithinFormattingToolbarHoverBridge(
{ x: 368, y: 104 },
rect(300, 130, 140, 90),
rect(322, 78, 96, 24),
),
).toBe(true)
})
it('suppresses hover updates when the pointer is already over the toolbar', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
const toolbarButton = document.createElement('button')
toolbar.appendChild(toolbarButton)
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: toolbarButton,
point: { x: 350, y: 90 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('suppresses hover updates while the pointer crosses the image-toolbar bridge', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 368, y: 104 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(true)
})
it('leaves unrelated pointer movement alone', () => {
const container = document.createElement('div')
const block = document.createElement('div')
block.className = 'bn-block'
block.dataset.id = 'image-block'
const fileBlock = document.createElement('div')
fileBlock.dataset.fileBlock = 'true'
block.appendChild(fileBlock)
container.appendChild(block)
document.body.appendChild(container)
const toolbar = document.createElement('div')
toolbar.className = 'bn-formatting-toolbar'
document.body.appendChild(toolbar)
setRect(fileBlock, rect(300, 130, 140, 90))
setRect(toolbar, rect(322, 78, 96, 24))
expect(
shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: document.body,
point: { x: 520, y: 220 },
container,
doc: document,
selectedFileBlockId: 'image-block',
}),
).toBe(false)
})
})

View File

@@ -0,0 +1,179 @@
import { useEffect, useRef, type RefObject } from 'react'
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
const HOVER_BRIDGE_PADDING_X = 8
const HOVER_BRIDGE_PADDING_Y = 8
function isVisibleRect(rect: RectLike) {
return rect.right > rect.left && rect.bottom > rect.top
}
function getSelectedFileBlockBridgeElement(
container: HTMLElement,
blockId: string,
) {
const selectedBlock = container.querySelector<HTMLElement>(
`.bn-block[data-id="${blockId}"]`,
)
if (!selectedBlock) return null
return (
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-visual-media-wrapper',
) ??
selectedBlock.querySelector<HTMLElement>(
'[data-file-block] .bn-file-name-with-icon',
) ??
selectedBlock.querySelector<HTMLElement>('[data-file-block] .bn-add-file-button') ??
selectedBlock.querySelector<HTMLElement>('[data-file-block]')
)
}
export function isWithinFormattingToolbarHoverBridge(
point: { x: number; y: number },
fileBlockRect: RectLike,
toolbarRect: RectLike,
) {
if (!isVisibleRect(fileBlockRect) || !isVisibleRect(toolbarRect)) {
return false
}
const left = Math.min(fileBlockRect.left, toolbarRect.left) - HOVER_BRIDGE_PADDING_X
const right = Math.max(fileBlockRect.right, toolbarRect.right) + HOVER_BRIDGE_PADDING_X
const top = Math.min(fileBlockRect.top, toolbarRect.top) - HOVER_BRIDGE_PADDING_Y
const bottom = Math.max(fileBlockRect.bottom, toolbarRect.bottom) + HOVER_BRIDGE_PADDING_Y
return (
point.x >= left &&
point.x <= right &&
point.y >= top &&
point.y <= bottom
)
}
export function shouldSuppressFormattingToolbarHoverUpdate({
eventTarget,
point,
container,
doc,
selectedFileBlockId,
}: {
eventTarget: EventTarget | null
point: { x: number; y: number }
container: HTMLElement | null
doc: Document
selectedFileBlockId: string | null
}) {
if (!container || !selectedFileBlockId) return false
if (
eventTarget instanceof Element &&
eventTarget.closest('.bn-formatting-toolbar')
) {
return true
}
const selectedFileBlock = getSelectedFileBlockBridgeElement(
container,
selectedFileBlockId,
)
const toolbar = doc.querySelector<HTMLElement>('.bn-formatting-toolbar')
if (!selectedFileBlock || !toolbar) return false
return isWithinFormattingToolbarHoverBridge(
point,
selectedFileBlock.getBoundingClientRect(),
toolbar.getBoundingClientRect(),
)
}
function useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId: string | null,
isOpen: boolean,
) {
const lastSelectedFileBlockIdRef = useRef<string | null>(selectedFileBlockId)
useEffect(() => {
if (selectedFileBlockId) {
lastSelectedFileBlockIdRef.current = selectedFileBlockId
return
}
if (!isOpen) {
lastSelectedFileBlockIdRef.current = null
}
}, [isOpen, selectedFileBlockId])
return lastSelectedFileBlockIdRef
}
function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null) {
const doc = container?.ownerDocument
const view = doc?.defaultView
if (!container || !doc || !view) return null
return { container, doc, view }
}
function createFormattingToolbarHoverGuardHandler({
container,
doc,
selectedFileBlockIdRef,
}: {
container: HTMLElement
doc: Document
selectedFileBlockIdRef: RefObject<string | null>
}) {
return (event: MouseEvent) => {
if (
!shouldSuppressFormattingToolbarHoverUpdate({
eventTarget: event.target,
point: { x: event.clientX, y: event.clientY },
container,
doc,
selectedFileBlockId: selectedFileBlockIdRef.current,
})
) {
return
}
event.stopPropagation()
}
}
export function useBlockNoteFormattingToolbarHoverGuard({
container,
selectedFileBlockId,
isOpen,
}: {
container: HTMLElement | null
selectedFileBlockId: string | null
isOpen: boolean
}) {
const lastSelectedFileBlockIdRef = useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId,
isOpen,
)
useEffect(() => {
if (!isOpen) return
const environment = getFormattingToolbarHoverGuardEnvironment(container)
if (!environment) return
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
container: environment.container,
doc: environment.doc,
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
})
environment.view.addEventListener('mousemove', handleMouseMove, true)
return () => {
environment.view.removeEventListener('mousemove', handleMouseMove, true)
}
}, [container, isOpen, lastSelectedFileBlockIdRef])
}

View File

@@ -54,6 +54,7 @@ import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
@@ -166,6 +167,13 @@ type TolariaSelectedBlock = ReturnType<
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
>['block']
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
type TolariaBlockTypeSelectOption = ReturnType<
typeof getTolariaBlockTypeSelectItems
>[number] & {
@@ -263,6 +271,17 @@ function getTolariaBlockTypeSelectOptions(
}))
}
function getFormattingToolbarBridgeBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
: null
}
function updateSelectedBlocksToType(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
@@ -451,6 +470,19 @@ export function TolariaFormattingToolbarController(props: {
})
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const currentBridgeBlockId = useEditorState({
editor,
selector: ({ editor }) => getFormattingToolbarBridgeBlockId(editor),
})
useBlockNoteFormattingToolbarHoverGuard({
container:
editor.domElement?.closest('.editor__blocknote-container') ??
editor.domElement ??
null,
selectedFileBlockId: currentBridgeBlockId,
isOpen,
})
const position = useEditorState({
editor,

View File

@@ -73,6 +73,24 @@ async function seedImageBlock(page: Page) {
return image
}
async function moveMouseInSteps(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
{ steps, stepDelayMs }: { steps: number; stepDelayMs: number },
) {
await page.mouse.move(from.x, from.y)
for (let step = 1; step <= steps; step += 1) {
const progress = step / steps
await page.mouse.move(
from.x + (to.x - from.x) * progress,
from.y + (to.y - from.y) * progress,
)
await page.waitForTimeout(stepDelayMs)
}
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
@@ -86,28 +104,34 @@ test.afterEach(async () => {
test('image toolbar stays usable while the pointer crosses onto its controls', async ({ page }) => {
const image = await seedImageBlock(page)
await image.click()
const toolbar = page.locator('.bn-formatting-toolbar')
const replaceButton = page.getByRole('button', { name: /Replace image/i })
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
const imageBox = await image.boundingBox()
const replaceButtonBox = await replaceButton.boundingBox()
expect(imageBox).not.toBeNull()
expect(replaceButtonBox).not.toBeNull()
await page.mouse.move(
imageBox!.x + imageBox!.width / 2,
imageBox!.y + imageBox!.height / 2,
)
await page.mouse.move(
replaceButtonBox!.x + replaceButtonBox!.width / 2,
replaceButtonBox!.y + replaceButtonBox!.height / 2,
{ steps: 16 },
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
const replaceButtonBox = await replaceButton.boundingBox()
expect(replaceButtonBox).not.toBeNull()
await moveMouseInSteps(
page,
{
x: imageBox!.x + imageBox!.width / 2,
y: imageBox!.y + imageBox!.height / 2,
},
{
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
},
{ steps: 12, stepDelayMs: 35 },
)
await expect(toolbar).toBeVisible()