diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index c71ef586..9c36da13 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 2002dcbc..316f82b3 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -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, + setCloseGraceActive: Dispatch>, +) { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current) + timeoutRef.current = null + } + setCloseGraceActive(false) +} + +function startToolbarCloseGrace( + timeoutRef: MutableRefObject, + setCloseGraceActive: Dispatch>, +) { + 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(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: { {isOpen && (
{ + 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 } diff --git a/tests/smoke/image-toolbar-hover.spec.ts b/tests/smoke/image-toolbar-hover.spec.ts new file mode 100644 index 00000000..b81a6f13 --- /dev/null +++ b/tests/smoke/image-toolbar-hover.spec.ts @@ -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 { + 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() +})