UNPKG

jsdom

Version:

A JavaScript implementation of many web standards

815 lines (758 loc) • 28 kB
"use strict"; const DOMException = require("../../../generated/idl/DOMException.js"); const idlUtils = require("../../../generated/idl/utils.js"); const computedValueResolvers = require("../../../generated/css-property-computed-value-resolvers"); const propertyDefinitions = require("../../../generated/css-property-definitions"); const propertyDescriptors = require("../../../generated/css-property-descriptors"); const propertyMetadata = require("../../../generated/css-property-metadata"); const resolvedValueResolvers = require("../../../generated/css-property-resolved-value-resolvers"); const { asciiLowercase } = require("../helpers/strings"); const computedStyle = require("./helpers/computed-style"); const cssValues = require("./helpers/css-values"); const csstree = require("./helpers/patched-csstree"); const fontSizes = require("./helpers/font-sizes"); const { serializeShorthand } = require("./helpers/shorthand-serialization"); const { borderProperties, getPositionValue, normalizeProperties, prepareBorderProperties, prepareProperties, shorthandProperties } = require("./helpers/shorthand-properties"); const { systemColors } = require("./helpers/system-colors"); class CSSStyleDeclarationImpl { // https://drafts.csswg.org/cssom/#css-declaration-blocks // `_priorities` and `#values` together represent the spec's "declarations". _computed; // A computed declaration is either complete or cascades properties on demand; see // `computed-style.js`. An on-demand declaration records the properties cascaded so far and the // styles that might apply to its owner element. `_cascadedProperties` is null when complete. _cascadedProperties = null; _candidateStyles = null; _readonly = false; _priorities = new Map(); #values = new Map(); parentRule; #ownerNode; #updating = false; #pendingStyleUpdate = false; // Internal private fields. // Computed values are stable once cascaded: DOM and stylesheet changes discard the document's // cached declarations rather than modifying them. Clones made by `_copyDeclarationsFrom()` // therefore share this cache. #computedCache = { values: new Map(), colorOptions: null, dimension: null }; #cachedCssText = null; constructor(globalObject, args, { computed, ownerNode, parentRule } = {}) { this._globalObject = globalObject; this._computed = Boolean(computed); this.parentRule = parentRule || null; this.#ownerNode = ownerNode || null; } _copyDeclarationsFrom(source) { for (const [property, value] of source.#values) { this.#values.set(property, value); } for (const [property, priority] of source._priorities) { this._priorities.set(property, priority); } this.#computedCache = source.#computedCache; } /** * Returns the textual representation of the declaration block. * * @returns {string} The serialized CSS text. */ get cssText() { if (this._computed) { return ""; } if (this.#cachedCssText !== null) { return this.#cachedCssText; } const properties = new Set(); for (const property of this.#values.keys()) { if (shorthandProperties.has(property)) { const priority = this._priorities.get(property) ?? ""; const { shorthandFor } = shorthandProperties.get(property); for (const [longhand] of shorthandFor) { if (priority || !this._priorities.get(longhand)) { properties.delete(longhand); } } } properties.add(property); } const normalizedProperties = normalizeProperties(properties); const parts = []; for (const property of normalizedProperties) { const value = this.getPropertyValue(property); const priority = this._priorities.get(property) ?? ""; if (priority) { parts.push(`${property}: ${value} !${priority};`); } else { parts.push(`${property}: ${value};`); } } this.#cachedCssText = parts.join(" "); return this.#cachedCssText; } /** * Sets the textual representation of the declaration block. * This clears all existing properties and parses the new CSS text. * * @param {string} text - The new CSS text. */ set cssText(text) { if (this._readonly) { throw DOMException.create(this._globalObject, [ "cssText can not be modified.", "NoModificationAllowedError" ]); } const originalText = this.parentRule ? this.cssText : null; this.#cachedCssText = null; this.#values.clear(); this._priorities.clear(); this.#updating = true; text = cssValues.preprocessValue(text); const valueObj = csstree.parse(text, { context: "declarationList", parseValue: false }); if (valueObj?.children) { const properties = new Map(); let shouldSkipNext = false; for (const item of valueObj.children) { if (item.type === "Atrule") { continue; } if (item.type === "Rule") { shouldSkipNext = true; continue; } if (shouldSkipNext === true) { shouldSkipNext = false; continue; } const { important, property, value: { value } } = item; if (typeof property === "string" && typeof value === "string") { const priority = important ? "important" : ""; const isCustomProperty = property.startsWith("--"); if (isCustomProperty || cssValues.hasVarFunc(value)) { if (properties.has(property)) { const { priority: itemPriority } = properties.get(property); if (!itemPriority) { properties.set(property, { property, value, priority }); } } else { properties.set(property, { property, value, priority }); } } else { const parsedValue = cssValues.parsePropertyValue(property, value); if (parsedValue) { if (properties.has(property)) { const { priority: itemPriority } = properties.get(property); if (!itemPriority) { properties.set(property, { property, value, priority }); } } else { properties.set(property, { property, value, priority }); } } else { this.removeProperty(property); } } } } const parsedProperties = prepareProperties(properties); for (const [property, item] of parsedProperties) { const { priority, value } = item; this.setProperty(property, value, priority); } } this.#updating = false; if (!this.parentRule || this.cssText !== originalText) { this.#notifyChange(); } } /** * Returns the number of properties in the declaration block. * * @returns {number} The property count. */ get length() { return this.#values.size; } /** * Returns the property names in the declaration block, in declaration order. * * @returns {Iterator<string>} The property names. */ _propertyNames() { return this.#values.keys(); } /** * Returns the priority of the specified property (e.g. "important"). * * @param {string} property - The property name. * @returns {string} The priority string, or empty string if not set. */ getPropertyPriority(property) { return this._priorities.get(property) || ""; } /** * Returns the value of the specified property. * * For a computed style declaration this is the resolved value * (https://drafts.csswg.org/cssom/#resolved-value), which is the computed value except for the * properties in `resolvedValueResolvers`. * * @param {string} property - The property name. * @returns {string} The property value, or empty string if not set. */ getPropertyValue(property) { if (this._computed) { if (shorthandProperties.has(property)) { return serializeShorthand(property, longhand => this.getPropertyValue(longhand)); } const computedValue = this._getComputedPropertyValue(property); const getResolvedValue = resolvedValueResolvers.get(property); if (getResolvedValue) { return getResolvedValue(computedValue, { getComputedValue: otherProperty => this._getComputedPropertyValue(otherProperty) }); } return computedValue; } return this.#values.get(property) ?? ""; } /** * Returns the specified value of the specified property, even on a computed style declaration. * * The cascade copies these already-parsed values without running property setters or resolving * computed values. Each longhand can therefore be cascaded independently of its siblings. * * @param {string} property - The property name. * @returns {string} The specified value, or empty string if not set. */ _getSpecifiedPropertyValue(property) { if (this._cascadedProperties !== null && !this._cascadedProperties.has(property)) { computedStyle.cascadeProperty(this.#ownerNode, this, property); } return this.#values.get(property) ?? ""; } /** * Returns the computed value (https://drafts.csswg.org/css-cascade/#computed) of the specified * property. Only meaningful on a computed style declaration. * * This is the value inheritance transfers, as opposed to the resolved value that * `getPropertyValue()` returns: a child inherits the 5px of `border-top: 5px none`, while the * resolved value is 0px. * * @param {string} property - The property name. * @returns {string} The computed value, or empty string if not set. */ _getComputedPropertyValue(property) { const { values } = this.#computedCache; if (values.has(property)) { return values.get(property); } const value = this._getSpecifiedPropertyValue(property); const computedValue = this.#getComputedValue(property, value); if (propertyDefinitions.has(property)) { values.set(property, computedValue); } return computedValue; } /** * Returns the property name at the specified index. * * @param {number} index - The index. * @returns {string} The property name, or empty string if index is invalid. */ item(index) { if (index >= this.#values.size) { return ""; } let i = 0; for (const key of this.#values.keys()) { if (i === index) { return key; } i++; } return ""; } /** * Removes the specified property from the declaration block. * * @param {string} property - The property name to remove. * @returns {string} The value of the removed property. */ removeProperty(property) { if (this._readonly) { throw DOMException.create(this._globalObject, [ `Property ${property} can not be modified.`, "NoModificationAllowedError" ]); } if (!this.#values.has(property)) { return ""; } const prevValue = this.#values.get(property); this.#cachedCssText = null; this.#values.delete(property); this._priorities.delete(property); this.#notifyChange(); return prevValue; } /** * Sets a property value with an optional priority. * * @param {string} property - The property name. * @param {string} value - The property value. * @param {string} [priority=""] - The priority (e.g. "important"). */ setProperty(property, value, priority = "") { this._updateStyle(() => this._setPropertyValue(property, value, priority)); } _setPropertyValue(property, value, priority) { if (this._readonly) { throw DOMException.create(this._globalObject, [ `Property ${property} can not be modified.`, "NoModificationAllowedError" ]); } value = cssValues.preprocessValue(value).trim(); if (value === "") { if (Object.hasOwn(propertyDescriptors, property)) { // TODO: Refactor handlers to not require `.call()`. propertyDescriptors[property].set.call(this, value); } this.removeProperty(property); return; } // Custom property. if (property.startsWith("--")) { this._setProperty(property, value, priority); return; } property = asciiLowercase(property); if (!Object.hasOwn(propertyDescriptors, property)) { return; } propertyDescriptors[property].set.call(this, value, priority); } get [idlUtils.supportedPropertyIndices]() { return Array(this.#values.size).keys(); } [idlUtils.supportsPropertyIndex](index) { return index >= 0 && index < this.#values.size; } _updateStyle(update) { if (this.#updating || this._computed || (!this.#ownerNode && !this.parentRule)) { update(); return; } const originalText = this.cssText; this.#updating = true; this.#pendingStyleUpdate = false; try { update(); } finally { this.#updating = false; if (this.#pendingStyleUpdate && this.cssText !== originalText) { this.#notifyChange(); } } } #notifyChange() { if (this.#updating) { this.#pendingStyleUpdate = true; return; } if (this._computed) { return; } if (this.parentRule) { this.parentRule._invalidateStyleCache(); return; } // https://drafts.csswg.org/cssom/#update-style-attribute-for if (!this.#ownerNode || this.#ownerNode._settingCssText) { return; } this.#ownerNode._settingCssText = true; this.#ownerNode.setAttributeNS(null, "style", this.cssText); this.#ownerNode._settingCssText = false; } _setProperty(property, value, priority) { if (typeof value !== "string") { return; } if (value === "") { this.removeProperty(property); return; } let originalText = ""; if (this.#ownerNode && !this.#updating) { originalText = this.cssText; } if (priority === "important") { this._priorities.set(property, priority); } else { this._priorities.delete(property); } this.#cachedCssText = null; this.#values.set(property, value); if (this.#updating) { this.#pendingStyleUpdate = true; } else if (this.#ownerNode && this.cssText !== originalText) { this.#notifyChange(); } } #getComputedValue(property, value) { // Invalid or unsupported property. if (!propertyDefinitions.has(property) && !property.startsWith("--")) { return ""; } const { inherited, initial = "", longhands } = cssValues.getPropertyDefinition(property); const { caseSensitive, dimensionTypes = {}, functionTypes = {} } = this.#getPropertyMetadata(property, value); const isColor = Boolean(functionTypes.color || functionTypes.paint); if (!value || cssValues.isGlobalKeyword(value)) { value = computedStyle.replaceEmptyValueAndKeywords( property, value, this.#ownerNode, { inherit: inherited === "yes", initial, isColor } ); } if (property === "color" && /currentcolor/i.test(value)) { value = computedStyle.getInheritedPropertyValue( property, this.#ownerNode, { inherit: true, initial, isColor } ); } if (cssValues.hasVarFunc(value)) { // TODO: Resolve css var(). } if (longhands) { // Expanded shorthands are serialized from their longhands by the public getter. Shorthands we // still store as one value use the same defaulting and inheritance as other declarations. if (isColor) { value = asciiLowercase(value); } return value; } return this.#resolveLonghand(property, value, { caseSensitive, dimensionTypes, isColor }); } #getPropertyMetadata(property, value) { if (propertyMetadata.has(property)) { return propertyMetadata.get(property); } // TODO: Also check if all or part of the value is quoted. const caseSensitive = (cssValues.hasVarFunc(value) || value.startsWith("--")) ? true : undefined; return { caseSensitive }; } #resolveLonghand(property, value, metadata) { const resolveDefault = valueToResolve => this.#resolveLonghandDefault(property, valueToResolve, metadata); const resolveComputedValue = computedValueResolvers.get(property); if (resolveComputedValue) { return resolveComputedValue(value, { getInheritedValue: (inheritedProperty, options) => { return computedStyle.getInheritedPropertyValue(inheritedProperty, this.#ownerNode, options); }, resolveDefault }); } return resolveDefault(value); } #resolveLonghandDefault(property, value, { caseSensitive, dimensionTypes, isColor }) { // Preparing color options resolves inherited styles, so only do so when needed. const options = { format: "computedValue" }; if (isColor) { Object.assign(options, this.#prepareColorOptions()); } const parsedValue = cssValues.parsePropertyValue(property, value, { caseSensitive, ...options }); if (isColor) { const resolvedValue = cssValues.serializeColor(parsedValue, options); if (resolvedValue) { return resolvedValue; } } else if (dimensionTypes.length) { const isFontSize = property === "font-size"; const { documentElement } = this.#ownerNode._ownerDocument; // Relative lengths on the root element fall back to the initial font size inside // `resolveLengthInPixels()`. Elsewhere they resolve against inherited font sizes, which are // only prepared when needed. const dimension = this.#ownerNode === documentElement ? {} : { ...this.#prepareDimension() }; // Use the element's own font-size for em units if the property is not "font-size". if (!isFontSize) { const ownFontSize = this.getPropertyValue("font-size"); if (ownFontSize) { const parsedOwnFontSize = parseFloat(ownFontSize); if (!Number.isNaN(parsedOwnFontSize)) { dimension.em = parsedOwnFontSize; } } } const resolvedValue = fontSizes.resolveLengthInPixels(this.#ownerNode, value, dimension, isFontSize); if (typeof resolvedValue === "number" && !Number.isNaN(resolvedValue)) { return `${Number(resolvedValue.toPrecision(6))}px`; } // A percentage prevents the math function from being fully reduced, e.g. // width: calc(100% - 1rem) computes to calc(100% - 16px). Only serialize the partially // reduced string when the math function is the whole value, because reducing it // lowercases the value, which would destroy case-sensitive tokens surrounding it, e.g. // the line names in grid-template-rows: [Foo] minmax(1rem, calc(100% - 2rem)) [Bar]. if ( typeof resolvedValue === "string" && resolvedValue && Array.isArray(parsedValue) && parsedValue.length === 1 && parsedValue[0].type === cssValues.AST_TYPES.CALC ) { return resolvedValue; } } // TODO: Resolve special cases other than color. return value; } // Returns the color scheme and current color that color values resolve against. #prepareColorOptions() { const cache = this.#computedCache; if (cache.colorOptions !== null) { return cache.colorOptions; } const colorScheme = computedStyle.replaceEmptyValueAndKeywords( "color-scheme", this._getSpecifiedPropertyValue("color-scheme"), this.#ownerNode, { inherit: true, initial: "normal" } ); cache.values.set("color-scheme", colorScheme); let currentColor = computedStyle.replaceEmptyValueAndKeywords( "color", this._getSpecifiedPropertyValue("color"), this.#ownerNode, { inherit: true, initial: "canvastext" } ); currentColor = asciiLowercase(currentColor); // Replace currentcolor keyword. if (currentColor === "currentcolor") { currentColor = computedStyle.getInheritedPropertyValue( "color", this.#ownerNode, { inherit: true, initial: "canvastext", isColor: true } ); } // The color resolver does not support system colors. if (systemColors.has(currentColor)) { currentColor = cssValues.resolveSystemColorValue(currentColor, colorScheme); } else { currentColor = cssValues.resolveColor(currentColor, { format: "computedValue", colorScheme }); } cache.colorOptions = { colorScheme, currentColor }; return cache.colorOptions; } // Returns the lengths that relative units resolve against. Not used for the root element; see // `#resolveLonghandDefault()`. #prepareDimension() { const cache = this.#computedCache; if (cache.dimension !== null) { return cache.dimension; } const { documentElement } = this.#ownerNode._ownerDocument; const rootFontSize = computedStyle.getComputedStylePropertyValue(documentElement, "font-size"); const rem = fontSizes.resolveFontSizeInPixels(this.#ownerNode, rootFontSize); const em = this.#ownerNode.parentElement ? computedStyle.getParentFontSizeInPixels(this.#ownerNode) : rem; cache.dimension = { em, rem, vh: this._globalObject.innerHeight / 100, vw: this._globalObject.innerWidth / 100 }; return cache.dimension; } /** * Helper to handle border property expansion. * * @private * @param {string} property - The property name (e.g. "border"). * @param {object|Array|string} value - The value to set. * @param {string} priority - The priority. */ _borderSetter(property, value, priority) { const properties = new Map(); if (typeof priority !== "string") { priority = this._priorities.get(property) ?? ""; } if (property === "border") { properties.set(property, { property, value, priority }); } else { for (const itemProperty of this.#values.keys()) { if (borderProperties.has(itemProperty)) { const itemValue = this.#values.get(itemProperty) ?? ""; const longhandPriority = this._priorities.get(itemProperty) ?? ""; let itemPriority = longhandPriority; if (itemProperty === property) { itemPriority = priority; } properties.set(itemProperty, { property: itemProperty, value: itemValue, priority: itemPriority }); } } } const parsedProperties = prepareBorderProperties(property, value, priority, properties); for (const [itemProperty, item] of parsedProperties) { const { priority: itemPriority, value: itemValue } = item; this._setProperty(itemProperty, itemValue, itemPriority); } } /** * Helper to handle flexbox shorthand expansion. * * @private * @param {string} property - The property name. * @param {string} value - The property value. * @param {string} priority - The priority. * @param {string} shorthandProperty - The shorthand property name. */ _flexBoxSetter(property, value, priority, shorthandProperty) { if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) { return; } this.removeProperty(shorthandProperty); if (typeof priority !== "string") { priority = this._priorities.get(property) ?? ""; } this.removeProperty(property); this._setProperty(property, value, priority); if (value && !cssValues.hasVarFunc(value)) { const longhandValues = []; const shorthandItem = shorthandProperties.get(shorthandProperty); let hasGlobalKeyword = false; for (const [longhandProperty] of shorthandItem.shorthandFor) { if (longhandProperty === property) { if (cssValues.isGlobalKeyword(value)) { hasGlobalKeyword = true; } longhandValues.push(value); } else { const longhandValue = this.#values.get(longhandProperty) ?? ""; const longhandPriority = this._priorities.get(longhandProperty) ?? ""; if (!longhandValue || longhandPriority !== priority) { break; } if (cssValues.isGlobalKeyword(longhandValue)) { hasGlobalKeyword = true; } longhandValues.push(longhandValue); } } if (longhandValues.length === shorthandItem.shorthandFor.size) { if (hasGlobalKeyword) { const [firstValue, ...restValues] = longhandValues; if (restValues.every(val => val === firstValue)) { this._setProperty(shorthandProperty, firstValue, priority); } } else { const parsedValue = shorthandItem.parse(longhandValues.join(" ")); const shorthandValue = Object.values(parsedValue).join(" "); this._setProperty(shorthandProperty, shorthandValue, priority); } } } } /** * Helper to handle position shorthand expansion. * * @private * @param {string} property - The property name. * @param {Array|string} value - The property value. * @param {string} priority - The priority. */ _positionShorthandSetter(property, value, priority) { if (!shorthandProperties.has(property)) { return; } const shorthandValues = []; if (Array.isArray(value)) { shorthandValues.push(...value); } else if (typeof value === "string") { shorthandValues.push(value); } else { return; } if (typeof priority !== "string") { priority = this._priorities.get(property) ?? ""; } const { position, shorthandFor } = shorthandProperties.get(property); let hasPriority = false; for (const [longhandProperty, longhandItem] of shorthandFor) { const { position: longhandPosition } = longhandItem; const longhandValue = getPositionValue(shorthandValues, longhandPosition); if (priority) { this._setProperty(longhandProperty, longhandValue, priority); } else { const longhandPriority = this._priorities.get(longhandProperty) ?? ""; if (longhandPriority) { hasPriority = true; } else { this._setProperty(longhandProperty, longhandValue, priority); } } } if (hasPriority) { this.removeProperty(property); } else { const shorthandValue = getPositionValue(shorthandValues, position); this._setProperty(property, shorthandValue, priority); } } /** * Helper to handle position longhand updates affecting shorthands. * * @private * @param {string} property - The property name. * @param {string} value - The property value. * @param {string} priority - The priority. * @param {string} shorthandProperty - The shorthand property name. */ _positionLonghandSetter(property, value, priority, shorthandProperty) { if (!shorthandProperty || !shorthandProperties.has(shorthandProperty)) { return; } this.removeProperty(shorthandProperty); if (typeof priority !== "string") { priority = this._priorities.get(property) ?? ""; } this.removeProperty(property); this._setProperty(property, value, priority); if (value && !cssValues.hasVarFunc(value)) { const longhandValues = []; const { shorthandFor, position: shorthandPosition } = shorthandProperties.get(shorthandProperty); for (const [longhandProperty] of shorthandFor) { const longhandValue = this.#values.get(longhandProperty) ?? ""; const longhandPriority = this._priorities.get(longhandProperty) ?? ""; if (!longhandValue || longhandPriority !== priority) { return; } longhandValues.push(longhandValue); } if (longhandValues.length === shorthandFor.size) { const replacedValue = getPositionValue(longhandValues, shorthandPosition); this._setProperty(shorthandProperty, replacedValue, priority); } } } } exports.implementation = CSSStyleDeclarationImpl;