Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -1379,6 +1385,8 @@ export class EditorInputManager {
this.#callbacks.finalizeDragSelectionWithDom?.(pointer, dragAnchor, dragMode);
}

this.#callbacks.notifyDragSelectionEnded?.();

this.#callbacks.scheduleA11ySelectionAnnouncement?.({ immediate: true });

this.#dragLastPointer = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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<typeof vi.fn>).mockClear();

startDrag(50, 50);
moveDrag(60, 55);
endDrag(60, 55);
expect(mockCallbacks.notifyDragSelectionEnded).toHaveBeenCalledTimes(1);
});
});
});
Loading