fix: preserve image selection while hovering toolbar

This commit is contained in:
lucaronin
2026-04-19 10:48:45 +02:00
parent 9ecefc90d5
commit 862af864d3
3 changed files with 74 additions and 1 deletions

View File

@@ -1,9 +1,21 @@
import type {
BlockNoteEditor,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core'
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
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
function isVisibleRect(rect: RectLike) {
return rect.right > rect.left && rect.bottom > rect.top
@@ -90,6 +102,32 @@ export function shouldSuppressFormattingToolbarHoverUpdate({
)
}
function getActiveFormattingToolbarFileBlockId(
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 restoreFormattingToolbarFileBlockSelection(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedFileBlockIdRef: RefObject<string | null>,
) {
const selectedFileBlockId = selectedFileBlockIdRef.current
if (!selectedFileBlockId) return
if (getActiveFormattingToolbarFileBlockId(editor) === selectedFileBlockId) return
try {
editor.setTextCursorPosition(selectedFileBlockId)
} catch {
// The image block may have been deleted or replaced while the toolbar stayed open.
}
}
function useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId: string | null,
isOpen: boolean,
@@ -120,10 +158,12 @@ function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null
}
function createFormattingToolbarHoverGuardHandler({
editor,
container,
doc,
selectedFileBlockIdRef,
}: {
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
container: HTMLElement
doc: Document
selectedFileBlockIdRef: RefObject<string | null>
@@ -141,15 +181,18 @@ function createFormattingToolbarHoverGuardHandler({
return
}
restoreFormattingToolbarFileBlockSelection(editor, selectedFileBlockIdRef)
event.stopPropagation()
}
}
export function useBlockNoteFormattingToolbarHoverGuard({
editor,
container,
selectedFileBlockId,
isOpen,
}: {
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
container: HTMLElement | null
selectedFileBlockId: string | null
isOpen: boolean
@@ -166,6 +209,7 @@ export function useBlockNoteFormattingToolbarHoverGuard({
if (!environment) return
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
editor,
container: environment.container,
doc: environment.doc,
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
@@ -175,5 +219,5 @@ export function useBlockNoteFormattingToolbarHoverGuard({
return () => {
environment.view.removeEventListener('mousemove', handleMouseMove, true)
}
}, [container, isOpen, lastSelectedFileBlockIdRef])
}, [container, editor, isOpen, lastSelectedFileBlockIdRef])
}

View File

@@ -476,6 +476,7 @@ export function TolariaFormattingToolbarController(props: {
})
useBlockNoteFormattingToolbarHoverGuard({
editor,
container:
editor.domElement?.closest('.editor__blocknote-container') ??
editor.domElement ??

View File

@@ -91,6 +91,12 @@ async function moveMouseInSteps(
}
}
async function expectImageBlockSelected(image: ReturnType<Page['locator']>) {
await expect.poll(async () => (
image.evaluate((node) => Boolean(node.closest('.ProseMirror-selectednode')))
)).toBe(true)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
@@ -117,9 +123,12 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
await expectImageBlockSelected(image)
const replaceButtonBox = await replaceButton.boundingBox()
expect(replaceButtonBox).not.toBeNull()
const toolbarBox = await toolbar.boundingBox()
expect(toolbarBox).not.toBeNull()
await moveMouseInSteps(
page,
@@ -127,6 +136,24 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
x: imageBox!.x + imageBox!.width / 2,
y: imageBox!.y + imageBox!.height / 2,
},
{
x: toolbarBox!.x + toolbarBox!.width / 2,
y: toolbarBox!.y + toolbarBox!.height + 10,
},
{ steps: 12, stepDelayMs: 35 },
)
await page.waitForTimeout(180)
await expect(toolbar).toBeVisible()
await expect(replaceButton).toBeVisible()
await expectImageBlockSelected(image)
await moveMouseInSteps(
page,
{
x: toolbarBox!.x + toolbarBox!.width / 2,
y: toolbarBox!.y + toolbarBox!.height + 10,
},
{
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
@@ -136,6 +163,7 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
await expect(toolbar).toBeVisible()
await expect(replaceButton).toBeVisible()
await expectImageBlockSelected(image)
await replaceButton.click()
await expect(page.locator('.bn-panel-popover')).toBeVisible()