var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
// For firefox ViewTimeline support
import "scroll-timeline-polyfill/dist/scroll-timeline.js";
import { 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 } from "../../engine/engine_three_utils.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";
const debug = getParam("debugscroll");
/**
 * 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
 *   <div data-timeline-marker="0.0">Start of Timeline</div>
 *   <div data-timeline-marker="0.5">Middle of Timeline</div>
 *   <div data-timeline-marker="1.0">End of Timeline</div>
 * ```
 *
 * @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)
     */
    target = null;
    /**
     * Damping for the movement, set to 0 for instant movement
     * @default 0
     */
    damping = 0;
    /**
     * If true, the scroll value will be inverted (e.g. scrolling down will result in a value of 0)
     * @default false
     */
    invert = 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
     */
    htmlSelector = null;
    mode = "window";
    /**
     * Event fired when the scroll position changes
     */
    changed = new EventList();
    /**
     * Current scroll value in "pages" (0 = top of page, 1 = bottom of page)
     */
    get currentValue() {
        return this._current_value;
    }
    _current_value = 0;
    _target_value = 0;
    _appliedValue = -1;
    _needsUpdate = false;
    _firstUpdate = false;
    awake() {
        this._firstUpdate = true;
    }
    /** @internal */
    onEnable() {
        window.addEventListener("wheel", this.updateCurrentScrollValue, { passive: true });
        this._appliedValue = -1;
        this._needsUpdate = true;
    }
    /** @internal */
    onDisable() {
        window.removeEventListener("wheel", this.updateCurrentScrollValue);
    }
    /** @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 = {
                    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;
        }
    }
    _lastSelectorValue = null;
    _lastSelectorElement = null;
    updateCurrentScrollValue = () => {
        switch (this.mode) {
            case "window":
                if (this.htmlSelector?.length) {
                    if (this.htmlSelector !== this._lastSelectorValue) {
                        this._lastSelectorElement = document.querySelector(this.htmlSelector);
                        this._lastSelectorValue = this.htmlSelector;
                    }
                    if (this._lastSelectorElement) {
                        const rect = this._lastSelectorElement.getBoundingClientRect();
                        this._target_value = -rect.top / (rect.height - window.innerHeight);
                        break;
                    }
                }
                else {
                    if (window.document.body.scrollHeight <= window.innerHeight) {
                        // If the page is not scrollable we can still increment the scroll value to allow triggering timelines etc.
                    }
                    else {
                        const diff = window.document.body.scrollHeight - window.innerHeight;
                        this._target_value = window.scrollY / (diff || 1);
                    }
                }
                break;
        }
        if (isNaN(this._target_value) || !isFinite(this._target_value))
            this._target_value = -1;
    };
    applyScroll(target, value) {
        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;
            // 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"];
            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);
            }
        }
    }
    handleTimelineTarget(director, value) {
        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);
                        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 = document.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,
                        });
                    }
                    else if (isDevEnvironment() || debug) {
                        console.warn("[ScrollFollow] data-timeline-marker attribute is not a valid number. Supported are numbers only (e.g. <div data-timeline-marker=\"0.5\">)");
                    }
                });
            }
            // Init ViewTimeline for markers
            for (const marker of markersArray) {
                if (marker.element) {
                    // https://scroll-driven-animations.style/tools/view-timeline/ranges
                    /** @ts-ignore */
                    marker.timeline = new ViewTimeline({
                        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(", "));
            }
        }
    }
}
__decorate([
    serializable([Behaviour, Object3D])
], ScrollFollow.prototype, "target", void 0);
__decorate([
    serializable()
], ScrollFollow.prototype, "damping", void 0);
__decorate([
    serializable()
], ScrollFollow.prototype, "invert", void 0);
__decorate([
    serializable()
], ScrollFollow.prototype, "htmlSelector", void 0);
__decorate([
    serializable()
], ScrollFollow.prototype, "mode", void 0);
__decorate([
    serializable(EventList)
], ScrollFollow.prototype, "changed", void 0);
const timelineMarkerArrays = new WeakMap();
const weightsArray = [];
// type SelectorCache = {
//     /** The selector used to query the *elements */
//     selector: string,
//     elements: Element[] | null,
//     usedElementCount: number,
// }
// const querySelectorResults: Array<SelectorCache> = [];
const needleScrollMarkerCache = new Array();
let needsScrollMarkerRefresh = true;
function tryGetElementsForSelector(index) {
    if (!needsScrollMarkerRefresh) {
        const element = needleScrollMarkerCache[index] || null;
        return element;
    }
    needsScrollMarkerRefresh = false;
    needleScrollMarkerCache.length = 0;
    const markers = document.querySelectorAll(`[data-timeline-marker]`);
    markers.forEach((m, i) => {
        needleScrollMarkerCache[i] = m;
    });
    needsScrollMarkerRefresh = false;
    return tryGetElementsForSelector(index);
}
// #region ScrollTimeline
function calculateTimelinePositionNormalized(timeline) {
    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;
}
//# sourceMappingURL=ScrollFollow.js.map