fix(editor): support whitespace drag selection

This commit is contained in:
lucaronin
2026-05-05 16:33:15 +02:00
parent eb6b58eb36
commit bc97ade133
3 changed files with 361 additions and 5 deletions

View File

@@ -577,6 +577,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.
- 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

@@ -240,6 +240,19 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
function createEditor() {
const cursorBlock = { id: 'cursor-block', type: 'paragraph', content: [], children: [] }
const tiptapDom = document.createElement('div')
tiptapDom.getBoundingClientRect = vi.fn(() => ({
bottom: 420,
height: 360,
left: 120,
right: 720,
toJSON: () => ({}),
top: 60,
width: 600,
x: 120,
y: 60,
}))
return {
document: [
{ id: 'heading-block', type: 'heading', content: [], children: [] },
@@ -250,7 +263,17 @@ function createEditor() {
{ type: 'table', content: { type: 'tableContent' } },
]),
blocksToHTMLLossy: vi.fn(() => '<table>seeded</table>'),
_tiptapEditor: { commands: { setContent: vi.fn() } },
_tiptapEditor: {
commands: {
setContent: vi.fn(),
setTextSelection: vi.fn(),
},
state: { doc: { content: { size: 100 } } },
view: {
dom: tiptapDom,
posAtCoords: vi.fn(() => ({ pos: 1 })),
},
},
focus: vi.fn(),
getTextCursorPosition: vi.fn(() => ({ block: cursorBlock })),
insertBlocks: vi.fn(),
@@ -780,6 +803,71 @@ describe('SingleEditorView', () => {
expect(editor.focus).toHaveBeenCalled()
})
it('extends mouse selections from editor whitespace using clamped BlockNote coordinates', () => {
const { container, editor } = renderEditorHarness()
editor._tiptapEditor.view.posAtCoords
.mockReturnValueOnce({ pos: 4 })
.mockReturnValueOnce({ pos: 18 })
.mockReturnValueOnce({ pos: 18 })
fireEvent.mouseDown(container, { button: 0, clientX: 12, clientY: 72 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 680, clientY: 180 })
fireEvent.mouseUp(window, { clientX: 680, clientY: 180 })
expect(editor.focus).toHaveBeenCalled()
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(1, {
left: 121,
top: 72,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenNthCalledWith(1, {
from: 4,
to: 4,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({
from: 4,
to: 18,
})
fireEvent.click(container)
expect(editor.setTextCursorPosition).not.toHaveBeenCalled()
})
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
editor._tiptapEditor.view.posAtCoords
.mockReturnValueOnce({ pos: 7 })
.mockReturnValue(null)
fireEvent.mouseDown(container, { button: 0, clientX: 250, clientY: 80 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 260, clientY: 900 })
fireEvent.mouseUp(window, { clientX: 260, clientY: 900 })
expect(editor._tiptapEditor.view.posAtCoords).toHaveBeenNthCalledWith(2, {
left: 260,
top: 419,
})
expect(editor._tiptapEditor.commands.setTextSelection).toHaveBeenLastCalledWith({
from: 7,
to: 41,
})
})
it('leaves native BlockNote and non-primary mouse selections alone', () => {
const { container, editor } = renderEditorHarness()
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
container.appendChild(editable)
fireEvent.mouseDown(editable, { button: 0, clientX: 200, clientY: 80 })
fireEvent.mouseMove(window, { buttons: 1, clientX: 260, clientY: 120 })
fireEvent.mouseDown(container, { button: 2, clientX: 200, clientY: 80 })
expect(editor._tiptapEditor.view.posAtCoords).not.toHaveBeenCalled()
expect(editor._tiptapEditor.commands.setTextSelection).not.toHaveBeenCalled()
})
it('routes the custom link-toolbar open action through openExternalUrl', () => {
render(
<SingleEditorView

View File

@@ -559,14 +559,267 @@ function queueTitleHeadingCursorRepair(
return true
}
type EditorClientPoint = Pick<MouseEvent, 'clientX' | 'clientY'>
type TiptapSelectionRange = { from: number; to: number }
type TiptapSelectionBridge = {
commands?: {
setTextSelection?: (selection: number | TiptapSelectionRange) => unknown
}
state?: {
doc?: {
content?: { size?: unknown }
}
}
view?: {
dom?: Element
posAtCoords?: (coords: { left: number; top: number }) => { pos?: unknown } | null
}
}
type EditorWithTiptapSelection = {
_tiptapEditor?: TiptapSelectionBridge
}
type WhitespaceSelectionStart = {
anchor: number
tiptapEditor: TiptapSelectionBridge
}
type WhitespaceDragState = WhitespaceSelectionStart & {
moved: boolean
startX: number
startY: number
}
const EDGE_SELECTION_INSET_PX = 1
const DRAG_SELECTION_THRESHOLD_PX = 3
function getTiptapSelectionBridge(
editor: ReturnType<typeof useCreateBlockNote>,
): TiptapSelectionBridge | null {
return (editor as EditorWithTiptapSelection)._tiptapEditor ?? null
}
function isValidDocumentPosition(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
}
function textSelectionDocumentBounds(
tiptapEditor: TiptapSelectionBridge,
): { start: number; end: number } | null {
const size = tiptapEditor.state?.doc?.content?.size
if (!isValidDocumentPosition(size)) return null
if (size <= 0) return { start: 0, end: 0 }
const end = Math.max(1, Math.floor(size) - 1)
return { start: Math.min(1, end), end }
}
function clampCoordinate(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min
if (max <= min) return min
return Math.min(max, Math.max(min, value))
}
function clampedEditorCoords(
point: EditorClientPoint,
editorRect: DOMRect,
): { left: number; top: number } {
return {
left: clampCoordinate(
point.clientX,
editorRect.left + EDGE_SELECTION_INSET_PX,
editorRect.right - EDGE_SELECTION_INSET_PX,
),
top: clampCoordinate(
point.clientY,
editorRect.top + EDGE_SELECTION_INSET_PX,
editorRect.bottom - EDGE_SELECTION_INSET_PX,
),
}
}
function fallbackTextPosition(
tiptapEditor: TiptapSelectionBridge,
point: EditorClientPoint,
editorRect: DOMRect,
): number | null {
const bounds = textSelectionDocumentBounds(tiptapEditor)
if (!bounds) return null
return point.clientY < editorRect.top ? bounds.start : bounds.end
}
function textPositionAtEditorPoint(
tiptapEditor: TiptapSelectionBridge,
point: EditorClientPoint,
): number | null {
const view = tiptapEditor.view
const editorDom = view?.dom
if (!editorDom || typeof view.posAtCoords !== 'function') return null
const editorRect = editorDom.getBoundingClientRect()
const position = view.posAtCoords(clampedEditorCoords(point, editorRect))?.pos
if (isValidDocumentPosition(position)) return position
return fallbackTextPosition(tiptapEditor, point, editorRect)
}
function applyTiptapTextSelection(
tiptapEditor: TiptapSelectionBridge,
anchor: number,
head: number,
): boolean {
const setTextSelection = tiptapEditor.commands?.setTextSelection
if (typeof setTextSelection !== 'function') return false
const range = {
from: Math.min(anchor, head),
to: Math.max(anchor, head),
}
try {
setTextSelection(range)
return true
} catch {
return false
}
}
function suppressNextContainerClick(suppressNextContainerClickRef: React.MutableRefObject<boolean>) {
suppressNextContainerClickRef.current = true
window.setTimeout(() => {
suppressNextContainerClickRef.current = false
}, 0)
}
function whitespaceSelectionStartFromEvent(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
event: React.MouseEvent<HTMLDivElement>
}): WhitespaceSelectionStart | null {
const { editable, editor, event } = options
if (!editable || event.button !== 0) return null
const target = eventTargetElement(event.target)
if (!target || !event.currentTarget.contains(target)) return null
if (shouldIgnoreContainerClick(target)) return null
const tiptapEditor = getTiptapSelectionBridge(editor)
if (!tiptapEditor) return null
const anchor = textPositionAtEditorPoint(tiptapEditor, event)
return anchor === null ? null : { anchor, tiptapEditor }
}
function movedPastDragThreshold(state: WhitespaceDragState, point: EditorClientPoint): boolean {
const movedDistance = Math.max(
Math.abs(point.clientX - state.startX),
Math.abs(point.clientY - state.startY),
)
return movedDistance >= DRAG_SELECTION_THRESHOLD_PX
}
function updateWhitespaceDragSelection(
state: WhitespaceDragState,
point: EditorClientPoint,
): boolean {
const head = textPositionAtEditorPoint(state.tiptapEditor, point)
if (head === null) return false
state.moved = state.moved || movedPastDragThreshold(state, point) || head !== state.anchor
return applyTiptapTextSelection(state.tiptapEditor, state.anchor, head)
}
function installWhitespaceSelectionDrag(options: {
cleanupDragRef: React.MutableRefObject<(() => void) | null>
state: WhitespaceDragState
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}): () => void {
const { cleanupDragRef, state, suppressNextContainerClickRef } = options
function cleanupDrag() {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
if (cleanupDragRef.current === cleanupDrag) {
cleanupDragRef.current = null
}
}
function handleMouseMove(moveEvent: MouseEvent) {
if ((moveEvent.buttons & 1) !== 1) {
cleanupDrag()
return
}
if (updateWhitespaceDragSelection(state, moveEvent)) {
moveEvent.preventDefault()
}
}
function handleMouseUp(upEvent: MouseEvent) {
updateWhitespaceDragSelection(state, upEvent)
if (state.moved) {
suppressNextContainerClick(suppressNextContainerClickRef)
}
cleanupDrag()
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
return cleanupDrag
}
function useEditorWhitespaceMouseSelection(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}) {
const { 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 })
if (!selectionStart) return
cleanupDragRef.current?.()
editor.focus()
const { anchor, tiptapEditor } = selectionStart
if (!applyTiptapTextSelection(tiptapEditor, anchor, anchor)) return
event.preventDefault()
const state: WhitespaceDragState = {
...selectionStart,
moved: false,
startX: event.clientX,
startY: event.clientY,
}
cleanupDragRef.current = installWhitespaceSelectionDrag({
cleanupDragRef,
state,
suppressNextContainerClickRef,
})
}, [editable, editor, suppressNextContainerClickRef])
}
function useEditorContainerClickHandler(options: {
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
suppressNextContainerClickRef: React.MutableRefObject<boolean>
}) {
const { editable, editor } = options
const { editable, editor, suppressNextContainerClickRef } = options
return useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
if (suppressNextContainerClickRef.current) {
suppressNextContainerClickRef.current = false
return
}
const target = e.target as HTMLElement
if (queueTitleHeadingCursorRepair(target, editor)) return
if (shouldIgnoreContainerClick(target)) return
@@ -582,7 +835,7 @@ function useEditorContainerClickHandler(options: {
}
}
editor.focus()
}, [editor, editable])
}, [editor, editable, suppressNextContainerClickRef])
}
function useCompositionAwareEditorChange(options: {
@@ -877,7 +1130,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const { cssVars } = useEditorTheme()
const themeMode = useDocumentThemeMode()
const containerRef = useRef<HTMLDivElement>(null)
const handleContainerClick = useEditorContainerClickHandler({ editable, editor })
const suppressNextContainerClickRef = useRef(false)
const handleContainerClick = useEditorContainerClickHandler({
editable,
editor,
suppressNextContainerClickRef,
})
const handleWhitespaceMouseSelection = useEditorWhitespaceMouseSelection({
editable,
editor,
suppressNextContainerClickRef,
})
const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
@@ -927,6 +1190,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
activatePlainTextPaste()
handleCodeBlockCopyFocus(event)
}, [activatePlainTextPaste, handleCodeBlockCopyFocus])
const handleMouseDownCapture = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
activatePlainTextPaste()
handleWhitespaceMouseSelection(event)
}, [activatePlainTextPaste, handleWhitespaceMouseSelection])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const suggestionMenuItems = useSuggestionMenuItems({
baseItems,
@@ -946,7 +1213,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
onCopyCapture={handleCodeBlockCopy}
onFocusCapture={handleFocusCapture}
onMouseLeave={clearCopyTarget}
onMouseDownCapture={activatePlainTextPaste}
onMouseDownCapture={handleMouseDownCapture}
onMouseMove={handleCodeBlockCopyMouseMove}
onPasteCapture={handleTitleHeadingRichPaste}
>