diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 5d827cb5..117f4693 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -570,7 +570,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. -- `useEditorComposing` tracks `compositionstart` and `compositionend` events that target the BlockNote editor and closes the floating formatting toolbar while IME text is being composed, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior. +- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior. - `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path. - The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. 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. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references. diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx index b4fa03be..a71a420e 100644 --- a/src/components/tolariaEditorFormatting.behavior.test.tsx +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -330,35 +330,102 @@ describe('tolariaEditorFormatting behavior', () => { }) it('hides the floating toolbar while the editor is composing IME text', () => { - const editor = createMockEditor('paragraph') - const editorInput = editor.domElement.firstElementChild as HTMLElement + vi.useFakeTimers() + try { + const editor = createMockEditor('paragraph') + const editorInput = editor.domElement.firstElementChild as HTMLElement - useBlockNoteEditorMock.mockReturnValue(editor) + useBlockNoteEditorMock.mockReturnValue(editor) - render() + render() - expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ - position: { from: 1, to: 5 }, - useFloatingOptions: expect.objectContaining({ open: true }), - })) + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: { from: 1, to: 5 }, + useFloatingOptions: expect.objectContaining({ open: true }), + })) - act(() => { - fireEvent.compositionStart(editorInput) - }) + act(() => { + fireEvent.compositionStart(editorInput) + }) - expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ - position: undefined, - useFloatingOptions: expect.objectContaining({ open: false }), - })) + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: undefined, + useFloatingOptions: expect.objectContaining({ open: false }), + })) - act(() => { - fireEvent.compositionEnd(editorInput) - }) + act(() => { + fireEvent.compositionEnd(editorInput) + }) - expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ - position: { from: 1, to: 5 }, - useFloatingOptions: expect.objectContaining({ open: true }), - })) + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: undefined, + useFloatingOptions: expect.objectContaining({ open: false }), + })) + + act(() => { + vi.advanceTimersByTime(250) + }) + + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: { from: 1, to: 5 }, + useFloatingOptions: expect.objectContaining({ open: true }), + })) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the floating toolbar hidden through rapid Zhuyin composition settle cycles', () => { + vi.useFakeTimers() + try { + const editor = createMockEditor('paragraph') + const editorInput = editor.domElement.firstElementChild as HTMLElement + + useBlockNoteEditorMock.mockReturnValue(editor) + + render() + + act(() => { + fireEvent.compositionStart(editorInput) + fireEvent.compositionEnd(editorInput) + }) + + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: undefined, + useFloatingOptions: expect.objectContaining({ open: false }), + })) + + act(() => { + vi.advanceTimersByTime(120) + fireEvent.compositionStart(editorInput) + fireEvent.compositionEnd(editorInput) + }) + + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: undefined, + useFloatingOptions: expect.objectContaining({ open: false }), + })) + + act(() => { + vi.advanceTimersByTime(249) + }) + + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: undefined, + useFloatingOptions: expect.objectContaining({ open: false }), + })) + + act(() => { + vi.advanceTimersByTime(1) + }) + + expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({ + position: { from: 1, to: 5 }, + useFloatingOptions: expect.objectContaining({ open: true }), + })) + } finally { + vi.useRealTimers() + } }) it('ignores composition events that start outside the editor', () => { diff --git a/src/components/useEditorComposing.ts b/src/components/useEditorComposing.ts index b9d43aa0..eac89a1d 100644 --- a/src/components/useEditorComposing.ts +++ b/src/components/useEditorComposing.ts @@ -6,10 +6,31 @@ import type { } from '@blocknote/core' import { useEffect, useRef, useState } from 'react' +const COMPOSITION_SETTLE_MS = 250 + function eventTargetsEditor(editorElement: Element, target: EventTarget | null) { return target instanceof Node && editorElement.contains(target) } +function focusTargetsEditor(editorElement: Element) { + const activeElement = editorElement.ownerDocument.activeElement + return activeElement instanceof Node && editorElement.contains(activeElement) +} + +function selectionTargetsEditor(editorElement: Element) { + const anchorNode = editorElement.ownerDocument.getSelection()?.anchorNode + return anchorNode instanceof Node && editorElement.contains(anchorNode) +} + +function compositionEventTargetsEditor( + editorElement: Element, + event: CompositionEvent, +) { + return eventTargetsEditor(editorElement, event.target) + || focusTargetsEditor(editorElement) + || selectionTargetsEditor(editorElement) +} + export function useEditorComposing< BSchema extends BlockSchema, ISchema extends InlineContentSchema, @@ -17,41 +38,72 @@ export function useEditorComposing< >(editor: BlockNoteEditor) { const [isComposing, setIsComposing] = useState(false) const composingRef = useRef(false) + const settleTimeoutRef = useRef(null) const editorElement = editor.domElement ?? null useEffect(() => { + const clearSettleTimeout = () => { + if (settleTimeoutRef.current === null) return + window.clearTimeout(settleTimeoutRef.current) + settleTimeoutRef.current = null + } + const updateComposing = (nextIsComposing: boolean) => { if (composingRef.current === nextIsComposing) return composingRef.current = nextIsComposing setIsComposing(nextIsComposing) } + const startComposing = () => { + clearSettleTimeout() + updateComposing(true) + } + + const finishComposing = () => { + clearSettleTimeout() + settleTimeoutRef.current = window.setTimeout(() => { + settleTimeoutRef.current = null + updateComposing(false) + }, COMPOSITION_SETTLE_MS) + } + + clearSettleTimeout() updateComposing(false) if (!editorElement) return const handleCompositionStart = (event: CompositionEvent) => { - if (!eventTargetsEditor(editorElement, event.target)) return - updateComposing(true) + if (!compositionEventTargetsEditor(editorElement, event)) return + startComposing() + } + + const handleCompositionUpdate = (event: CompositionEvent) => { + if (!compositionEventTargetsEditor(editorElement, event)) return + startComposing() } const handleCompositionEnd = (event: CompositionEvent) => { if ( !composingRef.current - && !eventTargetsEditor(editorElement, event.target) + && !compositionEventTargetsEditor(editorElement, event) ) { return } - updateComposing(false) + finishComposing() } document.addEventListener('compositionstart', handleCompositionStart, true) + document.addEventListener('compositionupdate', handleCompositionUpdate, true) document.addEventListener('compositionend', handleCompositionEnd, true) + document.addEventListener('compositioncancel', handleCompositionEnd, true) return () => { + clearSettleTimeout() document.removeEventListener('compositionstart', handleCompositionStart, true) + document.removeEventListener('compositionupdate', handleCompositionUpdate, true) document.removeEventListener('compositionend', handleCompositionEnd, true) + document.removeEventListener('compositioncancel', handleCompositionEnd, true) } }, [editorElement])