From bc97ade133fed67debe878f8491997cafa27018e Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 16:33:15 +0200 Subject: [PATCH] fix(editor): support whitespace drag selection --- docs/ABSTRACTIONS.md | 1 + src/components/SingleEditorView.test.tsx | 90 +++++++- src/components/SingleEditorView.tsx | 275 ++++++++++++++++++++++- 3 files changed, 361 insertions(+), 5 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index fbc52964..51e1e2c5 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 455ce1e6..fd0896cd 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -240,6 +240,19 @@ function makeEntry(overrides: Partial = {}): 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(() => 'seeded
'), - _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( +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, +): 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) { + suppressNextContainerClickRef.current = true + window.setTimeout(() => { + suppressNextContainerClickRef.current = false + }, 0) +} + +function whitespaceSelectionStartFromEvent(options: { + editable: boolean + editor: ReturnType + event: React.MouseEvent +}): 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 +}): () => 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 + suppressNextContainerClickRef: React.MutableRefObject +}) { + const { editable, editor, suppressNextContainerClickRef } = options + const cleanupDragRef = useRef<(() => void) | null>(null) + + useEffect(() => () => { + cleanupDragRef.current?.() + }, []) + + return useCallback((event: React.MouseEvent) => { + 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 + suppressNextContainerClickRef: React.MutableRefObject }) { - const { editable, editor } = options + const { editable, editor, suppressNextContainerClickRef } = options return useCallback((e: React.MouseEvent) => { 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(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) => { + 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} >