From 37956a91bdcc7b5f1efe2f0c23dbac9a8a94b70f Mon Sep 17 00:00:00 2001 From: Bramus Date: Mon, 25 Dec 2023 23:34:48 +0100 Subject: [PATCH 01/18] Remove unused intersection-based-offset + used util function --- src/intersection-based-offset.js | 224 ------------------------------- src/utils.js | 15 --- 2 files changed, 239 deletions(-) delete mode 100644 src/intersection-based-offset.js diff --git a/src/intersection-based-offset.js b/src/intersection-based-offset.js deleted file mode 100644 index b997710c..00000000 --- a/src/intersection-based-offset.js +++ /dev/null @@ -1,224 +0,0 @@ -// Copyright 2019 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import { parseLength } from "./utils"; - -let IntersectionOptions = new WeakMap(); - -// Margin is stored as a 4 element array [top, right, bottom, left] but can be -// specified using anywhere from 1 - 4 elements. This map defines how to convert -// various length inputs to their components. -const TOP = 0; -const RIGHT = 1; -const BOTTOM = 2; -const LEFT = 3; -const MARGIN_MAP = [ - // 1 length maps to all positions. - [[TOP, RIGHT, BOTTOM, LEFT]], - // 2 lengths maps to vertical and horizontal margins. - [ - [TOP, BOTTOM], - [RIGHT, LEFT], - ], - // 3 lengths maps to top, horizontal, bottom margins. - [[TOP], [RIGHT, LEFT], [BOTTOM]], - // 4 lengths maps to each component. - [[TOP], [RIGHT], [BOTTOM], [LEFT]], -]; - -class IntersectionBasedOffset { - constructor(value) { - IntersectionOptions.set(this, { - target: null, - edge: "start", - threshold: 0, - rootMargin: [ - [0, "px"], - [0, "px"], - [0, "px"], - [0, "px"], - ], - }); - this.target = value.target; - this.edge = value.edge || "start"; - this.threshold = value.threshold || 0; - this.rootMargin = value.rootMargin || "0px 0px 0px 0px"; - this.clamp = value.clamp || false; - } - - set target(element) { - if (!(element instanceof Element)) { - IntersectionOptions.get(this).target = null; - throw Error("Intersection target must be an element."); - } - IntersectionOptions.get(this).target = element; - } - - get target() { - return IntersectionOptions.get(this).target; - } - - set edge(value) { - if (["start", "end"].indexOf(value) == -1) return; - IntersectionOptions.get(this).edge = value; - } - - get edge() { - return IntersectionOptions.get(this).edge; - } - - set threshold(value) { - let threshold = parseFloat(value); - // Throw a TypeError for a parse error. - if (threshold != threshold) - throw TypeError("Invalid threshold."); - // TODO(https://crbug.com/1136516): This should throw a RangeError - // consistent with the intersection observer spec but the current - // test expectations are looking for a TypeError. - if (threshold < 0 || threshold > 1) - throw TypeError("threshold must be in the range [0, 1]"); - IntersectionOptions.get(this).threshold = threshold; - } - - get threshold() { - return IntersectionOptions.get(this).threshold; - } - - set rootMargin(value) { - let margins = value.split(/ +/); - if (margins.length < 1 || margins.length > 4) - throw TypeError( - "rootMargin must contain between 1 and 4 length components" - ); - let parsedMargins = [[], [], [], []]; - for (let i = 0; i < margins.length; i++) { - let parsedValue = parseLength(margins[i], true); - if (!parsedValue) throw TypeError("Unrecognized rootMargin length"); - let positions = MARGIN_MAP[margins.length - 1][i]; - for (let j = 0; j < positions.length; j++) { - parsedMargins[positions[j]] = [ - parseFloat(parsedValue.value), - parsedValue.unit, - ]; - } - } - IntersectionOptions.get(this).rootMargin = parsedMargins; - } - - get rootMargin() { - // TODO: Simplify to the shortest matching specification for the given margins. - return IntersectionOptions.get(this) - .rootMargin.map((margin) => { - return margin.join(""); - }) - .join(" "); - } - - set clamp(value) { - // This is just for testing alternative proposals - not intended to be part - // of the specification. - IntersectionOptions.get(this).clamp = !!value; - } -} - -export function parseOffset(value) { - if (value.target) return new IntersectionBasedOffset(value); -} - -function resolveLength(length, containerSize) { - if (length[1] == "percent") return (length[0] * containerSize) / 100; - // Assumption is only px or % will be passed in. - // TODO: Support other length types (e.g. em, vh, etc). - return length[0]; -} - -export function calculateOffset(source, axis, offset, startOrEnd) { - // TODO: Support other writing directions. - if (axis == "block") axis = "y"; - else if (axis == "inline") axis = "x"; - let originalViewport = - source == document.scrollingElement - ? { - left: 0, - right: source.clientWidth, - top: 0, - bottom: source.clientHeight, - width: source.clientWidth, - height: source.clientHeight, - } - : source.getBoundingClientRect(); - - // Resolve margins and offset viewport. - let parsedMargins = IntersectionOptions.get(offset).rootMargin; - let computedMargins = []; - for (let i = 0; i < 4; i++) { - computedMargins.push( - resolveLength( - parsedMargins[i], - i % 2 == 0 ? originalViewport.height : originalViewport.width - ) - ); - } - let viewport = { - left: originalViewport.left - computedMargins[LEFT], - right: originalViewport.right + computedMargins[RIGHT], - width: - originalViewport.right - - originalViewport.left + - computedMargins[LEFT] + - computedMargins[RIGHT], - top: originalViewport.top - computedMargins[TOP], - bottom: originalViewport.bottom + computedMargins[BOTTOM], - height: - originalViewport.bottom - - originalViewport.top + - computedMargins[TOP] + - computedMargins[BOTTOM], - }; - - let clamped = IntersectionOptions.get(offset).clamp; - let target = offset.target.getBoundingClientRect(); - let threshold = offset.threshold; - // Invert threshold for start position. - if (offset.edge == "start") threshold = 1 - threshold; - // Projected point into the scroller scroll range. - if (axis == "y") { - let point = - target.top + - target.height * threshold - - viewport.top + - source.scrollTop; - if (clamped) { - if (offset.edge == "end") return Math.max(0, point - viewport.height); - return Math.min(point, source.scrollHeight - viewport.height); - } else { - if (offset.edge == "end") return point - viewport.height; - return point; - } - } else { - // axis == 'x' - let point = - target.left + - target.width * threshold - - viewport.left + - source.scrollLeft; - if (clamped) { - if (offset.edge == "end") return Math.max(0, point - viewport.width); - return Math.min(point, source.scrollWidth - viewport.width); - } else { - if (offset.edge == "end") return point - viewport.width; - return point; - } - } -} diff --git a/src/utils.js b/src/utils.js index 4b5d18b8..8bfb4989 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,18 +1,3 @@ -export function parseLength(obj, acceptStr) { - if (obj instanceof CSSUnitValue || obj instanceof CSSMathSum) - return obj; - if (!acceptStr) - return null; - let matches = obj.trim().match(/^(-?[0-9]*\.?[0-9]*)(px|%)$/); - if (matches) { - let value = matches[1]; - // The unit for % is percent. - let unit = matches[2] == '%' ? 'percent' : matches[2]; - return new CSSUnitValue(value, unit); - } - return null; -} - const canonicalUnits = new Set(["px", "deg", "s", "hz", "dppx", "number", "fr"]); export function isCanonical(unit) { From ea2abe64eda8cf9defc42b246a6f195cf038ce5c Mon Sep 17 00:00:00 2001 From: Bramus Date: Mon, 25 Dec 2023 23:35:14 +0100 Subject: [PATCH 02/18] calculateRelativePosition needs the subject as the 4th argument --- src/scroll-timeline-css.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scroll-timeline-css.js b/src/scroll-timeline-css.js index d31f0c1f..3c30461b 100644 --- a/src/scroll-timeline-css.js +++ b/src/scroll-timeline-css.js @@ -56,7 +56,7 @@ function relativePosition(phase, container, target, axis, optionsInset, percent) const subjectMeasurements = measureSubject(container, target) const phaseRange = calculateRange(phase, sourceMeasurements, subjectMeasurements, axis, optionsInset); const coverRange = calculateRange('cover', sourceMeasurements, subjectMeasurements, axis, optionsInset); - return calculateRelativePosition(phaseRange, percent, coverRange); + return calculateRelativePosition(phaseRange, percent, coverRange, target); } function createScrollTimeline(anim, animationName, target) { From ba3fc709d861e9e9d30394afbf01dd1abd4947cd Mon Sep 17 00:00:00 2001 From: Bramus Date: Mon, 25 Dec 2023 23:37:04 +0100 Subject: [PATCH 03/18] Introduce normalizeAxis util function --- src/scroll-timeline-base.js | 11 +++-------- src/utils.js | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/scroll-timeline-base.js b/src/scroll-timeline-base.js index 6bfc51da..2b2b13b4 100644 --- a/src/scroll-timeline-base.js +++ b/src/scroll-timeline-base.js @@ -14,6 +14,7 @@ import {installCSSOM} from "./proxy-cssom.js"; import {simplifyCalculation} from "./simplify-calculation"; +import {normalizeAxis} from './utils.js'; installCSSOM(); @@ -58,11 +59,8 @@ function directionAwareScrollOffset(source, axis) { // TODO: sideways-lr should flow bottom to top, but is currently unsupported // in Chrome. // http://drafts.csswg.org/css-writing-modes-4/#block-flow - const horizontalWritingMode = style.writingMode == 'horizontal-tb'; let currentScrollOffset = sourceMeasurements.scrollTop; - if (axis == 'x' || - (axis == 'inline' && horizontalWritingMode) || - (axis == 'block' && !horizontalWritingMode)) { + if (normalizeAxis(axis, style) === 'x') { // Negative values are reported for scrollLeft when the inline text // direction is right to left or for vertical text with a right to left // block flow. This is a consequence of shifting the scroll origin due to @@ -617,16 +615,13 @@ export function calculateRange(phase, sourceMeasurements, subjectMeasurements, a // Determine the view and container size based on the scroll direction. // The view position is the scroll position of the logical starting edge // of the view. - const horizontalWritingMode = sourceMeasurements.writingMode == 'horizontal-tb'; const rtl = sourceMeasurements.direction == 'rtl' || sourceMeasurements.writingMode == 'vertical-rl'; let viewSize = undefined; let viewPos = undefined; let sizes = { fontSize: subjectMeasurements.fontSize }; - if (axis == 'x' || - (axis == 'inline' && horizontalWritingMode) || - (axis == 'block' && !horizontalWritingMode)) { + if (normalizeAxis(axis, sourceMeasurements) === 'x') { viewSize = subjectMeasurements.offsetWidth; viewPos = subjectMeasurements.left; sizes.scrollPadding = [sourceMeasurements.scrollPaddingLeft, sourceMeasurements.scrollPaddingRight]; diff --git a/src/utils.js b/src/utils.js index 8bfb4989..73a65387 100644 --- a/src/utils.js +++ b/src/utils.js @@ -2,4 +2,23 @@ const canonicalUnits = new Set(["px", "deg", "s", "hz", "dppx", "number", "fr"]) export function isCanonical(unit) { return canonicalUnits.has(unit.toLowerCase()); +} + +export function normalizeAxis(axis, computedStyle) { + if (['x','y'].includes(axis)) return axis; + + if (!computedStyle) { + throw new Error('To determine the normalized axis the computedStyle of the source is required.'); + } + + const horizontalWritingMode = computedStyle.writingMode == 'horizontal-tb'; + if (axis === "block") { + axis = horizontalWritingMode ? "y" : "x"; + } else if (axis === "inline") { + axis = horizontalWritingMode ? "x" : "y"; + } else { + throw new TypeError(`Invalid axis “${axis}”`); + } + + return axis; } \ No newline at end of file From b8e1fc8335db7f745a99220f96c382fd3b68b510 Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 00:00:13 +0100 Subject: [PATCH 04/18] Add scroll-timeline with animation-range demo --- .../anonymous-scroll-timeline-with-range.html | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 demo/scroll-timeline-css/anonymous-scroll-timeline-with-range.html diff --git a/demo/scroll-timeline-css/anonymous-scroll-timeline-with-range.html b/demo/scroll-timeline-css/anonymous-scroll-timeline-with-range.html new file mode 100644 index 00000000..e907aaac --- /dev/null +++ b/demo/scroll-timeline-css/anonymous-scroll-timeline-with-range.html @@ -0,0 +1,43 @@ + +Anonymous Scroll Progress Timeline + + + + + +
+
+
+
+ + + \ No newline at end of file From 943fc964481d559aa8289dcedd36fc9dfbfad9ee Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 00:01:28 +0100 Subject: [PATCH 05/18] 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 a54d0e5b..e5a112d0 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'; @@ -822,26 +818,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. @@ -960,8 +948,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 }); } @@ -1761,98 +1748,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 2b2b13b4..b5af5219 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; @@ -480,6 +483,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. @@ -775,17 +834,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; @@ -875,4 +923,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 8e9edad8..7720a099 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 = { From b1ae6e31c84ac0e65e3af5e1464f9c77720de54a Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 00:26:03 +0100 Subject: [PATCH 06/18] Make call to correct function to parse the range offset --- src/proxy-animation.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index e5a112d0..17ebfa48 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -1338,9 +1338,9 @@ export class ProxyAnimation { return details.animation.rangeStart = value; } - if (details.timeline instanceof ViewTimeline) { + if (details.timeline instanceof ScrollTimeline) { const animationRange = details.animationRange; - animationRange.start = parseTimelineRangeOffset(value, 'start'); + animationRange.start = details.timeline.constructor.parseTimelineRangePart(value, 'start'); // Additional polyfill step to ensure that the native animation has the // correct value for current time. @@ -1359,9 +1359,9 @@ export class ProxyAnimation { return details.animation.rangeEnd = value; } - if (details.timeline instanceof ViewTimeline) { + if (details.timeline instanceof ScrollTimeline) { const animationRange = details.animationRange; - animationRange.end = parseTimelineRangeOffset(value, 'end'); + animationRange.end = details.timeline.constructor.parseTimelineRangePart(value, 'end'); // Additional polyfill step to ensure that the native animation has the // correct value for current time. @@ -1759,14 +1759,13 @@ export function animate(keyframes, options) { if (timeline instanceof ScrollTimeline) { animation.pause(); - if (timeline instanceof ViewTimeline) { - const details = proxyAnimations.get(proxyAnimation); - details.animationRange = { - start: parseTimelineRangeOffset(options.rangeStart, 'start'), - end: parseTimelineRangeOffset(options.rangeEnd, 'end'), - }; - } + const details = proxyAnimations.get(proxyAnimation); + details.animationRange = { + start: timeline.constructor.parseTimelineRangePart(options.rangeStart, 'start'), + end: timeline.constructor.parseTimelineRangePart(options.rangeEnd, 'end'), + }; + proxyAnimation.play(); } From 85d3e30e29c2603fbccd4c2c6141c7aeaea1b668 Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 00:26:53 +0100 Subject: [PATCH 07/18] Build the project before running tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4c940da3..e50abca6 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "build": "microbundle -f iife", "dev": "run-all \"serve\" \" microbundle watch -f iife \"", "deploy": "npm run build", - "test-setup": "node test/setup/checkout-wpt.mjs", "test:wpt": "npm run test-setup && cd test && cd wpt && (python wpt run --headless -y --log-wptreport ../report/data.json --log-wptscreenshot=../report/screenshots.txt --log-html=../report/index.html --inject-script ../../dist/scroll-timeline.js firefox scroll-animations || true)", "test:simple": "npm run test-setup && cd test && cd wpt && python wpt serve --inject-script ../../dist/scroll-timeline.js", + "test-setup": "node test/setup/checkout-wpt.mjs && npm run build", "test:compare": "node test/summarize-json.mjs test/report/data.json > test/report/summary.txt && echo 'Comparing test results. If different and expected, patch the following diff to test/expected.txt:' && diff test/expected.txt test/report/summary.txt" }, "repository": { From d1a5902268604b5cdf5ef25a5cb4af9f448340eb Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 01:17:26 +0100 Subject: [PATCH 08/18] Only parse animationRange if its a ScrollTimeline --- src/proxy-animation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 17ebfa48..86f39a24 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -948,7 +948,7 @@ export class ProxyAnimation { effect: null, // Range when using a view-timeline. The default range is cover 0% to // 100%. - animationRange: timeline.constructor.parseAnimationRange(animOptions['animation-range']), + animationRange: isScrollAnimation ? timeline.constructor.parseAnimationRange(animOptions['animation-range']) : null, proxy: this }); } From 69ee78fda61e7c317ba5726df51cd52025434b9b Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 01:17:49 +0100 Subject: [PATCH 09/18] Return 0 in case no animationRange is set --- src/proxy-animation.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 86f39a24..575b04c4 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -818,6 +818,7 @@ function createProxyEffect(details) { // Computes the start delay as a fraction of the active cover range. function fractionalStartDelay(details) { + if (!details.animationRange) return 0; if (details.animationRange.start === 'normal') { details.animationRange.start = details.timeline.constructor.getNormalStartRange(); } @@ -826,6 +827,7 @@ function fractionalStartDelay(details) { // Computes the ends delay as a fraction of the active cover range. function fractionalEndDelay(details) { + if (!details.animationRange) return 0; if (details.animationRange.end === 'normal') { details.animationRange.end = details.timeline.constructor.getNormalEndRange(); } From c48e980b51ce2cbff8f8fccd47389ffee202685b Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 01:17:56 +0100 Subject: [PATCH 10/18] Safer getting of rangeStart/rangeEnd --- src/proxy-animation.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 575b04c4..54b28e39 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -1331,7 +1331,7 @@ export class ProxyAnimation { } get rangeStart() { - return proxyAnimations.get(this).animationRange.start ?? 'normal'; + return proxyAnimations.get(this).animationRange?.start ?? 'normal'; } set rangeStart(value) { @@ -1352,7 +1352,7 @@ export class ProxyAnimation { } get rangeEnd() { - return proxyAnimations.get(this).animationRange.end ?? 'normal'; + return proxyAnimations.get(this).animationRange?.end ?? 'normal'; } set rangeEnd(value) { From 09839130280a8e7598ae33362084a73d35cb0734 Mon Sep 17 00:00:00 2001 From: Bramus Date: Tue, 26 Dec 2023 01:28:51 +0100 Subject: [PATCH 11/18] Update test results --- test/expected.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/expected.txt b/test/expected.txt index 0c443116..bf90a1a4 100644 --- a/test/expected.txt +++ b/test/expected.txt @@ -81,7 +81,7 @@ FAIL /scroll-animations/css/animation-timeline-deferred.html Animation.timeline FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html Changing animation-timeline changes the timeline (sanity check) 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) -FAIL /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 (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 @@ -919,14 +919,14 @@ FAIL /scroll-animations/scroll-timelines/scroll-animation.html Sending animation FAIL /scroll-animations/scroll-timelines/scroll-timeline-invalidation.html Animation current time and effect local time are updated after scroller content size changes. PASS /scroll-animations/scroll-timelines/scroll-timeline-invalidation.html Animation current time and effect local time are updated after scroller size changes. FAIL /scroll-animations/scroll-timelines/scroll-timeline-invalidation.html If scroll animation resizes its scroll timeline scroller, layout reruns once per frame. -FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with percentage range [JavaScript API] +PASS /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with percentage range [JavaScript API] FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with px range [JavaScript API] FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with calculated range [JavaScript API] FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with EM range [JavaScript API] -FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with percentage range [CSS] -FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with px range [CSS] -FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with calculated range [CSS] -FAIL /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with EM range [CSS] +TIMEOUT /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with percentage range [CSS] +NOTRUN /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with px range [CSS] +NOTRUN /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with calculated range [CSS] +NOTRUN /scroll-animations/scroll-timelines/scroll-timeline-range.html Scroll timeline with EM range [CSS] PASS /scroll-animations/scroll-timelines/scroll-timeline-snapshotting.html ScrollTimeline current time is updated after programmatic animated scroll. PASS /scroll-animations/scroll-timelines/setting-current-time.html Setting animation current time to null throws TypeError. FAIL /scroll-animations/scroll-timelines/setting-current-time.html Setting the current time to an absolute time value throws exception @@ -1079,4 +1079,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 448 of 1081 tests. +Passed 450 of 1081 tests. From aa29b30bf299a3c36822a5ea088c8cb5b462259b Mon Sep 17 00:00:00 2001 From: Bramus Date: Thu, 28 Dec 2023 23:11:09 +0100 Subject: [PATCH 12/18] Use cached sourceMeasurements instead of recomputing style --- src/scroll-timeline-base.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/scroll-timeline-base.js b/src/scroll-timeline-base.js index b5af5219..4376829f 100644 --- a/src/scroll-timeline-base.js +++ b/src/scroll-timeline-base.js @@ -487,11 +487,10 @@ export class ScrollTimeline { // Calculate the fractional offset of a range value relative to the full range. relativePosition(value, details) { const { axis, source } = details.timeline; + const { sourceMeasurements } = sourceDetails.get(source); - // @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') { + if (normalizeAxis(axis, sourceMeasurements) === 'x') { sourceScrollDistance = source.scrollWidth; } else { sourceScrollDistance = source.scrollHeight; From cf07f6e5a8ccf4ac6403d25463770016fdb8d22e Mon Sep 17 00:00:00 2001 From: Bramus Date: Wed, 3 Jan 2024 22:51:16 +0100 Subject: [PATCH 13/18] Update test results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There’s a flaky test that fails when ran through test:simple but (sometimes) passes when ran through test:wpt --- test/expected.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/expected.txt b/test/expected.txt index bf90a1a4..2f60369e 100644 --- a/test/expected.txt +++ b/test/expected.txt @@ -81,7 +81,7 @@ FAIL /scroll-animations/css/animation-timeline-deferred.html Animation.timeline FAIL /scroll-animations/css/animation-timeline-ignored.tentative.html Changing animation-timeline changes the timeline (sanity check) 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) +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 @@ -1079,4 +1079,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 450 of 1081 tests. +Passed 449 of 1081 tests. From a596e06253618950e16b1d96a7d874c5be4153b3 Mon Sep 17 00:00:00 2001 From: Bramus Date: Fri, 26 Jan 2024 23:07:25 +0100 Subject: [PATCH 14/18] =?UTF-8?q?Refactor:=20Don=E2=80=99t=20loiter=20the?= =?UTF-8?q?=20ScrollTimeline=20/=20ViewTimeline=20classes=20with=20non-sta?= =?UTF-8?q?ndards=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/proxy-animation.js | 163 ++++++++++++++++++++++++++-- src/scroll-timeline-base.js | 209 +++++++----------------------------- 2 files changed, 192 insertions(+), 180 deletions(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 54b28e39..9a7d4b95 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -1,7 +1,9 @@ import { + ANIMATION_RANGE_NAMES, ScrollTimeline, addAnimation, removeAnimation, + fractionalOffset, } from "./scroll-timeline-base"; const nativeDocumentGetAnimations = document.getAnimations; @@ -9,6 +11,8 @@ const nativeElementGetAnimations = window.Element.prototype.getAnimations; const nativeElementAnimate = window.Element.prototype.animate; const nativeAnimation = window.Animation; +const rangeNameRegExp = new RegExp(`(${ANIMATION_RANGE_NAMES.join('|')})(?!-)`); + class PromiseWrapper { constructor() { this.state = 'pending'; @@ -820,18 +824,18 @@ function createProxyEffect(details) { function fractionalStartDelay(details) { if (!details.animationRange) return 0; if (details.animationRange.start === 'normal') { - details.animationRange.start = details.timeline.constructor.getNormalStartRange(); + details.animationRange.start = getNormalStartRange(details.timeline); } - return details.timeline.relativePosition(details.animationRange.start, details); + return fractionalOffset(details.timeline, details.animationRange.start); } // Computes the ends delay as a fraction of the active cover range. function fractionalEndDelay(details) { if (!details.animationRange) return 0; if (details.animationRange.end === 'normal') { - details.animationRange.end = details.timeline.constructor.getNormalEndRange(); + details.animationRange.end = getNormalEndRange(details.timeline); } - return 1 - details.timeline.relativePosition(details.animationRange.end, details); + return 1 - fractionalOffset(details.timeline, details.animationRange.end); } // Map from an instance of ProxyAnimation to internal details about that animation. @@ -902,6 +906,147 @@ function autoAlignStartTime(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) { + const animationRange = { + start: getNormalStartRange(timeline), + end: getNormalEndRange(timeline), + }; + + if (!value) + return animationRange; + + if (timeline instanceof ViewTimeline) { + // 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; + } + + 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 = 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 }; + } + + 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. @@ -950,7 +1095,7 @@ export class ProxyAnimation { effect: null, // Range when using a view-timeline. The default range is cover 0% to // 100%. - animationRange: isScrollAnimation ? timeline.constructor.parseAnimationRange(animOptions['animation-range']) : null, + animationRange: isScrollAnimation ? parseAnimationRange(timeline, animOptions['animation-range']) : null, proxy: this }); } @@ -1342,7 +1487,7 @@ export class ProxyAnimation { if (details.timeline instanceof ScrollTimeline) { const animationRange = details.animationRange; - animationRange.start = details.timeline.constructor.parseTimelineRangePart(value, 'start'); + animationRange.start = parseTimelineRangePart(details.timeline, value, 'start'); // Additional polyfill step to ensure that the native animation has the // correct value for current time. @@ -1363,7 +1508,7 @@ export class ProxyAnimation { if (details.timeline instanceof ScrollTimeline) { const animationRange = details.animationRange; - animationRange.end = details.timeline.constructor.parseTimelineRangePart(value, 'end'); + animationRange.end = parseTimelineRangePart(details.timeline, value, 'end'); // Additional polyfill step to ensure that the native animation has the // correct value for current time. @@ -1764,8 +1909,8 @@ export function animate(keyframes, options) { const details = proxyAnimations.get(proxyAnimation); details.animationRange = { - start: timeline.constructor.parseTimelineRangePart(options.rangeStart, 'start'), - end: timeline.constructor.parseTimelineRangePart(options.rangeEnd, 'end'), + start: parseTimelineRangePart(timeline, options.rangeStart, 'start'), + end: parseTimelineRangePart(timeline, options.rangeEnd, 'end'), }; proxyAnimation.play(); diff --git a/src/scroll-timeline-base.js b/src/scroll-timeline-base.js index 4376829f..8ca7f3c3 100644 --- a/src/scroll-timeline-base.js +++ b/src/scroll-timeline-base.js @@ -24,7 +24,6 @@ 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; @@ -160,6 +159,10 @@ function validateAnonymousSource(timeline) { updateSource(timeline, source); } +function isValidAxis(axis) { + return ["block", "inline", "x", "y"].includes(axis); +} + /** * Read measurements of source element * @param {HTMLElement} source @@ -395,7 +398,7 @@ export class ScrollTimeline { if ((options && options.axis !== undefined) && (options.axis != DEFAULT_TIMELINE_AXIS)) { - if (!ScrollTimeline.isValidAxis(options.axis)) { + if (!isValidAxis(options.axis)) { throw TypeError("Invalid axis"); } @@ -415,7 +418,7 @@ export class ScrollTimeline { } set axis(axis) { - if (!ScrollTimeline.isValidAxis(axis)) { + if (!isValidAxis(axis)) { throw TypeError("Invalid axis"); } @@ -479,65 +482,6 @@ export class ScrollTimeline { get __polyfill() { return true; } - - 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; - const { sourceMeasurements } = sourceDetails.get(source); - - let sourceScrollDistance = undefined; - if (normalizeAxis(axis, sourceMeasurements) === '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. @@ -652,7 +596,7 @@ export function getScrollParent(node) { // specific phase on a view timeline. // TODO: Track changes to determine when associated animations require their // timing to be renormalized. -function range(timeline, phase) { +export function range(timeline, phase) { const details = scrollTimelineOptions.get(timeline); const subjectMeasurements = details.subjectMeasurements const sourceMeasurements = sourceDetails.get(details.source).sourceMeasurements @@ -833,6 +777,37 @@ function calculateInset(value, sizes) { return { start, end }; } +// Calculate the fractional offset of a range value relative to the normal range. +export function fractionalOffset(timeline, value) { + if (timeline instanceof ViewTimeline) { + const { rangeName, offset } = value; + + const phaseRange = range(timeline, rangeName); + const coverRange = range(timeline, 'cover'); + + return calculateRelativePosition(phaseRange, offset, coverRange, timeline.subject); + } + + if (timeline instanceof ScrollTimeline) { + const { axis, source } = timeline; + const { sourceMeasurements } = sourceDetails.get(source); + + let sourceScrollDistance = undefined; + if (normalizeAxis(axis, sourceMeasurements) === 'x') { + sourceScrollDistance = source.scrollWidth; + } else { + sourceScrollDistance = source.scrollHeight; + } + + const position = resolvePx(value, sourceScrollDistance); + const fractionalOffset = position / sourceScrollDistance; + + return fractionalOffset; + } + + unsupportedTimeline(timeline); +} + export function calculateRelativePosition(phaseRange, offset, coverRange, subject) { if (!phaseRange || !coverRange) return 0; @@ -922,112 +897,4 @@ 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 }; - } - } From db921baf2b5b54a6868665b668e599223a81d840 Mon Sep 17 00:00:00 2001 From: Bramus Date: Sat, 27 Jan 2024 00:04:03 +0100 Subject: [PATCH 15/18] Update comment --- src/proxy-animation.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index 9a7d4b95..e929b1dc 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -1093,8 +1093,8 @@ export class ProxyAnimation { // Effect proxy that performs the necessary time conversions when using a // progress-based timelines. effect: null, - // Range when using a view-timeline. The default range is cover 0% to - // 100%. + // 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 }); From 121b3705c2d7843939426de55f55f8f089ec44ae Mon Sep 17 00:00:00 2001 From: Bramus Date: Sat, 27 Jan 2024 00:14:57 +0100 Subject: [PATCH 16/18] Remove unncessary build step --- .github/workflows/gh-pages.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 65c3a8e8..0ff13662 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -44,8 +44,6 @@ jobs: uses: actions/configure-pages@v3 - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" - - name: Build with microbundle - run: "npm run build" - name: Checkout WPT run: "npm run test-setup" - name: WPT hosts From 9079a8e455cce98ecc5f353ce9eacf0615b6bcd8 Mon Sep 17 00:00:00 2001 From: Bramus Date: Sun, 28 Jan 2024 10:41:46 +0100 Subject: [PATCH 17/18] Revert changes to npm scripts / build process --- .github/workflows/gh-pages.yml | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 0ff13662..65c3a8e8 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -44,6 +44,8 @@ jobs: uses: actions/configure-pages@v3 - name: Install Node.js dependencies run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" + - name: Build with microbundle + run: "npm run build" - name: Checkout WPT run: "npm run test-setup" - name: WPT hosts diff --git a/package.json b/package.json index e50abca6..4c940da3 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,9 @@ "build": "microbundle -f iife", "dev": "run-all \"serve\" \" microbundle watch -f iife \"", "deploy": "npm run build", + "test-setup": "node test/setup/checkout-wpt.mjs", "test:wpt": "npm run test-setup && cd test && cd wpt && (python wpt run --headless -y --log-wptreport ../report/data.json --log-wptscreenshot=../report/screenshots.txt --log-html=../report/index.html --inject-script ../../dist/scroll-timeline.js firefox scroll-animations || true)", "test:simple": "npm run test-setup && cd test && cd wpt && python wpt serve --inject-script ../../dist/scroll-timeline.js", - "test-setup": "node test/setup/checkout-wpt.mjs && npm run build", "test:compare": "node test/summarize-json.mjs test/report/data.json > test/report/summary.txt && echo 'Comparing test results. If different and expected, patch the following diff to test/expected.txt:' && diff test/expected.txt test/report/summary.txt" }, "repository": { From 0f5797287bdf52fcc2f0c17dfe6915c6d651312d Mon Sep 17 00:00:00 2001 From: Bramus Date: Wed, 31 Jan 2024 18:06:52 +0100 Subject: [PATCH 18/18] Split on any whitespace character --- src/proxy-animation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy-animation.js b/src/proxy-animation.js index c23f47f3..79b7c952 100644 --- a/src/proxy-animation.js +++ b/src/proxy-animation.js @@ -951,7 +951,7 @@ function parseAnimationRange(timeline, value) { // // --> cover cover // TODO: Support all formatting options once ratified in the spec. - const parts = value.split(' '); + const parts = value.split(/\s+/); const rangeNames = []; const offsets = [];