@weft-js/scroll 0.0.0-stage → 0.1.5-nightly.20261111.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +579 -2
- package/package.json +39 -3
- package/src/autoscroll.ts +91 -0
- package/src/axis.ts +59 -0
- package/src/bar-input.ts +104 -0
- package/src/bar.ts +29 -0
- package/src/canvas.ts +111 -0
- package/src/engine.ts +374 -0
- package/src/index.ts +21 -0
- package/src/keys.ts +87 -0
- package/src/options.ts +88 -0
- package/src/physics.ts +65 -0
- package/src/render.ts +205 -0
- package/src/scroller-api.ts +75 -0
- package/src/scroller.ts +240 -0
- package/src/snap.ts +35 -0
- package/src/touch.ts +169 -0
- package/src/types.ts +178 -0
- package/src/wheel-input.ts +112 -0
- package/src/wheel.ts +32 -0
- package/src/wrap.ts +27 -0
- package/styles/scroll.css +199 -0
package/src/touch.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Touch and pen drags (and mouse drags with `grab`). One gesture at a time across every scroller: the innermost one
|
|
2
|
+
// under the finger starts it, and once the direction is clear it goes to the innermost scroller that can move that
|
|
3
|
+
// way, or to the page when none can.
|
|
4
|
+
import { Velocity } from './physics'
|
|
5
|
+
import { registry, type Engine } from './engine'
|
|
6
|
+
import type { Axis } from './types'
|
|
7
|
+
|
|
8
|
+
const SLOP = 8
|
|
9
|
+
|
|
10
|
+
interface Gesture {
|
|
11
|
+
id: number
|
|
12
|
+
chain: Engine[]
|
|
13
|
+
owner: Engine | null
|
|
14
|
+
page: Element | null
|
|
15
|
+
axes: Axis[]
|
|
16
|
+
x0: number
|
|
17
|
+
y0: number
|
|
18
|
+
lastX: number
|
|
19
|
+
lastY: number
|
|
20
|
+
raw: { x: number; y: number }
|
|
21
|
+
vel: Velocity
|
|
22
|
+
moved: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let g: Gesture | null = null
|
|
26
|
+
|
|
27
|
+
/** The scrollers from `e` outwards. */
|
|
28
|
+
function chainFrom(e: Engine) {
|
|
29
|
+
const out: Engine[] = []
|
|
30
|
+
for (let at: Engine | null = e; at; at = at.parent()) out.push(at)
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Listen for drags starting in the viewport. Returns the cleanup. */
|
|
35
|
+
export function touchInput(e: Engine) {
|
|
36
|
+
const onDown = (ev: PointerEvent) => {
|
|
37
|
+
if (g?.id === ev.pointerId || !ev.isPrimary) return
|
|
38
|
+
const mouse = ev.pointerType === 'mouse'
|
|
39
|
+
const js = e.o.touch === 'js' && e.o.mode === 'engine'
|
|
40
|
+
if (mouse ? !e.o.grab || ev.button !== 0 : !js) return
|
|
41
|
+
const chain = chainFrom(e)
|
|
42
|
+
let caught = false
|
|
43
|
+
for (const s of chain)
|
|
44
|
+
for (const a of s.axes) {
|
|
45
|
+
if (a.anim?.kind === 'coast' && Math.abs(a.v) > 0.1) caught = true
|
|
46
|
+
if (a.anim?.kind !== 'spring') s.stop(a.axis)
|
|
47
|
+
}
|
|
48
|
+
if (mouse) ev.preventDefault()
|
|
49
|
+
g = {
|
|
50
|
+
id: ev.pointerId,
|
|
51
|
+
chain,
|
|
52
|
+
owner: null,
|
|
53
|
+
page: null,
|
|
54
|
+
axes: [],
|
|
55
|
+
x0: ev.clientX,
|
|
56
|
+
y0: ev.clientY,
|
|
57
|
+
lastX: ev.clientX,
|
|
58
|
+
lastY: ev.clientY,
|
|
59
|
+
raw: { x: 0, y: 0 },
|
|
60
|
+
vel: new Velocity(),
|
|
61
|
+
moved: caught,
|
|
62
|
+
}
|
|
63
|
+
g.vel.add(ev.timeStamp, -ev.clientX, -ev.clientY)
|
|
64
|
+
addEventListener('pointermove', onMove)
|
|
65
|
+
addEventListener('pointerup', onUp)
|
|
66
|
+
addEventListener('pointercancel', onUp)
|
|
67
|
+
}
|
|
68
|
+
e.vp.addEventListener('pointerdown', onDown)
|
|
69
|
+
return () => {
|
|
70
|
+
e.vp.removeEventListener('pointerdown', onDown)
|
|
71
|
+
if (g?.chain[0] === e) end()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function onMove(ev: PointerEvent) {
|
|
76
|
+
if (!g || ev.pointerId !== g.id) return
|
|
77
|
+
const dx = ev.clientX - g.x0
|
|
78
|
+
const dy = ev.clientY - g.y0
|
|
79
|
+
if (!g.axes.length) {
|
|
80
|
+
if (Math.hypot(dx, dy) < SLOP) return
|
|
81
|
+
pick(g, dx, dy)
|
|
82
|
+
g.moved = true
|
|
83
|
+
}
|
|
84
|
+
g.vel.add(ev.timeStamp, -ev.clientX, -ev.clientY)
|
|
85
|
+
if (g.owner) {
|
|
86
|
+
for (const axis of g.axes) g.owner.drag(axis, g.raw[axis] - (axis === 'x' ? dx : dy))
|
|
87
|
+
} else if (g.page) {
|
|
88
|
+
g.page.scrollBy({
|
|
89
|
+
left: g.axes.includes('x') ? g.lastX - ev.clientX : 0,
|
|
90
|
+
top: g.axes.includes('y') ? g.lastY - ev.clientY : 0,
|
|
91
|
+
behavior: 'instant',
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
g.lastX = ev.clientX
|
|
95
|
+
g.lastY = ev.clientY
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lock the direction and choose who moves: the innermost scroller that can, as browsers chain overscroll. */
|
|
99
|
+
function pick(gest: Gesture, dx: number, dy: number) {
|
|
100
|
+
const ax = Math.abs(dx)
|
|
101
|
+
const ay = Math.abs(dy)
|
|
102
|
+
const both = gest.chain[0]!.ax
|
|
103
|
+
if (ax > ay * 1.5 || !both.y.on) gest.axes = ['x']
|
|
104
|
+
else if (ay > ax * 1.5 || !both.x.on) gest.axes = ['y']
|
|
105
|
+
else gest.axes = ['x', 'y']
|
|
106
|
+
const can = (s: Engine) => gest.axes.some((a) => s.canMove(a, -Math.sign(a === 'x' ? dx : dy)))
|
|
107
|
+
let owner: Engine | null = null
|
|
108
|
+
for (const s of gest.chain) {
|
|
109
|
+
if (can(s) || s.o.chain === 'contain') {
|
|
110
|
+
owner = s
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const outer = gest.chain.at(-1)!
|
|
115
|
+
if (!owner && outer.o.chain === 'auto') {
|
|
116
|
+
const page = scrollParent(outer.root)
|
|
117
|
+
if (page && gest.axes.some((a) => nativeRoom(page, a, -Math.sign(a === 'x' ? dx : dy))))
|
|
118
|
+
gest.page = page
|
|
119
|
+
}
|
|
120
|
+
// Nothing anywhere can move this way: the innermost area takes it, to rubber-band.
|
|
121
|
+
if (!owner && !gest.page) owner = gest.chain[0]!
|
|
122
|
+
gest.owner = owner
|
|
123
|
+
// Measured from where the slop ended, so the content doesn't jump by the slop when the drag starts.
|
|
124
|
+
if (owner) for (const a of gest.axes) gest.raw[a] = owner.rawPos(a) + (a === 'x' ? dx : dy)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onUp(ev: PointerEvent) {
|
|
128
|
+
if (!g || ev.pointerId !== g.id) return
|
|
129
|
+
const v = ev.type === 'pointercancel' ? { x: 0, y: 0 } : g.vel.get(ev.timeStamp)
|
|
130
|
+
if (g.owner) for (const axis of g.axes) g.owner.release(axis, v[axis])
|
|
131
|
+
if (g.moved) swallowClick()
|
|
132
|
+
end()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function end() {
|
|
136
|
+
g = null
|
|
137
|
+
removeEventListener('pointermove', onMove)
|
|
138
|
+
removeEventListener('pointerup', onUp)
|
|
139
|
+
removeEventListener('pointercancel', onUp)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A drag that moved, or a tap that caught a coast, isn't a click on what's under it. */
|
|
143
|
+
function swallowClick() {
|
|
144
|
+
const stop = (ev: Event) => {
|
|
145
|
+
ev.stopPropagation()
|
|
146
|
+
ev.preventDefault()
|
|
147
|
+
}
|
|
148
|
+
addEventListener('click', stop, { capture: true, once: true })
|
|
149
|
+
setTimeout(() => removeEventListener('click', stop, { capture: true }), 350)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The nearest natively scrolling ancestor, or the page. */
|
|
153
|
+
function scrollParent(from: Element): Element | null {
|
|
154
|
+
for (let el = from.parentElement; el; el = el.parentElement) {
|
|
155
|
+
if (registry.has(el)) return null
|
|
156
|
+
const s = getComputedStyle(el)
|
|
157
|
+
if (/(auto|scroll)/.test(s.overflowY + s.overflowX) && el.scrollHeight > el.clientHeight)
|
|
158
|
+
return el
|
|
159
|
+
}
|
|
160
|
+
return document.scrollingElement
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether a native scroller has room to move `dir` along `axis`. */
|
|
164
|
+
function nativeRoom(el: Element, axis: Axis, dir: number) {
|
|
165
|
+
const max = axis === 'y' ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth
|
|
166
|
+
const at = axis === 'y' ? el.scrollTop : Math.abs(el.scrollLeft)
|
|
167
|
+
const forward = axis === 'x' && getComputedStyle(el).direction === 'rtl' ? -dir : dir
|
|
168
|
+
return forward > 0 ? at < max - 0.5 : at > 0.5
|
|
169
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The public shapes: options in, state out, and what a renderer is handed each frame.
|
|
2
|
+
|
|
3
|
+
/** A scroll axis. */
|
|
4
|
+
export type Axis = 'x' | 'y'
|
|
5
|
+
/** Where the movement came from. `native` is the browser moving it: focus, find-in-page, an anchor. */
|
|
6
|
+
export type ScrollSource = 'wheel' | 'touch' | 'key' | 'bar' | 'api' | 'snap' | 'select' | 'native'
|
|
7
|
+
/** Maps 0..1 time to 0..1 progress. */
|
|
8
|
+
export type Easing = (t: number) => number
|
|
9
|
+
|
|
10
|
+
/** The thumb's size. Leave `size` out for a thumb that grows and shrinks with the content, as a native one does. */
|
|
11
|
+
export interface ThumbOptions {
|
|
12
|
+
/** A fixed length in px: the thumb never grows or shrinks. */
|
|
13
|
+
size?: number
|
|
14
|
+
/** The shortest a proportional thumb gets. Default 24. */
|
|
15
|
+
min?: number
|
|
16
|
+
/** The longest a proportional thumb gets. */
|
|
17
|
+
max?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** How the bars look and where they sit. Colours are any CSS colour. */
|
|
21
|
+
export interface BarOptions {
|
|
22
|
+
/** Thumb thickness at rest, px. Default 6. */
|
|
23
|
+
width?: number
|
|
24
|
+
/** Thumb thickness while hovered or dragged, px. Default 10. */
|
|
25
|
+
hoverWidth?: number
|
|
26
|
+
/** Gap between the thumb and the edge of the area, px. Default 2. */
|
|
27
|
+
inset?: number
|
|
28
|
+
/** Corner radius, px. Default: half the thickness. */
|
|
29
|
+
radius?: number
|
|
30
|
+
color?: string
|
|
31
|
+
hoverColor?: string
|
|
32
|
+
activeColor?: string
|
|
33
|
+
/** The track behind the thumb. Default transparent. */
|
|
34
|
+
track?: string
|
|
35
|
+
hoverTrack?: string
|
|
36
|
+
/** `overlay` floats over the content; `gutter` reserves room beside it. Default `overlay`. */
|
|
37
|
+
layout?: 'overlay' | 'gutter'
|
|
38
|
+
/** When the bar shows. `scroll` (default): while scrolling or hovered. `hover`: while the pointer is over the area. */
|
|
39
|
+
show?: 'always' | 'scroll' | 'hover' | 'never'
|
|
40
|
+
/** How long the bar stays after scrolling stops, ms. Default 900. */
|
|
41
|
+
hideDelay?: number
|
|
42
|
+
/** Fade length, ms. Default 200. */
|
|
43
|
+
fade?: number
|
|
44
|
+
/** A press on the track: `page` steps a page (repeating while held), `jump` moves there. Alt flips it. */
|
|
45
|
+
trackClick?: 'page' | 'jump'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Snapping, as CSS scroll-snap does it. */
|
|
49
|
+
export interface SnapOptions {
|
|
50
|
+
/** Snap positions in px, or a function returning them (read again on every resize). */
|
|
51
|
+
points?: readonly number[] | (() => readonly number[])
|
|
52
|
+
/** Elements in the content to snap to, aligned by `align`. */
|
|
53
|
+
selector?: string
|
|
54
|
+
align?: 'start' | 'center' | 'end'
|
|
55
|
+
/** `mandatory` always rests on a point; `proximity` only when within `proximity` px. Default `proximity`. */
|
|
56
|
+
type?: 'mandatory' | 'proximity'
|
|
57
|
+
proximity?: number
|
|
58
|
+
axis?: Axis
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a scroller does and how it looks. Every field is optional. */
|
|
62
|
+
export interface ScrollOptions {
|
|
63
|
+
/**
|
|
64
|
+
* Who moves the content. `native` (default): the browser scrolls it, on the compositor, so it keeps moving while
|
|
65
|
+
* the main thread is busy; this draws the bars and runs the API, and the wheel, touch and key options are the
|
|
66
|
+
* browser's own. `engine`: every input runs here, with this package's easing, momentum, rubber band and keys.
|
|
67
|
+
*/
|
|
68
|
+
mode?: 'native' | 'engine'
|
|
69
|
+
/** Which axes scroll. Default `both`: an axis whose content fits shows no bar. */
|
|
70
|
+
axes?: Axis | 'both'
|
|
71
|
+
thumb?: ThumbOptions
|
|
72
|
+
bar?: BarOptions
|
|
73
|
+
/** Draws the bars. Default: DOM elements styled by CSS. `canvas()` from `@weft-js/scroll/canvas` paints them. */
|
|
74
|
+
renderer?: Renderer
|
|
75
|
+
/** Animation length for wheel notches, keys and smooth `scrollTo`, ms. Default 220. */
|
|
76
|
+
duration?: number
|
|
77
|
+
easing?: Easing
|
|
78
|
+
/** Touch inertia: the share of velocity kept per ms. Default 0.998 (iOS's normal rate). */
|
|
79
|
+
friction?: number
|
|
80
|
+
/** Coast after a touch fling. Default true. */
|
|
81
|
+
momentum?: boolean
|
|
82
|
+
/** Wheel distance multiplier. Default 1. */
|
|
83
|
+
wheelSpeed?: number
|
|
84
|
+
/** px for a wheel "line" and an arrow key. Default 40. */
|
|
85
|
+
lineHeight?: number
|
|
86
|
+
/** Rubber-band past the edges on touch. Default `bounce`. */
|
|
87
|
+
overscroll?: 'bounce' | 'none'
|
|
88
|
+
/** At an edge, hand the gesture to the scroller outside (`auto`), or keep it (`contain`). Default `auto`. */
|
|
89
|
+
chain?: 'auto' | 'contain'
|
|
90
|
+
snap?: SnapOptions
|
|
91
|
+
/** Arrows, Page keys, Home, End and Space scroll the area. Default true. */
|
|
92
|
+
keyboard?: boolean
|
|
93
|
+
/** Scroll while a text selection or a drag is held past the edge. Default true. */
|
|
94
|
+
autoscroll?: boolean
|
|
95
|
+
/** Touch input: `js` (default) runs it here; `native` leaves touch to the browser. */
|
|
96
|
+
touch?: 'js' | 'native'
|
|
97
|
+
/** Drag the content with a mouse, as a hand tool. Default false. */
|
|
98
|
+
grab?: boolean
|
|
99
|
+
/** `reduce` makes every animated scroll instant. Default `auto`: follows prefers-reduced-motion. */
|
|
100
|
+
motion?: 'auto' | 'reduce' | 'full'
|
|
101
|
+
/** The viewport's accessible name. With it, the viewport is a `region` landmark. */
|
|
102
|
+
label?: string
|
|
103
|
+
/** Also fire a `scroll` event in the frame a position is written, so listeners draw with that frame. Default true. */
|
|
104
|
+
syncEvents?: boolean
|
|
105
|
+
/** Called once per frame in which the position or overscroll changed. */
|
|
106
|
+
onScroll?: (state: ScrollState) => void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Where the scroller is. `x` is the DOM's `scrollLeft`, so it runs negative in right-to-left content. */
|
|
110
|
+
export interface ScrollState {
|
|
111
|
+
x: number
|
|
112
|
+
y: number
|
|
113
|
+
/** The furthest `y` and furthest-from-zero `x` can go. */
|
|
114
|
+
maxX: number
|
|
115
|
+
maxY: number
|
|
116
|
+
/** 0 at the start, 1 at the end, for each axis. In right-to-left content `progressX` 0 is the right edge. */
|
|
117
|
+
progressX: number
|
|
118
|
+
progressY: number
|
|
119
|
+
/** Rubber-band offset past an edge, px. */
|
|
120
|
+
overscrollX: number
|
|
121
|
+
overscrollY: number
|
|
122
|
+
/** px per ms. */
|
|
123
|
+
velocityX: number
|
|
124
|
+
velocityY: number
|
|
125
|
+
source: ScrollSource
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One bar as a renderer draws it: boxes are px within the bar's own track. */
|
|
129
|
+
export interface BarFrame {
|
|
130
|
+
axis: Axis
|
|
131
|
+
track: { width: number; height: number }
|
|
132
|
+
thumb: { x: number; y: number; width: number; height: number }
|
|
133
|
+
/** 0 at rest, 1 fully hovered: a renderer can ease the thickness with it. */
|
|
134
|
+
grow: number
|
|
135
|
+
hover: boolean
|
|
136
|
+
active: boolean
|
|
137
|
+
progress: number
|
|
138
|
+
/** The thumb's offset along the track at the scroll start and at the end, px: what a scroll timeline animates. */
|
|
139
|
+
span: { start: number; end: number }
|
|
140
|
+
rtl: boolean
|
|
141
|
+
bar: Required<Omit<BarOptions, 'radius'>> & { radius: number }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Draws the bars. The scroller owns the track elements and their pointer handling; a renderer only paints. */
|
|
145
|
+
export interface Renderer {
|
|
146
|
+
/** Build what it needs inside a bar's track element. */
|
|
147
|
+
mount(track: HTMLElement, axis: Axis): void
|
|
148
|
+
/** Paint a frame. Called only when something about it changed. */
|
|
149
|
+
draw(track: HTMLElement, frame: BarFrame): void
|
|
150
|
+
/** Called while `grow` eases: true asks for another frame. */
|
|
151
|
+
animates?: boolean
|
|
152
|
+
unmount(track: HTMLElement): void
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What `scroller` returns. */
|
|
156
|
+
export interface Scroller {
|
|
157
|
+
/** The current position and limits. */
|
|
158
|
+
state(): ScrollState
|
|
159
|
+
scrollTo(to: { top?: number; left?: number; behavior?: ScrollBehavior }): void
|
|
160
|
+
scrollBy(by: { top?: number; left?: number; behavior?: ScrollBehavior }): void
|
|
161
|
+
/** Bring `el` into view, as `Element.scrollIntoView` does, within this scroller only. */
|
|
162
|
+
scrollIntoView(
|
|
163
|
+
el: Element,
|
|
164
|
+
o?: {
|
|
165
|
+
block?: ScrollLogicalPosition
|
|
166
|
+
inline?: ScrollLogicalPosition
|
|
167
|
+
behavior?: ScrollBehavior
|
|
168
|
+
margin?: number
|
|
169
|
+
},
|
|
170
|
+
): void
|
|
171
|
+
/** Stop any animation or coast where it is. */
|
|
172
|
+
stop(): void
|
|
173
|
+
/** Measure again: call after a change no ResizeObserver can see. */
|
|
174
|
+
refresh(): void
|
|
175
|
+
update(options: ScrollOptions): void
|
|
176
|
+
destroy(): void
|
|
177
|
+
readonly viewport: HTMLElement
|
|
178
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Wheel and trackpad input. A gesture latches to the scroller it started on, as browsers do: one that starts at an
|
|
2
|
+
// edge goes to the scroller outside (or to the page), one that reaches an edge midway stays put.
|
|
3
|
+
import { registry, type Engine } from './engine'
|
|
4
|
+
import { normalizeWheel, pageStep } from './wheel'
|
|
5
|
+
import type { Axis } from './types'
|
|
6
|
+
|
|
7
|
+
const LATCH_MS = 300
|
|
8
|
+
const SETTLE_MS = 140
|
|
9
|
+
let latch: { owner: Engine | Element; t: number } | null = null
|
|
10
|
+
|
|
11
|
+
/** Listen for wheel events on the viewport. Returns the cleanup. */
|
|
12
|
+
export function wheelInput(e: Engine) {
|
|
13
|
+
let settle: ReturnType<typeof setTimeout> | undefined
|
|
14
|
+
const onWheel = (ev: WheelEvent) => {
|
|
15
|
+
if (ev.defaultPrevented || ev.ctrlKey) return
|
|
16
|
+
const o = e.o
|
|
17
|
+
const step = normalizeWheel(ev, o.lineHeight, {
|
|
18
|
+
x: pageStep(e.ax.x.view),
|
|
19
|
+
y: pageStep(e.ax.y.view),
|
|
20
|
+
})
|
|
21
|
+
const dx = step.x * o.wheelSpeed
|
|
22
|
+
const dy = step.y * o.wheelSpeed
|
|
23
|
+
if (!dx && !dy) return
|
|
24
|
+
if (!claims(e, ev, dx, dy)) return
|
|
25
|
+
ev.preventDefault()
|
|
26
|
+
latch = { owner: e, t: performance.now() }
|
|
27
|
+
for (const [axis, d] of [
|
|
28
|
+
['x', dx],
|
|
29
|
+
['y', dy],
|
|
30
|
+
] as [Axis, number][]) {
|
|
31
|
+
if (!d || !e.ax[axis].on) continue
|
|
32
|
+
if (step.notched) e.tweenBy(axis, d, 'wheel')
|
|
33
|
+
else {
|
|
34
|
+
const a = e.ax[axis]
|
|
35
|
+
const from = a.anim?.kind === 'tween' ? a.anim.to : a.pos
|
|
36
|
+
e.moveTo(axis, from + d, 'wheel')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
clearTimeout(settle)
|
|
40
|
+
settle = setTimeout(() => {
|
|
41
|
+
e.settle('x')
|
|
42
|
+
e.settle('y')
|
|
43
|
+
}, SETTLE_MS)
|
|
44
|
+
}
|
|
45
|
+
e.vp.addEventListener('wheel', onWheel, { passive: false })
|
|
46
|
+
if (!watching) {
|
|
47
|
+
watching = true
|
|
48
|
+
addEventListener('wheel', pageTook, { passive: true })
|
|
49
|
+
}
|
|
50
|
+
return () => {
|
|
51
|
+
clearTimeout(settle)
|
|
52
|
+
e.vp.removeEventListener('wheel', onWheel)
|
|
53
|
+
if (latch?.owner === e) latch = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether this scroller takes the event: it is latched here, or a new gesture it can move with. */
|
|
58
|
+
function claims(e: Engine, ev: WheelEvent, dx: number, dy: number) {
|
|
59
|
+
const live = latch && performance.now() - latch.t < LATCH_MS && performance.now() >= latch.t
|
|
60
|
+
if (live && latch) {
|
|
61
|
+
if (latch.owner === e) {
|
|
62
|
+
latch.t = performance.now()
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
// Latched outside this scroller (a parent, or the page), or on a native scroller inside it: not ours.
|
|
66
|
+
const el = latch.owner instanceof Element ? latch.owner : latch.owner.vp
|
|
67
|
+
if (el.contains(e.vp) || (e.vp.contains(el) && el.contains(ev.target as Node))) {
|
|
68
|
+
latch.t = performance.now()
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const native = nativeTaker(ev.target as Element, e.vp, dx, dy)
|
|
73
|
+
if (native) {
|
|
74
|
+
latch = { owner: native, t: performance.now() }
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
if (e.canMove('x', Math.sign(dx)) || e.canMove('y', Math.sign(dy))) return true
|
|
78
|
+
if (e.o.chain === 'contain') return true
|
|
79
|
+
// Handed on: a scroller outside may claim it; if none does, the page has it (see `pageTook`).
|
|
80
|
+
handed = ev
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let handed: WheelEvent | null = null
|
|
85
|
+
let watching = false
|
|
86
|
+
/** Passive, so it costs the page's own scrolling nothing: notes that a handed-on gesture went to the page. */
|
|
87
|
+
const pageTook = (ev: WheelEvent) => {
|
|
88
|
+
if (ev === handed && !ev.defaultPrevented)
|
|
89
|
+
latch = { owner: document.documentElement, t: performance.now() }
|
|
90
|
+
handed = null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A natively scrolling element between the target and the viewport that can take this delta itself. */
|
|
94
|
+
function nativeTaker(target: Element | null, stop: Element, dx: number, dy: number) {
|
|
95
|
+
for (let el = target; el && el !== stop; el = el.parentElement) {
|
|
96
|
+
if (registry.has(el)) return null
|
|
97
|
+
if (el.scrollHeight <= el.clientHeight && el.scrollWidth <= el.clientWidth) continue
|
|
98
|
+
const s = getComputedStyle(el)
|
|
99
|
+
const scrolls = (v: string) => v === 'auto' || v === 'scroll'
|
|
100
|
+
if (dy && scrolls(s.overflowY) && room(el.scrollTop, el.scrollHeight - el.clientHeight, dy))
|
|
101
|
+
return el
|
|
102
|
+
if (dx && scrolls(s.overflowX)) {
|
|
103
|
+
const wide = el.scrollWidth - el.clientWidth
|
|
104
|
+
const rtl = s.direction === 'rtl'
|
|
105
|
+
const at = rtl ? el.scrollLeft + wide : el.scrollLeft
|
|
106
|
+
if (room(at, wide, dx)) return el
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const room = (at: number, max: number, d: number) => (d > 0 ? at < max - 0.5 : at > 0.5)
|
package/src/wheel.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Wheel events into pixels, and which kind of device sent them: a notched wheel animates each step, a trackpad's
|
|
2
|
+
// pixel deltas (already carrying the OS's own momentum) apply as they come.
|
|
3
|
+
|
|
4
|
+
/** One wheel event, normalised. */
|
|
5
|
+
export interface WheelStep {
|
|
6
|
+
x: number
|
|
7
|
+
y: number
|
|
8
|
+
/** A mouse wheel's notch, not a trackpad: animate it. */
|
|
9
|
+
notched: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Pixels for a wheel event. `line` px per line; a page is `page` px (the viewport less a little overlap). */
|
|
13
|
+
export function normalizeWheel(
|
|
14
|
+
e: Pick<WheelEvent, 'deltaX' | 'deltaY' | 'deltaMode' | 'shiftKey'> & { wheelDeltaY?: number },
|
|
15
|
+
line: number,
|
|
16
|
+
page: { x: number; y: number },
|
|
17
|
+
): WheelStep {
|
|
18
|
+
const scale = e.deltaMode === 1 ? line : 1
|
|
19
|
+
let x = e.deltaMode === 2 ? e.deltaX * page.x : e.deltaX * scale
|
|
20
|
+
let y = e.deltaMode === 2 ? e.deltaY * page.y : e.deltaY * scale
|
|
21
|
+
const raw = e.wheelDeltaY ?? 0
|
|
22
|
+
const notched = e.deltaMode !== 0 || (raw !== 0 && Math.abs(raw) % 120 === 0 && Math.abs(y) >= 50)
|
|
23
|
+
// Windows and Linux send Shift+wheel as vertical; macOS has already swapped it, so deltaX is set there.
|
|
24
|
+
if (e.shiftKey && !x && y) {
|
|
25
|
+
x = y
|
|
26
|
+
y = 0
|
|
27
|
+
}
|
|
28
|
+
return { x, y, notched }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The page step for a viewport of `size` px, as browsers size Page Down: most of a screen, keeping some context. */
|
|
32
|
+
export const pageStep = (size: number) => Math.max(size * 0.875, size - 40)
|
package/src/wrap.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// For pages without a framework: turn an element into a scroll area in place.
|
|
2
|
+
import { scroller } from './scroller'
|
|
3
|
+
import type { ScrollOptions, Scroller } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Make `el` a scroll area without writing the markup: its children move into a viewport and content wrapper, `el`
|
|
7
|
+
* becomes the root. `destroy` puts them back.
|
|
8
|
+
*/
|
|
9
|
+
export function wrap(el: HTMLElement, options: ScrollOptions = {}): Scroller {
|
|
10
|
+
const viewport = document.createElement('div')
|
|
11
|
+
viewport.dataset.part = 'viewport'
|
|
12
|
+
const content = document.createElement('div')
|
|
13
|
+
content.dataset.part = 'content'
|
|
14
|
+
content.append(...el.childNodes)
|
|
15
|
+
viewport.append(content)
|
|
16
|
+
el.append(viewport)
|
|
17
|
+
const s = scroller(el, options)
|
|
18
|
+
return {
|
|
19
|
+
...s,
|
|
20
|
+
destroy() {
|
|
21
|
+
s.destroy()
|
|
22
|
+
el.append(...content.childNodes)
|
|
23
|
+
viewport.remove()
|
|
24
|
+
delete el.dataset.scroll
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|