fix: expand editor whitespace selection hit area
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user