Skip to content

Commit

Permalink
Add support for animation-range with ScrollTimeline
Browse files Browse the repository at this point in the history
  • Loading branch information
bramus committed Dec 25, 2023
1 parent 6d778d9 commit 83ae4c2
Show file tree
Hide file tree
Showing 3 changed files with 175 additions and 125 deletions.
119 changes: 7 additions & 112 deletions src/proxy-animation.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@ import {
ScrollTimeline,
addAnimation,
removeAnimation,
relativePosition
} from "./scroll-timeline-base";

const nativeDocumentGetAnimations = document.getAnimations;
const nativeElementGetAnimations = window.Element.prototype.getAnimations;
const nativeElementAnimate = window.Element.prototype.animate;
const nativeAnimation = window.Animation;

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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
});
}
Expand Down Expand Up @@ -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:
// <start-name> <start-offset> <end-name> <end-offset>
// <name> --> <name> 0% <name> 100%
// <name> <start-offset> <end-offset> --> <name> <start-offset>
// <name> <end-offset>
// <start-offset> <end-offset> --> cover <start-offset> cover <end-offset>
// TODO: Support all formatting options once ratified in the spec.
const parts = 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;

Expand Down
178 changes: 167 additions & 11 deletions src/scroll-timeline-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
// <start-name> <start-offset> <end-name> <end-offset>
// <name> --> <name> 0% <name> 100%
// <name> <start-offset> <end-offset> --> <name> <start-offset>
// <name> <end-offset>
// <start-offset> <end-offset> --> cover <start-offset> cover <end-offset>
// TODO: Support all formatting options once ratified in the spec.
const parts = 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 };
}

}
3 changes: 1 addition & 2 deletions src/scroll-timeline-css-parser.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down

0 comments on commit 83ae4c2

Please sign in to comment.