UI: Migration from css.js to css.ts

This commit is contained in:
Vladyslav_Prykhodko 2025-05-15 11:21:17 +03:00
parent a27b30f3ce
commit bb3e828e36
3 changed files with 462 additions and 688 deletions

View File

@ -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 */

View File

@ -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<string>();
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);
}
}

View File

@ -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)) {