fix: keep image toolbar controls open

This commit is contained in:
lucaronin
2026-04-17 18:29:37 +02:00
parent fb55ed2f6d
commit c5e84c8218
3 changed files with 232 additions and 5 deletions

View File

@@ -438,6 +438,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.

View File

@@ -25,7 +25,18 @@ import type {
StyleSchema,
} from '@blocknote/core'
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
import { useCallback, useMemo, useState, type FC, type ReactElement } from 'react'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type FC,
type MutableRefObject,
type ReactElement,
type SetStateAction,
} from 'react'
import {
Button as MantineButton,
CheckIcon as MantineCheckIcon,
@@ -46,6 +57,78 @@ import {
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
const FORMATTER_CLOSE_GRACE_MS = 160
function isFocusStillWithinToolbar(
currentTarget: EventTarget & Element,
nextTarget: EventTarget | null,
) {
return nextTarget instanceof Node && currentTarget.contains(nextTarget)
}
function clearToolbarCloseGrace(
timeoutRef: MutableRefObject<number | null>,
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
) {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
setCloseGraceActive(false)
}
function startToolbarCloseGrace(
timeoutRef: MutableRefObject<number | null>,
setCloseGraceActive: Dispatch<SetStateAction<boolean>>,
) {
setCloseGraceActive(true)
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
timeoutRef.current = null
setCloseGraceActive(false)
}, FORMATTER_CLOSE_GRACE_MS)
}
function useFormattingToolbarCloseGrace({
show,
toolbarHasFocus,
toolbarHovered,
}: {
show: boolean
toolbarHasFocus: boolean
toolbarHovered: boolean
}) {
const [closeGraceActive, setCloseGraceActive] = useState(false)
const closeGraceTimeoutRef = useRef<number | null>(null)
const previousShowRef = useRef(show)
const clearCloseGrace = useCallback(() => {
clearToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
}, [])
useEffect(() => {
const toolbarInteractionActive = show || toolbarHasFocus || toolbarHovered
if (toolbarInteractionActive) {
clearCloseGrace()
} else if (previousShowRef.current) {
startToolbarCloseGrace(closeGraceTimeoutRef, setCloseGraceActive)
}
previousShowRef.current = show
}, [clearCloseGrace, show, toolbarHasFocus, toolbarHovered])
useEffect(() => () => {
if (closeGraceTimeoutRef.current !== null) {
window.clearTimeout(closeGraceTimeoutRef.current)
}
}, [])
return { closeGraceActive, clearCloseGrace }
}
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
bold: {
label: 'Bold',
@@ -360,7 +443,14 @@ export function TolariaFormattingToolbarController(props: {
editor,
})
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
const isOpen = show || toolbarHasFocus
const [toolbarHovered, setToolbarHovered] = useState(false)
const { closeGraceActive, clearCloseGrace } = useFormattingToolbarCloseGrace({
show,
toolbarHasFocus,
toolbarHovered,
})
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
const position = useEditorState({
editor,
@@ -398,6 +488,8 @@ export function TolariaFormattingToolbarController(props: {
formattingToolbar.store.setState(open)
if (!open) {
setToolbarHasFocus(false)
setToolbarHovered(false)
clearCloseGrace()
}
if (reason === 'escape-key') {
editor.focus()
@@ -413,7 +505,14 @@ export function TolariaFormattingToolbarController(props: {
...props.floatingUIOptions?.elementProps,
},
}),
[editor, formattingToolbar.store, isOpen, placement, props.floatingUIOptions],
[
clearCloseGrace,
editor,
formattingToolbar.store,
isOpen,
placement,
props.floatingUIOptions,
],
)
const Component = props.formattingToolbar || TolariaFormattingToolbar
@@ -422,12 +521,21 @@ export function TolariaFormattingToolbarController(props: {
<PositionPopover position={position} {...floatingUIOptions}>
{isOpen && (
<div
onPointerEnter={() => {
setToolbarHovered(true)
}}
onPointerLeave={(event) => {
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
return
}
setToolbarHovered(false)
}}
onFocusCapture={() => {
setToolbarHasFocus(true)
}}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
if (isFocusStillWithinToolbar(event.currentTarget, event.relatedTarget)) {
return
}

View File

@@ -0,0 +1,118 @@
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { openCommandPalette, executeCommand } from './helpers'
const IMAGE_DATA_URL =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yK9sAAAAASUVORK5CYII='
let tempVaultDir: string
async function openNote(page: Page, title: string) {
await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function openRawMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 5_000 })
}
async function openBlockNoteMode(page: Page) {
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
}
async function getRawEditorContent(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.querySelector('.cm-content')
if (!el) return ''
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return el.textContent ?? ''
return view.state.doc.toString() as string
})
}
async function setRawEditorContent(page: Page, content: string) {
await page.evaluate((nextContent) => {
const el = document.querySelector('.cm-content')
if (!el) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- CodeMirror view is attached to the DOM node.
const view = (el as any).cmTile?.view
if (!view) return
const fullDocumentRange = { from: 0, to: view.state.doc.length }
view.dispatch({
changes: { ...fullDocumentRange, insert: nextContent },
})
}, content)
}
async function seedImageBlock(page: Page) {
await openNote(page, 'Note B')
await openRawMode(page)
const rawContent = await getRawEditorContent(page)
const imageMarkdown = `\n\n![Toolbar hover regression](${IMAGE_DATA_URL})\n`
await setRawEditorContent(page, `${rawContent}${imageMarkdown}`)
await page.waitForTimeout(700)
await openBlockNoteMode(page)
const image = page.locator('.bn-editor img.bn-visual-media').last()
await expect(image).toBeVisible({ timeout: 5_000 })
return image
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(async () => {
removeFixtureVaultCopy(tempVaultDir)
})
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()
await expect(replaceButton).toBeVisible()
await replaceButton.click()
await expect(page.locator('.bn-panel-popover')).toBeVisible()
})