From 862af864d3eadd44aabf4be9cebcb03da24e8ba2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 19 Apr 2026 10:48:45 +0200 Subject: [PATCH] fix: preserve image selection while hovering toolbar --- .../blockNoteFormattingToolbarHoverGuard.ts | 46 ++++++++++++++++++- src/components/tolariaEditorFormatting.tsx | 1 + tests/smoke/image-toolbar-hover.spec.ts | 28 +++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/components/blockNoteFormattingToolbarHoverGuard.ts b/src/components/blockNoteFormattingToolbarHoverGuard.ts index d70d4f91..014315e9 100644 --- a/src/components/blockNoteFormattingToolbarHoverGuard.ts +++ b/src/components/blockNoteFormattingToolbarHoverGuard.ts @@ -1,9 +1,21 @@ +import type { + BlockNoteEditor, + BlockSchema, + InlineContentSchema, + StyleSchema, +} from '@blocknote/core' import { useEffect, useRef, type RefObject } from 'react' type RectLike = Pick 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, +) { + 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, + selectedFileBlockIdRef: RefObject, +) { + 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 container: HTMLElement doc: Document selectedFileBlockIdRef: RefObject @@ -141,15 +181,18 @@ function createFormattingToolbarHoverGuardHandler({ return } + restoreFormattingToolbarFileBlockSelection(editor, selectedFileBlockIdRef) event.stopPropagation() } } export function useBlockNoteFormattingToolbarHoverGuard({ + editor, container, selectedFileBlockId, isOpen, }: { + editor: BlockNoteEditor 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]) } diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index a9442da9..53b3107b 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -476,6 +476,7 @@ export function TolariaFormattingToolbarController(props: { }) useBlockNoteFormattingToolbarHoverGuard({ + editor, container: editor.domElement?.closest('.editor__blocknote-container') ?? editor.domElement ?? diff --git a/tests/smoke/image-toolbar-hover.spec.ts b/tests/smoke/image-toolbar-hover.spec.ts index 2a4f9748..992c850a 100644 --- a/tests/smoke/image-toolbar-hover.spec.ts +++ b/tests/smoke/image-toolbar-hover.spec.ts @@ -91,6 +91,12 @@ async function moveMouseInSteps( } } +async function expectImageBlockSelected(image: ReturnType) { + 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()