Skip to content

Commit 341c775

Browse files
committed
scroll controls works
1 parent 9af528b commit 341c775

File tree

8 files changed

+554
-10
lines changed

8 files changed

+554
-10
lines changed

libs/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export * from './lib/canvas';
22
export * from './lib/directives/args';
3-
export { NgtCamera, NgtComputeFunction, NgtThreeEvent } from './lib/events';
3+
export { NgtCamera, NgtComputeFunction, NgtDomEvent, NgtThreeEvent } from './lib/events';
44
export * from './lib/instance';
55
export * from './lib/loader';
66
export { addAfterEffect, addEffect, addTail } from './lib/loop';

libs/core/src/lib/renderer/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,12 @@ export class NgtRenderer implements Renderer2 {
184184
(newChild instanceof Text || cRS[NgtRendererClassId.type] === 'dom')
185185
) {
186186
addChild(parent, newChild);
187+
188+
if (newChild['__ngt_dom_parent__'] && newChild['__ngt_dom_parent__'] instanceof HTMLElement) {
189+
this.delegate.appendChild(newChild['__ngt_dom_parent__'], newChild);
190+
return;
191+
}
192+
187193
this.delegate.appendChild(parent, newChild);
188194
if (cRS) {
189195
setParent(newChild, parent);

libs/core/src/lib/renderer/state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type NgtRendererState = [
1414

1515
export interface NgtRendererNode {
1616
__ngt_renderer__: NgtRendererState;
17+
__ngt_dom_parent__?: HTMLElement;
1718
}
1819

1920
export function createNode(type: NgtRendererState[NgtRendererClassId.type], node: NgtAnyRecord, document: Document) {

libs/soba/controls/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './lib/camera-controls';
22
export * from './lib/orbit-controls';
3+
export * from './lib/scroll-controls';
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
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

Comments
 (0)