diff --git a/packages/core/src/auto-scroll-detection.ts b/packages/core/src/auto-scroll-detection.ts new file mode 100644 index 00000000..c8fa06c3 --- /dev/null +++ b/packages/core/src/auto-scroll-detection.ts @@ -0,0 +1,93 @@ +// phase 0: initial state +// phase 1: middle pointerdown // autoscroll started +// phase 2: pointerup // sticky autoscroll continues +// phase 3: pointerup (any button) // autoscroll stops + +import { Emitter } from './emitter' + +export class AutoScrollDetection { + private _phase = 0 + private _isEnabled = false + private readonly emitter = new Emitter() + + constructor(private readonly wrapper: HTMLElement) { + // native middle-click autoscroll only exists in Blink on Windows. + // "Chrome" matches all Chromium browsers (Edge/Brave/Opera keep the + // token) but not Firefox/Safari + const ua = navigator.userAgent + if (!(/Windows/.test(ua) && /Chrome/.test(ua))) return + + this.wrapper.addEventListener('pointerdown', this.onPointerDown) + this.wrapper.addEventListener('pointerup', this.onPointerUp) + window.addEventListener('blur', this.reset) + window.addEventListener('keydown', this.reset) + } + + destroy() { + this.wrapper.removeEventListener('pointerdown', this.onPointerDown) + this.wrapper.removeEventListener('pointerup', this.onPointerUp) + window.removeEventListener('blur', this.reset) + window.removeEventListener('keydown', this.reset) + this.emitter.destroy() + } + + private reset = () => { + this.phase = 0 + } + + private onPointerDown = (event: PointerEvent) => { + // while sticky autoscroll is active, this click is the exit click — + // keep phase 2 so its pointerup resolves to phase 3 + if (this.phase === 2) return + + if (event.button !== 1) return + + // middle-click on a link opens a tab, no autoscroll starts + const isLinkClick = event + .composedPath() + .some((target) => target instanceof HTMLElement && target.tagName === 'A') + if (isLinkClick) return + + this.phase = 1 + } + + private onPointerUp = (event: PointerEvent) => { + if (this.phase === 1 && event.button === 1) { + this.phase = 2 + } else if (this.phase === 2) { + // any button exits sticky autoscroll, not just middle + this.phase = 3 + } + } + + private set phase(value: number) { + if (value === this._phase) return + + this._phase = value + + this.isEnabled = value === 1 || value === 2 + } + + private get phase() { + return this._phase + } + + get isEnabled() { + return this._isEnabled + } + + private set isEnabled(value: boolean) { + if (value === this._isEnabled) return + + this._isEnabled = value + this.emitter.emit('toggle') + } + + on(event: 'toggle', callback: () => void): () => void { + return this.emitter.on(event, callback) + } + + off(event: 'toggle', callback: () => void): void { + this.emitter.off(event, callback) + } +} diff --git a/packages/core/src/lenis.ts b/packages/core/src/lenis.ts index fa1a73c2..0e079c38 100644 --- a/packages/core/src/lenis.ts +++ b/packages/core/src/lenis.ts @@ -1,5 +1,6 @@ import { version } from '../../../package.json' import { Animate } from './animate' +import { AutoScrollDetection } from './auto-scroll-detection' import { Dimensions } from './dimensions' import { Emitter } from './emitter' import { clamp, modulo } from './maths' @@ -101,6 +102,7 @@ export class Lenis { // These are instanciated in the constructor as they need information from the options readonly dimensions: Dimensions // This is not private because it's used in the Snap class private readonly virtualScroll: VirtualScroll + private readonly autoScrollDetection: AutoScrollDetection constructor({ wrapper = window, @@ -215,10 +217,10 @@ export class Lenis { ) } - this.options.wrapper.addEventListener( - 'pointerdown', - this.onPointerDown as EventListener + this.autoScrollDetection = new AutoScrollDetection( + this.options.wrapper as HTMLElement ) + this.autoScrollDetection.on('toggle', () => this.reset()) // Setup virtual scroll instance this.virtualScroll = new VirtualScroll(eventsTarget as HTMLElement, { @@ -249,10 +251,7 @@ export class Lenis { capture: true, }) - this.options.wrapper.removeEventListener( - 'pointerdown', - this.onPointerDown as EventListener - ) + this.autoScrollDetection.destroy() if (this.options.anchors || this.options.stopInertiaOnNavigate) { this.options.wrapper.removeEventListener( @@ -400,12 +399,6 @@ export class Lenis { } } - private onPointerDown = (event: PointerEvent | MouseEvent) => { - if (event.button === 1) { - this.reset() - } - } - // iOS renders text-selection handles at the start and end points of the // selection. A touch starting within a handle-sized radius of either point is // the user grabbing a handle, not scrolling. @@ -453,6 +446,12 @@ export class Lenis { const isTouch = event.type.includes('touch') const isWheel = event.type.includes('wheel') + // wheel events during the browser's middle-click autoscroll would fight + // the native scrolling, let the browser handle them instead (#528). + if (isWheel && this.autoScrollDetection.isEnabled) { + return + } + // If the touch grabbed an iOS text-selection handle, let the OS adjust the // selection instead of scrolling. Latched on touchstart, held until touchend. if (isTouch && this.isIos) { diff --git a/playground/core/test.ts b/playground/core/test.ts index b300ff10..805612a1 100644 --- a/playground/core/test.ts +++ b/playground/core/test.ts @@ -128,6 +128,9 @@ const renderDebug = (e: Lenis) => { } lenis.on('scroll', renderDebug) +lenis.on('scroll', (lenis) => { + console.log(lenis.isScrolling) +}) window.addEventListener('scroll', () => renderDebug(lenis), { passive: true }) // document.querySelectorAll('a[href*="#"]').forEach((node) => {