// For firefox ViewTimeline support — dynamic import to avoid SSR crashes (polyfill accesses window at module level) if (typeof window !== "undefined") import("scroll-timeline-polyfill/dist/scroll-timeline.js"); // ───────────────────────────────────────────────────────────────────────────────────────────── // NOTE / TODO — ScrollMarker tracking vs. the parent window (useTopWindowScroll) // // The ScrollMarker feature (see handleTimelineTarget) currently relies on `ViewTimeline` to know // how far a marker element has travelled through the viewport. This import polyfills `ViewTimeline` // on THIS window only — it never patches the parent/top window. So when `useTopWindowScroll` is on // and we resolve markers against the host page (top document), tracking only works if the *host* // window also has `ViewTimeline`: // - Chromium host (native ViewTimeline) ............. works // - Firefox/Safari host without the polyfill ........ does NOT work → falls back to linear scroll // - cross-origin host ............................... impossible (no access to top.document at all) // We cannot reliably install the polyfill onto a (same-origin) parent from inside the iframe — it is // designed to patch its own realm at import time, and doing it cross-realm is fragile. // // POSSIBLE CHANGE: drop `ViewTimeline` entirely and compute the marker's viewport progress manually // with getBoundingClientRect, e.g. for the default "cover 0%→100%" range: // const rect = el.getBoundingClientRect(); // const vh = scroller.clientHeight; // top's viewport when useTopWindowScroll // const t01 = (vh - rect.top) / (vh + rect.height); // This is mathematically equivalent to the current ViewTimeline values (0 = entering from bottom, // 0.5 = centered, 1 = exited past top — matches the existing `1 - |t-0.5|*2` weighting). // PROS: works with top/parent content in every browser (same-origin); removes the // scroll-timeline-polyfill dependency and this SSR-risky top-level import; cheaper // (no ViewTimeline objects / WeakMap per marker). `ViewTimeline` is used ONLY in this file. // CONS: re-implements view-progress ourselves instead of leaning on the platform/polyfill, so any // future spec range features (cover/contain/entry/exit, named ranges) would have to be // reproduced by hand; needs careful re-testing against the existing scrollytelling demos. // NOTE: cross-origin embeds still cannot work either way — that needs a postMessage scroll bridge. // ───────────────────────────────────────────────────────────────────────────────────────────── import { Box3, Object3D } from "three"; import { isDevEnvironment } from "../../engine/debug/debug.js"; import { Mathf } from "../../engine/engine_math.js"; import { serializable } from "../../engine/engine_serialization.js"; import { getBoundingBox, setVisibleInCustomShadowRendering } from "../../engine/engine_three_utils.js"; import { validate } from "../../engine/engine_util_decorator.js"; import { getParam } from "../../engine/engine_utils.js"; import { Animation } from "../Animation.js"; import { Animator } from "../Animator.js"; import { AudioSource } from "../AudioSource.js"; import { Behaviour } from "../Component.js"; import { EventList } from "../EventList.js"; import { Light } from "../Light.js"; import { SplineWalker } from "../splines/SplineWalker.js"; import { PlayableDirector } from "../timeline/PlayableDirector.js"; import { ScrollMarkerModel } from "../timeline/TimelineModels.js"; const debug = getParam("debugscroll"); type ScrollFollowEvent = { /** Event type */ type: "change", /** Current scroll value */ value: number, /** ScrollFollow component that raised the event */ component: ScrollFollow, /** Call to prevent invocation of default (e.g. updating targets) */ preventDefault: () => void, defaultPrevented: boolean, } /** * The [ScrollFollow](https://engine.needle.tools/docs/api/ScrollFollow) component allows you to link the scroll position of the page (or a specific element) to one or more target objects. * This can be used to create scroll-based animations, audio playback, or other effects. For example you can link the scroll position to a timeline (PlayableDirector) to create scroll-based storytelling effects or to an Animator component to change the animation state based on scroll. * * ![](https://cloud.needle.tools/-/media/SYuH-vXxO4Jf30oU1HhjKQ.gif) * ![](https://cloud.needle.tools/-/media/RplmU_j7-xb8XHXkOzc9PA.gif) * * Assign {@link target} objects to the component to have them updated based on the current scroll position (check the 'target' property for supported types). * * @link Example at https://scrollytelling-2-z23hmxby7c6x-u30ld.needle.run/ * @link Template at https://github.com/needle-engine/scrollytelling-template * @link [Scrollytelling Bike Demo](https://scrollytelling-bike-z23hmxb2gnu5a.needle.run/) * * ## How to use with an Animator * 1. Create an Animator component and set up a float parameter named "scroll". * 2. Create transitions between animation states based on the "scroll" parameter (e.g. from 0 to 1). * 3. Add a ScrollFollow component to the same GameObject or another GameObject in the scene. * 4. Assign the Animator component to the ScrollFollow's target property. * * ## How to use with a PlayableDirector (timeline) * 1. Create a PlayableDirector component and set up a timeline asset. * 2. Add a ScrollFollow component to the same GameObject or another GameObject in the scene. * 3. Assign the PlayableDirector component to the ScrollFollow's target property. * 4. The timeline will now scrub based on the scroll position of the page. * 5. (Optional) Add ScrollMarker markers to your HTML to define specific points in the timeline that correspond to elements on the page. For example: * ```html *
Start of Timeline
*
Middle of Timeline
*
End of Timeline
* ``` * * @summary Links scroll position to target objects * @category Web * @category Interaction * @group Components * @component */ export class ScrollFollow extends Behaviour { /** * Target object(s) to follow the scroll position of the page. * * Supported target types: * - PlayableDirector (timeline), the scroll position will be mapped to the timeline time * - Animator, the scroll position will be set to a float parameter named "scroll" * - Animation, the scroll position will be mapped to the animation time * - AudioSource, the scroll position will be mapped to the audio time * - SplineWalker, the scroll position will be mapped to the position01 property * - Light, the scroll position will be mapped to the intensity property * - Object3D, the object will move vertically based on the scroll position * - Any object with a `scroll` property (number or function) */ @serializable([Behaviour, Object3D]) target: object[] | object | null = null; /** * Damping for the movement, set to 0 for instant movement * @default 0 */ @serializable() damping: number = 0; /** * If true, the scroll value will be inverted (e.g. scrolling down will result in a value of 0) * @default false */ @serializable() invert: boolean = false; /** * **Experimental - might change in future updates** * If set, the scroll position will be read from the specified element instead of the window. * Use a CSS selector to specify the element, e.g. `#my-scrollable-div` or `.scroll-container`. * @default null */ @serializable() htmlSelector: string | null = null; @serializable() mode: "window" = "window"; /** * Read the scroll position from the **top-level window** instead of the window Needle Engine * runs in. This matters when the canvas is embedded in an iframe: * - When the page is **not** embedded this has no effect — the top window *is* the current window. * - When embedded in a **same-origin** iframe, the component follows the *host page's* scroll, * which is almost always the intent (an embedded iframe usually isn't scrollable itself). * * Set this to `false` to always follow the engine's own window scroll, e.g. when the iframe * itself is scrollable and you want to follow its own scroll rather than the host's. * * If the top window is **cross-origin** (and therefore inaccessible), the component * transparently falls back to the local window and logs a warning in development — * cross-origin embeds would require a `postMessage` scroll bridge from the host page. * @default true */ @validate() @serializable() useTopWindowScroll: boolean = true; /** * Event fired when the scroll position changes */ @serializable(EventList) changed: EventList = new EventList(); /** * Current scroll value in "pages" (0 = top of page, 1 = bottom of page) */ get currentValue() { return this._current_value; } private _current_value: number = 0; private _target_value: number = 0; private _appliedValue: number = -1; private _needsUpdate = false; private _firstUpdate = false; /** The window whose scroll position currently drives this component (resolved from {@link useTopWindowScroll}). */ private _scrollWindow: Window | null = null; /** The window the `wheel` listener is attached to — kept so we remove it from the same window. */ private _wheelTarget: Window | null = null; private _crossOriginWarned = false; private _viewTimelineWarned = false; awake() { this._firstUpdate = true; } /** @internal */ onEnable() { this.rebindScrollListener(); this._appliedValue = -1; this._needsUpdate = true; } /** @internal */ onDisable() { this._wheelTarget?.removeEventListener("wheel", this.updateCurrentScrollValue); this._wheelTarget = null; } /** @internal */ onValidate(property?: string) { // React to a runtime change of `useTopWindowScroll` while the component is active. if (property === "useTopWindowScroll" && this._wheelTarget) { this.rebindScrollListener(); this._needsUpdate = true; } } /** * Resolves which browsing context's scroll position should drive this component. * Honors {@link useTopWindowScroll}. When the top window is requested but is cross-origin * (and therefore inaccessible) it falls back to the local window and warns once — * cross-origin embeds would need a `postMessage` scroll bridge from the host page. */ private resolveScrollWindow(): Window { if (!this.useTopWindowScroll) return window; const top = window.top; // Not embedded, or we already *are* the top window. if (!top || top === window.self) return window; try { // Any property access on a cross-origin top window throws a SecurityError. void top.document; return top; } catch (_e) { if (!this._crossOriginWarned) { this._crossOriginWarned = true; if (debug || isDevEnvironment()) { console.warn("[ScrollFollow] useTopWindowScroll is enabled but the top-level window is cross-origin and cannot be read. Falling back to the local window. Supporting cross-origin embeds requires a postMessage scroll bridge from the host page."); } } return window; } } /** (Re)resolves the scroll window and moves the `wheel` listener onto it. */ private rebindScrollListener() { const next = this.resolveScrollWindow(); this._scrollWindow = next; if (next === this._wheelTarget) return; this._wheelTarget?.removeEventListener("wheel", this.updateCurrentScrollValue); this._wheelTarget = next; next.addEventListener("wheel", this.updateCurrentScrollValue, { passive: true }); // The selector cache may point into the previous document — invalidate it. this._lastSelectorValue = null; this._lastSelectorElement = null; if(debug) { console.debug(`[ScrollFollow] Scroll listener bound to ${next === window ? "local window" : "top-level window"}`, { scrollWindow: this._scrollWindow, isTopWindow: next === window, }); } } /** @internal */ lateUpdate() { this.updateCurrentScrollValue(); if (this._target_value >= 0) { if (this.damping > 0 && !this._firstUpdate) { // apply damping this._current_value = Mathf.lerp(this._current_value, this._target_value, this.context.time.deltaTime / this.damping); if (Math.abs(this._current_value - this._target_value) < 0.001) { this._current_value = this._target_value; } } else { this._current_value = this._target_value; } } if (this._needsUpdate || this._current_value !== this._appliedValue) { this._appliedValue = this._current_value; this._needsUpdate = false; let defaultPrevented = false; if (this.changed.listenerCount > 0) { // fire change event const event: ScrollFollowEvent = { type: "change", value: this._current_value, component: this, preventDefault: () => { event.defaultPrevented = true; }, defaultPrevented: false, }; this.changed.invoke(event); defaultPrevented = event.defaultPrevented; } // if not prevented apply scroll if (!defaultPrevented) { const value = this.invert ? 1 - this._current_value : this._current_value; // apply scroll to target(s) if (Array.isArray(this.target)) { this.target.forEach(t => t && this.applyScroll(t, value)); } else if (this.target) { this.applyScroll(this.target, value); } if (debug && this.context.time.frame % 30 === 0) { console.debug(`[ScrollFollow] ${this._current_value.toFixed(5)} — ${(this._target_value * 100).toFixed(0)}%, targets [${Array.isArray(this.target) ? this.target.length : 1}]`); } } this._firstUpdate = false; } } private _lastSelectorValue: string | null = null; private _lastSelectorElement: Element | null = null; private updateCurrentScrollValue = () => { const win = this._scrollWindow ?? window; const doc = win.document; // The page's scrollable extent lives on the root scrolling element (documentElement in // standards mode, body in quirks mode) — document.body.scrollHeight is unreliable and is // often just the viewport height even when the page is scrolled. scrollingElement resolves // to the correct one for the current document mode. const scroller = doc.scrollingElement ?? doc.documentElement; switch (this.mode) { case "window": if (this.htmlSelector?.length) { if (this.htmlSelector !== this._lastSelectorValue) { this._lastSelectorElement = doc.querySelector(this.htmlSelector); this._lastSelectorValue = this.htmlSelector; } if (this._lastSelectorElement) { const rect = this._lastSelectorElement.getBoundingClientRect(); this._target_value = -rect.top / (rect.height - win.innerHeight); break; } } else { const maxScroll = scroller.scrollHeight - scroller.clientHeight; if (maxScroll <= 0) { // Page is not scrollable — keep the current value (still allows triggering timelines etc.). } else { this._target_value = scroller.scrollTop / maxScroll; } } break; } if (isNaN(this._target_value) || !isFinite(this._target_value)) this._target_value = -1; if (debug) { console.debug(`[ScrollFollow] Scroll value updated: ${this._target_value.toFixed(5)} (ScrollTop: ${scroller.scrollTop}, ScrollHeight: ${scroller.scrollHeight}, ClientHeight: ${scroller.clientHeight})`); } } private applyScroll(target: object, value: number) { if (!target) return; if (target instanceof PlayableDirector) { this.handleTimelineTarget(target, value); if (target.isPlaying) target.pause(); target.evaluate(); } else if (target instanceof Animator) { target.setFloat("scroll", value); } else if (target instanceof Animation) { target.time = value * target.duration; } else if (target instanceof AudioSource) { if (!target.duration) return; target.time = value * target.duration; } else if (target instanceof SplineWalker) { target.position01 = value; } else if (target instanceof Light) { target.intensity = value; } else if (target instanceof Object3D) { const t = target as any; // When objects are assigned they're expected to move vertically based on scroll if (t["needle:scrollbounds"] === undefined) { t["needle:scrollbounds"] = getBoundingBox(target) || null; } const bounds = t["needle:scrollbounds"] as Box3; if (bounds) { // TODO: remap position to use upper screen edge and lower edge instead of center target.position.y = -bounds.min.y - value * (bounds.max.y - bounds.min.y); } } else if ("scroll" in target) { if (typeof target.scroll === "number") { target.scroll = value; } else if (typeof target.scroll === "function") { target.scroll(value); } } } private handleTimelineTarget(director: PlayableDirector, value: number) { // Resolve markers against the same browsing context we read scroll from (see useTopWindowScroll). const scrollWin = this._scrollWindow ?? window; const doc = scrollWin.document; const duration = director.duration; let markersArray = timelineMarkerArrays.get(director); // Create markers array if (!markersArray) { markersArray = []; timelineMarkerArrays.set(director, markersArray); let markerIndex = 0; for (const marker of director.foreachMarker("ScrollMarker")) { const index = markerIndex++; // Get marker elements from DOM if ((marker.element === undefined || marker.needsUpdate === true || /** element is not in DOM anymore? */ (marker.element && !marker.element?.parentNode))) { marker.needsUpdate = false; try { // TODO: with this it's currently not possible to remap markers from HTML. For example if I have two sections and I want to now use the marker["center"] multiple times to stay at that marker for a longer time marker.element = tryGetElementsForSelector(index, doc) as HTMLElement | null; if (debug) console.debug(`ScrollMarker #${index} (${marker.time.toFixed(2)}) found`, marker.element); if (!marker.element) { if (debug || isDevEnvironment()) console.warn(`No HTML element found for ScrollMarker: ${marker.name} (index ${index})`); continue; } } catch (error) { marker.element = null; console.error("ScrollMarker selector is not valid: " + marker.name + "\n", error); } } // skip markers without element (e.g. if the selector didn't return any element) if (!marker.element) continue; markersArray.push(marker); } // If the timeline has no markers defined we can use timeline-marker elements in the DOM. These must define times then if (markersArray.length <= 0) { const markers = doc.querySelectorAll(`[data-timeline-marker]`); markers.forEach((element) => { const value = element.getAttribute("data-timeline-marker"); const time = parseFloat(value || ("NaN")); if (!isNaN(time)) { markersArray!.push({ time, element: element as HTMLElement, }); } else if (isDevEnvironment() || debug) { console.warn("[ScrollFollow] data-timeline-marker attribute is not a valid number. Supported are numbers only (e.g.
)"); } }); } // Init ViewTimeline for markers. Use the constructor from the *owning* window so that // markers living in a same-origin host page (useTopWindowScroll) are tracked in the // correct realm rather than against the iframe's own (non-scrolling) document. const ViewTimelineCtor = (scrollWin as unknown as { ViewTimeline?: new (opts: { subject: Element, axis: string }) => ViewTimeline }).ViewTimeline; if (!ViewTimelineCtor) { // ViewTimeline isn't available on the target window yet. This is expected transiently // on the "self" path while the async scroll-timeline polyfill is still loading, and // permanently when useTopWindowScroll targets a host page without ViewTimeline support. // Don't persist the half-built marker set — drop the cache so it's rebuilt next frame // (self-heals once the polyfill loads), and fall back to linear scrubbing for now. if (!this._viewTimelineWarned && (debug || isDevEnvironment())) { this._viewTimelineWarned = true; console.warn("[ScrollFollow] ViewTimeline is not available on the target window — falling back to linear scroll. If this persists with useTopWindowScroll, the scroll-timeline polyfill must be present on the host page."); } timelineMarkerArrays.delete(director); markersArray.length = 0; } else { for (const marker of markersArray) { if (marker.element) { // https://scroll-driven-animations.style/tools/view-timeline/ranges marker.timeline = new ViewTimelineCtor({ subject: marker.element, axis: 'block', // https://drafts.csswg.org/scroll-animations/#scroll-notation }); } } } } weightsArray.length = 0; let sum = 0; const oneFrameTime = 1 / 60; // We keep a separate count here in case there are some markers that could not be resolved so point to *invalid* elements - the timeline should fallback to 0-1 scroll behaviour then let markerCount = 0; for (let i = 0; i < markersArray.length; i++) { const marker = markersArray[i]; if (!marker.element) continue; const nextMarker = markersArray[i + 1]; const nextTime = nextMarker ? (nextMarker.time - oneFrameTime) : duration; markerCount += 1; const timeline = marker.timeline; if (timeline) { const time01 = calculateTimelinePositionNormalized(timeline); // remap 0-1 to 0 - 1 - 0 (full weight at center) const weight = 1 - Math.abs(time01 - 0.5) * 2; const name = `marker${i}`; if (time01 > 0 && time01 <= 1) { const lerpTime = marker.time + (nextTime - marker.time) * time01; weightsArray.push({ name, time: lerpTime, weight: weight }); sum += weight; } // Before the first marker is reached else if (i === 0 && time01 <= 0) { weightsArray.push({ name, time: 0, weight: 1 }); sum += 1; } // After the last marker is reached else if (i === markersArray.length - 1 && time01 >= 1) { weightsArray.push({ name, time: duration, weight: 1 }); sum += 1; } } } if (weightsArray.length <= 0 && markerCount <= 0) { director.time = value * duration; } else if (weightsArray.length > 0) { // normalize and calculate weighted time let time = weightsArray[0].time; // fallback to first time if (weightsArray.length > 1) { for (const entry of weightsArray) { const weight = entry.weight / Math.max(0.00001, sum); // console.log(weight.toFixed(2)) // lerp time based on weight const diff = Math.abs(entry.time - time); time += diff * weight; } } if (this.damping <= 0 || this._firstUpdate) { director.time = time; } else { director.time = Mathf.lerp(director.time, time, this.context.time.deltaTime / this.damping); } const delta = Math.abs(director.time - time); if (delta > .001) { // if the time is > 1/100th of a second off we need another update this._needsUpdate = true; } if (debug && this.context.time.frame % 30 === 0) { console.log(`[ScrollFollow ] Timeline ${director.name}: ${time.toFixed(3)}`, weightsArray.map(w => `[${w.name} ${(w.weight * 100).toFixed(0)}%]`).join(", ")); } } } } const timelineMarkerArrays: WeakMap > = new WeakMap(); type OverlapInfo = { name: string, /** Marker time */ time: number, /** Overlap in pixels */ weight: number, } const weightsArray: OverlapInfo[] = []; // type SelectorCache = { // /** The selector used to query the *elements */ // selector: string, // elements: Element[] | null, // usedElementCount: number, // } // const querySelectorResults: Array = []; const needleScrollMarkerCache = new Array(); let needsScrollMarkerRefresh = true; function tryGetElementsForSelector(index: number, doc: Document): Element | null { if (!needsScrollMarkerRefresh) { const element = needleScrollMarkerCache[index] || null; return element; } needsScrollMarkerRefresh = false; needleScrollMarkerCache.length = 0; const markers = doc.querySelectorAll(`[data-timeline-marker]`); markers.forEach((m, i) => { needleScrollMarkerCache[i] = m; }); needsScrollMarkerRefresh = false; return tryGetElementsForSelector(index, doc); } // #region ScrollTimeline function calculateTimelinePositionNormalized(timeline: ViewTimeline) { if (!timeline.source) return 0; const currentTime = timeline.currentTime; const duration = timeline.duration; let durationValue = 1; if (duration.unit === "seconds") { durationValue = duration.value; } else if (duration.unit === "percent") { durationValue = duration.value; } const t01 = currentTime.unit === "seconds" ? (currentTime.value / durationValue) : (currentTime.value / 100); return t01; } declare global { interface ViewTimeline { axis: 'block' | 'inline'; currentTime: { unit: 'seconds' | 'percent', value: number }; duration: { unit: 'seconds' | 'percent', value: number }; source: Element | null; } }