fix: hide toolbar during ime composition
This commit is contained in:
@@ -570,6 +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.
|
||||
- `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.
|
||||
|
||||
@@ -329,6 +329,90 @@ describe('tolariaEditorFormatting behavior', () => {
|
||||
expect(formattingToolbarStore.setState).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('hides the floating toolbar while the editor is composing IME text', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const editorInput = editor.domElement.firstElementChild as HTMLElement
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionEnd(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('ignores composition events that start outside the editor', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const outsideInput = document.createElement('input')
|
||||
document.body.appendChild(outsideInput)
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(outsideInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('binds composition listeners after BlockNote provides its editor element', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const lateEditorElement = editor.domElement
|
||||
const editorInput = lateEditorElement.firstElementChild as HTMLElement
|
||||
|
||||
editor.domElement = undefined as unknown as HTMLElement
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
const { rerender } = render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
editor.domElement = lateEditorElement
|
||||
rerender(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not open the floating toolbar when the editor anchor element is unavailable', () => {
|
||||
const editor = createMockEditor()
|
||||
editor.domElement = document.createElement('div')
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core'
|
||||
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
|
||||
import { useEditorComposing } from './useEditorComposing'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -517,6 +518,7 @@ export function TolariaFormattingToolbarController(props: {
|
||||
const show = useExtensionState(FormattingToolbarExtension, {
|
||||
editor,
|
||||
})
|
||||
const isComposing = useEditorComposing(editor)
|
||||
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
|
||||
const [toolbarHovered, setToolbarHovered] = useState(false)
|
||||
const { closeGraceActive, clearCloseGrace } = useFormattingToolbarCloseGrace({
|
||||
@@ -529,7 +531,8 @@ export function TolariaFormattingToolbarController(props: {
|
||||
show,
|
||||
)
|
||||
|
||||
const isOpen = show || toolbarHasFocus || toolbarHovered || closeGraceActive
|
||||
const isOpen = !isComposing
|
||||
&& (show || toolbarHasFocus || toolbarHovered || closeGraceActive)
|
||||
const hasFloatingToolbarAnchor = getFormattingToolbarAnchorElement(editor) !== null
|
||||
const shouldRenderFloatingToolbar = isOpen && hasFloatingToolbarAnchor
|
||||
const currentBridgeBlockId = useEditorState({
|
||||
|
||||
59
src/components/useEditorComposing.ts
Normal file
59
src/components/useEditorComposing.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
BlockNoteEditor,
|
||||
BlockSchema,
|
||||
InlineContentSchema,
|
||||
StyleSchema,
|
||||
} from '@blocknote/core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
function eventTargetsEditor(editorElement: Element, target: EventTarget | null) {
|
||||
return target instanceof Node && editorElement.contains(target)
|
||||
}
|
||||
|
||||
export function useEditorComposing<
|
||||
BSchema extends BlockSchema,
|
||||
ISchema extends InlineContentSchema,
|
||||
SSchema extends StyleSchema,
|
||||
>(editor: BlockNoteEditor<BSchema, ISchema, SSchema>) {
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const composingRef = useRef(false)
|
||||
const editorElement = editor.domElement ?? null
|
||||
|
||||
useEffect(() => {
|
||||
const updateComposing = (nextIsComposing: boolean) => {
|
||||
if (composingRef.current === nextIsComposing) return
|
||||
composingRef.current = nextIsComposing
|
||||
setIsComposing(nextIsComposing)
|
||||
}
|
||||
|
||||
updateComposing(false)
|
||||
|
||||
if (!editorElement) return
|
||||
|
||||
const handleCompositionStart = (event: CompositionEvent) => {
|
||||
if (!eventTargetsEditor(editorElement, event.target)) return
|
||||
updateComposing(true)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = (event: CompositionEvent) => {
|
||||
if (
|
||||
!composingRef.current
|
||||
&& !eventTargetsEditor(editorElement, event.target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
updateComposing(false)
|
||||
}
|
||||
|
||||
document.addEventListener('compositionstart', handleCompositionStart, true)
|
||||
document.addEventListener('compositionend', handleCompositionEnd, true)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('compositionstart', handleCompositionStart, true)
|
||||
document.removeEventListener('compositionend', handleCompositionEnd, true)
|
||||
}
|
||||
}, [editorElement])
|
||||
|
||||
return isComposing
|
||||
}
|
||||
Reference in New Issue
Block a user