-
Notifications
You must be signed in to change notification settings - Fork 648
/
ScrollControls.tsx
281 lines (254 loc) · 9.96 KB
/
ScrollControls.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
import * as THREE from 'three'
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import { context as fiberContext, RootState, useFrame, useThree } from '@react-three/fiber'
import { DomEvent } from '@react-three/fiber/dist/declarations/src/core/events'
import { easing } from 'maath'
import { ForwardRefComponent } from '../helpers/ts-utils'
export type ScrollControlsProps = {
/** Precision, default 0.00001 */
eps?: number
/** Horizontal scroll, default false (vertical) */
horizontal?: boolean
/** Infinite scroll, default false (experimental!) */
infinite?: boolean
/** Defines the lenght of the scroll area, each page is height:100%, default 1 */
pages?: number
/** A factor that increases scroll bar travel,default: 1 */
distance?: number
/** Friction in seconds, default: 0.25 (1/4 second) */
damping?: number
/** maxSpeed optionally allows you to clamp the maximum speed. If damping is 0.2s and looks OK
* going between, say, page 1 and 2, but not for pages far apart as it'll move very rapid,
* then a maxSpeed of e.g. 3 which will clamp the speed to 3 units per second, it may now
* take much longer than damping to reach the target if it is far away. Default: Infinity */
maxSpeed?: number
/** If true attaches the scroll container before the canvas */
prepend?: boolean
enabled?: boolean
style?: React.CSSProperties
children: React.ReactNode
}
export type ScrollControlsState = {
el: HTMLDivElement
eps: number
fill: HTMLDivElement
fixed: HTMLDivElement
horizontal: boolean | undefined
damping: number
offset: number
delta: number
pages: number
range(from: number, distance: number, margin?: number): number
curve(from: number, distance: number, margin?: number): number
visible(from: number, distance: number, margin?: number): boolean
}
const context = /* @__PURE__ */ React.createContext<ScrollControlsState>(null!)
export function useScroll() {
return React.useContext(context)
}
export function ScrollControls({
eps = 0.00001,
enabled = true,
infinite,
horizontal,
pages = 1,
distance = 1,
damping = 0.25,
maxSpeed = Infinity,
prepend = false,
style = {},
children,
}: ScrollControlsProps) {
const { get, setEvents, gl, size, invalidate, events } = useThree()
const [el] = React.useState(() => document.createElement('div'))
const [fill] = React.useState(() => document.createElement('div'))
const [fixed] = React.useState(() => document.createElement('div'))
const target = gl.domElement.parentNode! as HTMLElement
const scroll = React.useRef(0)
const state = React.useMemo(() => {
const state = {
el,
eps,
fill,
fixed,
horizontal,
damping,
offset: 0,
delta: 0,
scroll,
pages,
// 0-1 for a range between from -> from + distance
range(from: number, distance: number, margin: number = 0) {
const start = from - margin
const end = start + distance + margin * 2
return this.offset < start ? 0 : this.offset > end ? 1 : (this.offset - start) / (end - start)
},
// 0-1-0 for a range between from -> from + distance
curve(from: number, distance: number, margin: number = 0) {
return Math.sin(this.range(from, distance, margin) * Math.PI)
},
// true/false for a range between from -> from + distance
visible(from: number, distance: number, margin: number = 0) {
const start = from - margin
const end = start + distance + margin * 2
return this.offset >= start && this.offset <= end
},
}
return state
}, [eps, damping, horizontal, pages])
React.useEffect(() => {
el.style.position = 'absolute'
el.style.width = '100%'
el.style.height = '100%'
el.style[horizontal ? 'overflowX' : 'overflowY'] = 'auto'
el.style[horizontal ? 'overflowY' : 'overflowX'] = 'hidden'
el.style.top = '0px'
el.style.left = '0px'
for (const key in style) {
el.style[key] = style[key]
}
fixed.style.position = 'sticky'
fixed.style.top = '0px'
fixed.style.left = '0px'
fixed.style.width = '100%'
fixed.style.height = '100%'
fixed.style.overflow = 'hidden'
el.appendChild(fixed)
fill.style.height = horizontal ? '100%' : `${pages * distance * 100}%`
fill.style.width = horizontal ? `${pages * distance * 100}%` : '100%'
fill.style.pointerEvents = 'none'
el.appendChild(fill)
if (prepend) target.prepend(el)
else target.appendChild(el)
// Init scroll one pixel in to allow upward/leftward scroll
el[horizontal ? 'scrollLeft' : 'scrollTop'] = 1
const oldTarget = (events.connected || gl.domElement) as HTMLElement
requestAnimationFrame(() => events.connect?.(el))
const oldCompute = get().events.compute
setEvents({
compute(event: DomEvent, state: RootState) {
// we are using boundingClientRect because we could not rely on target.offsetTop as canvas could be positioned anywhere in dom
const { left, top } = target.getBoundingClientRect()
const offsetX = event.clientX - left
const offsetY = event.clientY - top
state.pointer.set((offsetX / state.size.width) * 2 - 1, -(offsetY / state.size.height) * 2 + 1)
state.raycaster.setFromCamera(state.pointer, state.camera)
},
})
return () => {
target.removeChild(el)
setEvents({ compute: oldCompute })
events.connect?.(oldTarget)
}
}, [pages, distance, horizontal, el, fill, fixed, target])
React.useEffect(() => {
if (events.connected === el) {
const containerLength = size[horizontal ? 'width' : 'height']
const scrollLength = el[horizontal ? 'scrollWidth' : 'scrollHeight']
const scrollThreshold = scrollLength - containerLength
let current = 0
let disableScroll = true
let firstRun = true
const onScroll = () => {
// Prevent first scroll because it is indirectly caused by the one pixel offset
if (!enabled || firstRun) return
invalidate()
current = el[horizontal ? 'scrollLeft' : 'scrollTop']
scroll.current = current / scrollThreshold
if (infinite) {
if (!disableScroll) {
if (current >= scrollThreshold) {
const damp = 1 - state.offset
el[horizontal ? 'scrollLeft' : 'scrollTop'] = 1
scroll.current = state.offset = -damp
disableScroll = true
} else if (current <= 0) {
const damp = 1 + state.offset
el[horizontal ? 'scrollLeft' : 'scrollTop'] = scrollLength
scroll.current = state.offset = damp
disableScroll = true
}
}
if (disableScroll) setTimeout(() => (disableScroll = false), 40)
}
}
el.addEventListener('scroll', onScroll, { passive: true })
requestAnimationFrame(() => (firstRun = false))
const onWheel = (e) => (el.scrollLeft += e.deltaY / 2)
if (horizontal) el.addEventListener('wheel', onWheel, { passive: true })
return () => {
el.removeEventListener('scroll', onScroll)
if (horizontal) el.removeEventListener('wheel', onWheel)
}
}
}, [el, events, size, infinite, state, invalidate, horizontal, enabled])
let last = 0
useFrame((_, delta) => {
last = state.offset
easing.damp(state, 'offset', scroll.current, damping, delta, maxSpeed, undefined, eps)
easing.damp(state, 'delta', Math.abs(last - state.offset), damping, delta, maxSpeed, undefined, eps)
if (state.delta > eps) invalidate()
})
return <context.Provider value={state}>{children}</context.Provider>
}
const ScrollCanvas = /* @__PURE__ */ React.forwardRef(
({ children }: ScrollProps, ref: React.ForwardedRef<THREE.Group>) => {
const group = React.useRef<THREE.Group>(null!)
React.useImperativeHandle(ref, () => group.current, [])
const state = useScroll()
const { width, height } = useThree((state) => state.viewport)
useFrame(() => {
group.current.position.x = state.horizontal ? -width * (state.pages - 1) * state.offset : 0
group.current.position.y = state.horizontal ? 0 : height * (state.pages - 1) * state.offset
})
return <group ref={group}>{children}</group>
}
)
const ScrollHtml: ForwardRefComponent<{ children?: React.ReactNode; style?: React.CSSProperties }, HTMLDivElement> =
React.forwardRef(
({ children, style, ...props }: { children?: React.ReactNode; style?: React.CSSProperties }, ref) => {
const state = useScroll()
const group = React.useRef<HTMLDivElement>(null!)
React.useImperativeHandle(ref, () => group.current, [])
const { width, height } = useThree((state) => state.size)
const fiberState = React.useContext(fiberContext)
const root = React.useMemo(() => ReactDOM.createRoot(state.fixed), [state.fixed])
useFrame(() => {
if (state.delta > state.eps) {
group.current.style.transform = `translate3d(${
state.horizontal ? -width * (state.pages - 1) * state.offset : 0
}px,${state.horizontal ? 0 : height * (state.pages - 1) * -state.offset}px,0)`
}
})
root.render(
<div
ref={group}
style={{ ...style, position: 'absolute', top: 0, left: 0, willChange: 'transform' }}
{...props}
>
<context.Provider value={state}>
<fiberContext.Provider value={fiberState}>{children}</fiberContext.Provider>
</context.Provider>
</div>
)
return null
}
)
interface ScrollPropsWithFalseHtml {
children?: React.ReactNode
html?: false
style?: never
}
interface ScrollPropsWithTrueHtml {
children?: React.ReactNode
html: true
style?: React.CSSProperties
}
type ScrollProps = ScrollPropsWithFalseHtml | ScrollPropsWithTrueHtml
export const Scroll: ForwardRefComponent<ScrollProps, THREE.Group & HTMLDivElement> = /* @__PURE__ */ React.forwardRef(
({ html, ...props }: ScrollProps, ref) => {
const El = html ? ScrollHtml : ScrollCanvas
return <El ref={ref} {...(props as ScrollProps)} />
}
)