From 83ae4c2b76887babdab1278014b34d15362d102c Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 00:01:28 +0100 Subject: [PATCH] Add support for animation-range with ScrollTimeline --- src/proxy-animation.js | 119 ++------------------ src/scroll-timeline-base.js | 178 ++++++++++++++++++++++++++++-- src/scroll-timeline-css-parser.js | 3 +- 3 files changed, 175 insertions(+), 125 deletions(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 0cfb0a21..9d9e5878 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -2,7 +2,6 @@ import { ScrollTimeline, addAnimation, removeAnimation, - relativePosition } from "./scroll-timeline-base"; const nativeDocumentGetAnimations = document.getAnimations; @@ -10,9 +9,6 @@ const nativeElementGetAnimations = window.Element.prototype.getAnimations; const nativeElementAnimate = window.Element.prototype.animate; const nativeAnimation = window.Animation; -export const ANIMATION_RANGE_NAMES = ['entry', 'exit', 'cover', 'contain', 'entry-crossing', 'exit-crossing']; -const rangeNameRegExp = new RegExp(`(${ANIMATION_RANGE_NAMES.join('|')})(?!-)`); - class PromiseWrapper { constructor() { this.state = 'pending'; @@ -818,26 +814,18 @@ function createProxyEffect(details) { // Computes the start delay as a fraction of the active cover range. function fractionalStartDelay(details) { - if (!(details.timeline instanceof ViewTimeline)) - return 0; - - let startTime = details.animationRange.start; - if (startTime === 'normal') { - startTime = {rangeName: 'cover', offset: CSS.percent(0)}; + if (details.animationRange.start === 'normal') { + details.animationRange.start = details.timeline.constructor.getNormalStartRange(); } - return relativePosition(details.timeline, startTime.rangeName, startTime.offset); + return details.timeline.relativePosition(details.animationRange.start, details); } // Computes the ends delay as a fraction of the active cover range. function fractionalEndDelay(details) { - if (!(details.timeline instanceof ViewTimeline)) - return 0; - - let endTime = details.animationRange.end; - if (endTime === 'normal') { - endTime = {rangeName: 'cover', offset: CSS.percent(100)}; + if (details.animationRange.end === 'normal') { + details.animationRange.end = details.timeline.constructor.getNormalEndRange(); } - return 1 - relativePosition(details.timeline, endTime.rangeName, endTime.offset); + return 1 - details.timeline.relativePosition(details.animationRange.end, details); } // Map from an instance of ProxyAnimation to internal details about that animation. @@ -956,8 +944,7 @@ export class ProxyAnimation { effect: null, // Range when using a view-timeline. The default range is cover 0% to // 100%. - animationRange: timeline instanceof ViewTimeline ? - parseAnimationRange(animOptions['animation-range']) : null, + animationRange: timeline.constructor.parseAnimationRange(animOptions['animation-range']), proxy: this }); } @@ -1757,98 +1744,6 @@ export class ProxyAnimation { } }; -// Parses an individual TimelineRangeOffset -// TODO: Support all formatting options -function parseTimelineRangeOffset(value, position) { - if(!value || value === 'normal') return 'normal'; - - // 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 = value.split(rangeNameRegExp).map(part => part.trim()).filter(Boolean); - - if (parts.length === 1) { - if (ANIMATION_RANGE_NAMES.includes(parts[0])) { - rangeName = parts[0]; - } else { - offset = CSSNumericValue.parse(parts[0]); - } - } else if (parts.length === 2) { - rangeName = parts[0]; - offset = CSSNumericValue.parse(parts[1]); - } - } - - // Validate rangeName - if (!ANIMATION_RANGE_NAMES.includes(rangeName)) { - throw TypeError("Invalid range name"); - } - - return { rangeName, offset }; -} - -// Parses a given animation-range value (string) -function parseAnimationRange(value) { - if (!value) - return { - start: 'normal', - end: 'normal' - }; - - const animationRange = { - start: { rangeName: 'cover', offset: CSS.percent(0) }, - end: { rangeName: 'cover', offset: CSS.percent(100) }, - }; - - // Format: - // - // --> 0% 100% - // --> - // - // --> cover cover - // TODO: Support all formatting options once ratified in the spec. - const parts = value.split(' '); - const rangeNames = []; - const offsets = []; - - parts.forEach(part => { - if (part.endsWith('%')) - offsets.push(parseFloat(part)); - else - rangeNames.push(part); - }); - - 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]; - } - - // TODO: allow calc() in the offsets - if (offsets.length > 1) { - animationRange.start.offset = CSS.percent(offsets[0]); - animationRange.end.offset = CSS.percent(offsets[1]); - } - - return animationRange; -} - export function animate(keyframes, options) { const timeline = options.timeline; diff --git a/src/scroll-timeline-base.js b/src/scroll-timeline-base.js index db687e2a..798f46f4 100644 --- a/src/scroll-timeline-base.js +++ b/src/scroll-timeline-base.js @@ -23,6 +23,9 @@ const DEFAULT_TIMELINE_AXIS = 'block'; let scrollTimelineOptions = new WeakMap(); let sourceDetails = new WeakMap(); +export const ANIMATION_RANGE_NAMES = ['entry', 'exit', 'cover', 'contain', 'entry-crossing', 'exit-crossing']; +const rangeNameRegExp = new RegExp(`(${ANIMATION_RANGE_NAMES.join('|')})(?!-)`); + function scrollEventSource(source) { if (source === document.scrollingElement) return document; return source; @@ -474,6 +477,62 @@ export class ScrollTimeline { static isValidAxis(axis) { return ["block", "inline", "x", "y"].includes(axis); } + + // Calculate the fractional offset of a range value relative to the full range. + relativePosition(value, details) { + const { axis, source } = details.timeline; + + // @TODO: Make use of sourceMeasurements here, yet these don’t seem to be stored + const style = getComputedStyle(source); + let sourceScrollDistance = undefined; + if (normalizeAxis(axis, style) === 'x') { + sourceScrollDistance = source.scrollWidth; + } else { + sourceScrollDistance = source.scrollHeight; + } + + const position = resolvePx(value, sourceScrollDistance); + const relativePosition = position / sourceScrollDistance; + + return relativePosition; + } + + static getNormalStartRange() { + return CSS.percent(0); + } + + static getNormalEndRange() { + return CSS.percent(100); + } + + static parseAnimationRange(value) { + const animationRange = { + start: this.getNormalStartRange(), + end: this.getNormalEndRange(), + }; + + if (!value) + return animationRange; + + // @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; + } + + static parseTimelineRangePart(value, position) { + if(!value || value === 'normal') return 'normal'; + + // The value is a standalone offset, so simply parse it. + return CSSNumericValue.parse(value); + } } // Methods for calculation of the containing block. @@ -758,17 +817,6 @@ function calculateInset(value, sizes) { return { start, end }; } - -// Calculate the fractional offset of a (phase, percent) pair relative to the -// full cover range. -export function relativePosition(timeline, phase, offset) { - const phaseRange = range(timeline, phase); - const coverRange = range(timeline, 'cover'); - return calculateRelativePosition(phaseRange, offset, coverRange, timeline.subject); -} - - - export function calculateRelativePosition(phaseRange, offset, coverRange, subject) { if (!phaseRange || !coverRange) return 0; @@ -858,4 +906,112 @@ export class ViewTimeline extends ScrollTimeline { return CSS.px(range(this,'cover').end); } + // Calculate the fractional offset of a (phase, percent) pair relative to the + // full cover range. + relativePosition(value, details) { + const { rangeName, offset } = value; + + // @TODO: Precalc and store these + const phaseRange = range(this, rangeName); + const coverRange = range(this, 'cover'); + + return calculateRelativePosition(phaseRange, offset, coverRange, details.timeline.subject); + } + + static getNormalStartRange() { + return { rangeName: 'cover', offset: CSS.percent(0) }; + } + + static getNormalEndRange() { + return { rangeName: 'cover', offset: CSS.percent(100) }; + } + + static parseAnimationRange(value) { + const animationRange = { + start: this.getNormalStartRange(), + end: this.getNormalEndRange(), + }; + + if (!value) + return animationRange; + + // Format: + // + // --> 0% 100% + // --> + // + // --> cover cover + // TODO: Support all formatting options once ratified in the spec. + const parts = value.split(' '); + const rangeNames = []; + const offsets = []; + + parts.forEach(part => { + if (part.endsWith('%')) + offsets.push(parseFloat(part)); + else + rangeNames.push(part); + }); + + 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]; + } + + // TODO: allow calc() in the offsets + if (offsets.length > 1) { + animationRange.start.offset = CSS.percent(offsets[0]); + animationRange.end.offset = CSS.percent(offsets[1]); + } + + return animationRange; + } + + // Parses an individual part of a TimelineRange + // TODO: Support all formatting options + static parseTimelineRangePart(value, position) { + if(!value || value === 'normal') return 'normal'; + + // 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 = value.split(rangeNameRegExp).map(part => part.trim()).filter(Boolean); + + if (parts.length === 1) { + if (ANIMATION_RANGE_NAMES.includes(parts[0])) { + rangeName = parts[0]; + } else { + offset = CSSNumericValue.parse(parts[0]); + } + } else if (parts.length === 2) { + rangeName = parts[0]; + offset = CSSNumericValue.parse(parts[1]); + } + } + + // Validate rangeName + if (!ANIMATION_RANGE_NAMES.includes(rangeName)) { + throw TypeError("Invalid range name"); + } + + return { rangeName, offset }; + } + } diff --git a/src/scroll-timeline-css-parser.js b/src/scroll-timeline-css-parser.js index cb167e5e..f3ddf9c2 100644 --- a/src/scroll-timeline-css-parser.js +++ b/src/scroll-timeline-css-parser.js @@ -1,5 +1,4 @@ -import { ANIMATION_RANGE_NAMES } from './proxy-animation'; -import { getAnonymousSourceElement } from './scroll-timeline-base'; +import { ANIMATION_RANGE_NAMES, getAnonymousSourceElement } from './scroll-timeline-base'; // This is also used in scroll-timeline-css.js export const RegexMatcher = {