angular-cn/packages/animations/browser/src/dsl/animation_ast_builder.ts
Matias Niemelä e5e1b0da33 fix(animations): treat numeric state name values as strings (#22923)
This patch ensures that if a numeric state name value in an animation
is detected then it will not throw an error. Normally this wouldn't
occur, but some JS optimizers may convert a quoted numeric value
(like "1" to 1) in some cases to save space. This patch makes sure
that Angular doesn't throw an error when this occurs.

PR Close #22923
2018-03-22 19:00:58 -04:00

569 lines
21 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AUTO_STYLE, AnimateTimings, AnimationAnimateChildMetadata, AnimationAnimateMetadata, AnimationAnimateRefMetadata, AnimationGroupMetadata, AnimationKeyframesSequenceMetadata, AnimationMetadata, AnimationMetadataType, AnimationOptions, AnimationQueryMetadata, AnimationQueryOptions, AnimationReferenceMetadata, AnimationSequenceMetadata, AnimationStaggerMetadata, AnimationStateMetadata, AnimationStyleMetadata, AnimationTransitionMetadata, AnimationTriggerMetadata, style, ɵStyleData} from '@angular/animations';
import {AnimationDriver} from '../render/animation_driver';
import {getOrSetAsInMap} from '../render/shared';
import {ENTER_SELECTOR, LEAVE_SELECTOR, NG_ANIMATING_SELECTOR, NG_TRIGGER_SELECTOR, SUBSTITUTION_EXPR_START, copyObj, extractStyleParams, iteratorToArray, normalizeAnimationEntry, resolveTiming, validateStyleParams, visitDslNode} from '../util';
import {AnimateAst, AnimateChildAst, AnimateRefAst, Ast, DynamicTimingAst, GroupAst, KeyframesAst, QueryAst, ReferenceAst, SequenceAst, StaggerAst, StateAst, StyleAst, TimingAst, TransitionAst, TriggerAst} from './animation_ast';
import {AnimationDslVisitor} from './animation_dsl_visitor';
import {parseTransitionExpr} from './animation_transition_expr';
const SELF_TOKEN = ':self';
const SELF_TOKEN_REGEX = 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 behaviour 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.
*/
export function buildAnimationAst(
driver: AnimationDriver, metadata: AnimationMetadata | AnimationMetadata[],
errors: any[]): Ast<AnimationMetadataType> {
return new AnimationAstBuilderVisitor(driver).build(metadata, errors);
}
const ROOT_SELECTOR = '';
export class AnimationAstBuilderVisitor implements AnimationDslVisitor {
constructor(private _driver: AnimationDriver) {}
build(metadata: AnimationMetadata|AnimationMetadata[], errors: any[]):
Ast<AnimationMetadataType> {
const context = new AnimationAstBuilderContext(errors);
this._resetContextStyleTimingState(context);
return <Ast<AnimationMetadataType>>visitDslNode(
this, normalizeAnimationEntry(metadata), context);
}
private _resetContextStyleTimingState(context: AnimationAstBuilderContext) {
context.currentQuerySelector = ROOT_SELECTOR;
context.collectedStyles = {};
context.collectedStyles[ROOT_SELECTOR] = {};
context.currentTime = 0;
}
visitTrigger(metadata: AnimationTriggerMetadata, context: AnimationAstBuilderContext):
TriggerAst {
let queryCount = context.queryCount = 0;
let depCount = context.depCount = 0;
const states: StateAst[] = [];
const transitions: TransitionAst[] = [];
if (metadata.name.charAt(0) == '@') {
context.errors.push(
'animation triggers cannot be prefixed with an `@` sign (e.g. trigger(\'@foo\', [...]))');
}
metadata.definitions.forEach(def => {
this._resetContextStyleTimingState(context);
if (def.type == AnimationMetadataType.State) {
const stateDef = def as AnimationStateMetadata;
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 as AnimationTransitionMetadata, context);
queryCount += transition.queryCount;
depCount += transition.depCount;
transitions.push(transition);
} else {
context.errors.push(
'only state() and transition() definitions can sit inside of a trigger()');
}
});
return {
type: AnimationMetadataType.Trigger,
name: metadata.name, states, transitions, queryCount, depCount,
options: null
};
}
visitState(metadata: AnimationStateMetadata, context: AnimationAstBuilderContext): StateAst {
const styleAst = this.visitStyle(metadata.styles, context);
const astParams = (metadata.options && metadata.options.params) || null;
if (styleAst.containsDynamicStyles) {
const missingSubs = new Set<string>();
const params = astParams || {};
styleAst.styles.forEach(value => {
if (isObject(value)) {
const stylesObj = value as any;
Object.keys(stylesObj).forEach(prop => {
extractStyleParams(stylesObj[prop]).forEach(sub => {
if (!params.hasOwnProperty(sub)) {
missingSubs.add(sub);
}
});
});
}
});
if (missingSubs.size) {
const missingSubsArr = iteratorToArray(missingSubs.values());
context.errors.push(
`state("${metadata.name}", ...) must define default values for all the following style substitutions: ${missingSubsArr.join(', ')}`);
}
}
return {
type: AnimationMetadataType.State,
name: metadata.name,
style: styleAst,
options: astParams ? {params: astParams} : null
};
}
visitTransition(metadata: AnimationTransitionMetadata, context: AnimationAstBuilderContext):
TransitionAst {
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: AnimationSequenceMetadata, context: AnimationAstBuilderContext):
SequenceAst {
return {
type: AnimationMetadataType.Sequence,
steps: metadata.steps.map(s => visitDslNode(this, s, context)),
options: normalizeAnimationOptions(metadata.options)
};
}
visitGroup(metadata: AnimationGroupMetadata, context: AnimationAstBuilderContext): GroupAst {
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: AnimationAnimateMetadata, context: AnimationAstBuilderContext):
AnimateAst {
const timingAst = constructTimingAst(metadata.timings, context.errors);
context.currentAnimateTimings = timingAst;
let styleAst: StyleAst|KeyframesAst;
let styleMetadata: AnimationMetadata = metadata.styles ? metadata.styles : style({});
if (styleMetadata.type == AnimationMetadataType.Keyframes) {
styleAst = this.visitKeyframes(styleMetadata as AnimationKeyframesSequenceMetadata, context);
} else {
let styleMetadata = metadata.styles as AnimationStyleMetadata;
let isEmpty = false;
if (!styleMetadata) {
isEmpty = true;
const newStyleData: {[prop: string]: string | number} = {};
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: AnimationStyleMetadata, context: AnimationAstBuilderContext): StyleAst {
const ast = this._makeStyleAst(metadata, context);
this._validateStyleAst(ast, context);
return ast;
}
private _makeStyleAst(metadata: AnimationStyleMetadata, context: AnimationAstBuilderContext):
StyleAst {
const styles: (ɵStyleData | string)[] = [];
if (Array.isArray(metadata.styles)) {
(metadata.styles as(ɵStyleData | string)[]).forEach(styleTuple => {
if (typeof styleTuple == 'string') {
if (styleTuple == AUTO_STYLE) {
styles.push(styleTuple as string);
} else {
context.errors.push(`The provided style string value ${styleTuple} is not allowed.`);
}
} else {
styles.push(styleTuple as ɵStyleData);
}
});
} else {
styles.push(metadata.styles);
}
let containsDynamicStyles = false;
let collectedEasing: string|null = null;
styles.forEach(styleData => {
if (isObject(styleData)) {
const styleMap = styleData as ɵStyleData;
const easing = styleMap['easing'];
if (easing) {
collectedEasing = easing as string;
delete styleMap['easing'];
}
if (!containsDynamicStyles) {
for (let prop in styleMap) {
const value = styleMap[prop];
if (value.toString().indexOf(SUBSTITUTION_EXPR_START) >= 0) {
containsDynamicStyles = true;
break;
}
}
}
}
});
return {
type: AnimationMetadataType.Style,
styles,
easing: collectedEasing,
offset: metadata.offset, containsDynamicStyles,
options: null
};
}
private _validateStyleAst(ast: StyleAst, context: AnimationAstBuilderContext): void {
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;
Object.keys(tuple).forEach(prop => {
if (!this._driver.validateStyleProperty(prop)) {
context.errors.push(
`The provided animation property "${prop}" is not a supported CSS property for animations`);
return;
}
const collectedStyles = context.collectedStyles[context.currentQuerySelector !];
const collectedEntry = collectedStyles[prop];
let updateCollectedStyle = true;
if (collectedEntry) {
if (startTime != endTime && startTime >= collectedEntry.startTime &&
endTime <= collectedEntry.endTime) {
context.errors.push(
`The CSS property "${prop}" that exists between the times of "${collectedEntry.startTime}ms" and "${collectedEntry.endTime}ms" is also being animated in a parallel animation between the times of "${startTime}ms" and "${endTime}ms"`);
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[prop] = {startTime, endTime};
}
if (context.options) {
validateStyleParams(tuple[prop], context.options, context.errors);
}
});
});
}
visitKeyframes(metadata: AnimationKeyframesSequenceMetadata, context: AnimationAstBuilderContext):
KeyframesAst {
const ast: KeyframesAst = {type: AnimationMetadataType.Keyframes, styles: [], options: null};
if (!context.currentAnimateTimings) {
context.errors.push(`keyframes() must be placed inside of a call to animate()`);
return ast;
}
const MAX_KEYFRAME_OFFSET = 1;
let totalKeyframesWithOffsets = 0;
const offsets: number[] = [];
let offsetsOutOfOrder = false;
let keyframesOutOfRange = false;
let previousOffset: number = 0;
const keyframes: StyleAst[] = metadata.steps.map(styles => {
const style = this._makeStyleAst(styles, context);
let offsetVal: number|null =
style.offset != null ? style.offset : consumeOffset(style.styles);
let offset: number = 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(`Please ensure that all keyframe offsets are between 0 and 1`);
}
if (offsetsOutOfOrder) {
context.errors.push(`Please ensure that all keyframe offsets are in order`);
}
const length = metadata.steps.length;
let generatedOffset = 0;
if (totalKeyframesWithOffsets > 0 && totalKeyframesWithOffsets < length) {
context.errors.push(`Not all style() steps within the declared keyframes() contain offsets`);
} 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: AnimationReferenceMetadata, context: AnimationAstBuilderContext):
ReferenceAst {
return {
type: AnimationMetadataType.Reference,
animation: visitDslNode(this, normalizeAnimationEntry(metadata.animation), context),
options: normalizeAnimationOptions(metadata.options)
};
}
visitAnimateChild(metadata: AnimationAnimateChildMetadata, context: AnimationAstBuilderContext):
AnimateChildAst {
context.depCount++;
return {
type: AnimationMetadataType.AnimateChild,
options: normalizeAnimationOptions(metadata.options)
};
}
visitAnimateRef(metadata: AnimationAnimateRefMetadata, context: AnimationAstBuilderContext):
AnimateRefAst {
return {
type: AnimationMetadataType.AnimateRef,
animation: this.visitReference(metadata.animation, context),
options: normalizeAnimationOptions(metadata.options)
};
}
visitQuery(metadata: AnimationQueryMetadata, context: AnimationAstBuilderContext): QueryAst {
const parentSelector = context.currentQuerySelector !;
const options = (metadata.options || {}) as AnimationQueryOptions;
context.queryCount++;
context.currentQuery = metadata;
const [selector, includeSelf] = normalizeSelector(metadata.selector);
context.currentQuerySelector =
parentSelector.length ? (parentSelector + ' ' + selector) : selector;
getOrSetAsInMap(context.collectedStyles, context.currentQuerySelector, {});
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: AnimationStaggerMetadata, context: AnimationAstBuilderContext):
StaggerAst {
if (!context.currentQuery) {
context.errors.push(`stagger() can only be used inside of query()`);
}
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: string): [string, boolean] {
const hasAmpersand = selector.split(/\s*,\s*/).find(token => token == SELF_TOKEN) ? true : false;
if (hasAmpersand) {
selector = selector.replace(SELF_TOKEN_REGEX, '');
}
// the :enter and :leave selectors are filled in at runtime during timeline building
selector = selector.replace(/@\*/g, NG_TRIGGER_SELECTOR)
.replace(/@\w+/g, match => NG_TRIGGER_SELECTOR + '-' + match.substr(1))
.replace(/:animating/g, NG_ANIMATING_SELECTOR);
return [selector, hasAmpersand];
}
function normalizeParams(obj: {[key: string]: any} | any): {[key: string]: any}|null {
return obj ? copyObj(obj) : null;
}
export type StyleTimeTuple = {
startTime: number; endTime: number;
};
export class AnimationAstBuilderContext {
public queryCount: number = 0;
public depCount: number = 0;
public currentTransition: AnimationTransitionMetadata|null = null;
public currentQuery: AnimationQueryMetadata|null = null;
public currentQuerySelector: string|null = null;
public currentAnimateTimings: TimingAst|null = null;
public currentTime: number = 0;
public collectedStyles: {[selectorName: string]: {[propName: string]: StyleTimeTuple}} = {};
public options: AnimationOptions|null = null;
constructor(public errors: any[]) {}
}
function consumeOffset(styles: ɵStyleData | string | (ɵStyleData | string)[]): number|null {
if (typeof styles == 'string') return null;
let offset: number|null = null;
if (Array.isArray(styles)) {
styles.forEach(styleTuple => {
if (isObject(styleTuple) && styleTuple.hasOwnProperty('offset')) {
const obj = styleTuple as ɵStyleData;
offset = parseFloat(obj['offset'] as string);
delete obj['offset'];
}
});
} else if (isObject(styles) && styles.hasOwnProperty('offset')) {
const obj = styles as ɵStyleData;
offset = parseFloat(obj['offset'] as string);
delete obj['offset'];
}
return offset;
}
function isObject(value: any): boolean {
return !Array.isArray(value) && typeof value == 'object';
}
function constructTimingAst(value: string | number | AnimateTimings, errors: any[]) {
let timings: AnimateTimings|null = null;
if (value.hasOwnProperty('duration')) {
timings = value as AnimateTimings;
} else if (typeof value == 'number') {
const duration = resolveTiming(value as number, errors).duration;
return makeTimingAst(duration as number, 0, '');
}
const strValue = value as string;
const isDynamic = strValue.split(/\s+/).some(v => v.charAt(0) == '{' && v.charAt(1) == '{');
if (isDynamic) {
const ast = makeTimingAst(0, 0, '') as any;
ast.dynamic = true;
ast.strValue = strValue;
return ast as DynamicTimingAst;
}
timings = timings || resolveTiming(strValue, errors);
return makeTimingAst(timings.duration, timings.delay, timings.easing);
}
function normalizeAnimationOptions(options: AnimationOptions | null): AnimationOptions {
if (options) {
options = copyObj(options);
if (options['params']) {
options['params'] = normalizeParams(options['params']) !;
}
} else {
options = {};
}
return options;
}
function makeTimingAst(duration: number, delay: number, easing: string | null): TimingAst {
return {duration, delay, easing};
}