scroll-timeline-polyfill
Version:
A polyfill for scroll-driven animations on the web via ScrollTimeline
1,331 lines (1,148 loc) • 73.4 kB
JavaScript
import {
ANIMATION_RANGE_NAMES,
ScrollTimeline,
addAnimation,
removeAnimation,
fractionalOffset,
} from "./scroll-timeline-base";
import {splitIntoComponentValues} from './utils';
import {simplifyCalculation} from './simplify-calculation';
const nativeDocumentGetAnimations = document.getAnimations;
const nativeElementGetAnimations = window.Element.prototype.getAnimations;
const nativeElementAnimate = window.Element.prototype.animate;
const nativeAnimation = window.Animation;
class PromiseWrapper {
constructor() {
this.state = 'pending';
this.nativeResolve = this.nativeReject = null;
this.promise = new Promise((resolve, reject) => {
this.nativeResolve = resolve;
this.nativeReject = reject;
});
}
resolve(value) {
this.state = 'resolved';
this.nativeResolve(value);
}
reject(reason) {
this.state = 'rejected';
// Do not report unhandled promise rejections.
this.promise.catch(() => {});
this.nativeReject(reason);
}
}
function createReadyPromise(details) {
details.readyPromise = new PromiseWrapper();
// Trigger the pending task on the next animation frame.
requestAnimationFrame(() => {
const timelineTime = details.timeline?.currentTime ?? null;
if (timelineTime === null) {
return
}
// Run auto align start time procedure, in case measurements are ready
autoAlignStartTime(details);
if (details.pendingTask === 'play' && (details.startTime !== null || details.holdTime !== null)) {
commitPendingPlay(details);
} else if (details.pendingTask === 'pause') {
commitPendingPause(details);
}
});
}
function createAbortError() {
return new DOMException("The user aborted a request", "AbortError");
}
// Converts a time from its internal representation to a percent. For a
// monotonic timeline, time is reported as a double with implicit units of
// milliseconds. For progress-based animations, times are reported as
// percentages.
function toCssNumberish(details, value) {
if (value === null)
return value;
if (typeof value !== 'number') {
throw new DOMException(
`Unexpected value: ${value}. Cannot convert to CssNumberish`,
"InvalidStateError");
}
const rangeDuration = details.rangeDuration ?? 100;
const limit = effectEnd(details);
const percent = limit ? rangeDuration * value / limit : 0;
return CSS.percent(percent);
}
// Covnerts a time to its internal representation. Progress-based animations
// use times expressed as percentages. Each progress-based animation is backed
// by a native animation with a document timeline in the polyfill. Thus, we
// need to convert the timing from percent to milliseconds with implicit units.
function fromCssNumberish(details, value) {
if (!details.timeline) {
// Document timeline
if (value == null || typeof value === 'number')
return value;
const convertedTime = value.to('ms');
if (convertedTime)
return convertedTime.value;
throw new DOMException(
"CSSNumericValue must be either a number or a time value for " +
"time based animations.",
"InvalidStateError");
} else {
// Scroll timeline.
if (value === null)
return value;
if (value.unit === 'percent') {
const rangeDuration = details.rangeDuration ?? 100;
const duration = effectEnd(details);
return value.value * duration / rangeDuration;
}
throw new DOMException(
"CSSNumericValue must be a percentage for progress based animations.",
"NotSupportedError");
}
}
function normalizedTiming(details) {
// Used normalized timing in the case of a progress-based animation or
// specified timing with a document timeline. The normalizedTiming property
// is initialized and cached when fetching the timing information.
const timing = details.proxy.effect.getTiming();
return details.normalizedTiming || timing;
}
function commitPendingPlay(details) {
// https://drafts4.csswg.org/web-animations-2/#playing-an-animation-section
// Refer to steps listed under "Schedule a task to run ..."
const timelineTime = fromCssNumberish(details, details.timeline.currentTime);
if (details.holdTime != null) {
// A: If animation’s hold time is resolved,
// A.1. Apply any pending playback rate on animation.
// A.2. Let new start time be the result of evaluating:
// ready time - hold time / playback rate for animation.
// If the playback rate is zero, let new start time be simply ready
// time.
// A.3. Set the start time of animation to new start time.
// A.4. If animation’s playback rate is not 0, make animation’s hold
// time unresolved.
applyPendingPlaybackRate(details);
if (details.animation.playbackRate == 0) {
details.startTime = timelineTime;
} else {
details.startTime
= timelineTime -
details.holdTime / details.animation.playbackRate;
details.holdTime = null;
}
} else if (details.startTime !== null &&
details.pendingPlaybackRate !== null) {
// B: If animation’s start time is resolved and animation has a pending
// playback rate,
// B.1. Let current time to match be the result of evaluating:
// (ready time - start time) × playback rate for animation.
// B.2 Apply any pending playback rate on animation.
// B.3 If animation’s playback rate is zero, let animation’s hold time
// be current time to match.
// B.4 Let new start time be the result of evaluating:
// ready time - current time to match / playback rate
// for animation.
// If the playback rate is zero, let new start time be simply ready
// time.
// B.5 Set the start time of animation to new start time.
const currentTimeToMatch =
(timelineTime - details.startTime) * details.animation.playbackRate;
applyPendingPlaybackRate(details);
const playbackRate = details.animation.playbackRate;
if (playbackRate == 0) {
details.holdTime = null;
details.startTime = timelineTime;
} else {
details.startTime = timelineTime - currentTimeToMatch / playbackRate;
}
}
// 8.4 Resolve animation’s current ready promise with animation.
if (details.readyPromise && details.readyPromise.state == 'pending')
details.readyPromise.resolve(details.proxy);
// 8.5 Run the procedure to update an animation’s finished state for
// animation with the did seek flag set to false, and the
// synchronously notify flag set to false.
updateFinishedState(details, false, false);
// Additional polyfill step to update the native animation's current time.
syncCurrentTime(details);
details.pendingTask = null;
};
function commitPendingPause(details) {
// https://www.w3.org/TR/web-animations-1/#pausing-an-animation-section
// Refer to steps listed under "Schedule a task to run ..."
// 1. Let ready time be the time value of the timeline associated with
// animation at the moment when the user agent completed processing
// necessary to suspend playback of animation’s target effect.
const readyTime = fromCssNumberish(details, details.timeline.currentTime);
// 2. If animation’s start time is resolved and its hold time is not
// resolved, let animation’s hold time be the result of evaluating
// (ready time - start time) × playback rate.
if (details.startTime != null && details.holdTime == null) {
details.holdTime =
(readyTime - details.startTime) * details.animation.playbackRate;
}
// 3. Apply any pending playback rate on animation.
applyPendingPlaybackRate(details);
// 4. Make animation’s start time unresolved.
details.startTime = null;
// 5. Resolve animation’s current ready promise with animation.
details.readyPromise.resolve(details.proxy);
// 6. Run the procedure to update an animation’s finished state for
// animation with the did seek flag set to false, and the synchronously
// notify flag set to false.
updateFinishedState(details, false, false);
// Additional polyfill step to update the native animation's current time.
syncCurrentTime(details);
details.pendingTask = null;
};
function commitFinishedNotification(details) {
if (!details.finishedPromise || details.finishedPromise.state != 'pending')
return;
if (details.proxy.playState != 'finished')
return;
details.finishedPromise.resolve(details.proxy);
details.animation.pause();
// Event times are speced as doubles in web-animations-1.
// Cannot dispatch a proxy to an event since the proxy is not a fully
// transparent replacement. As a workaround, use a custom event and inject
// the necessary getters.
const finishedEvent =
new CustomEvent('finish',
{ detail: {
currentTime: details.proxy.currentTime,
timelineTime: details.proxy.timeline.currentTime
}});
Object.defineProperty(finishedEvent, 'currentTime', {
get: function() { return this.detail.currentTime; }
});
Object.defineProperty(finishedEvent, 'timelineTime', {
get: function() { return this.detail.timelineTime; }
});
requestAnimationFrame(() => {
queueMicrotask(() => {
details.animation.dispatchEvent(finishedEvent);
});
});
}
function effectivePlaybackRate(details) {
if (details.pendingPlaybackRate !== null)
return details.pendingPlaybackRate;
return details.animation.playbackRate;
}
function applyPendingPlaybackRate(details) {
if (details.pendingPlaybackRate !== null) {
details.animation.playbackRate = details.pendingPlaybackRate;
details.pendingPlaybackRate = null;
}
}
/**
* Procedure to silently set the current time of an animation to seek time
* https://drafts.csswg.org/web-animations-2/#silently-set-the-current-time
* @param details
* @param {CSSUnitValue} seekTime
*/
function silentlySetTheCurrentTime(details, seekTime) {
// The procedure to silently set the current time of an animation, animation, to seek time is as follows:
// 1. If seek time is an unresolved time value, then perform the following steps.
// 1. If the current time is resolved, then throw a TypeError.
// 2. Abort these steps.
if (seekTime == null) {
if (details.currentTime !== null) {
throw new TypeError();
}
}
// 2. Let valid seek time be the result of running the validate a CSSNumberish time procedure with seek time as the input.
// 3. If valid seek time is false, abort this procedure.
seekTime = fromCssNumberish(details, seekTime);
// 4. Set auto align start time to false.
details.autoAlignStartTime = false;
// 5. Update either animation’s hold time or start time as follows:
//
// 5a If any of the following conditions are true:
// - animation’s hold time is resolved, or
// - animation’s start time is unresolved, or
// - animation has no associated timeline or the associated timeline is inactive, or
// - animation’s playback rate is 0,
// 1. Set animation’s hold time to seek time.
//
// 5b Otherwise,
// Set animation’s start time to the result of evaluating timeline time - (seek time / playback rate) where
// timeline time is the current time value of timeline associated with animation.
if (details.holdTime !== null || details.startTime === null ||
details.timeline.phase === 'inactive' || details.animation.playbackRate === 0) {
details.holdTime = seekTime;
} else {
details.startTime =
fromCssNumberish(details, details.timeline.currentTime) - seekTime / details.animation.playbackRate;
}
// 6. If animation has no associated timeline or the associated timeline is inactive, make animation’s start time
// unresolved.
// This preserves the invariant that when we don’t have an active timeline it is only possible to set either the
// start time or the animation’s current time.
if (details.timeline.phase === 'inactive') {
details.startTime = null;
}
// 7. Make animation’s previous current time unresolved.
details.previousCurrentTime = null
}
function calculateCurrentTime(details) {
if (!details.timeline)
return null;
const timelineTime = fromCssNumberish(details, details.timeline.currentTime);
if (timelineTime === null)
return null;
if (details.startTime === null)
return null;
let currentTime =
(timelineTime - details.startTime) * details.animation.playbackRate;
// Handle special case.
if (currentTime == -0)
currentTime = 0;
return currentTime;
}
function calculateStartTime(details, currentTime) {
if (!details.timeline)
return null;
const timelineTime = fromCssNumberish(details, details.timeline.currentTime);
if (timelineTime == null)
return null;
return timelineTime - currentTime / details.animation.playbackRate;
}
function updateFinishedState(details, didSeek, synchronouslyNotify) {
if (!details.timeline)
return;
// https://www.w3.org/TR/web-animations-1/#updating-the-finished-state
// 1. Calculate the unconstrained current time. The dependency on did_seek is
// required to accommodate timelines that may change direction. Without this
// distinction, a once-finished animation would remain finished even when its
// timeline progresses in the opposite direction.
let unconstrainedCurrentTime =
didSeek ? fromCssNumberish(details, details.proxy.currentTime)
: calculateCurrentTime(details);
// 2. Conditionally update the hold time.
if (unconstrainedCurrentTime && details.startTime != null &&
!details.proxy.pending) {
// Can seek outside the bounds of the active effect. Set the hold time to
// the unconstrained value of the current time in the event that this update
// is the result of explicitly setting the current time and the new time
// is out of bounds. An update due to a time tick should not snap the hold
// value back to the boundary if previously set outside the normal effect
// boundary. The value of previous current time is used to retain this
// value.
const playbackRate = effectivePlaybackRate(details);
const upperBound = effectEnd(details);
let boundary = details.previousCurrentTime;
if (playbackRate > 0 && unconstrainedCurrentTime >= upperBound &&
details.previousCurrentTime != null) {
if (boundary === null || boundary < upperBound)
boundary = upperBound;
details.holdTime = didSeek ? unconstrainedCurrentTime : boundary;
} else if (playbackRate < 0 && unconstrainedCurrentTime <= 0) {
if (boundary == null || boundary > 0)
boundary = 0;
details.holdTime = didSeek ? unconstrainedCurrentTime : boundary;
} else if (playbackRate != 0) {
// Update start time and reset hold time.
if (didSeek && details.holdTime !== null)
details.startTime = calculateStartTime(details, details.holdTime);
details.holdTime = null;
}
}
// Additional step to ensure that the native animation has the same value for
// current time as the proxy.
syncCurrentTime(details);
// 3. Set the previous current time.
details.previousCurrentTime = fromCssNumberish(details,
details.proxy.currentTime);
// 4. Set the current finished state.
const playState = details.proxy.playState;
if (playState == 'finished') {
if (!details.finishedPromise)
details.finishedPromise = new PromiseWrapper();
if (details.finishedPromise.state == 'pending') {
// 5. Setup finished notification.
if (synchronouslyNotify) {
commitFinishedNotification(details);
} else {
Promise.resolve().then(() => {
commitFinishedNotification(details);
});
}
}
} else {
// 6. If not finished but the current finished promise is already resolved,
// create a new promise.
if (details.finishedPromise &&
details.finishedPromise.state == 'resolved') {
details.finishedPromise = new PromiseWrapper();
}
if (details.animation.playState != 'paused')
details.animation.pause();
}
}
function effectEnd(details) {
// https://www.w3.org/TR/web-animations-1/#end-time
const timing = normalizedTiming(details);
const totalDuration =
timing.delay + timing.endDelay + timing.iterations * timing.duration;
return Math.max(0, totalDuration);
}
function hasActiveTimeline(details) {
return !details.timeline || details.timeline.phase != 'inactive';
}
function syncCurrentTime(details) {
if (!details.timeline)
return;
if (details.startTime !== null) {
const timelineTime = details.timeline.currentTime;
if (timelineTime == null)
return;
const timelineTimeMs = fromCssNumberish(details, timelineTime);
setNativeCurrentTime(details,
(timelineTimeMs - details.startTime) *
details.animation.playbackRate);
} else if (details.holdTime !== null) {
setNativeCurrentTime(details, details.holdTime);
}
}
// Sets the time of the underlying animation, nudging the time slightly if at
// a scroll-timeline boundary to remain in the active phase.
function setNativeCurrentTime(details, time) {
const timeline = details.timeline;
const playbackRate = details.animation.playbackRate;
const atScrollTimelineBoundary =
timeline.currentTime &&
timeline.currentTime.value == (playbackRate < 0 ? 0 : 100);
const delta =
atScrollTimelineBoundary ? (playbackRate < 0 ? 0.001 : -0.001) : 0;
details.animation.currentTime = time + delta;
}
function resetPendingTasks(details) {
// https://www.w3.org/TR/web-animations-1/#reset-an-animations-pending-tasks
// 1. If animation does not have a pending play task or a pending pause task,
// abort this procedure.
if (!details.pendingTask)
return;
// 2. If animation has a pending play task, cancel that task.
// 3. If animation has a pending pause task, cancel that task.
details.pendingTask = null;
// 4. Apply any pending playback rate on animation.
applyPendingPlaybackRate(details);
// 5. Reject animation’s current ready promise with a DOMException named
// "AbortError".
details.readyPromise.reject(createAbortError());
// 6. Let animation’s current ready promise be the result of creating a new
// resolved Promise object.
createReadyPromise(details);
details.readyPromise.resolve(details.proxy);
}
function playInternal(details, autoRewind) {
if (!details.timeline)
return;
// https://drafts.csswg.org/web-animations/#playing-an-animation-section.
// 1. Let aborted pause be a boolean flag that is true if animation has a
// pending pause task, and false otherwise.
const abortedPause =
details.proxy.playState == 'paused' && details.proxy.pending;
// 2. Let has pending ready promise be a boolean flag that is initially
// false.
let hasPendingReadyPromise = false;
// 3. Let has finite timeline be true if animation has an associated
// timeline that is not monotonically increasing.
// Note: this value will always true at this point in the polyfill.
// Following steps are pruned based on the procedure for scroll
// timelines.
//
// 4. Let previous current time be the animation’s current time
//
// 5. Let enable seek be true if the auto-rewind flag is true and has finite timeline is false.
// Otherwise, initialize to false.
//
// 6. Perform the steps corresponding to the first matching condition from
// the following, if any:
//
// 6a If animation’s effective playback rate > 0, enable seek is
// true and either animation’s:
// previous current time is unresolved, or
// previous current time < zero, or
// previous current time >= associated effect end,
// 6a1. Set the animation’s hold time to zero.
//
// 6b If animation’s effective playback rate < 0, enable seek is
// true and either animation’s:
// previous current time is unresolved, or
// previous current time is ≤ zero, or
// previous current time is > associated effect end,
// 6b1. If associated effect end is positive infinity,
// throw an "InvalidStateError" DOMException and abort these steps.
// 6b2. Otherwise,
// 5b2a Set the animation’s hold time to the animation’s associated effect end.
//
// 6c If animation’s effective playback rate = 0 and animation’s current time
// is unresolved,
// 6c1. Set the animation’s hold time to zero.
let previousCurrentTime = fromCssNumberish(details,
details.proxy.currentTime);
const playbackRate = effectivePlaybackRate(details);
if (playbackRate == 0 && previousCurrentTime == null) {
details.holdTime = 0;
}
// 7. If has finite timeline and previous current time is unresolved:
// Set the flag auto align start time to true.
// NOTE: If play is called for a CSS animation during style update, the animation’s start time cannot be reliably
// calculated until post layout since the start time is to align with the start or end of the animation range
// (depending on the playback rate). In this case, the animation is said to have an auto-aligned start time,
// whereby the start time is automatically adjusted as needed to align the animation’s progress to the
// animation range.
if (previousCurrentTime == null) {
details.autoAlignStartTime = true;
}
// Not by spec, but required by tests in play-animation.html:
// - Playing a finished animation restarts the animation aligned at the start
// - Playing a pause-pending but previously finished animation realigns with the scroll position
// - Playing a finished animation clears the start time
if (details.proxy.playState === 'finished' || abortedPause) {
details.holdTime = null
details.startTime = null
details.autoAlignStartTime = true;
}
// 8. If animation's hold time is resolved, let its start time be
// unresolved.
if (details.holdTime) {
details.startTime = null;
}
// 9. If animation has a pending play task or a pending pause task,
// 9.1 Cancel that task.
// 9.2 Set has pending ready promise to true.
if (details.pendingTask) {
details.pendingTask = null;
hasPendingReadyPromise = true;
}
// 10. If the following three conditions are all satisfied:
// animation’s hold time is unresolved, and
// aborted pause is false, and
// animation does not have a pending playback rate,
// abort this procedure.
// Additonal check for polyfill: Does not have the auto align start time flag set.
// If we return when this flag is set, a play task will not be scheduled, leaving the animation in the
// idle state. If the animation is in the idle state, the auto align procedure will bail.
// TODO: update with results of https://github.com/w3c/csswg-drafts/issues/9871
if (details.holdTime === null && !details.autoAlignStartTime &&
!abortedPause && details.pendingPlaybackRate === null)
return;
// 11. If has pending ready promise is false, let animation’s current ready
// promise be a new promise in the relevant Realm of animation.
if (details.readyPromise && !hasPendingReadyPromise)
details.readyPromise = null;
// Additional polyfill step to ensure that the native animation has the
// correct value for current time.
syncCurrentTime(details);
// 12. Schedule a task to run as soon as animation is ready.
if (!details.readyPromise)
createReadyPromise(details);
details.pendingTask = 'play';
// Additional step for the polyfill.
// This must run after setting up the ready promise, otherwise we will run
// the procedure for calculating auto aligned start time before play state is running
addAnimation(details.timeline, details.animation,
tickAnimation.bind(details.proxy));
// 13. Run the procedure to update an animation’s finished state for animation
// with the did seek flag set to false, and the synchronously notify flag
// set to false.
updateFinishedState(details, /* seek */ false, /* synchronous */ false);
}
function tickAnimation(timelineTime) {
const details = proxyAnimations.get(this);
if (!details) return;
if (timelineTime == null) {
// While the timeline is inactive, it's effect should not be applied.
// To polyfill this behavior, we cancel the underlying animation.
if (details.proxy.playState !== 'paused' && details.animation.playState != 'idle')
details.animation.cancel();
return;
}
// When updating timeline current time, the start time of any attached animation is conditionally updated. For each
// attached animation, run the procedure for calculating an auto-aligned start time.
autoAlignStartTime(details);
if (details.pendingTask) {
// Commit pending tasks asynchronously if they are ready after aligning start time
requestAnimationFrame(() => {
if (details.pendingTask === 'play' && (details.startTime !== null || details.holdTime !== null)) {
commitPendingPlay(details);
} else if (details.pendingTask === 'pause') {
commitPendingPause(details);
}
});
}
const playState = this.playState;
if (playState == 'running' || playState == 'finished') {
const timelineTimeMs = fromCssNumberish(details, timelineTime);
setNativeCurrentTime(
details,
(timelineTimeMs - fromCssNumberish(details, this.startTime)) *
this.playbackRate);
updateFinishedState(details, false, false);
}
}
function renormalizeTiming(details) {
// Force renormalization.
details.specifiedTiming = null;
}
function createProxyEffect(details) {
const effect = details.animation.effect;
const nativeUpdateTiming = effect.updateTiming;
// Generic pass-through handler for any method or attribute that is not
// explicitly overridden.
const handler = {
get: function(obj, prop) {
const result = obj[prop];
if (typeof result === 'function')
return result.bind(effect);
return result;
},
set: function(obj, prop, value) {
obj[prop] = value;
return true;
}
};
// Override getComputedTiming to convert to percentages when using a
// progress-based timeline.
const getComputedTimingHandler = {
apply: function(target) {
// Ensure that the native animation is using normalized values.
effect.getTiming();
const timing = target.apply(effect);
if (details.timeline) {
const rangeDuration = details.duration ?? 100;
timing.localTime = toCssNumberish(details, timing.localTime);
timing.endTime = toCssNumberish(details, timing.endTime);
timing.activeDuration =
toCssNumberish(details, timing.activeDuration);
const limit = effectEnd(details);
const iteration_duration = timing.iterations ?
(limit - timing.delay - timing.endDelay) / timing.iterations : 0;
timing.duration = limit ?
CSS.percent(rangeDuration * iteration_duration / limit) :
CSS.percent(0);
// Correct for inactive timeline.
if (details.timeline.currentTime === undefined) {
timing.localTime = null;
}
}
return timing;
}
};
// Override getTiming to normalize the timing. EffectEnd for the animation
// align with the range duration.
const getTimingHandler = {
apply: function(target, thisArg) {
// Arbitrary conversion of 100% to ms.
const INTERNAL_DURATION_MS = 100000;
if (details.specifiedTiming)
return details.specifiedTiming;
details.specifiedTiming = target.apply(effect);
let timing = Object.assign({}, details.specifiedTiming);
let totalDuration;
if (timing.duration === Infinity) {
throw TypeError(
"Effect duration cannot be Infinity when used with Scroll " +
"Timelines");
}
// Duration 'auto' case.
if (timing.duration === null || timing.duration === 'auto' || details.autoDurationEffect) {
if (details.timeline) {
details.autoDurationEffect = true
// TODO: start and end delay are specced as doubles and currently
// ignored for a progress based animation. Support delay and endDelay
// once CSSNumberish.
timing.delay = 0;
timing.endDelay = 0;
totalDuration = timing.iterations ? INTERNAL_DURATION_MS : 0;
timing.duration = timing.iterations
? (totalDuration - timing.delay - timing.endDelay) /
timing.iterations
: 0;
// When the rangeStart comes after the rangeEnd, we end up in a situation
// that cannot work. We can tell this by having ended up with a negative
// duration. In that case, we need to adjust the computed timings. We do
// this by setting the duration to 0 and then assigning the remainder of
// the totalDuration to the endDelay
if (timing.duration < 0) {
timing.duration = 0;
timing.endDelay = totalDuration - timing.delay;
}
// Set the timing on the native animation to the normalized values
// while preserving the specified timing.
nativeUpdateTiming.apply(effect, [timing]);
}
}
details.normalizedTiming = timing;
return details.specifiedTiming;
}
};
const updateTimingHandler = {
apply: function(target, thisArg, argumentsList) {
if (!argumentsList || !argumentsList.length)
return;
// Additional validation that is specific to scroll timelines.
if (details.timeline && argumentsList[0]) {
const options = argumentsList[0];
const duration = options.duration;
if (duration === Infinity) {
throw TypeError(
"Effect duration cannot be Infinity when used with Scroll " +
"Timelines");
}
const iterations = options.iterations;
if (iterations === Infinity) {
throw TypeError(
"Effect iterations cannot be Infinity when used with Scroll " +
"Timelines");
}
if (typeof duration !== 'undefined' && duration !== 'auto') {
details.autoDurationEffect = null
}
}
// Apply updates on top of the original specified timing.
if (details.specifiedTiming) {
target.apply(effect, [details.specifiedTiming]);
}
target.apply(effect, argumentsList);
renormalizeTiming(details);
}
};
const proxy = new Proxy(effect, handler);
proxy.getComputedTiming = new Proxy(effect.getComputedTiming,
getComputedTimingHandler);
proxy.getTiming = new Proxy(effect.getTiming, getTimingHandler);
proxy.updateTiming = new Proxy(effect.updateTiming, updateTimingHandler);
return proxy;
}
// Computes the start delay as a fraction of the active cover range.
function fractionalStartDelay(details) {
if (!details.animationRange) return 0;
const rangeStart = details.animationRange.start === 'normal' ?
getNormalStartRange(details.timeline) :
details.animationRange.start;
return fractionalOffset(details.timeline, rangeStart);
}
// Computes the ends delay as a fraction of the active cover range.
function fractionalEndDelay(details) {
if (!details.animationRange) return 0;
const rangeEnd = details.animationRange.end === 'normal' ?
getNormalEndRange(details.timeline) :
details.animationRange.end;
return 1 - fractionalOffset(details.timeline, rangeEnd);
}
// Map from an instance of ProxyAnimation to internal details about that animation.
// See ProxyAnimation constructor for details.
let proxyAnimations = new WeakMap();
// Clear cache containing the ProxyAnimation instances when leaving the page.
// See https://github.com/flackr/scroll-timeline/issues/146#issuecomment-1698159183
// for details.
window.addEventListener('pagehide', (e) => {
proxyAnimations = new WeakMap();
}, false);
// Map from the real underlying native animation to the ProxyAnimation proxy of it.
let proxiedAnimations = new WeakMap();
/**
* Procedure for calculating an auto-aligned start time.
* https://drafts.csswg.org/web-animations-2/#animation-calculating-an-auto-aligned-start-time
* @param details
*/
function autoAlignStartTime(details) {
// When attached to a non-monotonic timeline, the start time of the animation may be layout dependent. In this case,
// we defer calculation of the start time until the timeline has been updated post layout. When updating timeline
// current time, the start time of any attached animation is conditionally updated. The procedure for calculating an
// auto-aligned start time is as follows:
// 1. If the auto-align start time flag is false, abort this procedure.
if (!details.autoAlignStartTime) {
return;
}
// 2. If the timeline is inactive, abort this procedure.
if (!details.timeline || !details.timeline.currentTime) {
return;
}
// 3. If play state is idle, abort this procedure.
// 4. If play state is paused, and hold time is resolved, abort this procedure.
if (details.proxy.playState === 'idle' ||
(details.proxy.playState === 'paused' && details.holdTime !== null)) {
return;
}
const previousRangeDuration = details.rangeDuration;
let startOffset, endOffset;
// 5. Let start offset be the resolved timeline time corresponding to the start of the animation attachment range.
// In the case of view timelines, it requires a calculation based on the proportion of the cover range.
try {
startOffset = CSS.percent(fractionalStartDelay(details) * 100);
} catch (e) {
// TODO: Validate supported values for range start, to avoid exceptions when resolving the values.
// Range start is invalid, falling back to default value
startOffset = CSS.percent(0);
details.animationRange.start = 'normal';
console.warn("Exception when calculating start offset", e);
}
// 6. Let end offset be the resolved timeline time corresponding to the end of the animation attachment range.
// In the case of view timelines, it requires a calculation based on the proportion of the cover range.
try {
endOffset = CSS.percent((1 - fractionalEndDelay(details)) * 100);
} catch (e) {
// TODO: Validate supported values for range end, to avoid exceptions when resolving the values.
// Range start is invalid, falling back to default value
endOffset = CSS.percent(100);
details.animationRange.end = 'normal';
console.warn("Exception when calculating end offset", e);
}
// Store the range duration, until we can find a spec aligned method to calculate iteration duration
// TODO: Clarify how range duration should be resolved
details.rangeDuration = endOffset.value - startOffset.value;
// 7. Set start time to start offset if effective playback rate ≥ 0, and end offset otherwise.
const playbackRate = effectivePlaybackRate(details);
details.startTime = fromCssNumberish(details,playbackRate >= 0 ? startOffset : endOffset);
// 8. Clear hold time.
details.holdTime = null;
// Additional polyfill step needed to renormalize timing when range has changed
if (details.rangeDuration !== previousRangeDuration) {
renormalizeTiming(details);
}
}
function unsupportedTimeline(timeline) {
throw new Error('Unsupported timeline class');
}
function getNormalStartRange(timeline) {
if (timeline instanceof ViewTimeline) {
return { rangeName: 'cover', offset: CSS.percent(0) };
}
if (timeline instanceof ScrollTimeline) {
return CSS.percent(0);
}
unsupportedTimeline(timeline);
}
function getNormalEndRange(timeline) {
if (timeline instanceof ViewTimeline) {
return { rangeName: 'cover', offset: CSS.percent(100) };
}
if (timeline instanceof ScrollTimeline) {
return CSS.percent(100);
}
unsupportedTimeline(timeline);
}
function parseAnimationRange(timeline, value) {
if (!value)
return {
start: 'normal',
end: 'normal',
};
const animationRange = {
start: getNormalStartRange(timeline),
end: getNormalEndRange(timeline),
};
if (timeline instanceof ViewTimeline) {
// Format:
// <start-name> <start-offset> <end-name> <end-offset>
// <name> --> <name> 0% <name> 100%
// <name> <start-offset> <end-offset> --> <name> <start-offset>
// <name> <end-offset>
// <start-offset> <end-offset> --> cover <start-offset> cover <end-offset>
// TODO: Support all formatting options once ratified in the spec.
const parts = splitIntoComponentValues(value);
const rangeNames = [];
const offsets = [];
parts.forEach(part => {
if (ANIMATION_RANGE_NAMES.includes(part)) {
rangeNames.push(part);
} else {
try {
offsets.push(CSSNumericValue.parse(part));
} catch (e) {
throw TypeError(`Could not parse range "${value}"`);
}
}
});
if (rangeNames.length > 2 || offsets.length > 2 || offsets.length == 1) {
throw TypeError("Invalid time range or unsupported time range format.");
}
if (rangeNames.length) {
animationRange.start.rangeName = rangeNames[0];
animationRange.end.rangeName = rangeNames.length > 1 ? rangeNames[1] : rangeNames[0];
}
if (offsets.length > 1) {
animationRange.start.offset = offsets[0];
animationRange.end.offset = offsets[1];
}
return animationRange;
}
if (timeline instanceof ScrollTimeline) {
// @TODO: Play nice with only 1 offset being set
// @TODO: Play nice with expressions such as `calc(50% + 10px) 100%`
const parts = value.split(' ');
if (parts.length != 2) {
throw TypeError("Invalid time range or unsupported time range format.");
}
animationRange.start = CSSNumericValue.parse(parts[0]);
animationRange.end = CSSNumericValue.parse(parts[1]);
return animationRange;
}
unsupportedTimeline(timeline);
}
function parseTimelineRangePart(timeline, value, position) {
if (!value || value === 'normal') return 'normal';
if (timeline instanceof ViewTimeline) {
// Extract parts from the passed in value.
let rangeName = 'cover'
let offset = position === 'start' ? CSS.percent(0) : CSS.percent(100)
// Author passed in something like `{ rangeName: 'cover', offset: CSS.percent(100) }`
if (value instanceof Object) {
if (value.rangeName !== undefined) {
rangeName = value.rangeName;
}
if (value.offset !== undefined) {
offset = value.offset;
}
}
// Author passed in something like `"cover 100%"`
else {
const parts = splitIntoComponentValues(value);
if (parts.length === 1) {
if (ANIMATION_RANGE_NAMES.includes(parts[0])) {
rangeName = parts[0];
} else {
offset = simplifyCalculation(CSSNumericValue.parse(parts[0]), {});
}
} else if (parts.length === 2) {
rangeName = parts[0];
offset = simplifyCalculation(CSSNumericValue.parse(parts[1]), {});
}
}
// Validate rangeName
if (!ANIMATION_RANGE_NAMES.includes(rangeName)) {
throw TypeError("Invalid range name");
}
return { rangeName, offset };
}
if (timeline instanceof ScrollTimeline) {
// The value is a standalone offset, so simply parse it.
return CSSNumericValue.parse(value);
}
unsupportedTimeline(timeline);
}
// Create an alternate Animation class which proxies API requests.
// TODO: Create a full-fledged proxy so missing methods are automatically
// fetched from Animation.
export class ProxyAnimation {
constructor(effect, timeline, animOptions={}) {
const isScrollAnimation = timeline instanceof ScrollTimeline;
const animationTimeline = isScrollAnimation ? undefined : timeline;
const animation =
(effect instanceof nativeAnimation) ?
effect : new nativeAnimation(effect, animationTimeline);
proxiedAnimations.set(animation, this);
proxyAnimations.set(this, {
animation: animation,
timeline: isScrollAnimation ? timeline : undefined,
playState: isScrollAnimation ? "idle" : null,
readyPromise: null,
finishedPromise: null,
// Start and hold times are directly tracked in the proxy despite being
// accessible via the animation so that direct manipulation of these
// properties does not affect the play state of the underlying animation.
// Note that any changes to these values require an update of current
// time for the underlying animation to ensure that its hold time is set
// to the correct position. These values are represented as floating point
// numbers in milliseconds.
startTime: null,
holdTime: null,
rangeDuration: null,
previousCurrentTime: null,
autoAlignStartTime: false,
// Calls to reverse and updatePlaybackRate set a pending rate that does
// not immediately take effect. The value of this property is
// inaccessible via the web animations API and therefore explicitly
// tracked.
pendingPlaybackRate: null,
pendingTask: null,
// Record the specified timing since it may be different than the timing
// actually used for the animation. When fetching the timing, this value
// will be returned, however, the native animation will use normalized
// values.
specifiedTiming: null,
// The normalized timing has the corrected timing with the intrinsic
// iteration duration resolved.
normalizedTiming: null,
// Effect proxy that performs the necessary time conversions when using a
// progress-based timelines.
effect: null,
// The animation attachment range, restricting the animation’s
// active interval to that range of a timeline
animationRange: isScrollAnimation ? parseAnimationRange(timeline, animOptions['animation-range']) : null,
proxy: this
});
}
// -----------------------------------------
// Web animation API
// -----------------------------------------
get effect() {
const details = proxyAnimations.get(this);
if (!details.timeline)
return details.animation.effect;
// Proxy the effect to support timing conversions for progress based
// animations.
if (!details.effect)
details.effect = createProxyEffect(details);
return details.effect;
}
set effect(newEffect) {
const details = proxyAnimations.get(this);
details.animation.effect = newEffect;
// Reset proxy to force re-initialization the next time it is accessed.
details.effect = null;
details.autoDurationEffect = null;
}
get timeline() {
const details = proxyAnimations.get(this);
// If we explicitly set a null timeline we will return the underlying
// animation's timeline.
return details.timeline || details.animation.timeline;
}
set timeline(newTimeline) {
// https://drafts4.csswg.org/web-animations-2/#setting-the-timeline
const details = proxyAnimations.get(this);
// 1. Let old timeline be the current timeline of animation, if any.
// 2. If new timeline is the same object as old timeline, abort this
// procedure.
const oldTimeline = this.timeline;
if (oldTimeline == newTimeline)
return;
// 3. Let previous play state be animation’s play state.
const previousPlayState = this.playState;
// 4. Let previous current time be the animation’s current time.
const previousCurrentTime = this.currentTime;
// 5. Set previous progress based in the first condition that applies:
// If previous current time is unresolved:
// Set previous progress to unresolved.
// If endTime time is zero:
// Set previous progress to zero.
// Otherwise
// Set previous progress = previous current time / endTime time
let end = effectEnd(details);
let previousProgress;
if (previousCurrentTime === null) {
previousProgress = null
} else if (end === 0) {
previousProgress = 0;
} else {
previousProgress = fromCssNumberish(details, previousCurrentTime) / end;
}
// 9. Let from finite timeline be true if old timeline is not null and not
// monotonically increasing.
const fromScrollTimeline = (oldTimeline instanceof ScrollTimeline);
// 10. Let to finite timeline be true if timeline is not null and not
// monotonically increasing.
const toScrollTimeline = (newTimeline instanceof ScrollTimeline);
// 11. Let the timeline of animation be new timeline.
// Cannot assume that the native implementation has mutable timeline
// support. Deferring this step until we know that we are either
// polyfilling, supporting natively, or throwing an error.
// Additional step required to track whether the animation was pending in
// order to set up a new ready promise if needed.
const pending = this.pending;
if (fromScrollTimeline) {
removeAnimation(details.timeline, details.animation);
}
// 12. Perform the steps corresponding to the first matching condition from
// the following, if any:
// If to finite timeline,
if (toScrollTimeline) {
// Deferred step 11.
details.timeline = newTimeline;
// 1. Apply any pending playback rate on animation
applyPendingPlaybackRate(details);
// 2. Set auto align start time to true.
details.autoAlignStartTime = true;
// 3. Set start time to unresolved.
details.startTime = null;
// 4. Set hold time to unresolved.
details.holdTime = null;
// 5. If previous play state is "finished" or "running"
if (previousPlayState === 'running' || previousPlayState === 'finished') {
// 1. Schedule a pending play task
if (!details.readyPromise || details.readyPromise.state === 'resolved') {
createReadyPromise(details);
}
details.pendingTask = 'play';
// Additional polyfill step needed to associate the animation with
// the scroll timeline.
addAnimation(details.timeline, details.animation,
tickAnimation.bind(this));
}
// 6. If previous play state is "paused" and previous progress is resolved:
if (previousPlayState === 'paused' && previousProgress !== null) {
// 1. Set hold time to previous progress * endTime time. This step ensures that previous progress is preserved
// even in the case of a pause-pending animation with a resolved start time.
details.holdTime = previousProgress * end;
}
// Additional steps required if the animation is pending as we need to
// associate the pending promise with proxy animation.
// Note: if the native promise already has an associated "then", we will
// lose this association.
if (pending) {
if (!details.readyPromise ||
details.readyPromise.state == 'resolved') {
createReadyPromise(details);
}
if (previousPlayState == 'paused')
details.pendingTask = 'pause';
else
details.pendingTask = 'play';
}
// Note that the following steps should apply when transitioning to
// a monotonic timeline as well; however, we do not have a direct means
// of applying the steps to the native animation.
// 15. If the start time of animation is resolved, make animation’s hold
// time unresolved. This step ensures that the finished play state of
// animation is not “sticky” but is re-evaluated based on its updated
// current time.
if (details.startTime !== null)
details.holdTime = null;
// 16. Run the procedure to update an animation’s finished state for
// animation with the did seek flag set to false, and the
// synchronously notify flag set to false.
updateFinishedState(details, false, false);
return;
}
// To monotonic timeline.
if (details.animation.timeline == newTimeline) {
// Deferred step 11 from above. Clearing the proxy's timeline will
// re-associate the proxy with the native animation.
removeAnimation(details.timeline, details.animation);
details.timeline = null;
// If from finite timeline and previous current time is resolved,
// Run the procedure to set the current time to previous current time.
if (fromScrollTimeline) {
if (previousCurrentTime !== null)
details.animation.currentTime = previousProgress * effectEnd(details);
switch (previousPlayState) {
case 'paused':
details.animation.pause();
break;
case 'running':
case 'finished':
details.animation.play();
}
}
} else {
throw TypeError("Unsupported timeline: " + newTimeline);
}
}
get startTime() {
const details = proxyAnimations.get(this);
if (details.timeline)
return toCssNumberish(details, details.startTime);
return details.animation.startTime;
}
set startTime(value) {
// https://drafts.csswg.org/web-animations/#setting-the-start-time-of-an-animation
const details = proxyAnimations.get(this);
// 1. Let valid start time be the result of running the validate a CSSNumberish time procedure with new start time
// as the input.
// 2. If valid start time is false, abort this procedure.
value = fromCssNumberish(details, value);
if (!details