@polygonjs/polygonjs
Version:
node-based WebGL 3D engine https://polygonjs.com
276 lines (257 loc) • 9.77 kB
text/typescript
/**
* Triggers events based on page scroll
*
*
* @remarks
*
* In order to test the [event/ScrollTrigger](/docs/nodes/event/ScrollTrigger) node, you can create the following setup:
*
* Using Polygonjs [local app](/local), add the following line to the `EditorConfig.ts` file, inside the `configureEditor` function:
*
* ```
* options.api.panel.viewer.setData({viewerId: 'my-viewer', html: require('./viewer.html')})
* ```
*
* and create a file `viewer.html` in the same folder as `EditorConfig.ts`, containing the following:
*
* ```<div id="main-container" style="position: relative; height: 100%">
* <div id="my-viewer" style="position: absolute; width: 100%; height: 100%"></div>
* <div id="scroll-container" style="position: relative; height: 100%; overflow-y: scroll">
* <div id="checkpoint1" style="height: 400px; color: white">checkpoint 1</div>
* <div id="checkpoint2" style="height: 400px; color: red">checkpoint 2</div>
* <div id="checkpoint3" style="height: 400px; color: green">checkpoint 3</div>
* <div id="checkpoint4" style="height: 400px; color: lightred">checkpoint 4</div>
* </div>
* </div>```
*
* Then reload the editor. You will then see the viewer with a scrollbar and other divs inside.
*
* Then create a [event/ScrollTrigger](/docs/nodes/event/ScrollTrigger) node, and set its parameters as follow:
*
* - param `element` to `#checkpoint2`
* - param `useViewport` to `false`
* - param `scroller` to `#scroll-container`
* - param `markers` to `true`
*
* You should now be able to connect other event nodes to the outputs of the scrollTrigger node,
* and have those be triggered as you scroll.
*
*/
import {EventConnectionPoint, EventConnectionPointType} from '../utils/io/connections/Event';
import {NodeParamsConfig, ParamConfig} from '../utils/params/ParamsConfig';
import {TypedEventNode} from './_Base';
import {isBooleanTrue} from '../../../core/Type';
import {VisibleIfParamOptions} from '../../params/utils/OptionsController';
import ScrollTrigger from 'gsap/ScrollTrigger';
import {BaseNodeType} from '../_Base';
import {gsap} from '../../../core/thirdParty/gsap/gsap';
import {ModuleName} from '../../poly/registers/modules/Common';
import {gsapLib} from '../../../core/thirdParty/gsap/gsapFactory';
import {EventContext} from '../../../core/event/EventContextType';
gsap.registerPlugin(ScrollTrigger);
enum ScrollTriggerNodeInput {
CREATE = 'create',
DISPOSE = 'dispose',
}
enum ScrollTriggerNodeOutput {
TOGGLE = 'toggle',
ENTER = 'enter',
LEAVE = 'leave',
ENTER_BACK = 'enterBack',
LEAVE_BACK = 'leaveBack',
}
interface DefaultParamOptionsOptions {
visibleIf?: VisibleIfParamOptions;
}
const defaultParamOptions = (options?: DefaultParamOptionsOptions) => {
let visibleIf: VisibleIfParamOptions | undefined = options?.visibleIf;
if (visibleIf) {
visibleIf.active = 1;
} else {
visibleIf = {active: 1};
}
return {
visibleIf,
callback: (node: BaseNodeType) => {
ScrollTriggerEventNode.PARAM_CALLBACK_updateScrollTrigger(node as ScrollTriggerEventNode);
},
};
};
const UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS = defaultParamOptions();
class ScrollTriggerParamsConfig extends NodeParamsConfig {
/** @param active */
active = ParamConfig.BOOLEAN(true, {
callback: (node: BaseNodeType) => {
ScrollTriggerEventNode.PARAM_CALLBACK_updateScrollTrigger(node as ScrollTriggerEventNode);
},
});
/** @param selector of the element the scroll events are detected for */
element = ParamConfig.STRING('', UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param use viewport as scroller */
useViewport = ParamConfig.BOOLEAN(1, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param override the scroller */
scroller = ParamConfig.STRING('', defaultParamOptions({visibleIf: {useViewport: 0}}));
/** @param add markers for debugging */
markers = ParamConfig.BOOLEAN('!playerMode()', {
...UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS,
separatorAfter: true,
});
/** @param define if progress should be updated */
tprogress = ParamConfig.BOOLEAN(0, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param progress */
progress = ParamConfig.FLOAT(0, {
editable: false,
visibleIf: {tprogress: 1},
});
/** @param define if the scroll is inside the element */
tinsideElement = ParamConfig.BOOLEAN(0, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param 1 if the scroll is inside the element */
insideElement = ParamConfig.BOOLEAN(0, {
editable: false,
visibleIf: {tinsideElement: 1},
});
/** @param sends a trigger when we leaving or entering the element */
onToggle = ParamConfig.BOOLEAN(1, {
...UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS,
separatorBefore: true,
});
/** @param sends a trigger when entering the element */
onEnter = ParamConfig.BOOLEAN(0, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param sends a trigger when leaving the element */
onLeave = ParamConfig.BOOLEAN(0, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param sends a trigger when entering again the element */
onEnterBack = ParamConfig.BOOLEAN(0, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
/** @param sends a trigger when leaving again the element */
onLeaveBack = ParamConfig.BOOLEAN(0, UPDATE_SCROLL_TRIGGER_PARAM_OPTIONS);
}
const ParamsConfig = new ScrollTriggerParamsConfig();
export class ScrollTriggerEventNode extends TypedEventNode<ScrollTriggerParamsConfig> {
override paramsConfig = ParamsConfig;
public gsap = gsapLib(); // give access to gsap to external scripts
public ScrollTrigger = ScrollTrigger; // give access to ScrollTrigger to external scripts
static override type() {
return 'scrollTrigger';
}
override requiredModules() {
return [ModuleName.GSAP];
}
override dispose() {
this._disposeScrollTrigger();
super.dispose();
}
override initializeNode() {
this.io.inputs.setNamedInputConnectionPoints([
new EventConnectionPoint(
ScrollTriggerNodeInput.CREATE,
EventConnectionPointType.BASE,
this._onCreateTrigger.bind(this)
),
new EventConnectionPoint(
ScrollTriggerNodeInput.DISPOSE,
EventConnectionPointType.BASE,
this._onDisposeTrigger.bind(this)
),
]);
this.io.outputs.setNamedOutputConnectionPoints([
new EventConnectionPoint(ScrollTriggerNodeOutput.TOGGLE, EventConnectionPointType.BASE),
new EventConnectionPoint(ScrollTriggerNodeOutput.ENTER, EventConnectionPointType.BASE),
new EventConnectionPoint(ScrollTriggerNodeOutput.LEAVE, EventConnectionPointType.BASE),
new EventConnectionPoint(ScrollTriggerNodeOutput.ENTER_BACK, EventConnectionPointType.BASE),
new EventConnectionPoint(ScrollTriggerNodeOutput.LEAVE_BACK, EventConnectionPointType.BASE),
]);
}
private _scrollTrigger: globalThis.ScrollTrigger | undefined;
private _onCreateTrigger(eventContext: EventContext<Event>) {
this._disposeScrollTrigger();
if (!isBooleanTrue(this.pv.active)) {
return;
}
this.states.error.clear();
const element = this._querySelector(this.pv.element);
if (!element) {
return;
}
// If the scroller element is not found, this function returns undefined,
// and then the scroll container will be assumed to be the window by gsap.
// This can be handy in cases where we want to use a container in the editor,
// but want to use the window once deployed
const _getScroller = () => {
if (isBooleanTrue(this.pv.useViewport)) {
return;
}
const scrollerElement = this._querySelector(this.pv.scroller);
if (!scrollerElement) {
return;
}
return scrollerElement;
};
const options: ScrollTrigger.StaticVars = {
trigger: element,
scroller: _getScroller(),
markers: this.pv.markers,
id: this.path(),
};
if (isBooleanTrue(this.pv.tinsideElement) || isBooleanTrue(this.pv.onToggle)) {
const updateInside = (scrollTrigger: globalThis.ScrollTrigger) =>
this.p.insideElement.set(scrollTrigger.isActive);
const dispatchOnToggle = (scrollTrigger: globalThis.ScrollTrigger) =>
this.dispatchEventToOutput(ScrollTriggerNodeOutput.TOGGLE, {});
const functions: Array<(scrollTrigger: globalThis.ScrollTrigger) => void> = [];
if (isBooleanTrue(this.pv.tinsideElement)) {
functions.push(updateInside);
}
if (isBooleanTrue(this.pv.onToggle)) {
functions.push(dispatchOnToggle);
}
options.onToggle = (scrollTrigger) => {
for (const func of functions) {
func(scrollTrigger);
}
};
}
if (isBooleanTrue(this.pv.onEnter)) {
options.onEnter = () => this.dispatchEventToOutput(ScrollTriggerNodeOutput.ENTER, {});
}
if (isBooleanTrue(this.pv.onLeave)) {
options.onLeave = () => this.dispatchEventToOutput(ScrollTriggerNodeOutput.LEAVE, {});
}
if (isBooleanTrue(this.pv.onEnterBack)) {
options.onEnterBack = () => this.dispatchEventToOutput(ScrollTriggerNodeOutput.ENTER_BACK, {});
}
if (isBooleanTrue(this.pv.onLeaveBack)) {
options.onLeaveBack = () => this.dispatchEventToOutput(ScrollTriggerNodeOutput.LEAVE_BACK, {});
}
if (isBooleanTrue(this.pv.tprogress)) {
options.onUpdate = (scrollTrigger) => {
this.p.progress.set(scrollTrigger.progress);
};
}
this._scrollTrigger = ScrollTrigger.create(options);
}
private _updateScrollTrigger() {
if (!this._scrollTrigger) {
return;
}
this._onCreateTrigger({});
}
private _onDisposeTrigger(eventContext: EventContext<Event>) {
this._disposeScrollTrigger();
}
private _disposeScrollTrigger() {
if (!this._scrollTrigger) {
return;
}
this._scrollTrigger.kill();
}
private _querySelector(selector: string) {
const element = document.querySelector(selector);
if (!element) {
this.states.error.set(`element with selector '${selector}' not found`);
return;
}
return element;
}
static PARAM_CALLBACK_updateScrollTrigger(node: ScrollTriggerEventNode) {
node._updateScrollTrigger();
}
}