UNPKG

cssstyle

Version:

CSSStyleDeclaration Object Model implementation

614 lines (579 loc) 17.3 kB
/** * This is a fork from the CSS Style Declaration part of * https://github.com/NV/CSSOM */ "use strict"; const CSSOM = require("rrweb-cssom"); const allExtraProperties = require("./allExtraProperties"); const allProperties = require("./generated/allProperties"); const implementedProperties = require("./generated/implementedProperties"); const generatedProperties = require("./generated/properties"); const { hasVarFunc, parseKeyword, parseShorthand, prepareValue, splitValue } = require("./parsers"); const { dashedToCamelCase } = require("./utils/camelize"); const { getPropertyDescriptor } = require("./utils/propertyDescriptors"); const { asciiLowercase } = require("./utils/strings"); /** * @see https://drafts.csswg.org/cssom/#the-cssstyledeclaration-interface */ class CSSStyleDeclaration { /** * @param {Function} onChangeCallback * @param {object} [opt] * @param {object} [opt.context] - Window, Element or CSSRule. */ constructor(onChangeCallback, opt = {}) { // Make constructor and internals non-enumerable. Object.defineProperties(this, { constructor: { enumerable: false, writable: true }, // Window _global: { value: globalThis, enumerable: false, writable: true }, // Element _ownerNode: { value: null, enumerable: false, writable: true }, // CSSRule _parentNode: { value: null, enumerable: false, writable: true }, _onChange: { value: null, enumerable: false, writable: true }, _values: { value: new Map(), enumerable: false, writable: true }, _priorities: { value: new Map(), enumerable: false, writable: true }, _length: { value: 0, enumerable: false, writable: true }, _computed: { value: false, enumerable: false, writable: true }, _readonly: { value: false, enumerable: false, writable: true }, _setInProgress: { value: false, enumerable: false, writable: true } }); const { context } = opt; if (context) { if (typeof context.getComputedStyle === "function") { this._global = context; this._computed = true; this._readonly = true; } else if (context.nodeType === 1 && Object.hasOwn(context, "style")) { this._global = context.ownerDocument.defaultView; this._ownerNode = context; } else if (Object.hasOwn(context, "parentRule")) { this._parentRule = context; // Find Window from the owner node of the StyleSheet. const window = context?.parentStyleSheet?.ownerNode?.ownerDocument?.defaultView; if (window) { this._global = window; } } } if (typeof onChangeCallback === "function") { this._onChange = onChangeCallback; } } get cssText() { if (this._computed) { return ""; } const properties = []; for (let i = 0; i < this._length; i++) { const property = this[i]; const value = this.getPropertyValue(property); const priority = this.getPropertyPriority(property); if (priority === "important") { properties.push(`${property}: ${value} !${priority};`); } else { properties.push(`${property}: ${value};`); } } return properties.join(" "); } set cssText(value) { if (this._readonly) { const msg = "cssText can not be modified."; const name = "NoModificationAllowedError"; throw new this._global.DOMException(msg, name); } Array.prototype.splice.call(this, 0, this._length); this._values.clear(); this._priorities.clear(); if (this._parentRule || (this._ownerNode && this._setInProgress)) { return; } this._setInProgress = true; let dummyRule; try { dummyRule = CSSOM.parse(`#bogus{${value}}`).cssRules[0].style; } catch { // Malformed css, just return. return; } for (let i = 0; i < dummyRule.length; i++) { const property = dummyRule[i]; this.setProperty( property, dummyRule.getPropertyValue(property), dummyRule.getPropertyPriority(property) ); } this._setInProgress = false; if (typeof this._onChange === "function") { this._onChange(this.cssText); } } get length() { return this._length; } // This deletes indices if the new length is less then the current length. // If the new length is more, it does nothing, the new indices will be // undefined until set. set length(len) { for (let i = len; i < this._length; i++) { delete this[i]; } this._length = len; } // Readonly get parentRule() { return this._parentRule; } get cssFloat() { return this.getPropertyValue("float"); } set cssFloat(value) { this._setProperty("float", value); } /** * @param {string} property */ getPropertyPriority(property) { return this._priorities.get(property) || ""; } /** * @param {string} property */ getPropertyValue(property) { if (this._values.has(property)) { return this._values.get(property).toString(); } return ""; } /** * @param {...number} args */ item(...args) { if (!args.length) { const msg = "1 argument required, but only 0 present."; throw new this._global.TypeError(msg); } let [index] = args; index = parseInt(index); if (Number.isNaN(index) || index < 0 || index >= this._length) { return ""; } return this[index]; } /** * @param {string} property */ removeProperty(property) { if (this._readonly) { const msg = `Property ${property} can not be modified.`; const name = "NoModificationAllowedError"; throw new this._global.DOMException(msg, name); } if (!this._values.has(property)) { return ""; } const prevValue = this._values.get(property); this._values.delete(property); this._priorities.delete(property); const index = Array.prototype.indexOf.call(this, property); if (index >= 0) { Array.prototype.splice.call(this, index, 1); if (typeof this._onChange === "function") { this._onChange(this.cssText); } } return prevValue; } /** * @param {string} property * @param {string} value * @param {string?} [priority] - "important" or null */ setProperty(property, value, priority = null) { if (this._readonly) { const msg = `Property ${property} can not be modified.`; const name = "NoModificationAllowedError"; throw new this._global.DOMException(msg, name); } value = prepareValue(value, this._global); if (value === "") { this[property] = ""; this.removeProperty(property); return; } const isCustomProperty = property.startsWith("--"); if (isCustomProperty) { this._setProperty(property, value); return; } property = asciiLowercase(property); if (!allProperties.has(property) && !allExtraProperties.has(property)) { return; } this[property] = value; if (priority) { this._priorities.set(property, priority); } else { this._priorities.delete(property); } } } // Internal methods Object.defineProperties(CSSStyleDeclaration.prototype, { _shorthandGetter: { /** * @param {string} property * @param {object} shorthandFor */ value(property, shorthandFor) { const parts = []; for (const key of shorthandFor.keys()) { const val = this.getPropertyValue(key); if (hasVarFunc(val)) { return ""; } if (val !== "") { parts.push(val); } } if (parts.length) { return parts.join(" "); } if (this._values.has(property)) { return this.getPropertyValue(property); } return ""; }, enumerable: false }, _implicitGetter: { /** * @param {string} property * @param {Array.<string>} positions */ value(property, positions = []) { const parts = []; for (const position of positions) { const val = this.getPropertyValue(`${property}-${position}`); if (val === "" || hasVarFunc(val)) { return ""; } parts.push(val); } if (!parts.length) { return ""; } switch (positions.length) { case 4: { const [top, right, bottom, left] = parts; if (top === right && top === bottom && right === left) { return top; } if (top !== right && top === bottom && right === left) { return `${top} ${right}`; } if (top !== right && top !== bottom && right === left) { return `${top} ${right} ${bottom}`; } return `${top} ${right} ${bottom} ${left}`; } case 2: { const [x, y] = parts; if (x === y) { return x; } return `${x} ${y}`; } default: return ""; } }, enumerable: false }, _setProperty: { /** * @param {string} property * @param {string} val * @param {string?} [priority] */ value(property, val, priority = null) { if (typeof val !== "string") { return; } if (val === "") { this.removeProperty(property); return; } let originalText = ""; if (typeof this._onChange === "function") { originalText = this.cssText; } if (this._values.has(property)) { const index = Array.prototype.indexOf.call(this, property); // The property already exists but is not indexed into `this` so add it. if (index < 0) { this[this._length] = property; this._length++; } } else { // New property. this[this._length] = property; this._length++; } this._values.set(property, val); if (priority) { this._priorities.set(property, priority); } else { this._priorities.delete(property); } if ( typeof this._onChange === "function" && this.cssText !== originalText && !this._setInProgress ) { this._onChange(this.cssText); } }, enumerable: false }, _shorthandSetter: { /** * @param {string} property * @param {string} val * @param {object} shorthandFor */ value(property, val, shorthandFor) { val = prepareValue(val, this._global); const obj = parseShorthand(val, shorthandFor); if (!obj) { return; } for (const subprop of Object.keys(obj)) { // In case subprop is an implicit property, this will clear *its* // subpropertiesX. const camel = dashedToCamelCase(subprop); this[camel] = obj[subprop]; // In case it gets translated into something else (0 -> 0px). obj[subprop] = this[camel]; this.removeProperty(subprop); // Don't add in empty properties. if (obj[subprop] !== "") { this._values.set(subprop, obj[subprop]); } } for (const [subprop] of shorthandFor) { if (!Object.hasOwn(obj, subprop)) { this.removeProperty(subprop); this._values.delete(subprop); } } // In case the value is something like 'none' that removes all values, // check that the generated one is not empty, first remove the property, // if it already exists, then call the shorthandGetter, if it's an empty // string, don't set the property. this.removeProperty(property); const calculated = this._shorthandGetter(property, shorthandFor); if (calculated !== "") { this._setProperty(property, calculated); } return obj; }, enumerable: false }, // Companion to shorthandSetter, but for the individual parts which takes // position value in the middle. _midShorthandSetter: { /** * @param {string} property * @param {string} val * @param {object} shorthandFor * @param {Array.<string>} positions */ value(property, val, shorthandFor, positions = []) { val = prepareValue(val, this._global); const obj = this._shorthandSetter(property, val, shorthandFor); if (!obj) { return; } for (const position of positions) { this.removeProperty(`${property}-${position}`); this._values.set(`${property}-${position}`, val); } }, enumerable: false }, _implicitSetter: { /** * @param {string} prefix * @param {string} part * @param {string} val * @param {Function} isValid * @param {Function} parser * @param {Array.<string>} positions */ value(prefix, part, val, isValid, parser, positions = []) { val = prepareValue(val, this._global); if (typeof val !== "string") { return; } part ||= ""; if (part) { part = `-${part}`; } let parts = []; if (val === "") { parts.push(val); } else { const key = parseKeyword(val); if (key) { parts.push(key); } else { parts.push(...splitValue(val)); } } if (!parts.length || parts.length > positions.length || !parts.every(isValid)) { return; } parts = parts.map((p) => parser(p)); this._setProperty(`${prefix}${part}`, parts.join(" ")); switch (positions.length) { case 4: if (parts.length === 1) { parts.push(parts[0], parts[0], parts[0]); } else if (parts.length === 2) { parts.push(parts[0], parts[1]); } else if (parts.length === 3) { parts.push(parts[1]); } break; case 2: if (parts.length === 1) { parts.push(parts[0]); } break; default: } for (let i = 0; i < positions.length; i++) { const property = `${prefix}-${positions[i]}${part}`; this.removeProperty(property); this._values.set(property, parts[i]); } }, enumerable: false }, // Companion to implicitSetter, but for the individual parts. // This sets the individual value, and checks to see if all sub-parts are // set. If so, it sets the shorthand version and removes the individual parts // from the cssText. _subImplicitSetter: { /** * @param {string} prefix * @param {string} part * @param {string} val * @param {Function} isValid * @param {Function} parser * @param {Array.<string>} positions */ value(prefix, part, val, isValid, parser, positions = []) { val = prepareValue(val, this._global); if (typeof val !== "string" || !isValid(val)) { return; } val = parser(val); const property = `${prefix}-${part}`; this._setProperty(property, val); const combinedPriority = this.getPropertyPriority(prefix); const subparts = []; for (const position of positions) { subparts.push(`${prefix}-${position}`); } const parts = subparts.map((subpart) => this._values.get(subpart)); const priorities = subparts.map((subpart) => this.getPropertyPriority(subpart)); const [priority] = priorities; // Combine into a single property if all values are set and have the same // priority. if ( priority === combinedPriority && parts.every((p) => p) && priorities.every((p) => p === priority) ) { for (let i = 0; i < subparts.length; i++) { this.removeProperty(subparts[i]); this._values.set(subparts[i], parts[i]); } this._setProperty(prefix, parts.join(" "), priority); } else { this.removeProperty(prefix); for (let i = 0; i < subparts.length; i++) { // The property we're setting won't be important, the rest will either // keep their priority or inherit it from the combined property const subPriority = subparts[i] === property ? "" : priorities[i] || combinedPriority; this._setProperty(subparts[i], parts[i], subPriority); } } }, enumerable: false } }); // Properties Object.defineProperties(CSSStyleDeclaration.prototype, generatedProperties); // Additional properties [...allProperties, ...allExtraProperties].forEach(function (property) { if (!implementedProperties.has(property)) { const declaration = getPropertyDescriptor(property); Object.defineProperty(CSSStyleDeclaration.prototype, property, declaration); const camel = dashedToCamelCase(property); Object.defineProperty(CSSStyleDeclaration.prototype, camel, declaration); if (/^webkit[A-Z]/.test(camel)) { const pascal = camel.replace(/^webkit/, "Webkit"); Object.defineProperty(CSSStyleDeclaration.prototype, pascal, declaration); } } }); exports.CSSStyleDeclaration = CSSStyleDeclaration;