diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 64498efb5d..0c988ce5a5 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -333,6 +333,8 @@ export class PresentationEditor extends EventEmitter { /** * When true, the next selection render scrolls the caret/selection head into view. * Only set for user-initiated actions (keyboard/mouse selection, image click, zoom). + * Not set on each `selectionUpdate` while a pointer drag is active — edge auto-scroll + * owns the viewport then; `notifyDragSelectionEnded` restores one scroll after mouseup. * Passive re-renders (virtualization remounts, layout completions, DOM rebuilds) leave * this unset so they don't fight the user's scroll position. */ @@ -3245,8 +3247,11 @@ export class PresentationEditor extends EventEmitter { } }; const handleSelection = () => { - // User-initiated selection change (keyboard, mouse) — scroll caret into view. - this.#shouldScrollSelectionIntoView = true; + // User-initiated selection change — scroll caret/head into view once, except during + // pointer drag: EditorInputManager edge auto-scroll must not fight #scrollActiveEndIntoView. + if (!this.#editorInputManager?.isDragging) { + this.#shouldScrollSelectionIntoView = true; + } // Use immediate rendering for selection-only changes (clicks, arrow keys). // Without immediate, the render is RAF-deferred — leaving a window where // a remote collaborator's edit can cancel the pending render via @@ -3566,6 +3571,10 @@ export class PresentationEditor extends EventEmitter { selectParagraphAt: (pos: number) => this.#selectParagraphAt(pos), finalizeDragSelectionWithDom: (pointer, dragAnchor, dragMode) => this.#finalizeDragSelectionWithDom(pointer, dragAnchor, dragMode), + notifyDragSelectionEnded: () => { + this.#shouldScrollSelectionIntoView = true; + this.#scheduleSelectionUpdate({ immediate: true }); + }, hitTestTable: (x: number, y: number) => this.#hitTestTable(x, y), }); } @@ -4990,7 +4999,8 @@ export class PresentationEditor extends EventEmitter { // (virtualization remounts, layout completions) never set this flag, so // they won't scroll the viewport to the caret — only real user-initiated // selection changes (keyboard, mouse, image click, zoom) will. - const shouldScrollIntoView = this.#shouldScrollSelectionIntoView; + // Belt-and-suspenders: never scroll from this path while pointer-drag is active. + const shouldScrollIntoView = this.#shouldScrollSelectionIntoView && !this.#editorInputManager?.isDragging; this.#shouldScrollSelectionIntoView = false; const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body'; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts index a9e90d5b1f..06e9bded9d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -354,6 +354,12 @@ export type EditorInputCallbacks = { dragAnchor: number, dragMode: 'char' | 'word' | 'para', ) => void; + /** + * Called when a pointer text-drag selection ends. + * Used to scroll the selection into view once after auto-scroll stops; during drag, + * selection-driven scroll is suppressed to avoid fighting edge auto-scroll. + */ + notifyDragSelectionEnded?: () => void; /** Hit test table at coordinates */ hitTestTable?: (x: number, y: number) => TableHitResult | null; }; @@ -1379,6 +1385,8 @@ export class EditorInputManager { this.#callbacks.finalizeDragSelectionWithDom?.(pointer, dragAnchor, dragMode); } + this.#callbacks.notifyDragSelectionEnded?.(); + this.#callbacks.scheduleA11ySelectionAnnouncement?.({ immediate: true }); this.#dragLastPointer = null; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts index adfe5bfd04..41d1beff84 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts @@ -135,6 +135,7 @@ describe('EditorInputManager - Drag Auto Scroll', () => { normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), updateSelectionVirtualizationPins: vi.fn(), scheduleSelectionUpdate: vi.fn(), + notifyDragSelectionEnded: vi.fn(), }; manager = new EditorInputManager(); @@ -254,6 +255,8 @@ describe('EditorInputManager - Drag Auto Scroll', () => { // Auto-scroll should be stopped expect(rafCallback).toBeNull(); + // one post-drag hook so PresentationEditor can scroll selection into view after auto-scroll stops + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); }); it('does not auto-scroll in header/footer mode', () => { @@ -328,4 +331,41 @@ describe('EditorInputManager - Drag Auto Scroll', () => { expect(scrollContainer.scrollLeft).toBe(0); }); }); + + describe('notifyDragSelectionEnded (selection scroll after drag)', () => { + it('invokes notifyDragSelectionEnded exactly once when a text drag ends after movement', () => { + startDrag(10, 10); + moveDrag(40, 25); + endDrag(40, 25); + + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + }); + + it('invokes notifyDragSelectionEnded when pointer goes down and up without move (click-hold-release)', () => { + startDrag(10, 10); + endDrag(10, 10); + + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + }); + + it('does not invoke notifyDragSelectionEnded on pointer up if no drag was started', () => { + endDrag(10, 10); + + expect(mockCallbacks.notifyDragSelectionEnded).not.toHaveBeenCalled(); + }); + + it('invokes notifyDragSelectionEnded once per completed drag gesture', () => { + startDrag(10, 10); + moveDrag(20, 15); + endDrag(20, 15); + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + + (mockCallbacks.notifyDragSelectionEnded as ReturnType).mockClear(); + + startDrag(50, 50); + moveDrag(60, 55); + endDrag(60, 55); + expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1); + }); + }); });