Created
May 6, 2020 17:56
-
-
Save cdata/e73dddcefe70b10b3f899bbaee2b6a13 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* @license | |
* Copyright 2019 Google LLC. All Rights Reserved. | |
* 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 | |
* | |
* http://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. | |
*/ | |
const numberNode = (value, unit) => ({ type: 'number', number: value, unit }); | |
/** | |
* Given a string representing a comma-separated set of CSS-like expressions, | |
* parses and returns an array of ASTs that correspond to those expressions. | |
* | |
* Currently supported syntax includes: | |
* | |
* - functions (top-level and nested) | |
* - calc() arithmetic operators | |
* - numbers with units | |
* - hexidecimal-encoded colors in 3, 6 or 8 digit form | |
* - idents | |
* | |
* All syntax is intended to match the parsing rules and semantics of the actual | |
* CSS spec as closely as possible. | |
* | |
* @see https://www.w3.org/TR/CSS2/ | |
* @see https://www.w3.org/TR/css-values-3/ | |
*/ | |
const parseExpressions = (() => { | |
const cache = {}; | |
const MAX_PARSE_ITERATIONS = 1000; // Arbitrarily large | |
return (inputString) => { | |
const cacheKey = inputString; | |
if (cacheKey in cache) { | |
return cache[cacheKey]; | |
} | |
const expressions = []; | |
let parseIterations = 0; | |
while (inputString) { | |
if (++parseIterations > MAX_PARSE_ITERATIONS) { | |
// Avoid a potentially infinite loop due to typos: | |
inputString = ''; | |
break; | |
} | |
const expressionParseResult = parseExpression(inputString); | |
const expression = expressionParseResult.nodes[0]; | |
if (expression == null || expression.terms.length === 0) { | |
break; | |
} | |
expressions.push(expression); | |
inputString = expressionParseResult.remainingInput; | |
} | |
return cache[cacheKey] = expressions; | |
}; | |
})(); | |
/** | |
* Parse a single expression. For the purposes of our supported syntax, an | |
* expression is the set of semantically meaningful terms that appear before the | |
* next comma, or between the parens of a function invokation. | |
*/ | |
const parseExpression = (() => { | |
const IS_IDENT_RE = /^(\-\-|[a-z\u0240-\uffff])/i; | |
const IS_OPERATOR_RE = /^([\*\+\/]|[\-]\s)/i; | |
const IS_EXPRESSION_END_RE = /^[\),]/; | |
const FUNCTION_ARGUMENTS_FIRST_TOKEN = '('; | |
const HEX_FIRST_TOKEN = '#'; | |
return (inputString) => { | |
const terms = []; | |
while (inputString.length) { | |
inputString = inputString.trim(); | |
if (IS_EXPRESSION_END_RE.test(inputString)) { | |
break; | |
} | |
else if (inputString[0] === FUNCTION_ARGUMENTS_FIRST_TOKEN) { | |
const { nodes, remainingInput } = parseFunctionArguments(inputString); | |
inputString = remainingInput; | |
terms.push({ | |
type: 'function', | |
name: { type: 'ident', value: 'calc' }, | |
arguments: nodes | |
}); | |
} | |
else if (IS_IDENT_RE.test(inputString)) { | |
const identParseResult = parseIdent(inputString); | |
const identNode = identParseResult.nodes[0]; | |
inputString = identParseResult.remainingInput; | |
if (inputString[0] === FUNCTION_ARGUMENTS_FIRST_TOKEN) { | |
const { nodes, remainingInput } = parseFunctionArguments(inputString); | |
terms.push({ type: 'function', name: identNode, arguments: nodes }); | |
inputString = remainingInput; | |
} | |
else { | |
terms.push(identNode); | |
} | |
} | |
else if (IS_OPERATOR_RE.test(inputString)) { | |
// Operators are always a single character, so just pluck them out: | |
terms.push({ type: 'operator', value: inputString[0] }); | |
inputString = inputString.slice(1); | |
} | |
else { | |
const { nodes, remainingInput } = inputString[0] === HEX_FIRST_TOKEN ? | |
parseHex(inputString) : | |
parseNumber(inputString); | |
// The remaining string may not have had any meaningful content. Exit | |
// early if this is the case: | |
if (nodes.length === 0) { | |
break; | |
} | |
terms.push(nodes[0]); | |
inputString = remainingInput; | |
} | |
} | |
return { nodes: [{ type: 'expression', terms }], remainingInput: inputString }; | |
}; | |
})(); | |
/** | |
* An ident is something like a function name or the keyword "auto". | |
*/ | |
const parseIdent = (() => { | |
const NOT_IDENT_RE = /[^a-z^0-9^_^\-^\u0240-\uffff]/i; | |
return (inputString) => { | |
const match = inputString.match(NOT_IDENT_RE); | |
const ident = match == null ? inputString : inputString.substr(0, match.index); | |
const remainingInput = match == null ? '' : inputString.substr(match.index); | |
return { nodes: [{ type: 'ident', value: ident }], remainingInput }; | |
}; | |
})(); | |
/** | |
* Parses a number. A number value can be expressed with an integer or | |
* non-integer syntax, and usually includes a unit (but does not strictly | |
* require one for our purposes). | |
*/ | |
const parseNumber = (() => { | |
// @see https://www.w3.org/TR/css-syntax/#number-token-diagram | |
const VALUE_RE = /[\+\-]?(\d+[\.]\d+|\d+|[\.]\d+)([eE][\+\-]?\d+)?/; | |
const UNIT_RE = /^[a-z%]+/i; | |
const ALLOWED_UNITS = /^(m|mm|cm|rad|deg|[%])$/; | |
return (inputString) => { | |
const valueMatch = inputString.match(VALUE_RE); | |
const value = valueMatch == null ? '0' : valueMatch[0]; | |
inputString = value == null ? inputString : inputString.slice(value.length); | |
const unitMatch = inputString.match(UNIT_RE); | |
let unit = unitMatch != null && unitMatch[0] !== '' ? unitMatch[0] : null; | |
const remainingInput = unitMatch == null ? inputString : inputString.slice(unit.length); | |
if (unit != null && !ALLOWED_UNITS.test(unit)) { | |
unit = null; | |
} | |
return { | |
nodes: [{ | |
type: 'number', | |
number: parseFloat(value) || 0, | |
unit: unit | |
}], | |
remainingInput | |
}; | |
}; | |
})(); | |
/** | |
* Parses a hexidecimal-encoded color in 3, 6 or 8 digit form. | |
*/ | |
const parseHex = (() => { | |
// TODO(cdata): right now we don't actually enforce the number of digits | |
const HEX_RE = /^[a-f0-9]*/i; | |
return (inputString) => { | |
inputString = inputString.slice(1).trim(); | |
const hexMatch = inputString.match(HEX_RE); | |
const nodes = hexMatch == null ? [] : [{ type: 'hex', value: hexMatch[0] }]; | |
return { | |
nodes, | |
remainingInput: hexMatch == null ? inputString : | |
inputString.slice(hexMatch[0].length) | |
}; | |
}; | |
})(); | |
/** | |
* Parses arguments passed to a function invokation (e.g., the expressions | |
* within a matched set of parens). | |
*/ | |
const parseFunctionArguments = (inputString) => { | |
const expressionNodes = []; | |
// Consume the opening paren | |
inputString = inputString.slice(1).trim(); | |
while (inputString.length) { | |
const expressionParseResult = parseExpression(inputString); | |
expressionNodes.push(expressionParseResult.nodes[0]); | |
inputString = expressionParseResult.remainingInput.trim(); | |
if (inputString[0] === ',') { | |
inputString = inputString.slice(1).trim(); | |
} | |
else if (inputString[0] === ')') { | |
// Consume the closing paren and stop parsing | |
inputString = inputString.slice(1); | |
break; | |
} | |
} | |
return { nodes: expressionNodes, remainingInput: inputString }; | |
}; | |
const $visitedTypes = Symbol('visitedTypes'); | |
/** | |
* An ASTWalker walks an array of ASTs such as the type produced by | |
* parseExpressions and invokes a callback for a configured set of nodes that | |
* the user wishes to "visit" during the walk. | |
*/ | |
class ASTWalker { | |
constructor(visitedTypes) { | |
this[$visitedTypes] = visitedTypes; | |
} | |
/** | |
* Walk the given set of ASTs, and invoke the provided callback for nodes that | |
* match the filtered set that the ASTWalker was constructed with. | |
*/ | |
walk(ast, callback) { | |
const remaining = ast.slice(); | |
while (remaining.length) { | |
const next = remaining.shift(); | |
if (this[$visitedTypes].indexOf(next.type) > -1) { | |
callback(next); | |
} | |
switch (next.type) { | |
case 'expression': | |
remaining.unshift(...next.terms); | |
break; | |
case 'function': | |
remaining.unshift(next.name, ...next.arguments); | |
break; | |
} | |
} | |
} | |
} | |
const ZERO = Object.freeze({ type: 'number', number: 0, unit: null }); | |
/* @license | |
* Copyright 2019 Google LLC. All Rights Reserved. | |
* 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 | |
* | |
* http://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. | |
*/ | |
/** | |
* Ensures that a given number is expressed in radians. If the number is already | |
* in radians, does nothing. If the value is in degrees, converts it to radians. | |
* If the value has no specified unit, the unit is assumed to be radians. If the | |
* value is not in radians or degrees, the value is resolved as 0 radians. | |
* | |
* Also accepts a second argument that is a default value to use if the input | |
* numberNode number is NaN or Infinity. | |
*/ | |
const degreesToRadians = (numberNode$$1, fallbackRadianValue = 0) => { | |
let { number, unit } = numberNode$$1; | |
if (!isFinite(number)) { | |
number = fallbackRadianValue; | |
unit = 'rad'; | |
} | |
else if (numberNode$$1.unit === 'rad' || numberNode$$1.unit == null) { | |
return numberNode$$1; | |
} | |
const valueIsDegrees = unit === 'deg' && number != null; | |
const value = valueIsDegrees ? number : 0; | |
const radians = value * Math.PI / 180; | |
return { type: 'number', number: radians, unit: 'rad' }; | |
}; | |
/** | |
* Ensures that a given number is expressed in degrees. If the number is alrady | |
* in degrees, does nothing. If the value is in radians or has no specified | |
* unit, converts it to degrees. If the value is not in radians or degrees, the | |
* value is resolved as 0 degrees. | |
* | |
* Also accepts a second argument that is a default value to use if the input | |
* numberNode number is NaN or Infinity. | |
*/ | |
const radiansToDegrees = (numberNode$$1, fallbackDegreeValue = 0) => { | |
let { number, unit } = numberNode$$1; | |
if (!isFinite(number)) { | |
number = fallbackDegreeValue; | |
unit = 'deg'; | |
} | |
else if (numberNode$$1.unit === 'deg') { | |
return numberNode$$1; | |
} | |
const valueIsRadians = (unit === null || unit === 'rad') && number != null; | |
const value = valueIsRadians ? number : 0; | |
const degrees = value * 180 / Math.PI; | |
return { type: 'number', number: degrees, unit: 'deg' }; | |
}; | |
/** | |
* Converts a given length to meters. Currently supported input units are | |
* meters, centimeters and millimeters. | |
* | |
* Also accepts a second argument that is a default value to use if the input | |
* numberNode number is NaN or Infinity. | |
*/ | |
const lengthToBaseMeters = (numberNode$$1, fallbackMeterValue = 0) => { | |
let { number, unit } = numberNode$$1; | |
if (!isFinite(number)) { | |
number = fallbackMeterValue; | |
unit = 'm'; | |
} | |
else if (numberNode$$1.unit === 'm') { | |
return numberNode$$1; | |
} | |
let scale; | |
switch (unit) { | |
default: | |
scale = 1; | |
break; | |
case 'cm': | |
scale = 1 / 100; | |
break; | |
case 'mm': | |
scale = 1 / 1000; | |
break; | |
} | |
const value = scale * number; | |
return { type: 'number', number: value, unit: 'm' }; | |
}; | |
/** | |
* Normalizes the unit of a given input number so that it is expressed in a | |
* preferred unit. For length nodes, the return value will be expressed in | |
* meters. For angle nodes, the return value will be expressed in radians. | |
* | |
* Also takes a fallback number that is used when the number value is not a | |
* valid number or when the unit of the given number cannot be normalized. | |
*/ | |
const normalizeUnit = (() => { | |
const identity = (node) => node; | |
const unitNormalizers = { | |
'rad': identity, | |
'deg': degreesToRadians, | |
'm': identity, | |
'mm': lengthToBaseMeters, | |
'cm': lengthToBaseMeters | |
}; | |
return (node, fallback = ZERO) => { | |
let { number, unit } = node; | |
if (!isFinite(number)) { | |
number = fallback.number; | |
unit = fallback.unit; | |
} | |
if (unit == null) { | |
return node; | |
} | |
const normalize = unitNormalizers[unit]; | |
if (normalize == null) { | |
return fallback; | |
} | |
return normalize(node); | |
}; | |
})(); | |
/* @license | |
* Copyright 2019 Google LLC. All Rights Reserved. | |
* 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 | |
* | |
* http://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. | |
*/ | |
var _a, _b, _c; | |
const $evaluate = Symbol('evaluate'); | |
const $lastValue = Symbol('lastValue'); | |
/** | |
* An Evaluator is used to derive a computed style from part (or all) of a CSS | |
* expression AST. This construct is particularly useful for complex ASTs | |
* containing function calls such as calc, var and env. Such styles could be | |
* costly to re-evaluate on every frame (and in some cases we may try to do | |
* that). The Evaluator construct allows us to mark sub-trees of the AST as | |
* constant, so that only the dynamic parts are re-evaluated. It also separates | |
* one-time AST preparation work from work that necessarily has to happen upon | |
* each evaluation. | |
*/ | |
class Evaluator { | |
constructor() { | |
this[_a] = null; | |
} | |
/** | |
* An Evaluatable is a NumberNode or an Evaluator that evaluates a NumberNode | |
* as the result of invoking its evaluate method. This is mainly used to | |
* ensure that CSS function nodes are cast to the corresponding Evaluators | |
* that will resolve the result of the function, but is also used to ensure | |
* that a percentage nested at arbitrary depth in the expression will always | |
* be evaluated against the correct basis. | |
*/ | |
static evaluatableFor(node, basis = ZERO) { | |
if (node instanceof Evaluator) { | |
return node; | |
} | |
if (node.type === 'number') { | |
if (node.unit === '%') { | |
return new PercentageEvaluator(node, basis); | |
} | |
return node; | |
} | |
switch (node.name.value) { | |
case 'calc': | |
return new CalcEvaluator(node, basis); | |
case 'env': | |
return new EnvEvaluator(node); | |
} | |
return ZERO; | |
} | |
/** | |
* If the input is an Evaluator, returns the result of evaluating it. | |
* Otherwise, returns the input. | |
* | |
* This is a helper to aide in resolving a NumberNode without conditionally | |
* checking if the Evaluatable is an Evaluator everywhere. | |
*/ | |
static evaluate(evaluatable) { | |
if (evaluatable instanceof Evaluator) { | |
return evaluatable.evaluate(); | |
} | |
return evaluatable; | |
} | |
/** | |
* If the input is an Evaluator, returns the value of its isConstant property. | |
* Returns true for all other input values. | |
*/ | |
static isConstant(evaluatable) { | |
if (evaluatable instanceof Evaluator) { | |
return evaluatable.isConstant; | |
} | |
return true; | |
} | |
/** | |
* This method applies a set of structured intrinsic metadata to an evaluated | |
* result from a parsed CSS-like string of expressions. Intrinsics provide | |
* sufficient metadata (e.g., basis values, analogs for keywords) such that | |
* omitted values in the input string can be backfilled, and keywords can be | |
* converted to concrete numbers. | |
* | |
* The result of applying intrinsics is a tuple of NumberNode values whose | |
* units match the units used by the basis of the intrinsics. | |
* | |
* The following is a high-level description of how intrinsics are applied: | |
* | |
* 1. Determine the value of 'auto' for the current term | |
* 2. If there is no corresponding input value for this term, substitute the | |
* 'auto' value. | |
* 3. If the term is an IdentNode, treat it as a keyword and perform the | |
* appropriate substitution. | |
* 4. If the term is still null, fallback to the 'auto' value | |
* 5. If the term is a percentage, apply it to the basis and return that | |
* value | |
* 6. Normalize the unit of the term | |
* 7. If the term's unit does not match the basis unit, return the basis | |
* value | |
* 8. Return the term as is | |
*/ | |
static applyIntrinsics(evaluated, intrinsics) { | |
const { basis, keywords } = intrinsics; | |
const { auto } = keywords; | |
return basis.map((basisNode, index) => { | |
// Use an auto value if we have it, otherwise the auto value is the basis: | |
const autoSubstituteNode = auto[index] == null ? basisNode : auto[index]; | |
// If the evaluated nodes do not have a node at the current | |
// index, fallback to the "auto" substitute right away: | |
let evaluatedNode = evaluated[index] ? evaluated[index] : autoSubstituteNode; | |
// Any ident node is considered a keyword: | |
if (evaluatedNode.type === 'ident') { | |
const keyword = evaluatedNode.value; | |
// Substitute any keywords for concrete values first: | |
if (keyword in keywords) { | |
evaluatedNode = keywords[keyword][index]; | |
} | |
} | |
// If we don't have a NumberNode at this point, fall back to whatever | |
// is specified for auto: | |
if (evaluatedNode == null || evaluatedNode.type === 'ident') { | |
evaluatedNode = autoSubstituteNode; | |
} | |
// For percentages, we always apply the percentage to the basis value: | |
if (evaluatedNode.unit === '%') { | |
return numberNode(evaluatedNode.number / 100 * basisNode.number, basisNode.unit); | |
} | |
// Otherwise, normalize whatever we have: | |
evaluatedNode = normalizeUnit(evaluatedNode, basisNode); | |
// If the normalized units do not match, return the basis as a fallback: | |
if (evaluatedNode.unit !== basisNode.unit) { | |
return basisNode; | |
} | |
// Finally, return the evaluated node with intrinsics applied: | |
return evaluatedNode; | |
}); | |
} | |
/** | |
* If true, the Evaluator will only evaluate its AST one time. If false, the | |
* Evaluator will re-evaluate the AST each time that the public evaluate | |
* method is invoked. | |
*/ | |
get isConstant() { | |
return false; | |
} | |
/** | |
* Evaluate the Evaluator and return the result. If the Evaluator is constant, | |
* the corresponding AST will only be evaluated once, and the result of | |
* evaluating it the first time will be returned on all subsequent | |
* evaluations. | |
*/ | |
evaluate() { | |
if (!this.isConstant || this[$lastValue] == null) { | |
this[$lastValue] = this[$evaluate](); | |
} | |
return this[$lastValue]; | |
} | |
} | |
_a = $lastValue; | |
const $percentage = Symbol('percentage'); | |
const $basis = Symbol('basis'); | |
/** | |
* A PercentageEvaluator scales a given basis value by a given percentage value. | |
* The evaluated result is always considered to be constant. | |
*/ | |
class PercentageEvaluator extends Evaluator { | |
constructor(percentage, basis) { | |
super(); | |
this[$percentage] = percentage; | |
this[$basis] = basis; | |
} | |
get isConstant() { | |
return true; | |
} | |
[$evaluate]() { | |
return numberNode(this[$percentage].number / 100 * this[$basis].number, this[$basis].unit); | |
} | |
} | |
const $identNode = Symbol('identNode'); | |
/** | |
* Evaluator for CSS-like env() functions. Currently, only one environment | |
* variable is accepted as an argument for such functions: window-scroll-y. | |
* | |
* The env() Evaluator is explicitly dynamic because it always refers to | |
* external state that changes as the user scrolls, so it should always be | |
* re-evaluated to ensure we get the most recent value. | |
* | |
* Some important notes about this feature include: | |
* | |
* - There is no such thing as a "window-scroll-y" CSS environment variable in | |
* any stable browser at the time that this comment is being written. | |
* - The actual CSS env() function accepts a second argument as a fallback for | |
* the case that the specified first argument isn't set; our syntax does not | |
* support this second argument. | |
* | |
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/env | |
*/ | |
class EnvEvaluator extends Evaluator { | |
constructor(envFunction) { | |
super(); | |
this[_b] = null; | |
const identNode = envFunction.arguments.length ? envFunction.arguments[0].terms[0] : null; | |
if (identNode != null && identNode.type === 'ident') { | |
this[$identNode] = identNode; | |
} | |
} | |
get isConstant() { | |
return false; | |
} | |
; | |
[(_b = $identNode, $evaluate)]() { | |
if (this[$identNode] != null) { | |
switch (this[$identNode].value) { | |
case 'window-scroll-y': | |
const verticalScrollPosition = window.pageYOffset; | |
const verticalScrollMax = Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight); | |
const scrollY = verticalScrollPosition / | |
(verticalScrollMax - window.innerHeight) || | |
0; | |
return { type: 'number', number: scrollY, unit: null }; | |
} | |
} | |
return ZERO; | |
} | |
} | |
const IS_MULTIPLICATION_RE = /[\*\/]/; | |
const $evaluator = Symbol('evalutor'); | |
/** | |
* Evaluator for CSS-like calc() functions. Our implementation of calc() | |
* evaluation currently support nested function calls, an unlimited number of | |
* terms, and all four algebraic operators (+, -, * and /). | |
* | |
* The Evaluator is marked as constant unless the calc expression contains an | |
* internal env expression at any depth, in which case it will be marked as | |
* dynamic. | |
* | |
* @see https://www.w3.org/TR/css-values-3/#calc-syntax | |
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc | |
*/ | |
class CalcEvaluator extends Evaluator { | |
constructor(calcFunction, basis = ZERO) { | |
super(); | |
this[_c] = null; | |
if (calcFunction.arguments.length !== 1) { | |
return; | |
} | |
const terms = calcFunction.arguments[0].terms.slice(); | |
const secondOrderTerms = []; | |
while (terms.length) { | |
const term = terms.shift(); | |
if (secondOrderTerms.length > 0) { | |
const previousTerm = secondOrderTerms[secondOrderTerms.length - 1]; | |
if (previousTerm.type === 'operator' && | |
IS_MULTIPLICATION_RE.test(previousTerm.value)) { | |
const operator = secondOrderTerms.pop(); | |
const leftValue = secondOrderTerms.pop(); | |
if (leftValue == null) { | |
return; | |
} | |
secondOrderTerms.push(new OperatorEvaluator(operator, Evaluator.evaluatableFor(leftValue, basis), Evaluator.evaluatableFor(term, basis))); | |
continue; | |
} | |
} | |
secondOrderTerms.push(term.type === 'operator' ? term : | |
Evaluator.evaluatableFor(term, basis)); | |
} | |
while (secondOrderTerms.length > 2) { | |
const [left, operator, right] = secondOrderTerms.splice(0, 3); | |
if (operator.type !== 'operator') { | |
return; | |
} | |
secondOrderTerms.unshift(new OperatorEvaluator(operator, Evaluator.evaluatableFor(left, basis), Evaluator.evaluatableFor(right, basis))); | |
} | |
// There should only be one combined evaluator at this point: | |
if (secondOrderTerms.length === 1) { | |
this[$evaluator] = secondOrderTerms[0]; | |
} | |
} | |
get isConstant() { | |
return this[$evaluator] == null || Evaluator.isConstant(this[$evaluator]); | |
} | |
[(_c = $evaluator, $evaluate)]() { | |
return this[$evaluator] != null ? Evaluator.evaluate(this[$evaluator]) : | |
ZERO; | |
} | |
} | |
const $operator = Symbol('operator'); | |
const $left = Symbol('left'); | |
const $right = Symbol('right'); | |
/** | |
* An Evaluator for the operators found inside CSS calc() functions. | |
* The evaluator accepts an operator and left/right operands. The operands can | |
* be any valid expression term typically allowed inside a CSS calc function. | |
* | |
* As detail of this implementation, the only supported unit types are angles | |
* expressed as radians or degrees, and lengths expressed as meters, centimeters | |
* or millimeters. | |
* | |
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc | |
*/ | |
class OperatorEvaluator extends Evaluator { | |
constructor(operator, left, right) { | |
super(); | |
this[$operator] = operator; | |
this[$left] = left; | |
this[$right] = right; | |
} | |
get isConstant() { | |
return Evaluator.isConstant(this[$left]) && | |
Evaluator.isConstant(this[$right]); | |
} | |
[$evaluate]() { | |
const leftNode = normalizeUnit(Evaluator.evaluate(this[$left])); | |
const rightNode = normalizeUnit(Evaluator.evaluate(this[$right])); | |
const { number: leftValue, unit: leftUnit } = leftNode; | |
const { number: rightValue, unit: rightUnit } = rightNode; | |
// Disallow operations for mismatched normalized units e.g., m and rad: | |
if (rightUnit != null && leftUnit != null && rightUnit != leftUnit) { | |
return ZERO; | |
} | |
// NOTE(cdata): rules for calc type checking are defined here | |
// https://drafts.csswg.org/css-values-3/#calc-type-checking | |
// This is a simplification and may not hold up once we begin to support | |
// additional unit types: | |
const unit = leftUnit || rightUnit; | |
let value; | |
switch (this[$operator].value) { | |
case '+': | |
value = leftValue + rightValue; | |
break; | |
case '-': | |
value = leftValue - rightValue; | |
break; | |
case '/': | |
value = leftValue / rightValue; | |
break; | |
case '*': | |
value = leftValue * rightValue; | |
break; | |
default: | |
return ZERO; | |
} | |
return { type: 'number', number: value, unit }; | |
} | |
} | |
const $evaluatables = Symbol('evaluatables'); | |
const $intrinsics = Symbol('intrinsics'); | |
/** | |
* A VectorEvaluator evaluates a series of numeric terms that usually represent | |
* a data structure such as a multi-dimensional vector or a spherical | |
* | |
* The form of the evaluator's result is determined by the Intrinsics that are | |
* given to it when it is constructed. For example, spherical intrinsics would | |
* establish two angle terms and a length term, so the result of evaluating the | |
* evaluator that is configured with spherical intrinsics is a three element | |
* array where the first two elements represent angles in radians and the third | |
* element representing a length in meters. | |
*/ | |
class StyleEvaluator extends Evaluator { | |
constructor(expressions, intrinsics) { | |
super(); | |
this[$intrinsics] = intrinsics; | |
const firstExpression = expressions[0]; | |
const terms = firstExpression != null ? firstExpression.terms : []; | |
this[$evaluatables] = | |
intrinsics.basis.map((basisNode, index) => { | |
const term = terms[index]; | |
if (term == null) { | |
return { type: 'ident', value: 'auto' }; | |
} | |
if (term.type === 'ident') { | |
return term; | |
} | |
return Evaluator.evaluatableFor(term, basisNode); | |
}); | |
} | |
get isConstant() { | |
for (const evaluatable of this[$evaluatables]) { | |
if (!Evaluator.isConstant(evaluatable)) { | |
return false; | |
} | |
} | |
return true; | |
} | |
[$evaluate]() { | |
const evaluated = this[$evaluatables].map(evaluatable => Evaluator.evaluate(evaluatable)); | |
return Evaluator.applyIntrinsics(evaluated, this[$intrinsics]) | |
.map(numberNode$$1 => numberNode$$1.number); | |
} | |
} | |
/* @license | |
* Copyright 2019 Google LLC. All Rights Reserved. | |
* 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 | |
* | |
* http://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. | |
*/ | |
var _a$1, _b$1, _c$1, _d; | |
const $instances = Symbol('instances'); | |
const $activateListener = Symbol('activateListener'); | |
const $deactivateListener = Symbol('deactivateListener'); | |
const $notifyInstances = Symbol('notifyInstances'); | |
const $notify = Symbol('notify'); | |
const $scrollCallback = Symbol('callback'); | |
/** | |
* This internal helper is intended to work as a reference-counting manager of | |
* scroll event listeners. Only one scroll listener is ever registered for all | |
* instances of the class, and when the last ScrollObserver "disconnects", that | |
* event listener is removed. This spares us from thrashing | |
* the {add,remove}EventListener API (the binding cost of these methods has been | |
* known to show up in performance anlyses) as well as potential memory leaks. | |
*/ | |
class ScrollObserver { | |
constructor(callback) { | |
this[$scrollCallback] = callback; | |
} | |
static [$notifyInstances]() { | |
for (const instance of ScrollObserver[$instances]) { | |
instance[$notify](); | |
} | |
} | |
static [(_a$1 = $instances, $activateListener)]() { | |
window.addEventListener('scroll', this[$notifyInstances], { passive: true }); | |
} | |
static [$deactivateListener]() { | |
window.removeEventListener('scroll', this[$notifyInstances]); | |
} | |
/** | |
* Listen for scroll events. The configured callback (passed to the | |
* constructor) will be invoked for subsequent global scroll events. | |
*/ | |
observe() { | |
if (ScrollObserver[$instances].size === 0) { | |
ScrollObserver[$activateListener](); | |
} | |
ScrollObserver[$instances].add(this); | |
} | |
/** | |
* Stop listening for scroll events. | |
*/ | |
disconnect() { | |
ScrollObserver[$instances].delete(this); | |
if (ScrollObserver[$instances].size === 0) { | |
ScrollObserver[$deactivateListener](); | |
} | |
} | |
[$notify]() { | |
this[$scrollCallback](); | |
} | |
; | |
} | |
ScrollObserver[_a$1] = new Set(); | |
const $computeStyleCallback = Symbol('computeStyleCallback'); | |
const $astWalker = Symbol('astWalker'); | |
const $dependencies = Symbol('dependencies'); | |
const $scrollHandler = Symbol('scrollHandler'); | |
const $onScroll = Symbol('onScroll'); | |
/** | |
* The StyleEffector is configured with a callback that will be invoked at the | |
* optimal time that some array of CSS expression ASTs ought to be evaluated. | |
* | |
* For example, our CSS-like expression syntax supports usage of the env() | |
* function to incorporate the current top-level scroll position into a CSS | |
* expression: env(window-scroll-y). | |
* | |
* This "environment variable" will change dynamically as the user scrolls the | |
* page. If an AST contains such a usage of env(), we would have to evaluate the | |
* AST on every frame in order to be sure that the computed style stays up to | |
* date. | |
* | |
* The StyleEffector spares us from evaluating the expressions on every frame by | |
* correlating specific parts of an AST with observers of the external effects | |
* that they refer to (if any). So, if the AST contains env(window-scroll-y), | |
* the StyleEffector manages the lifetime of a global scroll event listener and | |
* notifies the user at the optimal time to evaluate the computed style. | |
*/ | |
class StyleEffector { | |
constructor(callback) { | |
this[_b$1] = {}; | |
this[_c$1] = new ASTWalker(['function']); | |
this[_d] = () => this[$onScroll](); | |
this[$computeStyleCallback] = callback; | |
} | |
/** | |
* Sets the expressions that govern when the StyleEffector callback will be | |
* invoked. | |
*/ | |
observeEffectsFor(ast) { | |
const newDependencies = {}; | |
const oldDependencies = this[$dependencies]; | |
this[$astWalker].walk(ast, functionNode => { | |
const { name } = functionNode; | |
const firstArgument = functionNode.arguments[0]; | |
const firstTerm = firstArgument.terms[0]; | |
if (name.value !== 'env' || firstTerm == null || | |
firstTerm.type !== 'ident') { | |
return; | |
} | |
switch (firstTerm.value) { | |
case 'window-scroll-y': | |
if (newDependencies['window-scroll'] == null) { | |
const observer = 'window-scroll' in oldDependencies ? | |
oldDependencies['window-scroll'] : | |
new ScrollObserver(this[$scrollHandler]); | |
observer.observe(); | |
delete oldDependencies['window-scroll']; | |
newDependencies['window-scroll'] = observer; | |
} | |
break; | |
} | |
}); | |
for (const environmentState in oldDependencies) { | |
const observer = oldDependencies[environmentState]; | |
observer.disconnect(); | |
} | |
this[$dependencies] = newDependencies; | |
} | |
/** | |
* Disposes of the StyleEffector by disconnecting all observers of external | |
* effects. | |
*/ | |
dispose() { | |
for (const environmentState in this[$dependencies]) { | |
const observer = this[$dependencies][environmentState]; | |
observer.disconnect(); | |
} | |
} | |
[(_b$1 = $dependencies, _c$1 = $astWalker, _d = $scrollHandler, $onScroll)]() { | |
this[$computeStyleCallback]({ relatedState: 'window-scroll' }); | |
} | |
} | |
/* @license | |
* Copyright 2019 Google LLC. All Rights Reserved. | |
* 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 | |
* | |
* http://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. | |
*/ | |
/** | |
* The @style decorator is responsible for coordinating the conversion of a | |
* CSS-like string property value into numbers that can be applied to | |
* lower-level constructs. It also can optionally manage the lifecycle of a | |
* StyleEffector which allows automatic updates for styles that use env() or | |
* var() functions. | |
* | |
* The decorator is configured with Intrinsics and the property key for a | |
* method that handles updates. The named update handler is invoked with the | |
* result of parsing and evaluating the raw property string value. The format of | |
* the evaluated result is derived from the basis of the configured Intrinsics, | |
* and is always an array of numbers of fixed length. | |
* | |
* NOTE: This decorator depends on the property updating mechanism defined by | |
* UpdatingElement as exported by the lit-element module. That means it *must* | |
* be used in conjunction with the @property decorator, or equivalent | |
* JavaScript. | |
* | |
* Supported configurations are: | |
* | |
* - `intrinsics`: An Intrinsics struct that describes how to interpret a | |
* serialized style attribute. For more detail on intrinsics see | |
* ./styles/evaluators.ts | |
* - `updateHandler`: A string or Symbol that is the key of a method to be | |
* invoked with the result of parsing and evaluating a serialized style string. | |
* - `observeEffects`: Optional, if set to true then styles that use env() will | |
* cause their update handlers to be invoked every time the corresponding | |
* environment variable changes (even if the style attribute itself remains | |
* static). | |
*/ | |
const style = (config) => { | |
const observeEffects = config.observeEffects || false; | |
const getIntrinsics = config.intrinsics instanceof Function ? | |
config.intrinsics : | |
(() => config.intrinsics); | |
return (proto, propertyName) => { | |
const originalUpdated = proto.updated; | |
const originalConnectedCallback = proto.connectedCallback; | |
const originalDisconnectedCallback = proto.disconnectedCallback; | |
const $styleEffector = Symbol(`${propertyName}StyleEffector`); | |
const $styleEvaluator = Symbol(`${propertyName}StyleEvaluator`); | |
const $updateEvaluator = Symbol(`${propertyName}UpdateEvaluator`); | |
const $evaluateAndSync = Symbol(`${propertyName}EvaluateAndSync`); | |
Object.defineProperties(proto, { | |
[$styleEffector]: { value: null, writable: true }, | |
[$styleEvaluator]: { value: null, writable: true }, | |
[$updateEvaluator]: { | |
value: function () { | |
const ast = parseExpressions(this[propertyName]); | |
this[$styleEvaluator] = | |
new StyleEvaluator(ast, getIntrinsics(this)); | |
if (this[$styleEffector] == null && observeEffects) { | |
this[$styleEffector] = | |
new StyleEffector(() => this[$evaluateAndSync]()); | |
} | |
if (this[$styleEffector] != null) { | |
this[$styleEffector].observeEffectsFor(ast); | |
} | |
} | |
}, | |
[$evaluateAndSync]: { | |
value: function () { | |
if (this[$styleEvaluator] == null) { | |
return; | |
} | |
const result = this[$styleEvaluator].evaluate(); | |
// @see https://github.com/microsoft/TypeScript/pull/30769 | |
// @see https://github.com/Microsoft/TypeScript/issues/1863 | |
this[config.updateHandler](result); | |
} | |
}, | |
updated: { | |
value: function (changedProperties) { | |
// Always invoke updates to styles first. This gives a class that | |
// uses this decorator the opportunity to override the effect, or | |
// respond to it, in its own implementation of `updated`. | |
if (changedProperties.has(propertyName)) { | |
this[$updateEvaluator](); | |
this[$evaluateAndSync](); | |
} | |
originalUpdated.call(this, changedProperties); | |
} | |
}, | |
connectedCallback: { | |
value: function () { | |
originalConnectedCallback.call(this); | |
this.requestUpdate(propertyName, this[propertyName]); | |
} | |
}, | |
disconnectedCallback: { | |
value: function () { | |
originalDisconnectedCallback.call(this); | |
if (this[$styleEffector] != null) { | |
this[$styleEffector].dispose(); | |
this[$styleEffector] = null; | |
} | |
} | |
} | |
}); | |
}; | |
}; | |
export { style, numberNode, parseExpressions, ASTWalker, ZERO, Evaluator, PercentageEvaluator, EnvEvaluator, CalcEvaluator, OperatorEvaluator, StyleEvaluator, degreesToRadians, radiansToDegrees, lengthToBaseMeters, normalizeUnit }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment