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
93 changes: 93 additions & 0 deletions packages/core/src/auto-scroll-detection.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 12 additions & 13 deletions packages/core/src/lenis.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions playground/core/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down