UNPKG

@angular/animations

Version:

Angular - animations integration with web-animations

1,193 lines (1,183 loc) 172 kB
/** * @license Angular v20.1.4 * (c) 2010-2025 Google LLC. https://angular.io/ * License: MIT */ import * as i0 from '@angular/core'; import { Injectable } from '@angular/core'; import { validateStyleProperty, containsElement, getParentElement, invokeQuery, dashCaseToCamelCase, invalidCssUnitValue, invalidExpression, invalidTransitionAlias, visitDslNode, invalidTrigger, invalidDefinition, extractStyleParams, invalidState, invalidStyleValue, SUBSTITUTION_EXPR_START, invalidParallelAnimation, validateStyleParams, invalidKeyframes, invalidOffset, keyframeOffsetsOutOfOrder, keyframesMissingOffsets, getOrSetDefaultValue, invalidStagger, resolveTiming, NG_TRIGGER_SELECTOR, NG_ANIMATING_SELECTOR, normalizeAnimationEntry, resolveTimingValue, interpolateParams, invalidQuery, registerFailed, normalizeKeyframes, LEAVE_CLASSNAME, ENTER_CLASSNAME, missingOrDestroyedAnimation, createAnimationFailed, optimizeGroupPlayer, missingPlayer, listenOnPlayer, makeAnimationEvent, triggerTransitionsFailed, eraseStyles, setStyles, transitionFailed, missingTrigger, missingEvent, unsupportedTriggerEvent, NG_TRIGGER_CLASSNAME, unregisteredTrigger, NG_ANIMATING_CLASSNAME, triggerBuildFailed, parseTimelineCommand, computeStyle, camelCaseToDashCase, validateWebAnimatableStyleProperty, allowPreviousPlayerStylesMerge, normalizeKeyframes$1, balancePreviousStylesIntoKeyframes, validationFailed, normalizeStyles, buildingFailed } from './util.mjs'; import { NoopAnimationPlayer, AnimationMetadataType, style, AUTO_STYLE, ɵPRE_STYLE as _PRE_STYLE, AnimationGroupPlayer } from './private_export.mjs'; /** * @publicApi * * `AnimationDriver` implentation for Noop animations */ class NoopAnimationDriver { /** * @returns Whether `prop` is a valid CSS property */ validateStyleProperty(prop) { return validateStyleProperty(prop); } /** * * @returns Whether elm1 contains elm2. */ containsElement(elm1, elm2) { return containsElement(elm1, elm2); } /** * @returns Rhe parent of the given element or `null` if the element is the `document` */ getParentElement(element) { return getParentElement(element); } /** * @returns The result of the query selector on the element. The array will contain up to 1 item * if `multi` is `false`. */ query(element, selector, multi) { return invokeQuery(element, selector, multi); } /** * @returns The `defaultValue` or empty string */ computeStyle(element, prop, defaultValue) { return defaultValue || ''; } /** * @returns An `NoopAnimationPlayer` */ animate(element, keyframes, duration, delay, easing, previousPlayers = [], scrubberAccessRequested) { return new NoopAnimationPlayer(duration, delay); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: NoopAnimationDriver, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: NoopAnimationDriver }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.4", ngImport: i0, type: NoopAnimationDriver, decorators: [{ type: Injectable }] }); /** * @publicApi */ class AnimationDriver { /** * @deprecated Use the NoopAnimationDriver class. */ static NOOP = new NoopAnimationDriver(); } class AnimationStyleNormalizer { } class NoopAnimationStyleNormalizer { normalizePropertyName(propertyName, errors) { return propertyName; } normalizeStyleValue(userProvidedProperty, normalizedProperty, value, errors) { return value; } } const DIMENSIONAL_PROP_SET = new Set([ 'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight', 'left', 'top', 'bottom', 'right', 'fontSize', 'outlineWidth', 'outlineOffset', 'paddingTop', 'paddingLeft', 'paddingBottom', 'paddingRight', 'marginTop', 'marginLeft', 'marginBottom', 'marginRight', 'borderRadius', 'borderWidth', 'borderTopWidth', 'borderLeftWidth', 'borderRightWidth', 'borderBottomWidth', 'textIndent', 'perspective', ]); class WebAnimationsStyleNormalizer extends AnimationStyleNormalizer { normalizePropertyName(propertyName, errors) { return dashCaseToCamelCase(propertyName); } normalizeStyleValue(userProvidedProperty, normalizedProperty, value, errors) { let unit = ''; const strVal = value.toString().trim(); if (DIMENSIONAL_PROP_SET.has(normalizedProperty) && value !== 0 && value !== '0') { if (typeof value === 'number') { unit = 'px'; } else { const valAndSuffixMatch = value.match(/^[+-]?[\d\.]+([a-z]*)$/); if (valAndSuffixMatch && valAndSuffixMatch[1].length == 0) { errors.push(invalidCssUnitValue(userProvidedProperty, value)); } } } return strVal + unit; } } function createListOfWarnings(warnings) { const LINE_START = '\n - '; return `${LINE_START}${warnings .filter(Boolean) .map((warning) => warning) .join(LINE_START)}`; } function warnValidation(warnings) { console.warn(`animation validation warnings:${createListOfWarnings(warnings)}`); } function warnTriggerBuild(name, warnings) { console.warn(`The animation trigger "${name}" has built with the following warnings:${createListOfWarnings(warnings)}`); } function warnRegister(warnings) { console.warn(`Animation built with the following warnings:${createListOfWarnings(warnings)}`); } function pushUnrecognizedPropertiesWarning(warnings, props) { if (props.length) { warnings.push(`The following provided properties are not recognized: ${props.join(', ')}`); } } const ANY_STATE = '*'; function parseTransitionExpr(transitionValue, errors) { const expressions = []; if (typeof transitionValue == 'string') { transitionValue .split(/\s*,\s*/) .forEach((str) => parseInnerTransitionStr(str, expressions, errors)); } else { expressions.push(transitionValue); } return expressions; } function parseInnerTransitionStr(eventStr, expressions, errors) { if (eventStr[0] == ':') { const result = parseAnimationAlias(eventStr, errors); if (typeof result == 'function') { expressions.push(result); return; } eventStr = result; } const match = eventStr.match(/^(\*|[-\w]+)\s*(<?[=-]>)\s*(\*|[-\w]+)$/); if (match == null || match.length < 4) { errors.push(invalidExpression(eventStr)); return expressions; } const fromState = match[1]; const separator = match[2]; const toState = match[3]; expressions.push(makeLambdaFromStates(fromState, toState)); const isFullAnyStateExpr = fromState == ANY_STATE && toState == ANY_STATE; if (separator[0] == '<' && !isFullAnyStateExpr) { expressions.push(makeLambdaFromStates(toState, fromState)); } return; } function parseAnimationAlias(alias, errors) { switch (alias) { case ':enter': return 'void => *'; case ':leave': return '* => void'; case ':increment': return (fromState, toState) => parseFloat(toState) > parseFloat(fromState); case ':decrement': return (fromState, toState) => parseFloat(toState) < parseFloat(fromState); default: errors.push(invalidTransitionAlias(alias)); return '* => *'; } } // DO NOT REFACTOR ... keep the follow set instantiations // with the values intact (closure compiler for some reason // removes follow-up lines that add the values outside of // the constructor... const TRUE_BOOLEAN_VALUES = new Set(['true', '1']); const FALSE_BOOLEAN_VALUES = new Set(['false', '0']); function makeLambdaFromStates(lhs, rhs) { const LHS_MATCH_BOOLEAN = TRUE_BOOLEAN_VALUES.has(lhs) || FALSE_BOOLEAN_VALUES.has(lhs); const RHS_MATCH_BOOLEAN = TRUE_BOOLEAN_VALUES.has(rhs) || FALSE_BOOLEAN_VALUES.has(rhs); return (fromState, toState) => { let lhsMatch = lhs == ANY_STATE || lhs == fromState; let rhsMatch = rhs == ANY_STATE || rhs == toState; if (!lhsMatch && LHS_MATCH_BOOLEAN && typeof fromState === 'boolean') { lhsMatch = fromState ? TRUE_BOOLEAN_VALUES.has(lhs) : FALSE_BOOLEAN_VALUES.has(lhs); } if (!rhsMatch && RHS_MATCH_BOOLEAN && typeof toState === 'boolean') { rhsMatch = toState ? TRUE_BOOLEAN_VALUES.has(rhs) : FALSE_BOOLEAN_VALUES.has(rhs); } return lhsMatch && rhsMatch; }; } const SELF_TOKEN = ':self'; const SELF_TOKEN_REGEX = /* @__PURE__ */ new RegExp(`s*${SELF_TOKEN}s*,?`, 'g'); /* * [Validation] * The visitor code below will traverse the animation AST generated by the animation verb functions * (the output is a tree of objects) and attempt to perform a series of validations on the data. The * following corner-cases will be validated: * * 1. Overlap of animations * Given that a CSS property cannot be animated in more than one place at the same time, it's * important that this behavior is detected and validated. The way in which this occurs is that * each time a style property is examined, a string-map containing the property will be updated with * the start and end times for when the property is used within an animation step. * * If there are two or more parallel animations that are currently running (these are invoked by the * group()) on the same element then the validator will throw an error. Since the start/end timing * values are collected for each property then if the current animation step is animating the same * property and its timing values fall anywhere into the window of time that the property is * currently being animated within then this is what causes an error. * * 2. Timing values * The validator will validate to see if a timing value of `duration delay easing` or * `durationNumber` is valid or not. * * (note that upon validation the code below will replace the timing data with an object containing * {duration,delay,easing}. * * 3. Offset Validation * Each of the style() calls are allowed to have an offset value when placed inside of keyframes(). * Offsets within keyframes() are considered valid when: * * - No offsets are used at all * - Each style() entry contains an offset value * - Each offset is between 0 and 1 * - Each offset is greater to or equal than the previous one * * Otherwise an error will be thrown. */ function buildAnimationAst(driver, metadata, errors, warnings) { return new AnimationAstBuilderVisitor(driver).build(metadata, errors, warnings); } const ROOT_SELECTOR = ''; class AnimationAstBuilderVisitor { _driver; constructor(_driver) { this._driver = _driver; } build(metadata, errors, warnings) { const context = new AnimationAstBuilderContext(errors); this._resetContextStyleTimingState(context); const ast = (visitDslNode(this, normalizeAnimationEntry(metadata), context)); if (typeof ngDevMode === 'undefined' || ngDevMode) { if (context.unsupportedCSSPropertiesFound.size) { pushUnrecognizedPropertiesWarning(warnings, [ ...context.unsupportedCSSPropertiesFound.keys(), ]); } } return ast; } _resetContextStyleTimingState(context) { context.currentQuerySelector = ROOT_SELECTOR; context.collectedStyles = new Map(); context.collectedStyles.set(ROOT_SELECTOR, new Map()); context.currentTime = 0; } visitTrigger(metadata, context) { let queryCount = (context.queryCount = 0); let depCount = (context.depCount = 0); const states = []; const transitions = []; if (metadata.name.charAt(0) == '@') { context.errors.push(invalidTrigger()); } metadata.definitions.forEach((def) => { this._resetContextStyleTimingState(context); if (def.type == AnimationMetadataType.State) { const stateDef = def; const name = stateDef.name; name .toString() .split(/\s*,\s*/) .forEach((n) => { stateDef.name = n; states.push(this.visitState(stateDef, context)); }); stateDef.name = name; } else if (def.type == AnimationMetadataType.Transition) { const transition = this.visitTransition(def, context); queryCount += transition.queryCount; depCount += transition.depCount; transitions.push(transition); } else { context.errors.push(invalidDefinition()); } }); return { type: AnimationMetadataType.Trigger, name: metadata.name, states, transitions, queryCount, depCount, options: null, }; } visitState(metadata, context) { const styleAst = this.visitStyle(metadata.styles, context); const astParams = (metadata.options && metadata.options.params) || null; if (styleAst.containsDynamicStyles) { const missingSubs = new Set(); const params = astParams || {}; styleAst.styles.forEach((style) => { if (style instanceof Map) { style.forEach((value) => { extractStyleParams(value).forEach((sub) => { if (!params.hasOwnProperty(sub)) { missingSubs.add(sub); } }); }); } }); if (missingSubs.size) { context.errors.push(invalidState(metadata.name, [...missingSubs.values()])); } } return { type: AnimationMetadataType.State, name: metadata.name, style: styleAst, options: astParams ? { params: astParams } : null, }; } visitTransition(metadata, context) { context.queryCount = 0; context.depCount = 0; const animation = visitDslNode(this, normalizeAnimationEntry(metadata.animation), context); const matchers = parseTransitionExpr(metadata.expr, context.errors); return { type: AnimationMetadataType.Transition, matchers, animation, queryCount: context.queryCount, depCount: context.depCount, options: normalizeAnimationOptions(metadata.options), }; } visitSequence(metadata, context) { return { type: AnimationMetadataType.Sequence, steps: metadata.steps.map((s) => visitDslNode(this, s, context)), options: normalizeAnimationOptions(metadata.options), }; } visitGroup(metadata, context) { const currentTime = context.currentTime; let furthestTime = 0; const steps = metadata.steps.map((step) => { context.currentTime = currentTime; const innerAst = visitDslNode(this, step, context); furthestTime = Math.max(furthestTime, context.currentTime); return innerAst; }); context.currentTime = furthestTime; return { type: AnimationMetadataType.Group, steps, options: normalizeAnimationOptions(metadata.options), }; } visitAnimate(metadata, context) { const timingAst = constructTimingAst(metadata.timings, context.errors); context.currentAnimateTimings = timingAst; let styleAst; let styleMetadata = metadata.styles ? metadata.styles : style({}); if (styleMetadata.type == AnimationMetadataType.Keyframes) { styleAst = this.visitKeyframes(styleMetadata, context); } else { let styleMetadata = metadata.styles; let isEmpty = false; if (!styleMetadata) { isEmpty = true; const newStyleData = {}; if (timingAst.easing) { newStyleData['easing'] = timingAst.easing; } styleMetadata = style(newStyleData); } context.currentTime += timingAst.duration + timingAst.delay; const _styleAst = this.visitStyle(styleMetadata, context); _styleAst.isEmptyStep = isEmpty; styleAst = _styleAst; } context.currentAnimateTimings = null; return { type: AnimationMetadataType.Animate, timings: timingAst, style: styleAst, options: null, }; } visitStyle(metadata, context) { const ast = this._makeStyleAst(metadata, context); this._validateStyleAst(ast, context); return ast; } _makeStyleAst(metadata, context) { const styles = []; const metadataStyles = Array.isArray(metadata.styles) ? metadata.styles : [metadata.styles]; for (let styleTuple of metadataStyles) { if (typeof styleTuple === 'string') { if (styleTuple === AUTO_STYLE) { styles.push(styleTuple); } else { context.errors.push(invalidStyleValue(styleTuple)); } } else { styles.push(new Map(Object.entries(styleTuple))); } } let containsDynamicStyles = false; let collectedEasing = null; styles.forEach((styleData) => { if (styleData instanceof Map) { if (styleData.has('easing')) { collectedEasing = styleData.get('easing'); styleData.delete('easing'); } if (!containsDynamicStyles) { for (let value of styleData.values()) { if (value.toString().indexOf(SUBSTITUTION_EXPR_START) >= 0) { containsDynamicStyles = true; break; } } } } }); return { type: AnimationMetadataType.Style, styles, easing: collectedEasing, offset: metadata.offset, containsDynamicStyles, options: null, }; } _validateStyleAst(ast, context) { const timings = context.currentAnimateTimings; let endTime = context.currentTime; let startTime = context.currentTime; if (timings && startTime > 0) { startTime -= timings.duration + timings.delay; } ast.styles.forEach((tuple) => { if (typeof tuple === 'string') return; tuple.forEach((value, prop) => { if (typeof ngDevMode === 'undefined' || ngDevMode) { if (!this._driver.validateStyleProperty(prop)) { tuple.delete(prop); context.unsupportedCSSPropertiesFound.add(prop); return; } } // This is guaranteed to have a defined Map at this querySelector location making it // safe to add the assertion here. It is set as a default empty map in prior methods. const collectedStyles = context.collectedStyles.get(context.currentQuerySelector); const collectedEntry = collectedStyles.get(prop); let updateCollectedStyle = true; if (collectedEntry) { if (startTime != endTime && startTime >= collectedEntry.startTime && endTime <= collectedEntry.endTime) { context.errors.push(invalidParallelAnimation(prop, collectedEntry.startTime, collectedEntry.endTime, startTime, endTime)); updateCollectedStyle = false; } // we always choose the smaller start time value since we // want to have a record of the entire animation window where // the style property is being animated in between startTime = collectedEntry.startTime; } if (updateCollectedStyle) { collectedStyles.set(prop, { startTime, endTime }); } if (context.options) { validateStyleParams(value, context.options, context.errors); } }); }); } visitKeyframes(metadata, context) { const ast = { type: AnimationMetadataType.Keyframes, styles: [], options: null }; if (!context.currentAnimateTimings) { context.errors.push(invalidKeyframes()); return ast; } const MAX_KEYFRAME_OFFSET = 1; let totalKeyframesWithOffsets = 0; const offsets = []; let offsetsOutOfOrder = false; let keyframesOutOfRange = false; let previousOffset = 0; const keyframes = metadata.steps.map((styles) => { const style = this._makeStyleAst(styles, context); let offsetVal = style.offset != null ? style.offset : consumeOffset(style.styles); let offset = 0; if (offsetVal != null) { totalKeyframesWithOffsets++; offset = style.offset = offsetVal; } keyframesOutOfRange = keyframesOutOfRange || offset < 0 || offset > 1; offsetsOutOfOrder = offsetsOutOfOrder || offset < previousOffset; previousOffset = offset; offsets.push(offset); return style; }); if (keyframesOutOfRange) { context.errors.push(invalidOffset()); } if (offsetsOutOfOrder) { context.errors.push(keyframeOffsetsOutOfOrder()); } const length = metadata.steps.length; let generatedOffset = 0; if (totalKeyframesWithOffsets > 0 && totalKeyframesWithOffsets < length) { context.errors.push(keyframesMissingOffsets()); } else if (totalKeyframesWithOffsets == 0) { generatedOffset = MAX_KEYFRAME_OFFSET / (length - 1); } const limit = length - 1; const currentTime = context.currentTime; const currentAnimateTimings = context.currentAnimateTimings; const animateDuration = currentAnimateTimings.duration; keyframes.forEach((kf, i) => { const offset = generatedOffset > 0 ? (i == limit ? 1 : generatedOffset * i) : offsets[i]; const durationUpToThisFrame = offset * animateDuration; context.currentTime = currentTime + currentAnimateTimings.delay + durationUpToThisFrame; currentAnimateTimings.duration = durationUpToThisFrame; this._validateStyleAst(kf, context); kf.offset = offset; ast.styles.push(kf); }); return ast; } visitReference(metadata, context) { return { type: AnimationMetadataType.Reference, animation: visitDslNode(this, normalizeAnimationEntry(metadata.animation), context), options: normalizeAnimationOptions(metadata.options), }; } visitAnimateChild(metadata, context) { context.depCount++; return { type: AnimationMetadataType.AnimateChild, options: normalizeAnimationOptions(metadata.options), }; } visitAnimateRef(metadata, context) { return { type: AnimationMetadataType.AnimateRef, animation: this.visitReference(metadata.animation, context), options: normalizeAnimationOptions(metadata.options), }; } visitQuery(metadata, context) { const parentSelector = context.currentQuerySelector; const options = (metadata.options || {}); context.queryCount++; context.currentQuery = metadata; const [selector, includeSelf] = normalizeSelector(metadata.selector); context.currentQuerySelector = parentSelector.length ? parentSelector + ' ' + selector : selector; getOrSetDefaultValue(context.collectedStyles, context.currentQuerySelector, new Map()); const animation = visitDslNode(this, normalizeAnimationEntry(metadata.animation), context); context.currentQuery = null; context.currentQuerySelector = parentSelector; return { type: AnimationMetadataType.Query, selector, limit: options.limit || 0, optional: !!options.optional, includeSelf, animation, originalSelector: metadata.selector, options: normalizeAnimationOptions(metadata.options), }; } visitStagger(metadata, context) { if (!context.currentQuery) { context.errors.push(invalidStagger()); } const timings = metadata.timings === 'full' ? { duration: 0, delay: 0, easing: 'full' } : resolveTiming(metadata.timings, context.errors, true); return { type: AnimationMetadataType.Stagger, animation: visitDslNode(this, normalizeAnimationEntry(metadata.animation), context), timings, options: null, }; } } function normalizeSelector(selector) { const hasAmpersand = selector.split(/\s*,\s*/).find((token) => token == SELF_TOKEN) ? true : false; if (hasAmpersand) { selector = selector.replace(SELF_TOKEN_REGEX, ''); } // Note: the :enter and :leave aren't normalized here since those // selectors are filled in at runtime during timeline building selector = selector .replace(/@\*/g, NG_TRIGGER_SELECTOR) .replace(/@\w+/g, (match) => NG_TRIGGER_SELECTOR + '-' + match.slice(1)) .replace(/:animating/g, NG_ANIMATING_SELECTOR); return [selector, hasAmpersand]; } function normalizeParams(obj) { return obj ? { ...obj } : null; } class AnimationAstBuilderContext { errors; queryCount = 0; depCount = 0; currentTransition = null; currentQuery = null; currentQuerySelector = null; currentAnimateTimings = null; currentTime = 0; collectedStyles = new Map(); options = null; unsupportedCSSPropertiesFound = new Set(); constructor(errors) { this.errors = errors; } } function consumeOffset(styles) { if (typeof styles == 'string') return null; let offset = null; if (Array.isArray(styles)) { styles.forEach((styleTuple) => { if (styleTuple instanceof Map && styleTuple.has('offset')) { const obj = styleTuple; offset = parseFloat(obj.get('offset')); obj.delete('offset'); } }); } else if (styles instanceof Map && styles.has('offset')) { const obj = styles; offset = parseFloat(obj.get('offset')); obj.delete('offset'); } return offset; } function constructTimingAst(value, errors) { if (value.hasOwnProperty('duration')) { return value; } if (typeof value == 'number') { const duration = resolveTiming(value, errors).duration; return makeTimingAst(duration, 0, ''); } const strValue = value; const isDynamic = strValue.split(/\s+/).some((v) => v.charAt(0) == '{' && v.charAt(1) == '{'); if (isDynamic) { const ast = makeTimingAst(0, 0, ''); ast.dynamic = true; ast.strValue = strValue; return ast; } const timings = resolveTiming(strValue, errors); return makeTimingAst(timings.duration, timings.delay, timings.easing); } function normalizeAnimationOptions(options) { if (options) { options = { ...options }; if (options['params']) { options['params'] = normalizeParams(options['params']); } } else { options = {}; } return options; } function makeTimingAst(duration, delay, easing) { return { duration, delay, easing }; } function createTimelineInstruction(element, keyframes, preStyleProps, postStyleProps, duration, delay, easing = null, subTimeline = false) { return { type: 1 /* AnimationTransitionInstructionType.TimelineAnimation */, element, keyframes, preStyleProps, postStyleProps, duration, delay, totalTime: duration + delay, easing, subTimeline, }; } class ElementInstructionMap { _map = new Map(); get(element) { return this._map.get(element) || []; } append(element, instructions) { let existingInstructions = this._map.get(element); if (!existingInstructions) { this._map.set(element, (existingInstructions = [])); } existingInstructions.push(...instructions); } has(element) { return this._map.has(element); } clear() { this._map.clear(); } } const ONE_FRAME_IN_MILLISECONDS = 1; const ENTER_TOKEN = ':enter'; const ENTER_TOKEN_REGEX = /* @__PURE__ */ new RegExp(ENTER_TOKEN, 'g'); const LEAVE_TOKEN = ':leave'; const LEAVE_TOKEN_REGEX = /* @__PURE__ */ new RegExp(LEAVE_TOKEN, 'g'); /* * The code within this file aims to generate web-animations-compatible keyframes from Angular's * animation DSL code. * * The code below will be converted from: * * ```ts * sequence([ * style({ opacity: 0 }), * animate(1000, style({ opacity: 0 })) * ]) * ``` * * To: * ```ts * keyframes = [{ opacity: 0, offset: 0 }, { opacity: 1, offset: 1 }] * duration = 1000 * delay = 0 * easing = '' * ``` * * For this operation to cover the combination of animation verbs (style, animate, group, etc...) a * combination of AST traversal and merge-sort-like algorithms are used. * * [AST Traversal] * Each of the animation verbs, when executed, will return an string-map object representing what * type of action it is (style, animate, group, etc...) and the data associated with it. This means * that when functional composition mix of these functions is evaluated (like in the example above) * then it will end up producing a tree of objects representing the animation itself. * * When this animation object tree is processed by the visitor code below it will visit each of the * verb statements within the visitor. And during each visit it will build the context of the * animation keyframes by interacting with the `TimelineBuilder`. * * [TimelineBuilder] * This class is responsible for tracking the styles and building a series of keyframe objects for a * timeline between a start and end time. The builder starts off with an initial timeline and each * time the AST comes across a `group()`, `keyframes()` or a combination of the two within a * `sequence()` then it will generate a sub timeline for each step as well as a new one after * they are complete. * * As the AST is traversed, the timing state on each of the timelines will be incremented. If a sub * timeline was created (based on one of the cases above) then the parent timeline will attempt to * merge the styles used within the sub timelines into itself (only with group() this will happen). * This happens with a merge operation (much like how the merge works in mergeSort) and it will only * copy the most recently used styles from the sub timelines into the parent timeline. This ensures * that if the styles are used later on in another phase of the animation then they will be the most * up-to-date values. * * [How Missing Styles Are Updated] * Each timeline has a `backFill` property which is responsible for filling in new styles into * already processed keyframes if a new style shows up later within the animation sequence. * * ```ts * sequence([ * style({ width: 0 }), * animate(1000, style({ width: 100 })), * animate(1000, style({ width: 200 })), * animate(1000, style({ width: 300 })) * animate(1000, style({ width: 400, height: 400 })) // notice how `height` doesn't exist anywhere * else * ]) * ``` * * What is happening here is that the `height` value is added later in the sequence, but is missing * from all previous animation steps. Therefore when a keyframe is created it would also be missing * from all previous keyframes up until where it is first used. For the timeline keyframe generation * to properly fill in the style it will place the previous value (the value from the parent * timeline) or a default value of `*` into the backFill map. * * When a sub-timeline is created it will have its own backFill property. This is done so that * styles present within the sub-timeline do not accidentally seep into the previous/future timeline * keyframes * * [Validation] * The code in this file is not responsible for validation. That functionality happens with within * the `AnimationValidatorVisitor` code. */ function buildAnimationTimelines(driver, rootElement, ast, enterClassName, leaveClassName, startingStyles = new Map(), finalStyles = new Map(), options, subInstructions, errors = []) { return new AnimationTimelineBuilderVisitor().buildKeyframes(driver, rootElement, ast, enterClassName, leaveClassName, startingStyles, finalStyles, options, subInstructions, errors); } class AnimationTimelineBuilderVisitor { buildKeyframes(driver, rootElement, ast, enterClassName, leaveClassName, startingStyles, finalStyles, options, subInstructions, errors = []) { subInstructions = subInstructions || new ElementInstructionMap(); const context = new AnimationTimelineContext(driver, rootElement, subInstructions, enterClassName, leaveClassName, errors, []); context.options = options; const delay = options.delay ? resolveTimingValue(options.delay) : 0; context.currentTimeline.delayNextStep(delay); context.currentTimeline.setStyles([startingStyles], null, context.errors, options); visitDslNode(this, ast, context); // this checks to see if an actual animation happened const timelines = context.timelines.filter((timeline) => timeline.containsAnimation()); // note: we just want to apply the final styles for the rootElement, so we do not // just apply the styles to the last timeline but the last timeline which // element is the root one (basically `*`-styles are replaced with the actual // state style values only for the root element) if (timelines.length && finalStyles.size) { let lastRootTimeline; for (let i = timelines.length - 1; i >= 0; i--) { const timeline = timelines[i]; if (timeline.element === rootElement) { lastRootTimeline = timeline; break; } } if (lastRootTimeline && !lastRootTimeline.allowOnlyTimelineStyles()) { lastRootTimeline.setStyles([finalStyles], null, context.errors, options); } } return timelines.length ? timelines.map((timeline) => timeline.buildKeyframes()) : [createTimelineInstruction(rootElement, [], [], [], 0, delay, '', false)]; } visitTrigger(ast, context) { // these values are not visited in this AST } visitState(ast, context) { // these values are not visited in this AST } visitTransition(ast, context) { // these values are not visited in this AST } visitAnimateChild(ast, context) { const elementInstructions = context.subInstructions.get(context.element); if (elementInstructions) { const innerContext = context.createSubContext(ast.options); const startTime = context.currentTimeline.currentTime; const endTime = this._visitSubInstructions(elementInstructions, innerContext, innerContext.options); if (startTime != endTime) { // we do this on the upper context because we created a sub context for // the sub child animations context.transformIntoNewTimeline(endTime); } } context.previousNode = ast; } visitAnimateRef(ast, context) { const innerContext = context.createSubContext(ast.options); innerContext.transformIntoNewTimeline(); this._applyAnimationRefDelays([ast.options, ast.animation.options], context, innerContext); this.visitReference(ast.animation, innerContext); context.transformIntoNewTimeline(innerContext.currentTimeline.currentTime); context.previousNode = ast; } _applyAnimationRefDelays(animationsRefsOptions, context, innerContext) { for (const animationRefOptions of animationsRefsOptions) { const animationDelay = animationRefOptions?.delay; if (animationDelay) { const animationDelayValue = typeof animationDelay === 'number' ? animationDelay : resolveTimingValue(interpolateParams(animationDelay, animationRefOptions?.params ?? {}, context.errors)); innerContext.delayNextStep(animationDelayValue); } } } _visitSubInstructions(instructions, context, options) { const startTime = context.currentTimeline.currentTime; let furthestTime = startTime; // this is a special-case for when a user wants to skip a sub // animation from being fired entirely. const duration = options.duration != null ? resolveTimingValue(options.duration) : null; const delay = options.delay != null ? resolveTimingValue(options.delay) : null; if (duration !== 0) { instructions.forEach((instruction) => { const instructionTimings = context.appendInstructionToTimeline(instruction, duration, delay); furthestTime = Math.max(furthestTime, instructionTimings.duration + instructionTimings.delay); }); } return furthestTime; } visitReference(ast, context) { context.updateOptions(ast.options, true); visitDslNode(this, ast.animation, context); context.previousNode = ast; } visitSequence(ast, context) { const subContextCount = context.subContextCount; let ctx = context; const options = ast.options; if (options && (options.params || options.delay)) { ctx = context.createSubContext(options); ctx.transformIntoNewTimeline(); if (options.delay != null) { if (ctx.previousNode.type == AnimationMetadataType.Style) { ctx.currentTimeline.snapshotCurrentStyles(); ctx.previousNode = DEFAULT_NOOP_PREVIOUS_NODE; } const delay = resolveTimingValue(options.delay); ctx.delayNextStep(delay); } } if (ast.steps.length) { ast.steps.forEach((s) => visitDslNode(this, s, ctx)); // this is here just in case the inner steps only contain or end with a style() call ctx.currentTimeline.applyStylesToKeyframe(); // this means that some animation function within the sequence // ended up creating a sub timeline (which means the current // timeline cannot overlap with the contents of the sequence) if (ctx.subContextCount > subContextCount) { ctx.transformIntoNewTimeline(); } } context.previousNode = ast; } visitGroup(ast, context) { const innerTimelines = []; let furthestTime = context.currentTimeline.currentTime; const delay = ast.options && ast.options.delay ? resolveTimingValue(ast.options.delay) : 0; ast.steps.forEach((s) => { const innerContext = context.createSubContext(ast.options); if (delay) { innerContext.delayNextStep(delay); } visitDslNode(this, s, innerContext); furthestTime = Math.max(furthestTime, innerContext.currentTimeline.currentTime); innerTimelines.push(innerContext.currentTimeline); }); // this operation is run after the AST loop because otherwise // if the parent timeline's collected styles were updated then // it would pass in invalid data into the new-to-be forked items innerTimelines.forEach((timeline) => context.currentTimeline.mergeTimelineCollectedStyles(timeline)); context.transformIntoNewTimeline(furthestTime); context.previousNode = ast; } _visitTiming(ast, context) { if (ast.dynamic) { const strValue = ast.strValue; const timingValue = context.params ? interpolateParams(strValue, context.params, context.errors) : strValue; return resolveTiming(timingValue, context.errors); } else { return { duration: ast.duration, delay: ast.delay, easing: ast.easing }; } } visitAnimate(ast, context) { const timings = (context.currentAnimateTimings = this._visitTiming(ast.timings, context)); const timeline = context.currentTimeline; if (timings.delay) { context.incrementTime(timings.delay); timeline.snapshotCurrentStyles(); } const style = ast.style; if (style.type == AnimationMetadataType.Keyframes) { this.visitKeyframes(style, context); } else { context.incrementTime(timings.duration); this.visitStyle(style, context); timeline.applyStylesToKeyframe(); } context.currentAnimateTimings = null; context.previousNode = ast; } visitStyle(ast, context) { const timeline = context.currentTimeline; const timings = context.currentAnimateTimings; // this is a special case for when a style() call // directly follows an animate() call (but not inside of an animate() call) if (!timings && timeline.hasCurrentStyleProperties()) { timeline.forwardFrame(); } const easing = (timings && timings.easing) || ast.easing; if (ast.isEmptyStep) { timeline.applyEmptyStep(easing); } else { timeline.setStyles(ast.styles, easing, context.errors, context.options); } context.previousNode = ast; } visitKeyframes(ast, context) { const currentAnimateTimings = context.currentAnimateTimings; const startTime = context.currentTimeline.duration; const duration = currentAnimateTimings.duration; const innerContext = context.createSubContext(); const innerTimeline = innerContext.currentTimeline; innerTimeline.easing = currentAnimateTimings.easing; ast.styles.forEach((step) => { const offset = step.offset || 0; innerTimeline.forwardTime(offset * duration); innerTimeline.setStyles(step.styles, step.easing, context.errors, context.options); innerTimeline.applyStylesToKeyframe(); }); // this will ensure that the parent timeline gets all the styles from // the child even if the new timeline below is not used context.currentTimeline.mergeTimelineCollectedStyles(innerTimeline); // we do this because the window between this timeline and the sub timeline // should ensure that the styles within are exactly the same as they were before context.transformIntoNewTimeline(startTime + duration); context.previousNode = ast; } visitQuery(ast, context) { // in the event that the first step before this is a style step we need // to ensure the styles are applied before the children are animated const startTime = context.currentTimeline.currentTime; const options = (ast.options || {}); const delay = options.delay ? resolveTimingValue(options.delay) : 0; if (delay && (context.previousNode.type === AnimationMetadataType.Style || (startTime == 0 && context.currentTimeline.hasCurrentStyleProperties()))) { context.currentTimeline.snapshotCurrentStyles(); context.previousNode = DEFAULT_NOOP_PREVIOUS_NODE; } let furthestTime = startTime; const elms = context.invokeQuery(ast.selector, ast.originalSelector, ast.limit, ast.includeSelf, options.optional ? true : false, context.errors); context.currentQueryTotal = elms.length; let sameElementTimeline = null; elms.forEach((element, i) => { context.currentQueryIndex = i; const innerContext = context.createSubContext(ast.options, element); if (delay) { innerContext.delayNextStep(delay); } if (element === context.element) { sameElementTimeline = innerContext.currentTimeline; } visitDslNode(this, ast.animation, innerContext); // this is here just incase the inner steps only contain or end // with a style() call (which is here to signal that this is a preparatory // call to style an element before it is animated again) innerContext.currentTimeline.applyStylesToKeyframe(); const endTime = innerContext.currentTimeline.currentTime; furthestTime = Math.max(furthestTime, endTime); }); context.currentQueryIndex = 0; context.currentQueryTotal = 0; context.transformIntoNewTimeline(furthestTime); if (sameElementTimeline) { context.currentTimeline.mergeTimelineCollectedStyles(sameElementTimeline); context.currentTimeline.snapshotCurrentStyles(); } context.previousNode = ast; } visitStagger(ast, context) { const parentContext = context.parentContext; const tl = context.currentTimeline; const timings = ast.timings; const duration = Math.abs(timings.duration); const maxTime = duration * (context.currentQueryTotal - 1); let delay = duration * context.currentQueryIndex; let staggerTransformer = timings.duration < 0 ? 'reverse' : timings.easing; switch (staggerTransformer) { case 'reverse': delay = maxTime - delay; break; case 'full': delay = parentContext.currentStaggerTime; break; } const timeline = context.currentTimeline; if (delay) { timeline.delayNextStep(delay); } const startingTime = timeline.currentTime; visitDslNode(this, ast.animation, context); context.previousNode = ast; // time = duration + delay // the reason why this computation is so complex is because // the inner timeline may either have a delay value or a stretched // keyframe depending on if a subtimeline is not used or is used. parentContext.currentStaggerTime = tl.currentTime - startingTime + (tl.startTime - parentContext.currentTimeline.startTime); } } const DEFAULT_NOOP_PREVIOUS_NODE = {}; class AnimationTimelineContext { _driver; element; subInstructions; _enterClassName; _leaveClassName; errors; timelines; parentContext = null; currentTimeline; currentAnimateTimings = null; previousNode = DEFAULT_NOOP_PREVIOUS_NODE; subContextCount = 0; options = {}; currentQueryIndex = 0; currentQueryTotal = 0; currentStaggerTime = 0; constructor(_driver, element, subInstructions, _enterClassName, _leaveClassName, errors, timelines, initialTimeline) { this._driver = _driver; this.element = element; this.subInstructions = subInstructions; this._enterClassName = _enterClassName; this._leaveClassName = _leaveClassName; this.errors = errors; this.timelines = timelines; this.currentTimeline = initialTimeline || new TimelineBuilder(this._driver, element, 0); timelines.push(this.currentTimeline); } get params() { return this.options.params; } updateOptions(options, skipIfExists) { if (!options) return; const newOptions = options; let optionsToUpdate = this.options; // NOTE: this will get patched up when other animation methods support duration overrides if (newOptions.duration != null) { optionsToUpdate.duration = resolveTimingValue(newOptions.duration); } if (newOptions.delay != null) { optionsToUpdate.delay = resolveTimingValue(newOptions.delay); } const newParams = newOptions.params; if (newParams) { let paramsToUpdate = optionsToUpdate.params; if (!paramsToUpdate) { paramsToUpdate = this.options.params = {}; } Object.keys(newParams).forEach((name) => { if (!skipIfExists || !paramsToUpdate.hasOwnProperty(name)) { paramsToUpd