|
| 1 | +import { DOCUMENT } from '@angular/common'; |
| 2 | +import { |
| 3 | + afterNextRender, |
| 4 | + ChangeDetectionStrategy, |
| 5 | + Component, |
| 6 | + CUSTOM_ELEMENTS_SCHEMA, |
| 7 | + DestroyRef, |
| 8 | + ElementRef, |
| 9 | + inject, |
| 10 | + input, |
| 11 | + model, |
| 12 | + untracked, |
| 13 | +} from '@angular/core'; |
| 14 | +import { extend, HTML, injectBeforeRender, injectStore, NgtAnyRecord, pick } from 'angular-three'; |
| 15 | +import { easing } from 'maath'; |
| 16 | +import { injectAutoEffect } from 'ngxtension/auto-effect'; |
| 17 | +import { mergeInputs } from 'ngxtension/inject-inputs'; |
| 18 | +import { Group } from 'three'; |
| 19 | + |
| 20 | +export interface NgtsScrollControlsOptions { |
| 21 | + /** Precision, default 0.00001 */ |
| 22 | + eps: number; |
| 23 | + /** Horizontal scroll, default false (vertical) */ |
| 24 | + horizontal: boolean; |
| 25 | + /** Infinite scroll, default false (experimental!) */ |
| 26 | + infinite: boolean; |
| 27 | + /** Defines the lenght of the scroll area, each page is height:100%, default 1 */ |
| 28 | + pages: number; |
| 29 | + /** A factor that increases scroll bar travel,default: 1 */ |
| 30 | + distance: number; |
| 31 | + /** Friction in seconds, default: 0.25 (1/4 second) */ |
| 32 | + damping: number; |
| 33 | + /** maxSpeed optionally allows you to clamp the maximum speed. If damping is 0.2s and looks OK |
| 34 | + * going between, say, page 1 and 2, but not for pages far apart as it'll move very rapid, |
| 35 | + * then a maxSpeed of e.g. 3 which will clamp the speed to 3 units per second, it may now |
| 36 | + * take much longer than damping to reach the target if it is far away. Default: Infinity */ |
| 37 | + maxSpeed: number; |
| 38 | + /** If true attaches the scroll container before the canvas */ |
| 39 | + prepend: boolean; |
| 40 | + enabled: boolean; |
| 41 | + style: Partial<CSSStyleDeclaration>; |
| 42 | +} |
| 43 | + |
| 44 | +const defaultOptions: NgtsScrollControlsOptions = { |
| 45 | + eps: 0.00001, |
| 46 | + horizontal: false, |
| 47 | + infinite: false, |
| 48 | + pages: 1, |
| 49 | + distance: 1, |
| 50 | + damping: 0.25, |
| 51 | + maxSpeed: Infinity, |
| 52 | + prepend: false, |
| 53 | + enabled: true, |
| 54 | + style: {}, |
| 55 | +}; |
| 56 | + |
| 57 | +@Component({ |
| 58 | + selector: 'ngts-scroll-controls', |
| 59 | + standalone: true, |
| 60 | + template: ` |
| 61 | + <ng-content /> |
| 62 | + `, |
| 63 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 64 | +}) |
| 65 | +export class NgtsScrollControls { |
| 66 | + progress = model(0); |
| 67 | + options = input(defaultOptions, { transform: mergeInputs(defaultOptions) }); |
| 68 | + |
| 69 | + private document = inject(DOCUMENT); |
| 70 | + private store = injectStore(); |
| 71 | + private gl = this.store.select('gl'); |
| 72 | + private events = this.store.select('events'); |
| 73 | + private invalidate = this.store.select('invalidate'); |
| 74 | + private size = this.store.select('size'); |
| 75 | + |
| 76 | + private domElement = pick(this.gl, 'domElement'); |
| 77 | + private target = pick(this.domElement, 'parentNode'); |
| 78 | + |
| 79 | + private _el = this.document.createElement('div'); |
| 80 | + private _fill = this.document.createElement('div'); |
| 81 | + private _fixed = this.document.createElement('div'); |
| 82 | + |
| 83 | + private style = pick(this.options, 'style'); |
| 84 | + private prepend = pick(this.options, 'prepend'); |
| 85 | + private enabled = pick(this.options, 'enabled'); |
| 86 | + private infinite = pick(this.options, 'infinite'); |
| 87 | + private maxSpeed = pick(this.options, 'maxSpeed'); |
| 88 | + eps = pick(this.options, 'eps'); |
| 89 | + horizontal = pick(this.options, 'horizontal'); |
| 90 | + pages = pick(this.options, 'pages'); |
| 91 | + distance = pick(this.options, 'distance'); |
| 92 | + damping = pick(this.options, 'damping'); |
| 93 | + scroll = 0; |
| 94 | + offset = 0; |
| 95 | + delta = 0; |
| 96 | + |
| 97 | + constructor() { |
| 98 | + const autoEffect = injectAutoEffect(); |
| 99 | + |
| 100 | + afterNextRender(() => { |
| 101 | + autoEffect(() => { |
| 102 | + const target = this.target(); |
| 103 | + if (!target) return; |
| 104 | + |
| 105 | + const parent = target as HTMLElement; |
| 106 | + const [pages, distance, horizontal, el, fill, fixed, style, prepend, domElement, events] = [ |
| 107 | + this.pages(), |
| 108 | + this.distance(), |
| 109 | + this.horizontal(), |
| 110 | + this.el, |
| 111 | + this.fill, |
| 112 | + this.fixed, |
| 113 | + untracked(this.style), |
| 114 | + untracked(this.prepend), |
| 115 | + untracked(this.domElement), |
| 116 | + untracked(this.events), |
| 117 | + ]; |
| 118 | + |
| 119 | + el.style.position = 'absolute'; |
| 120 | + el.style.width = '100%'; |
| 121 | + el.style.height = '100%'; |
| 122 | + el.style[horizontal ? 'overflowX' : 'overflowY'] = 'auto'; |
| 123 | + el.style[horizontal ? 'overflowY' : 'overflowX'] = 'hidden'; |
| 124 | + el.style.top = '0px'; |
| 125 | + el.style.left = '0px'; |
| 126 | + |
| 127 | + for (const key in style) { |
| 128 | + el.style[key] = (style as CSSStyleDeclaration)[key]; |
| 129 | + } |
| 130 | + |
| 131 | + fixed.style.position = 'sticky'; |
| 132 | + fixed.style.top = '0px'; |
| 133 | + fixed.style.left = '0px'; |
| 134 | + fixed.style.width = '100%'; |
| 135 | + fixed.style.height = '100%'; |
| 136 | + fixed.style.overflow = 'hidden'; |
| 137 | + el.appendChild(fixed); |
| 138 | + |
| 139 | + fill.style.height = horizontal ? '100%' : `${pages * distance * 100}%`; |
| 140 | + fill.style.width = horizontal ? `${pages * distance * 100}%` : '100%'; |
| 141 | + fill.style.pointerEvents = 'none'; |
| 142 | + el.appendChild(fill); |
| 143 | + |
| 144 | + if (prepend) parent.prepend(el); |
| 145 | + else parent.appendChild(el); |
| 146 | + |
| 147 | + // Init scroll one pixel in to allow upward/leftward scroll |
| 148 | + el[horizontal ? 'scrollLeft' : 'scrollTop'] = 1; |
| 149 | + |
| 150 | + const oldTarget = (events.connected || domElement) as HTMLElement; |
| 151 | + requestAnimationFrame(() => events.connect?.(el)); |
| 152 | + const oldCompute = events.compute?.bind(events); |
| 153 | + |
| 154 | + this.store.snapshot.setEvents({ |
| 155 | + compute(event, store) { |
| 156 | + const state = store.snapshot; |
| 157 | + // we are using boundingClientRect because we could not rely on target.offsetTop as canvas could be positioned anywhere in dom |
| 158 | + const { left, top } = parent.getBoundingClientRect(); |
| 159 | + const offsetX = event.clientX - left; |
| 160 | + const offsetY = event.clientY - top; |
| 161 | + state.pointer.set((offsetX / state.size.width) * 2 - 1, -(offsetY / state.size.height) * 2 + 1); |
| 162 | + state.raycaster.setFromCamera(state.pointer, state.camera); |
| 163 | + }, |
| 164 | + }); |
| 165 | + |
| 166 | + return () => { |
| 167 | + parent.removeChild(el); |
| 168 | + this.store.snapshot.setEvents({ compute: oldCompute }); |
| 169 | + events.connect?.(oldTarget); |
| 170 | + }; |
| 171 | + }); |
| 172 | + |
| 173 | + autoEffect(() => { |
| 174 | + const [el, events, size, infinite, invalidate, horizontal, enabled] = [ |
| 175 | + this.el, |
| 176 | + this.events(), |
| 177 | + this.size(), |
| 178 | + this.infinite(), |
| 179 | + this.invalidate(), |
| 180 | + this.horizontal(), |
| 181 | + this.enabled(), |
| 182 | + ]; |
| 183 | + |
| 184 | + if (events.connected !== el) return; |
| 185 | + |
| 186 | + const containerLength = size[horizontal ? 'width' : 'height']; |
| 187 | + const scrollLength = el[horizontal ? 'scrollWidth' : 'scrollHeight']; |
| 188 | + const scrollThreshold = scrollLength - containerLength; |
| 189 | + |
| 190 | + let current = 0; |
| 191 | + let disableScroll = true; |
| 192 | + let firstRun = true; |
| 193 | + |
| 194 | + const onScroll = () => { |
| 195 | + // Prevent first scroll because it is indirectly caused by the one pixel offset |
| 196 | + if (!enabled || firstRun) return; |
| 197 | + invalidate(); |
| 198 | + current = el[horizontal ? 'scrollLeft' : 'scrollTop']; |
| 199 | + this.scroll = current / scrollThreshold; |
| 200 | + |
| 201 | + if (infinite) { |
| 202 | + if (!disableScroll) { |
| 203 | + if (current >= scrollThreshold) { |
| 204 | + const damp = 1 - this.offset; |
| 205 | + el[horizontal ? 'scrollLeft' : 'scrollTop'] = 1; |
| 206 | + this.scroll = this.offset = -damp; |
| 207 | + disableScroll = true; |
| 208 | + } else if (current <= 0) { |
| 209 | + const damp = 1 + this.offset; |
| 210 | + el[horizontal ? 'scrollLeft' : 'scrollTop'] = scrollLength; |
| 211 | + this.scroll = this.offset = damp; |
| 212 | + disableScroll = true; |
| 213 | + } |
| 214 | + } |
| 215 | + if (disableScroll) setTimeout(() => (disableScroll = false), 40); |
| 216 | + } |
| 217 | + |
| 218 | + untracked(() => { |
| 219 | + this.progress.set(this.scroll); |
| 220 | + }); |
| 221 | + }; |
| 222 | + |
| 223 | + el.addEventListener('scroll', onScroll, { passive: true }); |
| 224 | + requestAnimationFrame(() => (firstRun = false)); |
| 225 | + |
| 226 | + const onWheel = (e: WheelEvent) => (el[horizontal ? 'scrollLeft' : 'scrollTop'] += e.deltaY / 2); |
| 227 | + if (horizontal) el.addEventListener('wheel', onWheel, { passive: true }); |
| 228 | + |
| 229 | + return () => { |
| 230 | + el.removeEventListener('scroll', onScroll); |
| 231 | + if (horizontal) el.removeEventListener('wheel', onWheel); |
| 232 | + }; |
| 233 | + }); |
| 234 | + }); |
| 235 | + |
| 236 | + let last = 0; |
| 237 | + injectBeforeRender(({ delta }) => { |
| 238 | + last = this.offset; |
| 239 | + easing.damp(this, 'offset', this.scroll, this.damping(), delta, this.maxSpeed(), undefined, this.eps()); |
| 240 | + easing.damp( |
| 241 | + this, |
| 242 | + 'delta', |
| 243 | + Math.abs(last - this.offset), |
| 244 | + this.damping(), |
| 245 | + delta, |
| 246 | + this.maxSpeed(), |
| 247 | + undefined, |
| 248 | + this.eps(), |
| 249 | + ); |
| 250 | + if (this.delta > this.eps()) this.invalidate()(); |
| 251 | + }); |
| 252 | + } |
| 253 | + |
| 254 | + get el() { |
| 255 | + return this._el; |
| 256 | + } |
| 257 | + |
| 258 | + get fill() { |
| 259 | + return this._fill; |
| 260 | + } |
| 261 | + |
| 262 | + get fixed() { |
| 263 | + return this._fixed; |
| 264 | + } |
| 265 | + |
| 266 | + range(from: number, distance: number, margin: number = 0): number { |
| 267 | + const start = from - margin; |
| 268 | + const end = start + distance + margin * 2; |
| 269 | + return this.offset < start ? 0 : this.offset > end ? 1 : (this.offset - start) / (end - start); |
| 270 | + } |
| 271 | + |
| 272 | + curve(from: number, distance: number, margin: number = 0): number { |
| 273 | + return Math.sin(this.range(from, distance, margin) * Math.PI); |
| 274 | + } |
| 275 | + |
| 276 | + visible(from: number, distance: number, margin: number = 0): boolean { |
| 277 | + const start = from - margin; |
| 278 | + const end = start + distance + margin * 2; |
| 279 | + return this.offset >= start && this.offset <= end; |
| 280 | + } |
| 281 | +} |
| 282 | + |
| 283 | +@Component({ |
| 284 | + selector: 'ngt-group[ngts-scroll-canvas]', |
| 285 | + standalone: true, |
| 286 | + template: ` |
| 287 | + <ng-content /> |
| 288 | + `, |
| 289 | + schemas: [CUSTOM_ELEMENTS_SCHEMA], |
| 290 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 291 | +}) |
| 292 | +export class NgtsScrollCanvas { |
| 293 | + private host = inject<ElementRef<Group>>(ElementRef); |
| 294 | + private scrollControls = inject(NgtsScrollControls); |
| 295 | + private store = injectStore(); |
| 296 | + private viewport = this.store.select('viewport'); |
| 297 | + |
| 298 | + constructor() { |
| 299 | + extend({ Group }); |
| 300 | + injectBeforeRender(() => { |
| 301 | + const group = this.host.nativeElement; |
| 302 | + |
| 303 | + group.position.x = this.scrollControls.horizontal() |
| 304 | + ? -this.viewport().width * (this.scrollControls.pages() - 1) * this.scrollControls.offset |
| 305 | + : 0; |
| 306 | + group.position.y = this.scrollControls.horizontal() |
| 307 | + ? 0 |
| 308 | + : this.viewport().height * (this.scrollControls.pages() - 1) * this.scrollControls.offset; |
| 309 | + }); |
| 310 | + } |
| 311 | +} |
| 312 | + |
| 313 | +@Component({ |
| 314 | + selector: 'div[ngts-scroll-html]', |
| 315 | + standalone: true, |
| 316 | + template: ` |
| 317 | + <ng-content /> |
| 318 | + `, |
| 319 | + changeDetection: ChangeDetectionStrategy.OnPush, |
| 320 | + host: { |
| 321 | + style: 'position: absolute; top: 0; left: 0; will-change: transform;', |
| 322 | + }, |
| 323 | +}) |
| 324 | +export class NgtsScrollHtml { |
| 325 | + static [HTML] = true; |
| 326 | + |
| 327 | + private scrollControls = inject(NgtsScrollControls); |
| 328 | + private host = inject<ElementRef<HTMLDivElement>>(ElementRef); |
| 329 | + private store = injectStore(); |
| 330 | + private size = this.store.select('size'); |
| 331 | + |
| 332 | + constructor() { |
| 333 | + // assigning dom parent so that the Renderer knows where to attach the element |
| 334 | + Object.assign(this.host.nativeElement, { |
| 335 | + __ngt_dom_parent__: this.scrollControls.fixed, |
| 336 | + }); |
| 337 | + |
| 338 | + injectBeforeRender(() => { |
| 339 | + if (this.scrollControls.delta > this.scrollControls.eps()) { |
| 340 | + this.host.nativeElement.style.transform = `translate3d(${ |
| 341 | + this.scrollControls.horizontal() |
| 342 | + ? -this.size().width * (this.scrollControls.pages() - 1) * this.scrollControls.offset |
| 343 | + : 0 |
| 344 | + }px,${this.scrollControls.horizontal() ? 0 : this.size().height * (this.scrollControls.pages() - 1) * -this.scrollControls.offset}px,0)`; |
| 345 | + } |
| 346 | + }); |
| 347 | + |
| 348 | + inject(DestroyRef).onDestroy(() => { |
| 349 | + delete (this.host.nativeElement as NgtAnyRecord)['__ngt_dom_parent__']; |
| 350 | + }); |
| 351 | + } |
| 352 | +} |
0 commit comments