diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 19c16acf..a0b47506 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index d6a98746..5b97b266 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -297,6 +297,26 @@ function renderEditorHarness(editor = createEditor()) { return { container: container!, editor } } +function renderEditorHarnessInScrollArea(editor = createEditor()) { + render( +
+
+ +
+
, + { 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 diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index f89ff2ea..3fbf00b2 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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 - event: React.MouseEvent + 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 editable: boolean editor: ReturnType suppressNextContainerClickRef: React.MutableRefObject }) { - 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) => { - 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) => { + 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,