From c2fcc08c7703e2739385e4d8e047d4c514d55b55 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 29 Jan 2026 22:05:06 -0800 Subject: [PATCH] fix: selection across pages with drag --- .../presentation-editor/PresentationEditor.ts | 1 + .../pointer-events/EditorInputManager.ts | 283 +++++++++++++++- ...InputManager.annotationDoubleClick.test.ts | 1 + .../EditorInputManager.dragAutoScroll.test.ts | 320 ++++++++++++++++++ 4 files changed, 593 insertions(+), 12 deletions(-) create mode 100644 packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index b0209ba30c..7486df9576 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2383,6 +2383,7 @@ export class PresentationEditor extends EventEmitter { getEpochMapper: () => this.#epochMapper, getViewportHost: () => this.#viewportHost, getVisibleHost: () => this.#visibleHost, + getLayoutMode: () => this.#layoutOptions.layoutMode ?? 'vertical', getHeaderFooterSession: () => this.#headerFooterSession, getPageGeometryHelper: () => this.#pageGeometryHelper, getZoom: () => this.#layoutOptions.zoom ?? 1, diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index 4d964be7e3..c613bfd6d4 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -43,6 +43,12 @@ import { debugLog } from '../selection/SelectionDebug.js'; const MULTI_CLICK_TIME_THRESHOLD_MS = 400; const MULTI_CLICK_DISTANCE_THRESHOLD_PX = 5; +const AUTO_SCROLL_EDGE_PX = 32; +const AUTO_SCROLL_MAX_SPEED_PX = 24; +/** Tolerance for detecting scrollability to handle sub-pixel rounding in browsers */ +const SCROLL_DETECTION_TOLERANCE_PX = 1; + +const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); // ============================================================================= // Types @@ -73,6 +79,8 @@ export type EditorInputDependencies = { getViewportHost: () => HTMLElement; /** Get visible host element (for scroll) */ getVisibleHost: () => HTMLElement; + /** Get current layout mode */ + getLayoutMode: () => 'vertical' | 'horizontal' | 'book'; /** Get header/footer session manager */ getHeaderFooterSession: () => HeaderFooterSessionManager | null; /** Get page geometry helper */ @@ -169,6 +177,10 @@ export class EditorInputManager { #dragLastPointer: SelectionDebugHudState['lastPointer'] = null; #dragLastRawHit: PositionHit | null = null; #dragUsedPageNotMountedFallback = false; + #autoScrollActive = false; + #autoScrollTimer: { id: number; kind: 'raf' | 'timeout' } | null = null; + #autoScrollVelocity: { x: number; y: number } = { x: 0, y: 0 }; + #lastPointerClient: { clientX: number; clientY: number } | null = null; // Click tracking for multi-click detection #clickCount = 0; @@ -429,6 +441,8 @@ export class EditorInputManager { this.#dragLastPointer = null; this.#dragLastRawHit = null; this.#dragUsedPageNotMountedFallback = false; + this.#lastPointerClient = null; + this.#stopAutoScroll(); } #clearCellAnchor(): void { @@ -506,6 +520,235 @@ export class EditorInputManager { return this.#callbacks.hitTestTable?.(x, y) ?? null; } + #getAutoScrollWindow(): Window | null { + const host = this.#deps?.getVisibleHost(); + return host?.ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : null); + } + + #getScrollTarget(): { + kind: 'element' | 'window'; + rect: { top: number; bottom: number; left: number; right: number }; + canScrollX: boolean; + canScrollY: boolean; + win: Window | null; + element?: HTMLElement; + scrollWidth?: number; + scrollHeight?: number; + } | null { + if (!this.#deps) return null; + const visibleHost = this.#deps.getVisibleHost(); + const doc = visibleHost.ownerDocument ?? document; + const win = doc.defaultView ?? (typeof window !== 'undefined' ? window : null); + if (!win) return null; + + const scrollContainer = this.#findScrollableAncestor(visibleHost); + if (scrollContainer) { + const elementCanScrollX = + scrollContainer.scrollWidth > scrollContainer.clientWidth + SCROLL_DETECTION_TOLERANCE_PX; + const elementCanScrollY = + scrollContainer.scrollHeight > scrollContainer.clientHeight + SCROLL_DETECTION_TOLERANCE_PX; + const rect = scrollContainer.getBoundingClientRect(); + return { + kind: 'element', + rect: { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }, + canScrollX: elementCanScrollX, + canScrollY: elementCanScrollY, + win, + element: scrollContainer, + }; + } + + const docEl = doc.documentElement; + const body = doc.body; + const scrollWidth = Math.max(docEl?.scrollWidth ?? 0, body?.scrollWidth ?? 0); + const scrollHeight = Math.max(docEl?.scrollHeight ?? 0, body?.scrollHeight ?? 0); + const clientWidth = win.innerWidth; + const clientHeight = win.innerHeight; + const canScrollX = scrollWidth > clientWidth + SCROLL_DETECTION_TOLERANCE_PX; + const canScrollY = scrollHeight > clientHeight + SCROLL_DETECTION_TOLERANCE_PX; + + return { + kind: 'window', + rect: { top: 0, bottom: clientHeight, left: 0, right: clientWidth }, + canScrollX, + canScrollY, + win, + scrollWidth, + scrollHeight, + }; + } + + #findScrollableAncestor(host: HTMLElement): HTMLElement | null { + const doc = host.ownerDocument ?? document; + const win = doc.defaultView ?? (typeof window !== 'undefined' ? window : null); + let node: HTMLElement | null = host; + while (node && node !== doc.body) { + const style = win?.getComputedStyle ? win.getComputedStyle(node) : null; + const overflowY = style?.overflowY ?? style?.overflow ?? ''; + const overflowX = style?.overflowX ?? style?.overflow ?? ''; + const canScrollY = node.scrollHeight > node.clientHeight + SCROLL_DETECTION_TOLERANCE_PX; + const canScrollX = node.scrollWidth > node.clientWidth + SCROLL_DETECTION_TOLERANCE_PX; + const allowsY = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'; + const allowsX = overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'overlay'; + + if ((canScrollY && allowsY) || (canScrollX && allowsX)) { + return node; + } + node = node.parentElement; + } + return null; + } + + #scheduleAutoScrollTick(): void { + if (this.#autoScrollTimer) return; + const win = this.#getAutoScrollWindow(); + if (win?.requestAnimationFrame) { + const id = win.requestAnimationFrame(() => this.#tickAutoScroll()); + this.#autoScrollTimer = { id, kind: 'raf' }; + return; + } + + const timeoutId = ( + win?.setTimeout ? win.setTimeout(() => this.#tickAutoScroll(), 16) : setTimeout(() => this.#tickAutoScroll(), 16) + ) as number; + this.#autoScrollTimer = { id: timeoutId, kind: 'timeout' }; + } + + #startAutoScroll(): void { + if (this.#autoScrollActive) return; + this.#autoScrollActive = true; + this.#scheduleAutoScrollTick(); + } + + #stopAutoScroll(): void { + this.#autoScrollActive = false; + this.#autoScrollVelocity = { x: 0, y: 0 }; + if (!this.#autoScrollTimer) return; + const win = this.#getAutoScrollWindow(); + if (this.#autoScrollTimer.kind === 'raf') { + const cancel = + win?.cancelAnimationFrame ?? (typeof cancelAnimationFrame !== 'undefined' ? cancelAnimationFrame : undefined); + cancel?.(this.#autoScrollTimer.id); + } else { + clearTimeout(this.#autoScrollTimer.id); + } + this.#autoScrollTimer = null; + } + + #updateAutoScrollFromPointer(clientX: number, clientY: number): void { + if (!this.#deps || !this.#isDragging) { + this.#stopAutoScroll(); + return; + } + + const sessionMode = this.#deps.getHeaderFooterSession()?.session?.mode ?? 'body'; + if (sessionMode !== 'body' || this.#deps.isViewLocked()) { + this.#stopAutoScroll(); + return; + } + + const target = this.#getScrollTarget(); + if (!target || (!target.canScrollX && !target.canScrollY)) { + this.#stopAutoScroll(); + return; + } + + const { rect } = target; + const topDist = clientY - rect.top; + const bottomDist = rect.bottom - clientY; + const leftDist = clientX - rect.left; + const rightDist = rect.right - clientX; + + let vx = 0; + let vy = 0; + + if (target.canScrollY) { + const topFactor = clamp((AUTO_SCROLL_EDGE_PX - topDist) / AUTO_SCROLL_EDGE_PX, 0, 1); + const bottomFactor = clamp((AUTO_SCROLL_EDGE_PX - bottomDist) / AUTO_SCROLL_EDGE_PX, 0, 1); + if (topFactor > 0) { + vy = -AUTO_SCROLL_MAX_SPEED_PX * topFactor; + } else if (bottomFactor > 0) { + vy = AUTO_SCROLL_MAX_SPEED_PX * bottomFactor; + } + } + + const layoutMode = this.#deps.getLayoutMode(); + if (layoutMode !== 'vertical' && target.canScrollX) { + const leftFactor = clamp((AUTO_SCROLL_EDGE_PX - leftDist) / AUTO_SCROLL_EDGE_PX, 0, 1); + const rightFactor = clamp((AUTO_SCROLL_EDGE_PX - rightDist) / AUTO_SCROLL_EDGE_PX, 0, 1); + if (leftFactor > 0) { + vx = -AUTO_SCROLL_MAX_SPEED_PX * leftFactor; + } else if (rightFactor > 0) { + vx = AUTO_SCROLL_MAX_SPEED_PX * rightFactor; + } + } + + if (vx === 0 && vy === 0) { + this.#stopAutoScroll(); + return; + } + + this.#autoScrollVelocity = { x: vx, y: vy }; + this.#startAutoScroll(); + } + + #tickAutoScroll(): void { + this.#autoScrollTimer = null; + if (!this.#autoScrollActive || !this.#deps || !this.#isDragging) { + this.#stopAutoScroll(); + return; + } + + const target = this.#getScrollTarget(); + if (!target || (!target.canScrollX && !target.canScrollY)) { + this.#stopAutoScroll(); + return; + } + + const { x, y } = this.#autoScrollVelocity; + if (x === 0 && y === 0) { + this.#stopAutoScroll(); + return; + } + + let didScroll = false; + if (target.kind === 'element' && target.element) { + const maxScrollTop = Math.max(0, target.element.scrollHeight - target.element.clientHeight); + const maxScrollLeft = Math.max(0, target.element.scrollWidth - target.element.clientWidth); + const nextTop = clamp(target.element.scrollTop + y, 0, maxScrollTop); + const nextLeft = clamp(target.element.scrollLeft + x, 0, maxScrollLeft); + didScroll = nextTop !== target.element.scrollTop || nextLeft !== target.element.scrollLeft; + if (didScroll) { + target.element.scrollTop = nextTop; + target.element.scrollLeft = nextLeft; + } + } else if (target.kind === 'window' && target.win) { + const scrollWidth = target.scrollWidth ?? 0; + const scrollHeight = target.scrollHeight ?? 0; + const maxScrollTop = Math.max(0, scrollHeight - target.win.innerHeight); + const maxScrollLeft = Math.max(0, scrollWidth - target.win.innerWidth); + const currentTop = target.win.scrollY ?? 0; + const currentLeft = target.win.scrollX ?? 0; + const nextTop = clamp(currentTop + y, 0, maxScrollTop); + const nextLeft = clamp(currentLeft + x, 0, maxScrollLeft); + didScroll = nextTop !== currentTop || nextLeft !== currentLeft; + if (didScroll) { + target.win.scrollTo(nextLeft, nextTop); + } + } + + if (didScroll) { + const lastPointer = this.#lastPointerClient; + if (lastPointer) { + this.#handleDragSelectionAt(lastPointer.clientX, lastPointer.clientY); + } + this.#scheduleAutoScrollTick(); + return; + } + + this.#stopAutoScroll(); + } + // ========================================================================== // Event Handlers // ========================================================================== @@ -692,6 +935,7 @@ export class EditorInputManager { this.#dragLastPointer = { clientX: event.clientX, clientY: event.clientY, x, y }; this.#dragLastRawHit = hit; this.#dragUsedPageNotMountedFallback = false; + this.#lastPointerClient = { clientX: event.clientX, clientY: event.clientY }; this.#isDragging = true; if (clickDepth >= 3) { @@ -752,16 +996,17 @@ export class EditorInputManager { const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) return; - const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); - if (!normalized) return; - // Handle drag selection if (this.#isDragging && this.#dragAnchor !== null && event.buttons & 1) { - this.#handleDragSelection(event, normalized); + this.#lastPointerClient = { clientX: event.clientX, clientY: event.clientY }; + this.#handleDragSelectionAt(event.clientX, event.clientY); + this.#updateAutoScrollFromPointer(event.clientX, event.clientY); return; } // Handle header/footer hover + const normalized = this.#callbacks.normalizeClientPoint?.(event.clientX, event.clientY); + if (!normalized) return; this.#handleHover(normalized); } @@ -770,7 +1015,10 @@ export class EditorInputManager { this.#suppressFocusInFromDraggable = false; - if (!this.#isDragging) return; + if (!this.#isDragging) { + this.#stopAutoScroll(); + return; + } // Release pointer capture const viewportHost = this.#deps.getViewportHost(); @@ -791,6 +1039,7 @@ export class EditorInputManager { const dragPointer = this.#dragLastPointer; this.#isDragging = false; + this.#stopAutoScroll(); // Reset cell drag mode if (this.#cellDragMode !== 'none') { @@ -811,6 +1060,7 @@ export class EditorInputManager { this.#dragLastPointer = null; this.#dragLastRawHit = null; this.#dragUsedPageNotMountedFallback = false; + this.#lastPointerClient = null; return; } @@ -819,6 +1069,9 @@ export class EditorInputManager { } #handlePointerLeave(): void { + if (!this.#isDragging) { + this.#stopAutoScroll(); + } this.#callbacks.clearHoverRegion?.(); } @@ -1148,25 +1401,29 @@ export class EditorInputManager { this.#focusEditor(); } - #handleDragSelection(event: PointerEvent, normalized: { x: number; y: number }): void { + #handleDragSelectionAt(clientX: number, clientY: number): void { if (!this.#deps) return; + const layoutState = this.#deps.getLayoutState(); + if (!layoutState.layout) return; + + const normalized = this.#callbacks.normalizeClientPoint?.(clientX, clientY); + if (!normalized) return; + this.#pendingMarginClick = null; - const prevPointer = this.#dragLastPointer; - this.#dragLastPointer = { clientX: event.clientX, clientY: event.clientY, x: normalized.x, y: normalized.y }; + this.#dragLastPointer = { clientX, clientY, x: normalized.x, y: normalized.y }; - const layoutState = this.#deps.getLayoutState(); const viewportHost = this.#deps.getViewportHost(); const pageGeometryHelper = this.#deps.getPageGeometryHelper(); const rawHit = clickToPosition( - layoutState.layout!, + layoutState.layout, layoutState.blocks, layoutState.measures, { x: normalized.x, y: normalized.y }, viewportHost, - event.clientX, - event.clientY, + clientX, + clientY, pageGeometryHelper ?? undefined, ); @@ -1401,6 +1658,8 @@ export class EditorInputManager { this.#dragLastPointer = null; this.#dragLastRawHit = null; this.#dragUsedPageNotMountedFallback = false; + this.#lastPointerClient = null; + this.#stopAutoScroll(); } #focusHeaderFooterShortcut(kind: 'header' | 'footer'): void { diff --git a/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.annotationDoubleClick.test.ts b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.annotationDoubleClick.test.ts index 6f2b1fb780..974a331604 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.annotationDoubleClick.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.annotationDoubleClick.test.ts @@ -83,6 +83,7 @@ describe('EditorInputManager - Annotation Double Click', () => { })) as unknown as EditorInputDependencies['getEpochMapper'], getViewportHost: vi.fn(() => viewportHost), getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical'), getHeaderFooterSession: vi.fn(() => null), getPageGeometryHelper: vi.fn(() => null), getZoom: vi.fn(() => 1), diff --git a/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts new file mode 100644 index 0000000000..a6d3205ce0 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.dragAutoScroll.test.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EditorInputManager, + type EditorInputDependencies, + type EditorInputCallbacks, +} from '../pointer-events/EditorInputManager.js'; + +vi.mock('@superdoc/layout-bridge', () => ({ + clickToPosition: vi.fn(() => ({ pos: 5, layoutEpoch: 1, pageIndex: 0 })), + getFragmentAtPosition: vi.fn(() => null), +})); + +vi.mock('prosemirror-state', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + TextSelection: { + ...original.TextSelection, + create: vi.fn(() => ({ + $from: { parent: { inlineContent: true } }, + empty: true, + })), + }, + }; +}); + +describe('EditorInputManager - Drag Auto Scroll', () => { + let manager: EditorInputManager; + let viewportHost: HTMLElement; + let visibleHost: HTMLElement; + let scrollContainer: HTMLElement; + let mockEditor: { + isEditable: boolean; + state: { + doc: { content: { size: number } }; + tr: { setSelection: ReturnType }; + selection: { $anchor?: null }; + storedMarks?: unknown; + }; + view: { dispatch: ReturnType; dom: HTMLElement; hasFocus: ReturnType }; + emit: ReturnType; + }; + let mockDeps: EditorInputDependencies; + let mockCallbacks: EditorInputCallbacks; + let rafCallback: FrameRequestCallback | null = null; + + beforeEach(() => { + rafCallback = null; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + rafCallback = cb; + return 1; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { + rafCallback = null; + }); + + scrollContainer = document.createElement('div'); + scrollContainer.style.overflowY = 'auto'; + scrollContainer.style.height = '100px'; + + visibleHost = document.createElement('div'); + visibleHost.className = 'presentation-editor'; + viewportHost = document.createElement('div'); + viewportHost.className = 'presentation-editor__viewport'; + visibleHost.appendChild(viewportHost); + scrollContainer.appendChild(visibleHost); + document.body.appendChild(scrollContainer); + + Object.defineProperty(scrollContainer, 'clientHeight', { value: 100, configurable: true }); + Object.defineProperty(scrollContainer, 'clientWidth', { value: 100, configurable: true }); + Object.defineProperty(scrollContainer, 'scrollHeight', { value: 300, configurable: true }); + Object.defineProperty(scrollContainer, 'scrollWidth', { value: 100, configurable: true }); + scrollContainer.scrollTop = 0; + scrollContainer.getBoundingClientRect = () => + ({ + top: 0, + bottom: 100, + left: 0, + right: 100, + width: 100, + height: 100, + }) as DOMRect; + + viewportHost.setPointerCapture = vi.fn(); + viewportHost.releasePointerCapture = vi.fn(); + viewportHost.hasPointerCapture = vi.fn(() => true); + + mockEditor = { + isEditable: true, + state: { + doc: { content: { size: 100 } }, + tr: { setSelection: vi.fn().mockReturnThis() }, + selection: { $anchor: null }, + }, + view: { + dispatch: vi.fn(), + dom: document.createElement('div'), + hasFocus: vi.fn(() => true), + }, + emit: vi.fn(), + }; + + mockDeps = { + getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), + getEpochMapper: vi.fn(() => ({ + mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 5, toEpoch: 1 })), + })) as unknown as EditorInputDependencies['getEpochMapper'], + getViewportHost: vi.fn(() => viewportHost), + getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical'), + getHeaderFooterSession: vi.fn(() => null), + getPageGeometryHelper: vi.fn(() => null), + getZoom: vi.fn(() => 1), + isViewLocked: vi.fn(() => false), + getDocumentMode: vi.fn(() => 'editing'), + getPageElement: vi.fn(() => null), + isSelectionAwareVirtualizationEnabled: vi.fn(() => false), + }; + + mockCallbacks = { + normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), + updateSelectionVirtualizationPins: vi.fn(), + scheduleSelectionUpdate: vi.fn(), + }; + + manager = new EditorInputManager(); + manager.setDependencies(mockDeps); + manager.setCallbacks(mockCallbacks); + manager.bind(); + }); + + afterEach(() => { + manager.destroy(); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + /** Helper to get PointerEvent constructor (falls back to MouseEvent in jsdom) */ + function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent { + return ( + (globalThis as unknown as { PointerEvent?: typeof PointerEvent; MouseEvent: typeof MouseEvent }).PointerEvent ?? + globalThis.MouseEvent + ); + } + + /** Helper to start a drag from a given position */ + function startDrag(clientX: number, clientY: number): void { + const PointerEventImpl = getPointerEventImpl(); + viewportHost.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + clientX, + clientY, + button: 0, + buttons: 1, + } as PointerEventInit), + ); + } + + /** Helper to move pointer during drag */ + function moveDrag(clientX: number, clientY: number): void { + const PointerEventImpl = getPointerEventImpl(); + viewportHost.dispatchEvent( + new PointerEventImpl('pointermove', { + bubbles: true, + cancelable: true, + clientX, + clientY, + button: 0, + buttons: 1, + } as PointerEventInit), + ); + } + + /** Helper to end a drag */ + function endDrag(clientX: number, clientY: number): void { + const PointerEventImpl = getPointerEventImpl(); + viewportHost.dispatchEvent( + new PointerEventImpl('pointerup', { + bubbles: true, + cancelable: true, + clientX, + clientY, + button: 0, + buttons: 0, + } as PointerEventInit), + ); + } + + it('auto-scrolls the nearest scrollable ancestor during drag selection', () => { + startDrag(10, 10); + moveDrag(10, 95); + + expect(rafCallback).not.toBeNull(); + rafCallback?.(0); + + expect(scrollContainer.scrollTop).toBeGreaterThan(0); + expect(mockEditor.view.dispatch).toHaveBeenCalled(); + }); + + it('stops auto-scroll when pointer moves away from edge zone', () => { + startDrag(10, 10); + moveDrag(10, 95); + + expect(rafCallback).not.toBeNull(); + rafCallback?.(0); + const scrollAfterFirstTick = scrollContainer.scrollTop; + expect(scrollAfterFirstTick).toBeGreaterThan(0); + + // Move pointer back to center (away from edge) + moveDrag(10, 50); + + // RAF should have been cancelled or not scheduled again + // The rafCallback should be null after stopAutoScroll is called + expect(rafCallback).toBeNull(); + }); + + it('does not auto-scroll when view is locked', () => { + (mockDeps.isViewLocked as ReturnType).mockReturnValue(true); + + startDrag(10, 10); + moveDrag(10, 95); + + // Should not schedule auto-scroll when view is locked + expect(rafCallback).toBeNull(); + expect(scrollContainer.scrollTop).toBe(0); + }); + + it('stops auto-scroll on pointer up', () => { + startDrag(10, 10); + moveDrag(10, 95); + + expect(rafCallback).not.toBeNull(); + rafCallback?.(0); + expect(scrollContainer.scrollTop).toBeGreaterThan(0); + + // End the drag + endDrag(10, 95); + + // Auto-scroll should be stopped + expect(rafCallback).toBeNull(); + }); + + it('does not auto-scroll in header/footer mode', () => { + (mockDeps.getHeaderFooterSession as ReturnType).mockReturnValue({ + session: { mode: 'header' }, + }); + + startDrag(10, 10); + moveDrag(10, 95); + + // Should not schedule auto-scroll in header/footer mode + expect(rafCallback).toBeNull(); + expect(scrollContainer.scrollTop).toBe(0); + }); + + it('scrolls upward when pointer is near top edge', () => { + // Start with some scroll position + scrollContainer.scrollTop = 100; + + startDrag(10, 50); + moveDrag(10, 5); // Move near top edge + + expect(rafCallback).not.toBeNull(); + rafCallback?.(0); + + expect(scrollContainer.scrollTop).toBeLessThan(100); + }); + + describe('horizontal scrolling', () => { + beforeEach(() => { + // Enable horizontal scrolling + Object.defineProperty(scrollContainer, 'scrollWidth', { value: 300, configurable: true }); + scrollContainer.scrollLeft = 0; + }); + + it('scrolls horizontally in horizontal layout mode when pointer is near right edge', () => { + (mockDeps.getLayoutMode as ReturnType).mockReturnValue('horizontal'); + + startDrag(50, 50); + moveDrag(95, 50); // Move near right edge + + expect(rafCallback).not.toBeNull(); + rafCallback?.(0); + + expect(scrollContainer.scrollLeft).toBeGreaterThan(0); + }); + + it('scrolls horizontally in book layout mode when pointer is near left edge', () => { + (mockDeps.getLayoutMode as ReturnType).mockReturnValue('book'); + scrollContainer.scrollLeft = 100; + + startDrag(50, 50); + moveDrag(5, 50); // Move near left edge + + expect(rafCallback).not.toBeNull(); + rafCallback?.(0); + + expect(scrollContainer.scrollLeft).toBeLessThan(100); + }); + + it('does NOT scroll horizontally in vertical layout mode', () => { + (mockDeps.getLayoutMode as ReturnType).mockReturnValue('vertical'); + + startDrag(50, 50); + moveDrag(95, 50); // Move near right edge + + // Even if RAF is scheduled for vertical scroll check, horizontal scroll should not happen + if (rafCallback) { + rafCallback(0); + } + + expect(scrollContainer.scrollLeft).toBe(0); + }); + }); +});