fix: expand editor whitespace selection hit area

This commit is contained in:
lucaronin
2026-05-06 10:39:16 +02:00
parent 7cf2a077ed
commit 691f5eec7c
3 changed files with 111 additions and 7 deletions

View File

@@ -578,7 +578,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow
Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tolariaEditorFormattingConfig.ts`:
- `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.
- `SingleEditorView` owns a narrow whitespace mouse-selection bridge around BlockNote: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling.
- `SingleEditorView` owns a whitespace mouse-selection bridge around BlockNote and its rich-editor scroll area: drag starts that land outside the editable text DOM are remapped through the ProseMirror view with clamped coordinates, while drags below the rendered document fall back to the document end. Drags that begin inside BlockNote's contenteditable surface, toolbars, side menu, dialogs, or non-primary mouse buttons stay on BlockNote/native handling.
- 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 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.

View File

@@ -297,6 +297,26 @@ function renderEditorHarness(editor = createEditor()) {
return { container: container!, editor }
}
function renderEditorHarnessInScrollArea(editor = createEditor()) {
render(
<div className="editor-scroll-area" data-testid="editor-scroll-area">
<div className="editor-content-wrapper">
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>
</div>
</div>,
{ wrapper: TooltipProvider },
)
const scrollArea = screen.getByTestId('editor-scroll-area')
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
return { container: container!, editor, scrollArea }
}
function createCodeBlockFixture(text: string) {
const codeBlock = document.createElement('div')
codeBlock.setAttribute('data-content-type', 'codeBlock')
@@ -890,6 +910,32 @@ describe('SingleEditorView', () => {
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
it('extends mouse selections from the surrounding editor scroll whitespace', () => {
const { editor, scrollArea } = renderEditorHarnessInScrollArea()
editor._tiptapEditor.view.posAtCoords
.mockReturnValueOnce({ pos: 5 })
.mockReturnValueOnce({ pos: 22 })
.mockReturnValueOnce({ pos: 22 })
fireEvent.mouseDown(scrollArea, { button: 0, clientX: 24, clientY: 96 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 920, clientY: 190 })
fireEvent.mouseUp(window, { clientX: 920, clientY: 190 })
expect(editor.focus).toHaveBeenCalled()
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(1, {
left: 121,
top: 96,
})
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(2, {
left: 719,
top: 190,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({
from: 5,
to: 22,
})
})
it('extends mouse selections to the document end when dragging below the editor content', () => {
const { container, editor } = renderEditorHarness()
editor._tiptapEditor.state.doc.content.size = 42

View File

@@ -618,6 +618,11 @@ type WhitespaceDragState = WhitespaceSelectionStart & {
startX: number
startY: number
}
type WhitespaceMouseDownEvent = EditorClientPoint & {
button: number
target: EventTarget | null
preventDefault: () => void
}
const EDGE_SELECTION_INSET_PX = 1
const DRAG_SELECTION_THRESHOLD_PX = 3
@@ -724,13 +729,14 @@ function suppressNextContainerClick(suppressNextContainerClickRef: React.Mutable
function whitespaceSelectionStartFromEvent(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
event: React.MouseEvent<HTMLDivElement>
event: WhitespaceMouseDownEvent
selectionRoot: HTMLElement
}): WhitespaceSelectionStart | null {
const { editable, editor, event } = options
const { editable, editor, event, selectionRoot } = options
if (!editable || event.button !== 0) return null
const target = eventTargetElement(event.target)
if (!target || !event.currentTarget.contains(target)) return null
if (!target || !selectionRoot.contains(target)) return null
if (shouldIgnoreContainerClick(target)) return null
const tiptapEditor = getTiptapSelectionBridge(editor)
@@ -799,20 +805,60 @@ function installWhitespaceSelectionDrag(options: {
return cleanupDrag
}
function closestEditorScrollArea(container: HTMLElement): HTMLElement | null {
const scrollArea = container.closest('.editor-scroll-area')
return scrollArea instanceof HTMLElement ? scrollArea : null
}
function eventTargetIsOutsideContainer(event: MouseEvent, container: HTMLElement): boolean {
const target = eventTargetElement(event.target)
return !target || !container.contains(target)
}
function installScrollAreaWhitespaceSelection(options: {
beginWhitespaceSelection: (event: WhitespaceMouseDownEvent, selectionRoot: HTMLElement) => void
container: HTMLElement
}): (() => void) | undefined {
const { beginWhitespaceSelection, container } = options
const scrollArea = closestEditorScrollArea(container)
if (!scrollArea || scrollArea === container) return undefined
const selectionRoot = scrollArea
function handleScrollAreaMouseDown(event: MouseEvent) {
if (eventTargetIsOutsideContainer(event, container)) {
beginWhitespaceSelection(event, selectionRoot)
}
}
selectionRoot.addEventListener('mousedown', handleScrollAreaMouseDown, true)
return () => {
selectionRoot.removeEventListener('mousedown', handleScrollAreaMouseDown, true)
}
}
function useEditorWhitespaceMouseSelection(options: {
containerRef: React.RefObject<HTMLDivElement | null>
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}) {
const { editable, editor, suppressNextContainerClickRef } = options
const { containerRef, editable, editor, suppressNextContainerClickRef } = options
const cleanupDragRef = useRef<(() => void) | null>(null)
useEffect(() => () => {
cleanupDragRef.current?.()
}, [])
return useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const selectionStart = whitespaceSelectionStartFromEvent({ editable, editor, event })
const beginWhitespaceSelection = useCallback((
event: WhitespaceMouseDownEvent,
selectionRoot: HTMLElement,
) => {
const selectionStart = whitespaceSelectionStartFromEvent({
editable,
editor,
event,
selectionRoot,
})
if (!selectionStart) return
cleanupDragRef.current?.()
@@ -835,6 +881,17 @@ function useEditorWhitespaceMouseSelection(options: {
suppressNextContainerClickRef,
})
}, [editable, editor, suppressNextContainerClickRef])
useEffect(() => {
const container = containerRef.current
if (!container) return
return installScrollAreaWhitespaceSelection({ beginWhitespaceSelection, container })
}, [beginWhitespaceSelection, containerRef])
return useCallback((event: React.MouseEvent<HTMLDivElement>) => {
beginWhitespaceSelection(event, event.currentTarget)
}, [beginWhitespaceSelection])
}
function useEditorContainerClickHandler(options: {
@@ -1193,6 +1250,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
suppressNextContainerClickRef,
})
const handleWhitespaceMouseSelection = useEditorWhitespaceMouseSelection({
containerRef,
editable,
editor,
suppressNextContainerClickRef,