UNPKG

jsdom

Version:

A JavaScript implementation of many web standards

742 lines (692 loc) 23.8 kB
"use strict"; const DOMException = require("../../../generated/idl/DOMException.js"); const idlUtils = require("../../../generated/idl/utils.js"); const propertyDefinitions = require("../../../generated/css-property-definitions"); const propertyDescriptors = require("../../../generated/css-property-descriptors"); const propertyMetadata = require("../../../generated/css-property-metadata"); const { asciiLowercase } = require("../helpers/strings"); const computedStyle = require("./helpers/computed-style"); const cssValues = require("./helpers/css-values"); const csstree = require("./helpers/patched-csstree"); 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; _readonly = false; _priorities = new Map(); #values = new Map(); parentRule; #ownerNode; #updating = false; // Internal private fields. #computedValueOpts = new Map(); #cachedPropertyValues = new Map(); constructor(globalObject, args, { computed, ownerNode, parentRule } = {}) { this._globalObject = globalObject; this._computed = Boolean(computed); this.parentRule = parentRule || null; this.#ownerNode = ownerNode || null; } /** * Returns the textual representation of the declaration block. * * @returns {string} The serialized CSS text. */ get cssText() { if (this._computed) { return ""; } const properties = new Map(); for (const property of this.#values.keys()) { const value = this.getPropertyValue(property); const priority = this._priorities.get(property) ?? ""; if (shorthandProperties.has(property)) { const { shorthandFor } = shorthandProperties.get(property); for (const [longhand] of shorthandFor) { if (priority || !this._priorities.get(longhand)) { properties.delete(longhand); } } } properties.set(property, { property, value, priority }); } const normalizedProperties = normalizeProperties(properties); const parts = []; for (const { property, value, priority } of normalizedProperties.values()) { if (priority) { parts.push(`${property}: ${value} !${priority};`); } else { parts.push(`${property}: ${value};`); } } return parts.join(" "); } /** * 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" ]); } this.#values.clear(); this._priorities.clear(); this.#updating = true; 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._priorities.set(property, priority); this.setProperty(property, value, priority); } } this.#updating = false; this.#updateStyleAttribute(); } /** * Returns the number of properties in the declaration block. * * @returns {number} The property count. */ get length() { return this.#values.size; } /** * 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. * * @param {string} property - The property name. * @returns {string} The property value, or empty string if not set. */ getPropertyValue(property) { const value = this.#values.get(property) ?? ""; if (this._computed) { if (this.#cachedPropertyValues.has(property)) { const cachedValue = this.#cachedPropertyValues.get(property); // Return the cached resolved value if the specified value haven't changed. if (value === cachedValue.value) { return cachedValue.resolvedValue; } } const resolvedValue = this.#getComputedValue(property, value); if (propertyDefinitions.has(property)) { const { longhands } = propertyDefinitions.get(property); if (!longhands) { this.#cachedPropertyValues.set(property, { resolvedValue, value }); } } return resolvedValue; } return value; } /** * 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.#values.delete(property); this._priorities.delete(property); this.#updateStyleAttribute(); 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 = "") { if (this._readonly) { throw DOMException.create(this._globalObject, [ `Property ${property} can not be modified.`, "NoModificationAllowedError" ]); } value = 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; } if (priority) { this._priorities.set(property, priority); } else { this._priorities.delete(property); } propertyDescriptors[property].set.call(this, value); } get [idlUtils.supportedPropertyIndices]() { return Array(this.#values.size).keys(); } [idlUtils.supportsPropertyIndex](index) { return index >= 0 && index < this.#values.size; } // https://drafts.csswg.org/cssom/#update-style-attribute-for #updateStyleAttribute() { if (this._computed || !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.#values.set(property, value); if (this.#ownerNode && !this.#updating && this.cssText !== originalText) { this.#updateStyleAttribute(); } } #getComputedValue(property, value) { // Invalid or unsupported property. if (!propertyDefinitions.has(property) && !property.startsWith("--")) { return ""; } const { inherited, initial = "", longhands } = cssValues.getPropertyDefinition(property); const { caseSensitive, functionTypes = {} } = this.#getPropertyMetadata(property); const isColor = Boolean(functionTypes.color || functionTypes.paint); if (!value || cssValues.isGlobalKeyword(value)) { value = computedStyle.replaceEmptyValueAndKeywords( property, value, this.#ownerNode, { inherit: inherited === "yes", initial, isColor, longhands } ); } 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) { if (isColor) { value = asciiLowercase(value); if (systemColors.has(value)) { return value; } } return this.#resolveShorthand(property, value); } return this.#resolveLonghand(property, value, { caseSensitive, isColor }); } #getPropertyMetadata(property) { if (propertyMetadata.has(property)) { return propertyMetadata.get(property); } const value = this.#values.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 }; } #resolveShorthand(property, value) { // TODO: resolve other shorthands e.g. background, flex etc. switch (property) { case "margin": case "padding": { return this.#resolvePositionShorthand(property); } case "border": case "border-top": case "border-right": case "border-bottom": case "border-left": case "border-width": case "border-style": case "border-color": { return this.#resolveBorderShorthands(property); } default: { return value; } } } #resolvePositionShorthand(property) { const shorthandItem = shorthandProperties.get(property); if (!shorthandItem || !shorthandItem.shorthandFor) { return ""; } const longhandValues = []; for (const [longhandProperty] of shorthandItem.shorthandFor) { longhandValues.push(this.getPropertyValue(longhandProperty)); } return getPositionValue(longhandValues); } #resolveLonghand(property, value, { caseSensitive, isColor }) { const options = this.#prepareComputedValueOpts(); const parsedValue = cssValues.parsePropertyValue(property, value, { caseSensitive, ...options }); if (isColor) { const resolvedValue = cssValues.serializeColor(parsedValue, options); if (resolvedValue) { return resolvedValue; } } // TODO: Resolve special cases other than color. return value; } #resolveBorderShorthands(property) { switch (property) { case "border": { const values = []; for (const item of ["top", "right", "bottom", "left"]) { const value = this.getPropertyValue(`border-${item}`); if (!value) { return ""; } values.push(value); } const [top, right, bottom, left] = values; if (top === right && top === bottom && top === left) { return top; } return ""; } case "border-top": case "border-right": case "border-bottom": case "border-left": { const values = []; for (const item of ["width", "style", "color"]) { const value = this.getPropertyValue(`${property}-${item}`); if (!value) { return ""; } values.push(value); } return values.join(" "); } // border-width, border-style, border-color default: { return this.#resolvePositionShorthand(property); } } } // Options are used when resolving relative values or specified values. #prepareComputedValueOpts() { if (!this.#computedValueOpts.has("options")) { this.#computedValueOpts.set("options", { format: "computedValue" }); } const options = this.#computedValueOpts.get("options"); // Return the cached options if the specified raw values haven't changed. const rawColorScheme = this.#values.get("color-scheme") ?? ""; const rawColor = this.#values.get("color") ?? ""; if ( this.#computedValueOpts.get("rawColorScheme") === rawColorScheme && this.#computedValueOpts.get("rawColor") === rawColor ) { return options; } // Store current raw values for future cache validation. this.#computedValueOpts.set("rawColorScheme", rawColorScheme); this.#computedValueOpts.set("rawColor", rawColor); // Prepare color-scheme. const colorScheme = computedStyle.replaceEmptyValueAndKeywords( "color-scheme", rawColorScheme, this.#ownerNode, { inherit: true, initial: "normal" } ); this.#cachedPropertyValues.set("color-scheme", { resolvedValue: colorScheme, value: rawColorScheme }); options.colorScheme = colorScheme; // Prepare current color. let currentColor = computedStyle.replaceEmptyValueAndKeywords( "color", rawColor, 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 } ); } // Resolve system colors. if (systemColors.has(currentColor)) { currentColor = cssValues.resolveSystemColorValue(currentColor, colorScheme); } else { // Resolve named colors. if (/^[a-z]+$/.test(currentColor)) { currentColor = cssValues.resolveColor(currentColor, { format: "computedValue" }); } this.#cachedPropertyValues.set("color", { resolvedValue: currentColor, value: rawColor }); } options.currentColor = currentColor; // TODO: Add customProperty, dimension etc. // Store options. this.#computedValueOpts.set("options", options); return options; } /** * 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.getPropertyValue(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; } const shorthandPriority = this._priorities.get(shorthandProperty); this.removeProperty(shorthandProperty); if (typeof priority !== "string") { priority = this._priorities.get(property) ?? ""; } this.removeProperty(property); if (shorthandPriority && priority) { this._setProperty(property, value); } else { 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.getPropertyValue(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; } const shorthandPriority = this._priorities.get(shorthandProperty); this.removeProperty(shorthandProperty); if (typeof priority !== "string") { priority = this._priorities.get(property) ?? ""; } this.removeProperty(property); if (shorthandPriority && priority) { this._setProperty(property, value); } else { 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.getPropertyValue(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); } } } } exports.implementation = CSSStyleDeclarationImpl;