From cbd416b418a91c2c2747bf46eb1a4db478195339 Mon Sep 17 00:00:00 2001 From: Johannes Odland Date: Sun, 4 Feb 2024 00:42:09 +0100 Subject: [PATCH] Add support for timeline-scope property --- src/proxy-animation.js | 5 +- src/scroll-timeline-base.js | 3 + src/scroll-timeline-css-parser.js | 188 ++++++++++++++++++------------ src/scroll-timeline-css.js | 20 +++- src/utils.js | 9 ++ test/expected.txt | 20 ++-- 6 files changed, 153 insertions(+), 92 deletions(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 0c13bc3..bac6188 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -803,7 +803,7 @@ function createProxyEffect(details) { } if (typeof duration !== 'undefined' && duration !== 'auto') { - details.autoDurationEffect = null + details.autoDurationEffect = false } } @@ -1127,6 +1127,7 @@ export class ProxyAnimation { // The animation attachment range, restricting the animation’s // active interval to that range of a timeline animationRange: isScrollAnimation ? parseAnimationRange(timeline, animOptions['animation-range']) : null, + autoDurationEffect: animOptions['auto-duration'] ?? false, proxy: this }); } @@ -1152,7 +1153,7 @@ export class ProxyAnimation { details.animation.effect = newEffect; // Reset proxy to force re-initialization the next time it is accessed. details.effect = null; - details.autoDurationEffect = null; + details.autoDurationEffect = false; } get timeline() { diff --git a/src/scroll-timeline-base.js b/src/scroll-timeline-base.js index 2c85492..aba6ae4 100644 --- a/src/scroll-timeline-base.js +++ b/src/scroll-timeline-base.js @@ -214,6 +214,9 @@ export function measureSubject(source, subject) { * @param {HTMLElement} source */ function updateMeasurements(source) { + if (!source) { + return; + } let details = sourceDetails.get(source); details.sourceMeasurements = measureSource(source); diff --git a/src/scroll-timeline-css-parser.js b/src/scroll-timeline-css-parser.js index 1f4ada6..194313e 100644 --- a/src/scroll-timeline-css-parser.js +++ b/src/scroll-timeline-css-parser.js @@ -1,4 +1,5 @@ import { ANIMATION_RANGE_NAMES, getAnonymousSourceElement } from './scroll-timeline-base'; +import { findLast } from './utils'; // This is also used in scroll-timeline-css.js export const RegexMatcher = { @@ -6,6 +7,7 @@ export const RegexMatcher = { WHITE_SPACE: /\s*/g, NUMBER: /^[0-9]+/, TIME: /^[0-9]+(s|ms)/, + TIMELINE_SCOPE: /timeline-scope\s*:([^;}]+)/, SCROLL_TIMELINE: /scroll-timeline\s*:([^;}]+)/, SCROLL_TIMELINE_NAME: /scroll-timeline-name\s*:([^;}]+)/, SCROLL_TIMELINE_AXIS: /scroll-timeline-axis\s*:([^;}]+)/, @@ -46,6 +48,7 @@ export class StyleParser { this.anonymousScrollTimelineOptions = new Map(); // save anonymous options by name this.anonymousViewTimelineOptions = new Map(); // save anonymous options by name this.sourceSelectorToScrollTimeline = []; + this.scopeSelectorToScopeName = [] this.subjectSelectorToViewTimeline = []; this.keyframeNamesSelectors = new Map(); } @@ -56,12 +59,13 @@ export class StyleParser { // This function is called twice, in the first pass we are interested in saving // the @keyframe names, in the second pass we will parse other rules to extract // scroll-animations related properties and values. - transpileStyleSheet(sheetSrc, firstPass, srcUrl) { + transpileStyleSheet(sheetSrc, firstPass, srcUrl, styleId) { // AdhocParser const p = { sheetSrc: sheetSrc, index: 0, name: srcUrl, + styleId }; while (p.index < p.sheetSrc.length) { @@ -121,7 +125,8 @@ export class StyleParser { if (!current['animation-name'] || current['animation-name'] == animationName) { return { 'animation-timeline': current['animation-timeline'], - 'animation-range': current['animation-range'] + 'animation-range': current['animation-range'], + 'auto-duration': current['auto-duration'] } } } @@ -147,42 +152,82 @@ export class StyleParser { return null; } - getScrollTimelineOptions(timelineName, target) { - const anonymousTimelineOptions = this.getAnonymousScrollTimelineOptions(timelineName, target); + getTimelineOptions(timelineName, target) { + const anonymousTimelineOptions = + this.getAnonymousScrollTimelineOptions(timelineName, target) || + this.getAnonymousViewTimelineOptions(timelineName, target); if(anonymousTimelineOptions) return anonymousTimelineOptions; - for (let i = this.sourceSelectorToScrollTimeline.length - 1; i >= 0; i--) { - const options = this.sourceSelectorToScrollTimeline[i]; - if(options.name == timelineName) { - const source = this.findPreviousSiblingOrAncestorMatchingSelector(target, options.selector); + // This method returns options for the closest timeline or timeline scope that has a matching selector. + // It does not take scope, layers, specificity and source order into account. + // TODO: support cascading order - if(source) { - return { - source, - ...(options.axis ? { axis: options.axis } : {}), - }; - } - } - } + const sourceSelector = this.sourceSelectorToScrollTimeline + .filter(o => o.name === timelineName) + .map(o => o.selector) + .join(','); - return null; - } + const subjectSelector = this.subjectSelectorToViewTimeline + .filter(o => o.name === timelineName) + .map(o => o.selector) + .join(','); - // TODO: Remove this old lookup mechanism and replace it by one that - // respects timeline-scope (https://github.com/flackr/scroll-timeline/issues/123) - findPreviousSiblingOrAncestorMatchingSelector(target, selector) { - // Target self - let candidate = target; + const scopeSelector = this.scopeSelectorToScopeName + .filter(o => o.name === timelineName || o.name === 'all') + .map(o => o.selector) + .join(','); - // Walk the DOM tree: preceding siblings and ancestors - while (candidate) { - if (candidate.matches(selector)) - return candidate; - candidate = candidate.previousElementSibling || candidate.parentElement; + const source = sourceSelector ? target.closest(sourceSelector) : undefined; + if (source) { + const options = findLast(this.sourceSelectorToScrollTimeline, + o => o.name === timelineName && source.matches(o.selector)); + + return { + source, + ...(options.axis ? { axis: options.axis } : {}), + }; } - // No match + const subject = subjectSelector ? target.closest(subjectSelector) : undefined; + if (subject) { + const options = findLast(this.subjectSelectorToViewTimeline, + o => o.name === timelineName && subject.matches(o.selector)); + + return { + subject, + axis: options.axis, + inset: options.inset + }; + } + + const scopeRoot = scopeSelector ? target.closest(scopeSelector) : undefined; + if (scopeRoot) { + const sourceCandidates = sourceSelector ? scopeRoot.querySelectorAll(sourceSelector) : []; + if (sourceCandidates.length === 1) { + const source = sourceCandidates[0]; + const options = findLast(this.sourceSelectorToScrollTimeline, + o => o.name === timelineName && source.matches(o.selector)); + + return { + source, + ...(options.axis ? {axis: options.axis} : {}), + }; + } + + const subjectCandidates = subjectSelector ? scopeRoot.querySelectorAll(subjectSelector) : []; + if (subjectCandidates.length === 1) { + const subject = subjectCandidates[0]; + const options = findLast(this.subjectSelectorToViewTimeline, + o => o.name === timelineName && subject.matches(o.selector)); + + return { + subject, + axis: options.axis, + inset: options.inset + }; + } + } return null; } @@ -199,28 +244,6 @@ export class StyleParser { return null; } - getViewTimelineOptions(timelineName, target) { - const anonymousTimelineOptions = this.getAnonymousViewTimelineOptions(timelineName, target); - if(anonymousTimelineOptions) - return anonymousTimelineOptions; - - for (let i = this.subjectSelectorToViewTimeline.length - 1; i >= 0; i--) { - const options = this.subjectSelectorToViewTimeline[i]; - if(options.name == timelineName) { - const subject = this.findPreviousSiblingOrAncestorMatchingSelector(target, options.selector); - if(subject) { - return { - subject, - axis: options.axis, - inset: options.inset - } - } - } - } - - return null; - } - handleScrollTimelineProps(rule, p) { // The animation-timeline property may not be used in keyframes if (rule.selector.includes("@keyframes")) { @@ -232,8 +255,9 @@ export class StyleParser { const hasAnimationTimeline = rule.block.contents.includes("animation-timeline:"); const hasAnimation = rule.block.contents.includes("animation:"); - this.saveSourceSelectorToScrollTimeline(rule); - this.saveSubjectSelectorToViewTimeline(rule); + this.saveSourceSelectorToScrollTimeline(rule, p.styleId); + this.saveScopeSelectorToScopeName(rule, p.styleId); + this.saveSubjectSelectorToViewTimeline(rule, p.styleId); if (!hasAnimationTimeline && !hasAnimationName && !hasAnimation) { return; @@ -241,7 +265,7 @@ export class StyleParser { let timelineNames = []; let animationNames = []; - let shouldReplacePart = false; + let replaceAutoDuration = false; if (hasAnimationTimeline) timelineNames = this.extractScrollTimelineNames(rule.block.contents); @@ -268,29 +292,22 @@ export class StyleParser { // Add 1s as duration to fix this. if(hasAnimationTimeline) { if(!this.hasDuration(shorthand)) { - + let replacedShorthand = shorthand // `auto` also is valid duration. Older browsers can’t always // handle it properly, so we remove it from the shorthand. if (this.hasAutoDuration(shorthand)) { - rule.block.contents = rule.block.contents.replace( - 'auto', - ' ' - ); + replacedShorthand = replacedShorthand.replace('auto', ' ') } - // TODO: Should keep track of whether duration is artificial or not, - // so that we can later track that we need to update timing to - // properly see duration as 'auto' for the polyfill. - rule.block.contents = rule.block.contents.replace( - shorthand, " 1s " + shorthand - ); - shouldReplacePart = true; + replacedShorthand = " 1s " + replacedShorthand + rule.block.contents = rule.block.contents.replace(shorthand, replacedShorthand); + replaceAutoDuration = true; } } }); } - if(shouldReplacePart) { + if(replaceAutoDuration) { this.replacePart( rule.block.startIndex, rule.block.endIndex, @@ -299,10 +316,10 @@ export class StyleParser { ); } - this.saveRelationInList(rule, timelineNames, animationNames); + this.saveRelationInList(rule, timelineNames, animationNames, replaceAutoDuration); } - saveSourceSelectorToScrollTimeline(rule) { + saveSourceSelectorToScrollTimeline(rule, styleId) { const hasScrollTimeline = rule.block.contents.includes("scroll-timeline:"); const hasScrollTimelineName = rule.block.contents.includes("scroll-timeline-name:"); const hasScrollTimelineAxis = rule.block.contents.includes("scroll-timeline-axis:"); @@ -314,7 +331,7 @@ export class StyleParser { const scrollTimelines = this.extractMatches(rule.block.contents, RegexMatcher.SCROLL_TIMELINE); for(const st of scrollTimelines) { const parts = this.split(st); - let options = {selector: rule.selector, name: ''}; + let options = {selector: rule.selector, name: '', styleId}; if(parts.length == 1) { options.name = parts[0]; @@ -336,7 +353,7 @@ export class StyleParser { // longhand overrides shorthand timelines[i].name = names[i]; } else { - let options = {selector: rule.selector, name: names[i]}; + let options = {selector: rule.selector, name: names[i], styleId}; timelines.push(options); } } @@ -359,7 +376,20 @@ export class StyleParser { this.sourceSelectorToScrollTimeline.push(...timelines); } - saveSubjectSelectorToViewTimeline(rule) { + saveScopeSelectorToScopeName(rule, styleId) { + const hasTimelineScope = rule.block.contents.includes("timeline-scope:"); + if (hasTimelineScope) { + const timelineScopes = this.extractMatches(rule.block.contents, RegexMatcher.TIMELINE_SCOPE); + for(const ts of timelineScopes) { + const parts = this.split(ts); + for (const part of parts) { + this.scopeSelectorToScopeName.push({selector: rule.selector, name: part, styleId}) + } + } + } + } + + saveSubjectSelectorToViewTimeline(rule, styleId) { const hasViewTimeline = rule.block.contents.includes("view-timeline:"); const hasViewTimelineName = rule.block.contents.includes("view-timeline-name:"); const hasViewTimelineAxis = rule.block.contents.includes("view-timeline-axis:"); @@ -373,7 +403,7 @@ export class StyleParser { const viewTimelines = this.extractMatches(rule.block.contents, RegexMatcher.VIEW_TIMELINE); for(let tl of viewTimelines) { const parts = this.split(tl); - let options = {selector: rule.selector, name: '', inset: null}; + let options = {selector: rule.selector, name: '', inset: null, styleId}; if(parts.length == 1) { options.name = parts[0]; } else if(parts.length == 2) { @@ -393,7 +423,7 @@ export class StyleParser { // longhand overrides shorthand timelines[i].name = names[i]; } else { - let options = {selector: rule.selector, name: names[i], inset: null}; + let options = {selector: rule.selector, name: names[i], inset: null, styleId}; timelines.push(options); } } @@ -424,6 +454,13 @@ export class StyleParser { this.subjectSelectorToViewTimeline.push(...timelines); } + + deleteMappingForStyle(styleId) { + this.sourceSelectorToScrollTimeline = this.sourceSelectorToScrollTimeline.filter(options => options.styleId !== styleId); + this.scopeSelectorToScopeName = this.scopeSelectorToScopeName.filter(options => options.styleId !== styleId); + this.subjectSelectorToViewTimeline = this.subjectSelectorToViewTimeline.filter(options => options.styleId !== styleId); + } + hasDuration(shorthand) { return shorthand.split(" ").filter(part => isTime(part)).length >= 1; } @@ -433,7 +470,7 @@ export class StyleParser { return shorthand.split(" ").filter(part => part === 'auto').length >= 1; } - saveRelationInList(rule, timelineNames, animationNames) { + saveRelationInList(rule, timelineNames, animationNames, autoDuration = false) { const hasAnimationRange = rule.block.contents.includes("animation-range:"); let animationRanges = []; @@ -449,6 +486,7 @@ export class StyleParser { 'animation-timeline': timelineNames[i % timelineNames.length], ...(animationNames.length ? {'animation-name': animationNames[i % animationNames.length]}: {}), ...(animationRanges.length ? {'animation-range': animationRanges[i % animationRanges.length]}: {}), + 'auto-duration': autoDuration }); } } diff --git a/src/scroll-timeline-css.js b/src/scroll-timeline-css.js index b31bd41..54e49fc 100644 --- a/src/scroll-timeline-css.js +++ b/src/scroll-timeline-css.js @@ -4,6 +4,7 @@ import { ScrollTimeline, ViewTimeline, getScrollParent, calculateRange, calculateRelativePosition, measureSubject, measureSource } from "./scroll-timeline-base"; const parser = new StyleParser(); +let currentStyleId = 0 function initMutationObserver() { const sheetObserver = new MutationObserver((entries) => { @@ -16,6 +17,11 @@ function initMutationObserver() { handleLinkedStylesheet(addedNode); } } + for (const removedNode of entry.removedNodes) { + if (removedNode instanceof HTMLStyleElement) { + parser.deleteMappingForStyle(removedNode.dataset.stpStyleId) + } + } } // TODO: Proxy element.style similar to how we proxy element.animate. @@ -37,10 +43,12 @@ function initMutationObserver() { if (el.innerHTML.trim().length === 0 || 'aphrodite' in el.dataset) { return; } + el.dataset.stpStyleId = `${currentStyleId}` // TODO: Do with one pass for better performance - let newSrc = parser.transpileStyleSheet(el.innerHTML, true); - newSrc = parser.transpileStyleSheet(newSrc, false); + let newSrc = parser.transpileStyleSheet(el.innerHTML, true, undefined, `${currentStyleId}`); + newSrc = parser.transpileStyleSheet(newSrc, false, undefined, `${currentStyleId}`); el.innerHTML = newSrc; + currentStyleId++ } function handleLinkedStylesheet(linkElement) { @@ -87,10 +95,10 @@ function createScrollTimeline(anim, animationName, target) { const timelineName = animOptions['animation-timeline']; if(!timelineName) return null; - - let options = parser.getScrollTimelineOptions(timelineName, target) || - parser.getViewTimelineOptions(timelineName, target); - if (!options) return null; + let options = parser.getTimelineOptions(timelineName, target); + if (!options) { + return { timeline: null }; + } // If this is a ViewTimeline if(options.subject) diff --git a/src/utils.js b/src/utils.js index 133a922..0ca1109 100644 --- a/src/utils.js +++ b/src/utils.js @@ -77,4 +77,13 @@ export function splitIntoComponentValues(input) { } } return res; +} + +export function findLast(array, callbackFn) { + for (let i = array.length - 1; i >= 0; i--) { + const entry = array[i]; + if (callbackFn(entry)) { + return entry + } + } } \ No newline at end of file diff --git a/test/expected.txt b/test/expected.txt index 003d769..f4cae44 100644 --- a/test/expected.txt +++ b/test/expected.txt @@ -75,7 +75,7 @@ FAIL /scroll-animations/css/animation-timeline-computed.html Property animation- FAIL /scroll-animations/css/animation-timeline-computed.html Property animation-timeline value 'view(1px y)' FAIL /scroll-animations/css/animation-timeline-computed.html Property animation-timeline value 'view(y auto)' FAIL /scroll-animations/css/animation-timeline-computed.html Property animation-timeline value 'view(y auto auto)' -FAIL /scroll-animations/css/animation-timeline-deferred.html Animation.timeline returns attached timeline +PASS /scroll-animations/css/animation-timeline-deferred.html Animation.timeline returns attached 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) @@ -307,7 +307,7 @@ FAIL /scroll-animations/css/scroll-timeline-name-shadow.html Outer animation can FAIL /scroll-animations/css/scroll-timeline-name-shadow.html Inner animation can see scroll timeline defined by ::part FAIL /scroll-animations/css/scroll-timeline-name-shadow.html Slotted element can see scroll timeline within the shadow PASS /scroll-animations/css/scroll-timeline-nearest-dirty.html Unrelated style mutation does not affect anonymous timeline -FAIL /scroll-animations/css/scroll-timeline-nearest-with-absolute-positioned-element.html Resolving scroll(nearest) for an absolutely positioned element +PASS /scroll-animations/css/scroll-timeline-nearest-with-absolute-positioned-element.html Resolving scroll(nearest) for an absolutely positioned element FAIL /scroll-animations/css/scroll-timeline-paused-animations.html Test that the scroll animation is paused FAIL /scroll-animations/css/scroll-timeline-paused-animations.html Test that the scroll animation is paused by updating animation-play-state FAIL /scroll-animations/css/scroll-timeline-range-animation.html Animation with ranges [initial, initial] @@ -404,16 +404,16 @@ PASS /scroll-animations/css/timeline-scope-parsing.tentative.html e.style['timel PASS /scroll-animations/css/timeline-scope-parsing.tentative.html e.style['timeline-scope'] = "\"foo\" \"bar\"" should not set the property value PASS /scroll-animations/css/timeline-scope-parsing.tentative.html e.style['timeline-scope'] = "rgb(1, 2, 3)" should not set the property value PASS /scroll-animations/css/timeline-scope-parsing.tentative.html e.style['timeline-scope'] = "#fefefe" should not set the property value -FAIL /scroll-animations/css/timeline-scope.html Descendant can attach to deferred timeline +PASS /scroll-animations/css/timeline-scope.html Descendant can attach to deferred timeline PASS /scroll-animations/css/timeline-scope.html Deferred timeline with no attachments -FAIL /scroll-animations/css/timeline-scope.html Inner timeline does not interfere with outer timeline +PASS /scroll-animations/css/timeline-scope.html Inner timeline does not interfere with outer timeline PASS /scroll-animations/css/timeline-scope.html Deferred timeline with two attachments FAIL /scroll-animations/css/timeline-scope.html Dynamically re-attaching FAIL /scroll-animations/css/timeline-scope.html Dynamically detaching -FAIL /scroll-animations/css/timeline-scope.html Removing/inserting element with attaching timeline -FAIL /scroll-animations/css/timeline-scope.html Ancestor attached element becoming display:none/block +PASS /scroll-animations/css/timeline-scope.html Removing/inserting element with attaching timeline +PASS /scroll-animations/css/timeline-scope.html Ancestor attached element becoming display:none/block FAIL /scroll-animations/css/timeline-scope.html A deferred timeline appearing dynamically in the ancestor chain -FAIL /scroll-animations/css/timeline-scope.html Animations prefer non-deferred timelines +PASS /scroll-animations/css/timeline-scope.html Animations prefer non-deferred timelines FAIL /scroll-animations/css/view-timeline-animation-range-update.tentative.html Ensure that animation is updated on a style change FAIL /scroll-animations/css/view-timeline-animation.html Default view-timeline FAIL /scroll-animations/css/view-timeline-animation.html Horizontal view-timeline @@ -524,8 +524,8 @@ PASS /scroll-animations/css/view-timeline-lookup.html view-timeline on self PASS /scroll-animations/css/view-timeline-lookup.html timeline-scope on preceding sibling PASS /scroll-animations/css/view-timeline-lookup.html view-timeline on ancestor PASS /scroll-animations/css/view-timeline-lookup.html timeline-scope on ancestor sibling -FAIL /scroll-animations/css/view-timeline-lookup.html timeline-scope on ancestor sibling, conflict remains unresolved -FAIL /scroll-animations/css/view-timeline-lookup.html timeline-scope on ancestor sibling, closer timeline wins +PASS /scroll-animations/css/view-timeline-lookup.html timeline-scope on ancestor sibling, conflict remains unresolved +PASS /scroll-animations/css/view-timeline-lookup.html timeline-scope on ancestor sibling, closer timeline wins PASS /scroll-animations/css/view-timeline-lookup.html view-timeline on ancestor sibling, scroll-timeline wins on same element FAIL /scroll-animations/css/view-timeline-name-computed.html Property view-timeline-name value 'initial' FAIL /scroll-animations/css/view-timeline-name-computed.html Property view-timeline-name value 'inherit' @@ -696,6 +696,7 @@ PASS /scroll-animations/scroll-timelines/current-time-root-scroller.html current PASS /scroll-animations/scroll-timelines/current-time-writing-modes.html currentTime handles direction: rtl correctly PASS /scroll-animations/scroll-timelines/current-time-writing-modes.html currentTime handles writing-mode: vertical-rl correctly PASS /scroll-animations/scroll-timelines/current-time-writing-modes.html currentTime handles writing-mode: vertical-lr correctly +PASS /scroll-animations/scroll-timelines/duration.html The duration of a scroll timeline is 100% PASS /scroll-animations/scroll-timelines/effect-updateTiming.html Allows setting the delay to a positive number PASS /scroll-animations/scroll-timelines/effect-updateTiming.html Allows setting the delay to a negative number PASS /scroll-animations/scroll-timelines/effect-updateTiming.html Allows setting the delay of an animation in progress: positive delay that causes the animation to be no longer in-effect @@ -1026,6 +1027,7 @@ PASS /scroll-animations/view-timelines/block-view-timeline-current-time.tentativ FAIL /scroll-animations/view-timelines/block-view-timeline-nested-subject.tentative.html View timeline with subject that is not a direct descendant of the scroll container FAIL /scroll-animations/view-timelines/change-animation-range-updates-play-state.html Changing the animation range updates the play state FAIL /scroll-animations/view-timelines/contain-alignment.html Stability of animated elements aligned to the bounds of a contain region +PASS /scroll-animations/view-timelines/duration.html The duration of a view timeline is 100% PASS /scroll-animations/view-timelines/fieldset-source.html Fieldset is a valid source for a view timeline FAIL /scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html Report specified timeline offsets FAIL /scroll-animations/view-timelines/get-keyframes-with-timeline-offset.html Computed offsets can be outside [0,1] for keyframes with timeline offsets