fix(editor): keep toolbar hidden through ime settle

This commit is contained in:
lucaronin
2026-05-04 01:02:07 +02:00
parent dfb9f98b7a
commit 1b218f5250
3 changed files with 146 additions and 27 deletions

View File

@@ -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(<TolariaFormattingToolbarController />)
render(<TolariaFormattingToolbarController />)
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(<TolariaFormattingToolbarController />)
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', () => {

View File

@@ -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<BSchema, ISchema, SSchema>) {
const [isComposing, setIsComposing] = useState(false)
const composingRef = useRef(false)
const settleTimeoutRef = useRef<number | null>(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])