diff --git a/ui-ngx/src/app/core/css/css.js b/ui-ngx/src/app/core/css/css.js deleted file mode 100644 index 7490890fcb..0000000000 --- a/ui-ngx/src/app/core/css/css.js +++ /dev/null @@ -1,686 +0,0 @@ -/* - * Copyright © 2016-2025 The Thingsboard Authors - * - * 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. - */ -/* eslint-disable */ - -/* jshint unused:false */ -/* global base64_decode, CSSWizardView, window, console, jQuery */ -var fi = function() { - - this.cssImportStatements = []; - this.cssKeyframeStatements = []; - - this.cssRegex = new RegExp("([\\s\\S]*?){([\\s\\S]*?)}", "gi"); - this.cssMediaQueryRegex = "((@media [\\s\\S]*?){([\\s\\S]*?}\\s*?)})"; - this.cssKeyframeRegex = "((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})"; - this.combinedCSSRegex = "((\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})"; //to match css & media queries together - this.cssCommentsRegex = "(\\/\\*[\\s\\S]*?\\*\\/)"; - this.cssImportStatementRegex = new RegExp("@import .*?;", "gi"); -}; - -/* - Strip outs css comments and returns cleaned css string - - @param css, the original css string to be stipped out of comments - - @return cleanedCSS contains no css comments - */ -fi.prototype.stripComments = function(cssString) { - var regex = new RegExp(this.cssCommentsRegex, "gi"); - - return cssString.replace(regex, ""); -}; - -/* - Parses given css string, and returns css object - keys as selectors and values are css rules - eliminates all css comments before parsing - - @param source css string to be parsed - - @return object css - */ -fi.prototype.parseCSS = function(source) { - - if (source === undefined) { - return []; - } - - var css = []; - //strip out comments - //source = this.stripComments(source); - - //get import statements - - while (true) { - var imports = this.cssImportStatementRegex.exec(source); - if (imports !== null) { - this.cssImportStatements.push(imports[0]); - css.push({ - selector: "@imports", - type: "imports", - styles: imports[0], - }); - } else { - break; - } - } - source = source.replace(this.cssImportStatementRegex, ""); - //get keyframe statements - var keyframesRegex = new RegExp(this.cssKeyframeRegex, "gi"); - var arr; - while (true) { - arr = keyframesRegex.exec(source); - if (arr === null) { - break; - } - css.push({ - selector: "@keyframes", - type: "keyframes", - styles: arr[0], - }); - } - source = source.replace(keyframesRegex, ""); - - //unified regex - var unified = new RegExp(this.combinedCSSRegex, "gi"); - - while (true) { - arr = unified.exec(source); - if (arr === null) { - break; - } - var selector = ""; - if (arr[2] === undefined) { - selector = arr[5].split("\r\n").join("\n").trim(); - } else { - selector = arr[2].split("\r\n").join("\n").trim(); - } - - /* - fetch comments and associate it with current selector - */ - var commentsRegex = new RegExp(this.cssCommentsRegex, "gi"); - var comments = commentsRegex.exec(selector); - if (comments !== null) { - selector = selector.replace(commentsRegex, "").trim(); - } - - //determine the type - if (selector.indexOf("@media") !== -1) { - //we have a media query - var cssObject = { - selector: selector, - type: "media", - subStyles: this.parseCSS(arr[3] + "\n}"), //recursively parse media query inner css - }; - if (comments !== null) { - cssObject.comments = comments[0]; - } - css.push(cssObject); - } else { - //we have standart css - var rules = this.parseRules(arr[6]); - var style = { - selector: selector, - rules: rules, - }; - if (selector === "@font-face") { - style.type = "font-face"; - } - if (comments !== null) { - style.comments = comments[0]; - } - css.push(style); - } - } - - return css; -}; - -/* - parses given string containing css directives - and returns an array of objects containing ruleName:ruleValue pairs - - @param rules, css directive string example - \n\ncolor:white;\n font-size:18px;\n - */ -fi.prototype.parseRules = function(rules) { - //convert all windows style line endings to unix style line endings - rules = rules.split("\r\n").join("\n"); - var ret = []; - - // Split all rules but keep semicolon for base64 url data - rules = rules.split(/;(?![^\(]*\))/); - - //proccess rules line by line - for (var i = 0; i < rules.length; i++) { - var line = rules[i]; - - //determine if line is a valid css directive, ie color:white; - line = line.trim(); - if (line.indexOf(":") !== -1) { - //line contains : - line = line.split(":"); - var cssDirective = line[0].trim(); - var cssValue = line.slice(1).join(":").trim(); - - //more checks - if (cssDirective.length < 1 || cssValue.length < 1) { - continue; //there is no css directive or value that is of length 1 or 0 - // PLAIN WRONG WHAT ABOUT margin:0; ? - } - - //push rule - ret.push({ - directive: cssDirective, - value: cssValue, - }); - } else { - //if there is no ':', but what if it was mis splitted value which starts with base64 - if (line.trim().substr(0, 7) == "base64,") { //hack :) - ret[ret.length - 1].value += line.trim(); - } else { - //add rule, even if it is defective - if (line.length > 0) { - ret.push({ - directive: "", - value: line, - defective: true, - }); - } - } - } - } - - return ret; //we are done! -}; -/* - just returns the rule having given directive - if not found returns false; - */ -fi.prototype.findCorrespondingRule = function(rules, directive, value) { - if (value === undefined) { - value = false; - } - var ret = false; - for (var i = 0; i < rules.length; i++) { - if (rules[i].directive == directive) { - ret = rules[i]; - if (value === rules[i].value) { - break; - } - } - } - return ret; -}; - -/* - Finds styles that have given selector, compress them, - and returns them - */ -fi.prototype.findBySelector = function(cssObjectArray, selector, contains) { - if (contains === undefined) { - contains = false; - } - - var found = []; - for (var i = 0; i < cssObjectArray.length; i++) { - if (contains === false) { - if (cssObjectArray[i].selector === selector) { - found.push(cssObjectArray[i]); - } - } else { - if (cssObjectArray[i].selector.indexOf(selector) !== -1) { - found.push(cssObjectArray[i]); - } - } - - } - if (found.length < 2) { - return found; - } else { - var base = found[0]; - for (i = 1; i < found.length; i++) { - this.intelligentCSSPush([base], found[i]); - } - return [base]; //we are done!! all properties merged into base! - } -}; - -/* - deletes cssObjects having given selector, and returns new array - */ -fi.prototype.deleteBySelector = function(cssObjectArray, selector) { - var ret = []; - for (var i = 0; i < cssObjectArray.length; i++) { - if (cssObjectArray[i].selector !== selector) { - ret.push(cssObjectArray[i]); - } - } - return ret; -}; - -/* - Compresses given cssObjectArray and tries to minimize - selector redundence. - */ -fi.prototype.compressCSS = function(cssObjectArray) { - var compressed = []; - var done = {}; - for (var i = 0; i < cssObjectArray.length; i++) { - var obj = cssObjectArray[i]; - if (done[obj.selector] === true) { - continue; - } - - var found = this.findBySelector(cssObjectArray, obj.selector); //found compressed - if (found.length !== 0) { - compressed.push(found[0]); - done[obj.selector] = true; - } - } - return compressed; -}; - -/* - Received 2 css objects with following structure - { - rules : [{directive:"", value:""}, {directive:"", value:""}, ...] - selector : "SOMESELECTOR" - } - - returns the changed(new,removed,updated) values on css1 parameter, on same structure - - if two css objects are the same, then returns false - - if a css directive exists in css1 and css2, and its value is different, it is included in diff - if a css directive exists in css1 and not css2, it is then included in diff - if a css directive exists in css2 but not css1, then it is deleted in css1, it would be included in diff but will be marked as type='DELETED' - - @object css1 css object - @object css2 css object - - @return diff css object contains changed values in css1 in regards to css2 see test input output in /test/data/css.js - */ -fi.prototype.cssDiff = function(css1, css2) { - if (css1.selector !== css2.selector) { - return false; - } - - //if one of them is media query return false, because diff function can not operate on media queries - if ((css1.type === "media" || css2.type === "media")) { - return false; - } - - var diff = { - selector: css1.selector, - rules: [], - }; - var rule1, rule2; - for (var i = 0; i < css1.rules.length; i++) { - rule1 = css1.rules[i]; - //find rule2 which has the same directive as rule1 - rule2 = this.findCorrespondingRule(css2.rules, rule1.directive, rule1.value); - if (rule2 === false) { - //rule1 is a new rule in css1 - diff.rules.push(rule1); - } else { - //rule2 was found only push if its value is different too - if (rule1.value !== rule2.value) { - diff.rules.push(rule1); - } - } - } - - //now for rules exists in css2 but not in css1, which means deleted rules - for (var ii = 0; ii < css2.rules.length; ii++) { - rule2 = css2.rules[ii]; - //find rule2 which has the same directive as rule1 - rule1 = this.findCorrespondingRule(css1.rules, rule2.directive); - if (rule1 === false) { - //rule1 is a new rule - rule2.type = "DELETED"; //mark it as a deleted rule, so that other merge operations could be true - diff.rules.push(rule2); - } - } - - if (diff.rules.length === 0) { - return false; - } - return diff; -}; - -/* - Merges 2 different css objects together - using intelligentCSSPush, - - @param cssObjectArray, target css object array - @param newArray, source array that will be pushed into cssObjectArray parameter - @param reverse, [optional], if given true, first parameter will be traversed on reversed order - effectively giving priority to the styles in newArray - */ -fi.prototype.intelligentMerge = function(cssObjectArray, newArray, reverse) { - if (reverse === undefined) { - reverse = false; - } - - for (var i = 0; i < newArray.length; i++) { - this.intelligentCSSPush(cssObjectArray, newArray[i], reverse); - } - for (i = 0; i < cssObjectArray.length; i++) { - var cobj = cssObjectArray[i]; - if (cobj.type === "media" || (cobj.type === "keyframes")) { - continue; - } - cobj.rules = this.compactRules(cobj.rules); - } -}; - -/* - inserts new css objects into a bigger css object - with same selectors groupped together - - @param cssObjectArray, array of bigger css object to be pushed into - @param minimalObject, single css object - @param reverse [optional] default is false, if given, cssObjectArray will be reversly traversed - resulting more priority in minimalObject's styles - */ -fi.prototype.intelligentCSSPush = function(cssObjectArray, minimalObject, reverse) { - var pushSelector = minimalObject.selector; - //find correct selector if not found just push minimalObject into cssObject - var cssObject = false; - - if (reverse === undefined) { - reverse = false; - } - - if (reverse === false) { - for (var i = 0; i < cssObjectArray.length; i++) { - if (cssObjectArray[i].selector === minimalObject.selector) { - cssObject = cssObjectArray[i]; - break; - } - } - } else { - for (var j = cssObjectArray.length - 1; j > -1; j--) { - if (cssObjectArray[j].selector === minimalObject.selector) { - cssObject = cssObjectArray[j]; - break; - } - } - } - - if (cssObject === false) { - cssObjectArray.push(minimalObject); //just push, because cssSelector is new - } else { - if (minimalObject.type !== "media") { - for (var ii = 0; ii < minimalObject.rules.length; ii++) { - var rule = minimalObject.rules[ii]; - //find rule inside cssObject - var oldRule = this.findCorrespondingRule(cssObject.rules, rule.directive); - if (oldRule === false) { - cssObject.rules.push(rule); - } else if (rule.type == "DELETED") { - oldRule.type = "DELETED"; - } else { - //rule found just update value - - oldRule.value = rule.value; - } - } - } else { - cssObject.subStyles = minimalObject.subStyles; //TODO, make this intelligent too - } - - } -}; - -/* - filter outs rule objects whose type param equal to DELETED - - @param rules, array of rules - - @returns rules array, compacted by deleting all unneccessary rules - */ -fi.prototype.compactRules = function(rules) { - var newRules = []; - for (var i = 0; i < rules.length; i++) { - if (rules[i].type !== "DELETED") { - newRules.push(rules[i]); - } - } - return newRules; -}; -/* - computes string for ace editor using this.css or given cssBase optional parameter - - @param [optional] cssBase, if given computes cssString from cssObject array - */ -fi.prototype.getCSSForEditor = function(cssBase, depth) { - if (depth === undefined) { - depth = 0; - } - var ret = ""; - if (cssBase === undefined) { - cssBase = this.css; - } - //append imports - for (var i = 0; i < cssBase.length; i++) { - if (cssBase[i].type == "imports") { - ret += cssBase[i].styles + "\n\n"; - } - } - for (i = 0; i < cssBase.length; i++) { - var tmp = cssBase[i]; - if (tmp.selector === undefined) { //temporarily omit media queries - continue; - } - var comments = ""; - if (tmp.comments !== undefined) { - comments = tmp.comments + "\n"; - } - - if (tmp.type == "media") { //also put media queries to output - ret += comments + tmp.selector + "{\n"; - ret += this.getCSSForEditor(tmp.subStyles, depth + 1); - ret += "}\n\n"; - } else if (tmp.type !== "keyframes" && tmp.type !== "imports") { - ret += this.getSpaces(depth) + comments + tmp.selector + " {\n"; - ret += this.getCSSOfRules(tmp.rules, depth + 1); - ret += this.getSpaces(depth) + "}\n\n"; - } - } - - //append keyFrames - for (i = 0; i < cssBase.length; i++) { - if (cssBase[i].type == "keyframes") { - ret += cssBase[i].styles + "\n\n"; - } - } - - return ret; -}; - -fi.prototype.getImports = function(cssObjectArray) { - var imps = []; - for (var i = 0; i < cssObjectArray.length; i++) { - if (cssObjectArray[i].type == "imports") { - imps.push(cssObjectArray[i].styles); - } - } - return imps; -}; -/* - given rules array, returns visually formatted css string - to be used inside editor - */ -fi.prototype.getCSSOfRules = function(rules, depth) { - var ret = ""; - for (var i = 0; i < rules.length; i++) { - if (rules[i] === undefined) { - continue; - } - if (rules[i].defective === undefined) { - ret += this.getSpaces(depth) + rules[i].directive + " : " + rules[i].value + ";\n"; - } else { - ret += this.getSpaces(depth) + rules[i].value + ";\n"; - } - - } - return ret || "\n"; -}; - -/* - A very simple helper function returns number of spaces appended in a single string, - the number depends input parameter, namely input*2 - */ -fi.prototype.getSpaces = function(num) { - var ret = ""; - for (var i = 0; i < num * 4; i++) { - ret += " "; - } - return ret; -}; - -/* - Given css string or objectArray, parses it and then for every selector, - prepends this.cssPreviewNamespace to prevent css collision issues - - @returns css string in which this.cssPreviewNamespace prepended - */ -fi.prototype.applyNamespacing = function(css, forcedNamespace) { - var cssObjectArray = css; - var namespaceClass = "." + this.cssPreviewNamespace; - if (forcedNamespace !== undefined) { - namespaceClass = forcedNamespace; - } - - if (typeof css === "string") { - cssObjectArray = this.parseCSS(css); - } - - for (var i = 0; i < cssObjectArray.length; i++) { - var obj = cssObjectArray[i]; - - //bypass namespacing for @font-face @keyframes @import - if (obj.selector.indexOf("@font-face") > -1 || obj.selector.indexOf("keyframes") > -1 || obj.selector.indexOf("@import") > -1 || obj.selector.indexOf(".form-all") > -1 || obj.selector.indexOf("#stage") > -1) { - continue; - } - - if (obj.type !== "media") { - var selector = obj.selector.split(","); - var newSelector = []; - for (var j = 0; j < selector.length; j++) { - if (selector[j].indexOf(".supernova") === -1) { //do not apply namespacing to selectors including supernova - newSelector.push(namespaceClass + " " + selector[j]); - } else { - newSelector.push(selector[j]); - } - } - obj.selector = newSelector.join(","); - } else { - obj.subStyles = this.applyNamespacing(obj.subStyles, forcedNamespace); //handle media queries as well - } - } - - return cssObjectArray; -}; - -/* - given css string or object array, clears possible namespacing from - all of the selectors inside the css - */ -fi.prototype.clearNamespacing = function(css, returnObj) { - if (returnObj === undefined) { - returnObj = false; - } - var cssObjectArray = css; - var namespaceClass = "." + this.cssPreviewNamespace; - if (typeof css === "string") { - cssObjectArray = this.parseCSS(css); - } - - for (var i = 0; i < cssObjectArray.length; i++) { - var obj = cssObjectArray[i]; - - if (obj.type !== "media") { - var selector = obj.selector.split(","); - var newSelector = []; - for (var j = 0; j < selector.length; j++) { - newSelector.push(selector[j].split(namespaceClass + " ").join("")); - } - obj.selector = newSelector.join(","); - } else { - obj.subStyles = this.clearNamespacing(obj.subStyles, true); //handle media queries as well - } - } - if (returnObj === false) { - return this.getCSSForEditor(cssObjectArray); - } else { - return cssObjectArray; - } - -}; - -/* - creates a new style tag (also destroys the previous one) - and injects given css string into that css tag - */ -fi.prototype.createStyleElement = function(id, css, format) { - if (format === undefined) { - format = false; - } - - if (this.testMode === false && format !== "nonamespace") { - //apply namespacing classes - css = this.applyNamespacing(css); - } - - if (typeof css != "string") { - css = this.getCSSForEditor(css); - } - //apply formatting for css - if (format === true) { - css = this.getCSSForEditor(this.parseCSS(css)); - } - - if (this.testMode !== false) { - return this.testMode("create style #" + id, css); //if test mode, just pass result to callback - } - - var __el = document.getElementById(id); - if (__el) { - __el.parentNode.removeChild(__el); - } - - var head = document.head || document.getElementsByTagName("head")[0], - style = document.createElement("style"); - - style.id = id; - style.type = "text/css"; - - head.appendChild(style); - - if (style.styleSheet && !style.sheet) { - style.styleSheet.cssText = css; - } else { - style.appendChild(document.createTextNode(css)); - } -}; - -export default fi; - -/* eslint-enable */ diff --git a/ui-ngx/src/app/core/css/css.ts b/ui-ngx/src/app/core/css/css.ts new file mode 100644 index 0000000000..ae310fd65e --- /dev/null +++ b/ui-ngx/src/app/core/css/css.ts @@ -0,0 +1,460 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// 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. +/// + +interface CSSRule { + directive: string; + value: string; + defective?: boolean; + type?: string; +} + +interface CSSObject { + selector: string; + type?: 'media' | 'keyframes' | 'imports' | 'font-face'; + rules?: CSSRule[]; + subStyles?: CSSObject[]; + styles?: string; + comments?: string; +} + +export default class CSSParser { + cssPreviewNamespace: string = ''; + testMode: boolean | ((action: string, css: string) => string) = false; + + cssImportStatements: string[] = []; + + private readonly cssKeyframeRegex: string = '((@.*?keyframes [\\s\\S]*?){([\\s\\S]*?}\\s*?)})'; + private readonly combinedCSSRegex: string = '((\\s*?@media[\\s\\S]*?){([\\s\\S]*?)}\\s*?})|(([\\s\\S]*?){([\\s\\S]*?)})'; + private readonly cssCommentsRegex: string = '(\\/\\*[\\s\\S]*?\\*\\/)'; + private readonly cssImportStatementRegex: RegExp = /@import .*?;/gi; + + /** + * Removes CSS comments from the provided CSS string. + * @param cssString - The CSS string to strip comments from. + * @returns The CSS string with comments removed. + */ + stripComments(cssString: string): string { + const regex = new RegExp(this.cssCommentsRegex, 'gi'); + return cssString.replace(regex, ''); + } + + /** + * Parses a CSS string into an array of CSS objects with selectors and rules. + * @param source - The CSS string to parse. + * @returns An array of CSS objects. + */ + parseCSS(source?: string): CSSObject[] { + if (!source) { + return []; + } + + const css: CSSObject[] = []; + let cssSource = source; + + const importStatementRegex = new RegExp(this.cssImportStatementRegex.source, this.cssImportStatementRegex.flags); + cssSource = cssSource.replace(importStatementRegex, (match) => { + this.cssImportStatements.push(match); + css.push({ selector: '@imports', type: 'imports', styles: match }); + return ''; + }); + + // Extract keyframe statements + const keyframesRegex = new RegExp(this.cssKeyframeRegex, 'gi'); + cssSource = cssSource.replace(keyframesRegex, (match) => { + css.push({ selector: '@keyframes', type: 'keyframes', styles: match }); + return ''; + }); + + // Parse remaining CSS + const unifiedRegex = new RegExp(this.combinedCSSRegex, 'gi'); + let match: RegExpExecArray | null; + while ((match = unifiedRegex.exec(cssSource)) !== null) { + const selector = (match[2] ?? match[5]).replace(/\r\n/g, '\n').trim(); + + // Extract comments + const commentsRegex = new RegExp(this.cssCommentsRegex, 'gi'); + const comments = commentsRegex.exec(selector); + const cleanSelector = comments ? selector.replace(commentsRegex, '').trim() : selector; + + if (cleanSelector.includes('@media')) { + css.push({ + selector: cleanSelector, + type: 'media', + subStyles: this.parseCSS(match[3] + '\n}'), + ...(comments && {comments: comments[0]}), + }); + } else { + const rules = this.parseRules(match[6]); + const style: CSSObject = { + selector: cleanSelector, + rules, + ...(cleanSelector === '@font-face' && {type: 'font-face'}), + ...(comments && {comments: comments[0]}), + }; + css.push(style); + } + } + + return css; + } + + /** + * Parses CSS rules into an array of rule objects. + * @param rules - The CSS rules string. + * @returns An array of rule objects with directive and value. + */ + parseRules(rules: string): CSSRule[] { + const normalizedRules = rules.replace(/\r\n/g, '\n'); + const ruleList = normalizedRules.split(/;(?![^(]*\))/); + const result: CSSRule[] = []; + + for (const line of ruleList) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + if (trimmedLine.includes(':')) { + const [directive, ...valueParts] = trimmedLine.split(':'); + const value = valueParts.join(':').trim(); + if (directive.trim() && value) { + result.push({directive: directive.trim(), value}); + } + } else if (trimmedLine.startsWith('base64,')) { + if (result.length > 0) { + result[result.length - 1].value += trimmedLine; + } + } else if (trimmedLine) { + result.push({directive: '', value: trimmedLine, defective: true}); + } + } + + return result; + } + + /** + * Finds a rule matching the given directive in the rules array. + * @param rules - The array of CSS rules. + * @param directive - The directive to search for. + * @param value - Optional value to match. + * @returns The matching rule or false if not found. + */ + findCorrespondingRule(rules: CSSRule[], directive: string, value?: string): CSSRule | false { + return rules.find(rule => rule.directive === directive && (!value || rule.value === value)) || false; + } + + /** + * Finds CSS objects by selector, optionally merging duplicates. + * @param cssObjectArray - The array of CSS objects. + * @param selector - The selector to search for. + * @param contains - If true, matches selectors containing the string. + * @returns An array of matching CSS objects. + */ + findBySelector(cssObjectArray: CSSObject[], selector: string, contains: boolean = false): CSSObject[] { + const found = cssObjectArray.filter(obj => contains ? obj.selector.includes(selector) : obj.selector === selector); + + if (found.length < 2) return found; + + const base = found[0]; + for (let i = 1; i < found.length; i++) { + this.intelligentCSSPush([base], found[i]); + } + return [base]; + } + + /** + * Deletes CSS objects with the given selector. + * @param cssObjectArray - The array of CSS objects. + * @param selector - The selector to delete. + * @returns A new array without the matching CSS objects. + */ + deleteBySelector(cssObjectArray: CSSObject[], selector: string): CSSObject[] { + return cssObjectArray.filter(obj => obj.selector !== selector); + } + + /** + * Compresses CSS objects by merging duplicates. + * @param cssObjectArray - The array of CSS objects to compress. + * @returns A compressed array of CSS objects. + */ + compressCSS(cssObjectArray: CSSObject[]): CSSObject[] { + const compressed: CSSObject[] = []; + const done = new Set(); + + for (const obj of cssObjectArray) { + if (done.has(obj.selector)) continue; + const found = this.findBySelector(cssObjectArray, obj.selector); + if (found.length) { + compressed.push(found[0]); + done.add(obj.selector); + } + } + return compressed; + } + + /** + * Computes the difference between two CSS objects. + * @param css1 - The first CSS object. + * @param css2 - The second CSS object. + * @returns A CSS object with the differences or false if no differences. + */ + cssDiff(css1: CSSObject, css2: CSSObject): CSSObject | false { + if (css1.selector !== css2.selector || css1.type === 'media' || css2.type === 'media') { + return false; + } + + const diff: CSSObject = {selector: css1.selector, rules: []}; + const rules1 = css1.rules ?? []; + const rules2 = css2.rules ?? []; + + for (const rule1 of rules1) { + const rule2 = this.findCorrespondingRule(rules2, rule1.directive, rule1.value); + if (!rule2 || rule1.value !== rule2.value) { + diff.rules!.push(rule1); + } + } + + for (const rule2 of rules2) { + if (!this.findCorrespondingRule(rules1, rule2.directive)) { + diff.rules!.push({...rule2, type: 'DELETED'}); + } + } + + return diff.rules!.length ? diff : false; + } + + /** + * Merges two CSS object arrays intelligently. + * @param cssObjectArray - The target CSS object array. + * @param newArray - The source CSS object array to merge. + * @param reverse - If true, prioritizes styles in newArray. + */ + intelligentMerge(cssObjectArray: CSSObject[], newArray: CSSObject[], reverse: boolean = false): void { + for (const obj of newArray) { + this.intelligentCSSPush(cssObjectArray, obj, reverse); + } + for (const obj of cssObjectArray) { + if (obj.type !== 'media' && obj.type !== 'keyframes') { + obj.rules = this.compactRules(obj.rules ?? []); + } + } + } + + /** + * Pushes a CSS object into an array, merging with existing selectors. + * @param cssObjectArray - The target CSS object array. + * @param minimalObject - The CSS object to push. + * @param reverse - If true, traverses array in reverse for priority. + */ + intelligentCSSPush(cssObjectArray: CSSObject[], minimalObject: CSSObject, reverse: boolean = false): void { + const cssObject = (reverse ? cssObjectArray.slice().reverse() : cssObjectArray) + .find(obj => obj.selector === minimalObject.selector) ?? false; + + if (!cssObject) { + cssObjectArray.push(minimalObject); + return; + } + + if (minimalObject.type !== 'media') { + for (const rule of minimalObject.rules ?? []) { + const oldRule = this.findCorrespondingRule(cssObject.rules ?? [], rule.directive); + if (!oldRule) { + cssObject.rules!.push(rule); + } else if (rule.type === 'DELETED') { + oldRule.type = 'DELETED'; + } else { + oldRule.value = rule.value; + } + } + } else { + cssObject.subStyles = minimalObject.subStyles; + } + } + + /** + * Filters out rules marked as DELETED. + * @param rules - The array of CSS rules. + * @returns A compacted array of rules. + */ + compactRules(rules: CSSRule[]): CSSRule[] { + return rules.filter(rule => rule.type !== 'DELETED'); + } + + /** + * Generates a formatted CSS string for an editor. + * @param cssBase - The CSS object array to format. + * @param depth - The indentation depth. + * @returns A formatted CSS string. + */ + getCSSForEditor(cssBase?: CSSObject[], depth: number = 0): string { + const css = cssBase ?? this.parseCSS(''); + let result = ''; + + // Append imports + for (const obj of css) { + if (obj.type === 'imports') { + result += `${obj.styles}\n\n`; + } + } + + // Append styles + for (const obj of css) { + if (!obj.selector) continue; + const comments = obj.comments ? `${obj.comments}\n` : ''; + + if (obj.type === 'media') { + result += `${comments}${obj.selector} {\n${this.getCSSForEditor(obj.subStyles, depth + 1)}}\n\n`; + } else if (obj.type !== 'keyframes' && obj.type !== 'imports') { + result += `${this.getSpaces(depth)}${comments}${obj.selector} {\n${this.getCSSOfRules(obj.rules ?? [], depth + 1)}${this.getSpaces(depth)}}\n\n`; + } + } + + // Append keyframes + for (const obj of css) { + if (obj.type === 'keyframes') { + result += `${obj.styles}\n\n`; + } + } + + return result; + } + + /** + * Retrieves all import statements from a CSS object array. + * @param cssObjectArray - The CSS object array. + * @returns An array of import statement strings. + */ + getImports(cssObjectArray: CSSObject[]): string[] { + return cssObjectArray.filter(obj => obj.type === 'imports').map(obj => obj.styles!); + } + + /** + * Formats CSS rules into a string for an editor. + * @param rules - The array of CSS rules. + * @param depth - The indentation depth. + * @returns A formatted CSS rules string. + */ + getCSSOfRules(rules: CSSRule[], depth: number): string { + let result = ''; + for (const rule of rules) { + if (!rule) continue; + if (rule.defective) { + result += `${this.getSpaces(depth)}${rule.value};\n`; + } else { + result += `${this.getSpaces(depth)}${rule.directive}: ${rule.value};\n`; + } + } + return result || '\n'; + } + + /** + * Generates indentation spaces based on depth. + * @param num - The indentation level. + * @returns A string of spaces. + */ + getSpaces(num: number): string { + return ' '.repeat(num * 4); + } + + /** + * Applies a namespace to CSS selectors to prevent collisions. + * @param css - The CSS string or object array. + * @param forcedNamespace - Optional custom namespace. + * @returns The namespaced CSS object array. + */ + applyNamespacing(css: string | CSSObject[], forcedNamespace?: string): CSSObject[] { + const namespaceClass = forcedNamespace ?? `.${this.cssPreviewNamespace}`; + const cssObjectArray = typeof css === 'string' ? this.parseCSS(css) : css; + + for (const obj of cssObjectArray) { + if (['@font-face', 'keyframes', '@import', '.form-all', '#stage'].some(s => obj.selector.includes(s))) { + continue; + } + + if (obj.type !== 'media') { + obj.selector = obj.selector.split(',') + .map(sel => sel.includes('.supernova') ? sel : `${namespaceClass} ${sel}`) + .join(','); + } else { + obj.subStyles = this.applyNamespacing(obj.subStyles ?? [], forcedNamespace); + } + } + + return cssObjectArray; + } + + /** + * Removes namespacing from CSS selectors. + * @param css - The CSS string or object array. + * @param returnObj - If true, returns the CSS object array. + * @returns The CSS string or object array with namespacing removed. + */ + clearNamespacing(css: string | CSSObject[], returnObj: boolean = false): string | CSSObject[] { + const namespaceClass = `.${this.cssPreviewNamespace}`; + const cssObjectArray = typeof css === 'string' ? this.parseCSS(css) : css; + + for (const obj of cssObjectArray) { + if (obj.type !== 'media') { + obj.selector = obj.selector + .split(',') + .map(sel => sel.split(namespaceClass + ' ').join('')) + .join(','); + } else { + obj.subStyles = this.clearNamespacing(obj.subStyles ?? [], true) as CSSObject[]; + } + } + + return returnObj ? cssObjectArray : this.getCSSForEditor(cssObjectArray); + } + + /** + * Creates a style element with the provided CSS. + * @param id - The ID for the style element. + * @param css - The CSS string or object array. + * @param format - If true, formats the CSS; if 'nonamespace', skips namespacing. + */ + createStyleElement(id: string, css: string | CSSObject[], format: boolean | 'nonamespace' = false): void | string { + let cssString = typeof css === 'string' ? css : this.getCSSForEditor(css); + + if (this.testMode === false && format !== 'nonamespace') { + cssString = this.getCSSForEditor(this.applyNamespacing(css)); + } + + if (format === true) { + cssString = this.getCSSForEditor(this.parseCSS(cssString)); + } + + if (typeof this.testMode === 'function') { + return this.testMode(`create style #${id}`, cssString); + } + + const existingElement = document.getElementById(id); + existingElement?.remove(); + + if (!css) { + return; + } + + const style = document.createElement('style'); + style.id = id; + + if ('styleSheet' in style && !('sheet' in style)) { + (style as any).styleSheet.cssText = cssString; + } else { + style.appendChild(document.createTextNode(cssString)); + } + + (document.head || document.getElementsByTagName('head')[0]).appendChild(style); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts index 10d5f9b1a0..3138f53c1e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts @@ -90,8 +90,8 @@ export class MarkdownWidgetComponent extends PageComponent implements OnInit { this.markdownClass = 'markdown-widget-' + hashCode(cssString); cssParser.cssPreviewNamespace = this.markdownClass; cssParser.testMode = false; - cssString = cssParser.applyNamespacing(cssString); - cssString = cssParser.getCSSForEditor(cssString); + const cssObjects = cssParser.applyNamespacing(cssString); + cssString = cssParser.getCSSForEditor(cssObjects); this.additionalStyles = [cssString]; } if (isDefinedAndNotNull(this.settings.applyDefaultMarkdownStyle)) {