From 0db39738bae66515a94569379b7f7eecada26a5e Mon Sep 17 00:00:00 2001 From: Robert Flack Date: Thu, 21 Dec 2023 10:55:30 -0500 Subject: [PATCH] Replace proxied animations in getAnimations reusing proxied CSS animations (#89) This makes document.getAnimations and Element.getAnimations correctly return proxied instances of the animation so as to behave as expected. Also by getting the proxied animations we can intelligently reuse the existing proxied CSS animation when only timing details have changed which implicitly fixes the performance issue of #84. --- src/index.js | 12 +++++++++++ src/proxy-animation.js | 41 +++++++++++++++++++++++++++++++++++--- src/scroll-timeline-css.js | 34 ++++++++++--------------------- test/expected.txt | 8 ++++---- 4 files changed, 64 insertions(+), 31 deletions(-) diff --git a/src/index.js b/src/index.js index a8ea59a7..09f0d3ed 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,8 @@ import { } from "./scroll-timeline-base"; import { animate, + elementGetAnimations, + documentGetAnimations, ProxyAnimation } from "./proxy-animation.js"; @@ -61,6 +63,16 @@ function initPolyfill() { if (!Reflect.defineProperty(window, 'Animation', { value: ProxyAnimation })) { throw Error('Error installing Animation constructor.'); } + if (!Reflect.defineProperty(Element.prototype, "getAnimations", { value: elementGetAnimations })) { + throw Error( + "Error installing ScrollTimeline polyfill: could not attach WAAPI's getAnimations to DOM Element" + ); + } + if (!Reflect.defineProperty(document, "getAnimations", { value: documentGetAnimations })) { + throw Error( + "Error installing ScrollTimeline polyfill: could not attach WAAPI's getAnimations to document" + ); + } } initPolyfill(); diff --git a/src/proxy-animation.js b/src/proxy-animation.js index a40150c5..8665c4f1 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -5,6 +5,8 @@ import { relativePosition } from "./scroll-timeline-base"; +const nativeDocumentGetAnimations = document.getAnimations; +const nativeElementGetAnimations = window.Element.prototype.getAnimations; const nativeElementAnimate = window.Element.prototype.animate; const nativeAnimation = window.Animation; @@ -829,6 +831,20 @@ function fractionalEndDelay(details) { return 1 - relativePosition(details.timeline, endTime.rangeName, endTime.offset); } +// 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 @@ -886,8 +902,6 @@ function autoAlignStartTime(details) { // Create an alternate Animation class which proxies API requests. // TODO: Create a full-fledged proxy so missing methods are automatically // fetched from Animation. -let proxyAnimations = new WeakMap(); - export class ProxyAnimation { constructor(effect, timeline, animOptions={}) { const animation = @@ -895,6 +909,7 @@ export class ProxyAnimation { effect : new nativeAnimation(effect, animationTimeline); const isScrollAnimation = timeline instanceof ScrollTimeline; const animationTimeline = isScrollAnimation ? undefined : timeline; + proxiedAnimations.set(animation, this); proxyAnimations.set(this, { animation: animation, timeline: isScrollAnimation ? timeline : undefined, @@ -1817,4 +1832,24 @@ export function animate(keyframes, options) { } return proxyAnimation; -}; +} + +function replaceProxiedAnimations(animationsList) { + for (let i = 0; i < animationsList.length; ++i) { + let proxyAnimation = proxiedAnimations.get(animationsList[i]); + if (proxyAnimation) { + animationsList[i] = proxyAnimation; + } + } + return animationsList; +} + +export function elementGetAnimations(options) { + let animations = nativeElementGetAnimations.apply(this, [options]); + return replaceProxiedAnimations(animations); +} + +export function documentGetAnimations(options) { + let animations = nativeDocumentGetAnimations.apply(this, [options]); + return replaceProxiedAnimations(animations); +} diff --git a/src/scroll-timeline-css.js b/src/scroll-timeline-css.js index 2da22bb1..5007da01 100644 --- a/src/scroll-timeline-css.js +++ b/src/scroll-timeline-css.js @@ -140,37 +140,23 @@ export function initCSSPolyfill() { initMutationObserver(); - // Cache all Proxy Animations - let proxyAnimations = new WeakMap(); - // We are not wrapping capturing 'animationstart' by a 'load' event, // because we may lose some of the 'animationstart' events by the time 'load' is completed. window.addEventListener('animationstart', (evt) => { evt.target.getAnimations().filter(anim => anim.animationName === evt.animationName).forEach(anim => { - // Create a per-element cache - if (!proxyAnimations.has(evt.target)) { - proxyAnimations.set(evt.target, new Map()); - } - const elementProxyAnimations = proxyAnimations.get(evt.target); - - // Store Proxy Animation in the cache - if (!elementProxyAnimations.has(anim.animationName)) { - const result = createScrollTimeline(anim, anim.animationName, evt.target); - if (result && result.timeline && anim.timeline != result.timeline) { - elementProxyAnimations.set(anim.animationName, new ProxyAnimation(anim, result.timeline, result.animOptions)); + const result = createScrollTimeline(anim, anim.animationName, evt.target); + if (result) { + // If the CSS Animation refers to a scroll or view timeline we need to proxy the animation instance. + if (result.timeline && !(anim instanceof ProxyAnimation)) { + const proxyAnimation = new ProxyAnimation(anim, result.timeline, result.animOptions); + anim.pause(); + proxyAnimation.play(); } else { - elementProxyAnimations.set(anim.animationName, null); + // If the timeline was removed or the animation was already an instance of a proxy animation, + // invoke the set the timeline procedure on the existing animation. + anim.timeline = result.timeline; } } - - // Get Proxy Animation from cache - const proxyAnimation = elementProxyAnimations.get(anim.animationName); - - // Swap the original animation with the proxied one - if (proxyAnimation !== null) { - anim.pause(); - proxyAnimation.play(); - } }); }); diff --git a/test/expected.txt b/test/expected.txt index 1aaa8fb5..58cd0f39 100644 --- a/test/expected.txt +++ b/test/expected.txt @@ -79,10 +79,10 @@ FAIL /scroll-animations/css/animation-timeline-deferred.html Animation.timeline FAIL /scroll-animations/css/animation-timeline-deferred.html Animation.timeline returns null for inactive deferred timeline FAIL /scroll-animations/css/animation-timeline-deferred.html Animation.timeline returns null for inactive (overattached) deferred timeline FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html Changing animation-timeline changes the timeline (sanity check) -FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (ScrollTimeline from JS) +PASS /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (ScrollTimeline from JS) FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (ScrollTimeline from CSS) -PASS /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (document timeline) -PASS /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (null) +FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (document timeline) +FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html animation-timeline ignored after setting timeline with JS (null) FAIL /scroll-animations/css/animation-timeline-in-keyframe.html The animation-timeline property may not be used in keyframes PASS /scroll-animations/css/animation-timeline-multiple.html animation-timeline works with multiple timelines FAIL /scroll-animations/css/animation-timeline-none.html Animation with animation-timeline:none holds current time at zero @@ -957,4 +957,4 @@ FAIL /scroll-animations/view-timelines/view-timeline-sticky-block.html View time FAIL /scroll-animations/view-timelines/view-timeline-sticky-inline.html View timeline with sticky target, block axis. FAIL /scroll-animations/view-timelines/view-timeline-subject-size-changes.html View timeline with subject size change after the creation of the animation FAIL /scroll-animations/view-timelines/zero-intrinsic-iteration-duration.tentative.html Intrinsic iteration duration is non-negative -Passed 392 of 959 tests. +Passed 391 of 959 tests.