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(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 {children} } const ScrollCanvas = /* @__PURE__ */ React.forwardRef( ({ children }: ScrollProps, ref: React.ForwardedRef) => { const group = React.useRef(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 {children} } ) 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(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(
{children}
) 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 = /* @__PURE__ */ React.forwardRef( ({ html, ...props }: ScrollProps, ref) => { const El = html ? ScrollHtml : ScrollCanvas return } )