diff --git a/demo/view-timeline/with-math-value-range.html b/demo/view-timeline/with-math-value-range.html new file mode 100644 index 00000000..b99d20a7 --- /dev/null +++ b/demo/view-timeline/with-math-value-range.html @@ -0,0 +1,128 @@ + + + + + + view-timeline demo + + + +
+
+
+
+
+
+
cover
+
cover calc(0% + 200px) calc(100% - 100px)
+
+
+
+ +
+
+
+
+
+
+
+ + + + + diff --git a/src/numeric-values.js b/src/numeric-values.js new file mode 100644 index 00000000..aaf251c4 --- /dev/null +++ b/src/numeric-values.js @@ -0,0 +1,408 @@ +/** + * @typedef {{[string]: integer}} UnitMap + * @typedef {[number, UnitMap]} SumValueItem + * @typedef {SumValueItem[]} SumValue + * @typedef {null} Failure + * @typedef {{[string]: integer} & {percentHint: string | undefined}} Type + */ + +const failure = null; +const baseTypes = ["percent", "length", "angle", "time", "frequency", "resolution", "flex"]; + +const unitGroups = { + // https://www.w3.org/TR/css-values-4/#font-relative-lengths + fontRelativeLengths: { + units: new Set(["em", "rem", "ex", "rex", "cap", "rcap", "ch", "rch", "ic", "ric", "lh", "rlh"]) + }, + // https://www.w3.org/TR/css-values-4/#viewport-relative-lengths + viewportRelativeLengths: { + units: new Set( + ["vw", "lvw", "svw", "dvw", "vh", "lvh", "svh", "dvh", "vi", "lvi", "svi", "dvi", "vb", "lvb", "svb", "dvb", + "vmin", "lvmin", "svmin", "dvmin", "vmax", "lvmax", "svmax", "dvmax"]) + }, + // https://www.w3.org/TR/css-values-4/#absolute-lengths + absoluteLengths: { + units: new Set(["cm", "mm", "Q", "in", "pt", "pc", "px"]), + compatible: true, + canonicalUnit: "px", + ratios: { + "cm": 96 / 2.54, "mm": (96 / 2.54) / 10, "Q": (96 / 2.54) / 40, "in": 96, "pc": 96 / 6, "pt": 96 / 72, "px": 1 + } + }, + // https://www.w3.org/TR/css-values-4/#angles + angle: { + units: new Set(["deg", "grad", "rad", "turn"]), + compatible: true, + canonicalUnit: "deg", + ratios: { + "deg": 1, "grad": 360 / 400, "rad": 180 / Math.PI, "turn": 360 + } + }, + // https://www.w3.org/TR/css-values-4/#time + time: { + units: new Set(["s", "ms"]), + compatible: true, + canonicalUnit: "s", + ratios: { + "s": 1, "ms": 1 / 1000 + } + }, + // https://www.w3.org/TR/css-values-4/#frequency + frequency: { + units: new Set(["hz", "khz"]), + compatible: true, + canonicalUnit: "hz", + ratios: { + "hz": 1, "khz": 1000 + } + }, + // https://www.w3.org/TR/css-values-4/#resolution + resolution: { + units: new Set(["dpi", "dpcm", "dppx"]), + compatible: true, + canonicalUnit: "dppx", + ratios: { + "dpi": 1 / 96, "dpcm": 2.54 / 96, "dppx": 1 + } + } +}; + +const unitToCompatibleUnitsMap = new Map(); +for (const group of Object.values(unitGroups)) { + if (!group.compatible) { + continue; + } + for (const unit of group.units) { + unitToCompatibleUnitsMap.set(unit, group); + } +} + +export function getSetOfCompatibleUnits(unit) { + return unitToCompatibleUnitsMap.get(unit); +} + +/** + * Implementation of `product of two unit maps` from css-typed-om-1: + * https://www.w3.org/TR/css-typed-om-1/#product-of-two-unit-maps + * + * @param {UnitMap} units1 map of units (strings) to powers (integers) + * @param {UnitMap} units2 map of units (strings) to powers (integers) + * @return {UnitMap} map of units (strings) to powers (integers) + */ +function productOfTwoUnitMaps(units1, units2) { + // 1. Let result be a copy of units1. + const result = {...units1}; + // 2. For each unit → power in units2: + for (const unit of Object.keys(units2)) { + if (result[unit]) { + // 1. If result[unit] exists, increment result[unit] by power. + result[unit] += units2[unit]; + } else { + // 2. Otherwise, set result[unit] to power. + result[unit] = units2[unit]; + } + } + // 3. Return result. + return result; +} + +/** + * Implementation of `create a type` from css-typed-om-1: + * https://www.w3.org/TR/css-typed-om-1/#create-a-type + * + * @param {string} unit + * @return {Type|Failure} + */ +export function createAType(unit) { + if (unit === "number") { + return {}; + } else if (unit === "percent") { + return {"percent": 1}; + } else if (unitGroups.absoluteLengths.units.has(unit) || unitGroups.fontRelativeLengths.units.has(unit) || + unitGroups.viewportRelativeLengths.units.has(unit)) { + return {"length": 1}; + } else if (unitGroups.angle.units.has(unit)) { + return {"angle": 1}; + } else if (unitGroups.time.units.has(unit)) { + return {"time": 1}; + } else if (unitGroups.frequency.units.has(unit)) { + return {"frequency": 1}; + } else if (unitGroups.resolution.units.has(unit)) { + return {"resolution": 1}; + } else if (unit === "fr") { + return {"flex": 1}; + } else { + return failure; + } +} + +/** + * Partial implementation of `create a sum value` from css-typed-om-1: + * https://www.w3.org/TR/css-typed-om-1/#create-a-sum-value + * + * Supports CSSUnitValue, CSSMathProduct and CSSMathInvert with a CSSUnitValue value. + * Other types are not supported, and will throw an error. + * + * @param {CSSNumericValue} cssNumericValue + * @return {SumValue} Abstract representation of a CSSNumericValue as a sum of numbers with (possibly complex) units + */ +export function createSumValue(cssNumericValue) { + if (cssNumericValue instanceof CSSUnitValue) { + let {unit, value} = cssNumericValue; + // Let unit be the value of this’s unit internal slot, and value be the value of this’s value internal slot. + // If unit is a member of a set of compatible units, and is not the set’s canonical unit, + // multiply value by the conversion ratio between unit and the canonical unit, and change unit to the canonical unit. + const compatibleUnits = getSetOfCompatibleUnits(cssNumericValue.unit); + if (compatibleUnits && unit !== compatibleUnits.canonicalUnit) { + value *= compatibleUnits.ratios[unit]; + unit = compatibleUnits.canonicalUnit; + } + + if (unit === "number") { + // If unit is "number", return «(value, «[ ]»)». + return [[value, {}]]; + } else { + // Otherwise, return «(value, «[unit → 1]»)». + return [[value, {[unit]: 1}]]; + } + } else if (cssNumericValue instanceof CSSMathInvert) { + if (!(cssNumericValue.value instanceof CSSUnitValue)) { + // Limit implementation to CSSMathInvert of CSSUnitValue + throw new Error("Not implemented"); + } + // 1. Let values be the result of creating a sum value from this’s value internal slot. + const values = createSumValue(cssNumericValue.value); + // 2. If values is failure, return failure. + if (values === failure) { + return failure; + } + // 3. If the length of values is more than one, return failure. + if (values.length > 1) { + return failure; + } + // 4. Invert (find the reciprocal of) the value of the item in values, and negate the value of each entry in its unit map. + const item = values[0]; + const tempUnionMap = {}; + for (const [unit, power] of Object.entries(item[1])) { + tempUnionMap[unit] = -1 * power; + } + values[0] = [1 / item[0], tempUnionMap]; + + // 5. Return values. + return values; + } else if (cssNumericValue instanceof CSSMathProduct) { + // 1. Let values initially be the sum value «(1, «[ ]»)». (I.e. what you’d get from 1.) + + let values = [[1, {}]]; + + // 2. For each item in this’s values internal slot: + for (const item of cssNumericValue.values) { + // 1. Let new values be the result of creating a sum value from item. Let temp initially be an empty list. + const newValues = createSumValue(item); + const temp = []; + // 2. If new values is failure, return failure. + if (newValues === failure) { + return failure; + } + // 3. For each item1 in values: + for (const item1 of values) { + // 1. For each item2 in new values: + for (const item2 of newValues) { + // 1. Let item be a tuple with its value set to the product of the values of item1 and item2, and its unit + // map set to the product of the unit maps of item1 and item2, with all entries with a zero value removed. + // 2. Append item to temp. + temp.push([item1[0] * item2[0], productOfTwoUnitMaps(item1[1], item2[1])]); + } + } + // 4. Set values to temp. + values = temp; + } + // Return values. + return values; + } else { + throw new Error("Not implemented"); + } +} + + +/** + * Implementation of `to(unit)` for CSSNumericValue from css-typed-om-1: + * https://www.w3.org/TR/css-typed-om-1/#dom-cssnumericvalue-to + * + * Converts an existing CSSNumeric value into another with the specified unit, if possible. + * + * @param {CSSNumericValue} cssNumericValue value to convert + * @param {string} unit + * @return {CSSUnitValue} + */ +export function to(cssNumericValue, unit) { + // Let type be the result of creating a type from unit. If type is failure, throw a SyntaxError. + const type = createAType(unit); + if (type === failure) { + throw new SyntaxError("The string did not match the expected pattern."); + } + + // Let sum be the result of creating a sum value from this. + const sumValue = createSumValue(cssNumericValue); + + // If sum is failure, throw a TypeError. + if (!sumValue) { + throw new TypeError(); + } + + // If sum has more than one item, throw a TypeError. + if (sumValue.length > 1) { + throw new TypeError("Sum has more than one item"); + } + + // Otherwise, let item be the result of creating a CSSUnitValue + // from the sole item in sum, then converting it to unit. + const item = convertCSSUnitValue(createCSSUnitValue(sumValue[0]), unit); + + + // If item is failure, throw a TypeError. + if (item === failure) { + throw new TypeError(); + } + // Return item. + return item; +} + +/** + * Implementation of `create a CSSUnitValue from a sum value item` from css-typed-om-1: + * https://www.w3.org/TR/css-typed-om-1/#create-a-cssunitvalue-from-a-sum-value-item + * + * @param {SumValueItem} sumValueItem a tuple of a value, and a unit map + * @return {CSSUnitValue|Failure} + */ +export function createCSSUnitValue(sumValueItem) { + const [value, unitMap] = sumValueItem; + // When asked to create a CSSUnitValue from a sum value item item, perform the following steps: + // If item has more than one entry in its unit map, return failure. + const entries = Object.entries(unitMap); + if (entries.length > 1) { + return failure; + } + // If item has no entries in its unit map, return a new CSSUnitValue whose unit internal slot is set to "number", + // and whose value internal slot is set to item’s value. + if (entries.length === 0) { + return new CSSUnitValue(value, "number"); + } + // Otherwise, item has a single entry in its unit map. If that entry’s value is anything other than 1, return failure. + const entry = entries[0]; + if (entry[1] !== 1) { + return failure; + } + // Otherwise, return a new CSSUnitValue whose unit internal slot is set to that entry’s key, and whose value internal slot is set to item’s value. + else { + return new CSSUnitValue(value, entry[0]); + } +} + +/** + * Implementation of `convert a CSSUnitValue` from css-typed-om-1: + * https://www.w3.org/TR/css-typed-om-1/#convert-a-cssunitvalue + + * @param {CSSUnitValue} cssUnitValue + * @param {string} unit + * @return {CSSUnitValue|Failure} + */ +export function convertCSSUnitValue(cssUnitValue, unit) { + // Let old unit be the value of this’s unit internal slot, and old value be the value of this’s value internal slot. + const oldUnit = cssUnitValue.unit; + const oldValue = cssUnitValue.value; + // If old unit and unit are not compatible units, return failure. + const oldCompatibleUnitGroup = getSetOfCompatibleUnits(oldUnit); + const compatibleUnitGroup = getSetOfCompatibleUnits(unit); + if (!compatibleUnitGroup || oldCompatibleUnitGroup !== compatibleUnitGroup) { + return failure; + } + // Return a new CSSUnitValue whose unit internal slot is set to unit, and whose value internal slot is set to + // old value multiplied by the conversation ratio between old unit and unit. + return new CSSUnitValue(oldValue * compatibleUnitGroup.ratios[oldUnit] / compatibleUnitGroup.ratios[unit], unit); +} + +/** + * Partial implementation of `toSum(...units)`: + * https://www.w3.org/TR/css-typed-om-1/#dom-cssnumericvalue-tosum + * + * The implementation is restricted to conversion without units. + * It simplifies a CSSNumericValue into a minimal sum of CSSUnitValues. + * Will throw an error if called with units. + * + * @param {CSSNumericValue} cssNumericValue value to convert to a CSSMathSum + * @param {string[]} units Not supported in this implementation + * @return {CSSMathSum} + */ +export function toSum(cssNumericValue, ...units) { + // The toSum(...units) method converts an existing CSSNumericValue this into a CSSMathSum of only CSSUnitValues + // with the specified units, if possible. (It’s like to(), but allows the result to have multiple units in it.) + // If called without any units, it just simplifies this into a minimal sum of CSSUnitValues. + // When called, it must perform the following steps: + // + // For each unit in units, if the result of creating a type from unit is failure, throw a SyntaxError. + // + if (units && units.length) { + // Only unitless method calls are implemented in this polyfill + throw new Error("Not implemented"); + } + + // Let sum be the result of creating a sum value from this. If sum is failure, throw a TypeError. + const sum = createSumValue(cssNumericValue); + + // Let values be the result of creating a CSSUnitValue for each item in sum. If any item of values is failure, + // throw a TypeError. + const values = sum.map(item => createCSSUnitValue(item)); + if (values.some(value => value === failure)) { + throw new TypeError("Type error"); + } + + // If units is empty, sort values in code point order according to the unit internal slot of its items, + // then return a new CSSMathSum object whose values internal slot is set to values. + return new CSSMathSum(...values); +} + +/** + * Implementation of `invert a type` from css-typed-om-1 Editors Draft: + * https://drafts.css-houdini.org/css-typed-om/ + * + * @param {Type} type + * @return {Type} + */ +export function invertType(type) { + // To invert a type type, perform the following steps: + // Let result be a new type with an initially empty ordered map and an initially null percent hint + // For each unit → exponent of type, set result[unit] to (-1 * exponent). + // Return result. + const result = {}; + for (const baseType of baseTypes) { + result[baseType] = -1 * type[baseType]; + } + return result; +} + +/** + * Implementation of `multiply two types` from css-typed-om-1 Editor's Draft: + * https://drafts.css-houdini.org/css-typed-om/#cssnumericvalue-multiply-two-types + * + * @param {Type} type1 a map of base types to integers and an associated percent hint + * @param {Type} type2 a map of base types to integers and an associated percent hint + * @return {Type|Failure} + */ +export function multiplyTypes(type1, type2) { + if (type1.percentHint && type2.percentHint && type1.percentHint !== type2.percentHint) { + return failure; + } + const finalType = { + ...type1, percentHint: type1.percentHint ?? type2.percentHint, + }; + + for (const baseType of baseTypes) { + if (!type2[baseType]) { + continue; + } + finalType[baseType] ??= 0; + finalType[baseType] += type2[baseType]; + } + return finalType; +} \ No newline at end of file diff --git a/src/proxy-cssom.js b/src/proxy-cssom.js index da068b66..656d4553 100644 --- a/src/proxy-cssom.js +++ b/src/proxy-cssom.js @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { createAType, invertType, multiplyTypes, to, toSum } from "./numeric-values"; export function installCSSOM() { // Object for storing details associated with an object which are to be kept @@ -88,6 +89,20 @@ export function installCSSOM() { return privateDetails.get(this).unit; } + to(unit) { + return to(this, unit) + } + + toSum(...units) { + return toSum(this, ...units) + } + + type() { + const details = privateDetails.get(this) + // The type of a CSSUnitValue is the result of creating a type from its unit internal slot. + return createAType(details.unit) + } + toString() { const details = privateDetails.get(this); return `${details.value}${displayUnit(details.unit)}`; @@ -114,6 +129,16 @@ export function installCSSOM() { constructor(values) { super(arguments, 'product', 'calc', ' * '); } + + toSum(...units) { + return toSum(this, ...units) + } + + type() { + const values = privateDetails.get(this).values; + // The type is the result of multiplying the types of each of the items in its values internal slot. + return values.map(v => v.type()).reduce(multiplyTypes) + } }, 'CSSMathNegate': class extends MathOperation { @@ -134,6 +159,12 @@ export function installCSSOM() { get value() { return privateDetails.get(this).values[1]; } + + type() { + const details = privateDetails.get(this) + // The type of a CSSUnitValue is the result of creating a type from its unit internal slot. + return invertType(details.values[1].type()) + } }, 'CSSMathMax': class extends MathOperation { diff --git a/src/scroll-timeline-base.js b/src/scroll-timeline-base.js index 8050c56c..f37a23d2 100644 --- a/src/scroll-timeline-base.js +++ b/src/scroll-timeline-base.js @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { installCSSOM } from "./proxy-cssom.js"; +import {installCSSOM} from "./proxy-cssom.js"; +import {simplifyCalculation} from "./simplify-calculation"; + installCSSOM(); const DEFAULT_TIMELINE_AXIS = 'block'; @@ -659,20 +661,26 @@ function parseInset(value, containerSize) { // Calculate the fractional offset of a (phase, percent) pair relative to the // full cover range. -export function relativePosition(timeline, phase, percent) { +export function relativePosition(timeline, phase, offset) { const phaseRange = range(timeline, phase); const coverRange = range(timeline, 'cover'); - return calculateRelativePosition(phaseRange, percent, coverRange); + return calculateRelativePosition(phaseRange, offset, coverRange); } -export function calculateRelativePosition(phaseRange, percent, coverRange) { + + +export function calculateRelativePosition(phaseRange, offset, coverRange) { if (!phaseRange || !coverRange) return 0; - const fraction = percent.value / 100; - const offset = - (phaseRange.end - phaseRange.start) * fraction + phaseRange.start; - return (offset - coverRange.start) / (coverRange.end - coverRange.start); + const info = {percentageReference: new CSSUnitValue(phaseRange.end - phaseRange.start, "px")}; + const simplifiedRangeOffset = simplifyCalculation(offset, info); + if (!(simplifiedRangeOffset instanceof CSSUnitValue) || simplifiedRangeOffset.unit !== 'px') { + throw new Error(`Unsupported offset '${simplifiedRangeOffset.toString()}'`) + } + + const offsetPX = simplifiedRangeOffset.value + phaseRange.start; + return (offsetPX - coverRange.start) / (coverRange.end - coverRange.start); } // https://drafts.csswg.org/scroll-animations-1/#view-progress-timelines diff --git a/src/simplify-calculation.js b/src/simplify-calculation.js new file mode 100644 index 00000000..da76e7fb --- /dev/null +++ b/src/simplify-calculation.js @@ -0,0 +1,301 @@ +import {isCanonical} from "./utils"; + +/** + * @typedef {{percentageReference: CSSUnitValue}} Info + */ + +/** + * Groups a list of objects by a given string keyed property + * + * @template T + * @param {T[]} items + * @param {string} key string key + * @return {Map} + */ +function groupBy(items, key) { + return items.reduce((groups, item) => { + if (groups.has(item[key])) { + groups.get(item[key]).push(item); + } else { + groups.set(item[key], [item]); + } + return groups; + }, new Map()); +} + +/** + * Partitions a list into a tuple of lists. + * The first item in the tuple contains a list of items that pass the test provided by the callback function. + * The second item in the tuple contains the remaining items + * + * @template T + * @param {T[]} items + * @param {(item:T) => boolean} callbackFn Returns truthy if item should be put in the first list in the tuple, falsy if it should be put in the second list. + * @return {[T[],T[]]} + */ +function partition(items, callbackFn) { + const partA = []; + const partB = []; + for (const item of items) { + if (callbackFn(item)) { + partA.push(item); + } else { + partB.push(item); + } + } + return [partA, partB]; +} + +/** + * Partial implementation of `simplify a calculation tree` applied to CSSNumericValue + * https://www.w3.org/TR/css-values-4/#simplify-a-calculation-tree + * + * @param {CSSNumericValue} root + * @param {Info} info information used to resolve + * @return {CSSNumericValue} + */ +export function simplifyCalculation(root, info) { + function simplifyNumericArray(values) { + return Array.from(values).map((value) => simplifyCalculation(value, info)); + } + + // To simplify a calculation tree root: + if (root instanceof CSSUnitValue) { + // 1. If root is a numeric value: + + if (root.unit === "percent" && info.percentageReference) { + // 1. If root is a percentage that will be resolved against another value, and there is enough information + // available to resolve it, do so, and express the resulting numeric value in the appropriate canonical unit. + // Return the value. + const resolvedValue = (root.value / 100) * info.percentageReference.value; + const resolvedUnit = info.percentageReference.unit; + return new CSSUnitValue(resolvedValue, resolvedUnit); + } + + // 2. If root is a dimension that is not expressed in its canonical unit, and there is enough information available + // to convert it to the canonical unit, do so, and return the value. + + // Use Typed OM toSum() to convert values in compatible sets to canonical units + const sum = root.toSum(); + if (sum && sum.values.length === 1) { + root = sum.values[0]; + } + // TODO: handle relative lengths + + // 3. If root is a , return its numeric value. + // 4. Otherwise, return root. + return root; + } + + // 2. If root is any other leaf node (not an operator node): + if (!root.operator) { + // 1. If there is enough information available to determine its numeric value, return its value, expressed in the value’s canonical unit. + // 2. Otherwise, return root. + return root; + } + + // 3. At this point, root is an operator node. Simplify all the calculation children of root. + switch (root.operator) { + case "sum": + root = new CSSMathSum(...simplifyNumericArray(root.values)); + break; + case "product": + root = new CSSMathProduct(...simplifyNumericArray(root.values)); + break; + case "negate": + root = new CSSMathNegate(simplifyCalculation(root.value, info)); + break; + case "clamp": + root = new CSSMathClamp(simplifyCalculation(root.lower, info), simplifyCalculation(root.value, info), + simplifyCalculation(root.upper, info)); + break; + case "invert": + root = new CSSMathInvert(simplifyCalculation(root.value, info)); + break; + case "min": + root = new CSSMathMin(...simplifyNumericArray(root.values)); + break; + case "max": + root = new CSSMathMax(...simplifyNumericArray(root.values)); + break; + } + + // 4. If root is an operator node that’s not one of the calc-operator nodes, and all of its calculation children are + // numeric values with enough information to compute the operation root represents, return the result of running + // root’s operation using its children, expressed in the result’s canonical unit. + if (root instanceof CSSMathMin || root instanceof CSSMathMax) { + const children = Array.from(root.values); + if (children.every( + (child) => child instanceof CSSUnitValue && child.unit !== "percent" && isCanonical(child.unit) && child.unit === + children[0].unit)) { + + const result = Math[root.operator].apply(Math, children.map(({value}) => value)); + return new CSSUnitValue(result, children[0].unit); + } + } + + // Note: If a percentage is left at this point, it will usually block simplification of the node, since it needs to be + // resolved against another value using information not currently available. (Otherwise, it would have been converted + // to a different value in an earlier step.) This includes operations such as "min", since percentages might resolve + // against a negative basis, and thus end up with an opposite comparative relationship than the raw percentage value + // would seem to indicate. + // + // However, "raw" percentages—ones which do not resolve against another value, such as in opacity—might not block + // simplification. + + // 5. If root is a Min or Max node, attempt to partially simplify it: + if (root instanceof CSSMathMin || root instanceof CSSMathMax) { + const children = Array.from(root.values); + const [numeric, rest] = partition(children, (child) => child instanceof CSSUnitValue && child.unit !== "percent"); + const unitGroups = Array.from(groupBy(numeric, "unit").values()); + // 1. For each node child of root’s children: + // + // If child is a numeric value with enough information to compare magnitudes with another child of the same + // unit (see note in previous step), and there are other children of root that are numeric children with the same + // unit, combine all such children with the appropriate operator per root, and replace child with the result, + // removing all other child nodes involved. + const hasComparableChildren = unitGroups.some(group => group.length > 0); + if (hasComparableChildren) { + const combinedGroups = unitGroups.map(group => { + const result = Math[root.operator].apply(Math, group.map(({value}) => value)); + return new CSSUnitValue(result, group[0].unit); + }); + if (root instanceof CSSMathMin) { + root = new CSSMathMin(...combinedGroups, ...rest); + } else { + root = new CSSMathMax(...combinedGroups, ...rest); + } + } + + // 2. Return root. + return root; + } + + // If root is a Negate node: + // + // If root’s child is a numeric value, return an equivalent numeric value, but with the value negated (0 - value). + // If root’s child is a Negate node, return the child’s child. + // Return root. + if (root instanceof CSSMathNegate) { + if (root.value instanceof CSSUnitValue) { + return new CSSUnitValue(0 - root.value.value, root.value.unit); + } else if (root.value instanceof CSSMathNegate) { + return root.value.value; + } else { + return root; + } + } + + // If root is an Invert node: + // + // If root’s child is a number (not a percentage or dimension) return the reciprocal of the child’s value. + // If root’s child is an Invert node, return the child’s child. + // Return root. + if (root instanceof CSSMathInvert) { + if (root.value instanceof CSSMathInvert) { + return root.value.value; + } else { + return root; + } + } + + // If root is a Sum node: + if (root instanceof CSSMathSum) { + let children = []; + // For each of root’s children that are Sum nodes, replace them with their children. + for (const value of root.values) { + if (value instanceof CSSMathSum) { + children.push(...value.values); + } else { + children.push(value); + } + } + + // For each set of root’s children that are numeric values with identical units, remove those children and + // replace them with a single numeric value containing the sum of the removed nodes, and with the same unit. + // + // (E.g. combine numbers, combine percentages, combine px values, etc.) + function sumValuesWithSameUnit(values) { + const numericValues = values.filter((c) => c instanceof CSSUnitValue); + const nonNumericValues = values.filter((c) => !(c instanceof CSSUnitValue)); + + const summedNumericValues = Array.from(groupBy(numericValues, "unit").entries()) + .map(([unit, values]) => { + const sum = values.reduce((a, {value}) => a + value, 0); + return new CSSUnitValue(sum, unit); + }); + return [...nonNumericValues, ...summedNumericValues]; + } + + children = sumValuesWithSameUnit(children); + + // If root has only a single child at this point, return the child. Otherwise, return root. + // NOTE: Zero-valued terms cannot be simply removed from a Sum; they can only be combined with other values + // that have identical units. (This is because the mere presence of a unit, even with a zero value, + // can sometimes imply a change in behavior.) + if (children.length === 1) { + return children[0]; + } else { + return new CSSMathSum(...children); + } + } + + // If root is a Product node: + // + // For each of root’s children that are Product nodes, replace them with their children. + if (root instanceof CSSMathProduct) { + let children = []; + for (const value of root.values) { + if (value instanceof CSSMathProduct) { + children.push(...value.values); + } else { + children.push(value); + } + } + + // If root has multiple children that are numbers (not percentages or dimensions), remove them and replace them with + // a single number containing the product of the removed nodes. + const [numbers, rest] = partition(children, (child) => child instanceof CSSUnitValue && child.unit === "number"); + if (numbers.length > 1) { + const product = numbers.reduce((a, {value}) => a * value, 1); + children = [new CSSUnitValue(product, "number"), ...rest]; + } + + // If root contains only two children, one of which is a number (not a percentage or dimension) and the other of + // which is a Sum whose children are all numeric values, multiply all of the Sum’s children by the number, + // then return the Sum. + if (children.length === 2) { + let numeric, sum; + for (const child of children) { + if (child instanceof CSSUnitValue && child.unit === "number") { + numeric = child; + } else if (child instanceof CSSMathSum && [...child.values].every((c) => c instanceof CSSUnitValue)) { + sum = child; + } + } + if (numeric && sum) { + return new CSSMathSum( + ...[...sum.values].map((value) => new CSSUnitValue(value.value * numeric.value, value.unit))); + } + } + + // If root contains only numeric values and/or Invert nodes containing numeric values, and multiplying the types of + // all the children (noting that the type of an Invert node is the inverse of its child’s type) results in a type + // that matches any of the types that a math function can resolve to, return the result of multiplying all the values + // of the children (noting that the value of an Invert node is the reciprocal of its child’s value), + // expressed in the result’s canonical unit. + if (children.every((child) => (child instanceof CSSUnitValue && isCanonical(child.unit)) || + (child instanceof CSSMathInvert && child.value instanceof CSSUnitValue && isCanonical(child.value.unit)))) { + // Use CSS Typed OM to multiply types + const sum = new CSSMathProduct(...children).toSum(); + if (sum && sum.values.length === 1) { + return sum.values[0]; + } + } + + // Return root. + return new CSSMathProduct(...children); + } + // Return root. + return root; +} diff --git a/src/utils.js b/src/utils.js index e18f1625..4b5d18b8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -11,4 +11,10 @@ export function parseLength(obj, acceptStr) { return new CSSUnitValue(value, unit); } return null; +} + +const canonicalUnits = new Set(["px", "deg", "s", "hz", "dppx", "number", "fr"]); + +export function isCanonical(unit) { + return canonicalUnits.has(unit.toLowerCase()); } \ No newline at end of file