lexical
Version:
Lexical is an extensible text editor framework that provides excellent reliability, accessible and performance.
243 lines (212 loc) • 6.31 kB
text/typescript
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
const IMPORTANT_FLAG = '!important';
/**
* Parses inline CSS text into an object that is compatible with
* `CSSStyleDeclaration.setProperty()`.
*
* Property names are expected to be kebab-case, such as `font-size`, and
* values are expected to include explicit units where needed, such as `12px`.
*/
export function getStyleObjectFromCSS(css: string): Record<string, string> {
const styles: Record<string, string> = {};
if (!css) {
return styles;
}
let currentProperty = '';
let currentValue = '';
let currentQuote: '"' | "'" | null = null;
let inComment = false;
let isEscaped = false;
let isParsingValue = false;
let parenthesisDepth = 0;
const length = css.length;
// Characters that belong to the current property/value (normal text, quotes,
// escapes and parentheses) are accumulated as contiguous slices rather than
// appended one-by-one, which avoids O(n) per-character string concatenation.
// `chunkStart` marks the start of the pending run, or -1 when nothing is
// pending. The run is flushed whenever a character is dropped (a comment) or
// acts as a delimiter (`:` or `;`).
let chunkStart = -1;
for (let i = 0; i < length; i++) {
const char = css[i];
if (inComment) {
if (char === '*' && css[i + 1] === '/') {
inComment = false;
i++;
}
continue;
}
if (isEscaped) {
if (chunkStart === -1) {
chunkStart = i;
}
isEscaped = false;
continue;
}
if (currentQuote !== null) {
if (chunkStart === -1) {
chunkStart = i;
}
if (char === '\\') {
isEscaped = true;
} else if (char === currentQuote) {
currentQuote = null;
}
continue;
}
if (char === '/' && css[i + 1] === '*') {
// The comment is dropped, so flush everything accumulated before it.
if (chunkStart !== -1) {
if (isParsingValue) {
currentValue += css.slice(chunkStart, i);
} else {
currentProperty += css.slice(chunkStart, i);
}
chunkStart = -1;
}
inComment = true;
i++;
continue;
}
if (char === '"' || char === "'") {
if (chunkStart === -1) {
chunkStart = i;
}
currentQuote = char;
continue;
}
if (char === '(') {
if (chunkStart === -1) {
chunkStart = i;
}
parenthesisDepth++;
continue;
}
if (char === ')') {
if (chunkStart === -1) {
chunkStart = i;
}
parenthesisDepth = Math.max(0, parenthesisDepth - 1);
continue;
}
if (!isParsingValue && char === ':' && parenthesisDepth === 0) {
// The separator is dropped; flush the accumulated property name.
if (chunkStart !== -1) {
currentProperty += css.slice(chunkStart, i);
chunkStart = -1;
}
isParsingValue = true;
continue;
}
if (char === ';' && parenthesisDepth === 0) {
if (chunkStart !== -1) {
if (isParsingValue) {
currentValue += css.slice(chunkStart, i);
} else {
currentProperty += css.slice(chunkStart, i);
}
chunkStart = -1;
}
const property = currentProperty.trim();
const value = currentValue.trim();
if (property !== '' && value !== '') {
styles[property] = value;
}
currentProperty = '';
currentValue = '';
isParsingValue = false;
continue;
}
if (chunkStart === -1) {
chunkStart = i;
}
}
if (chunkStart !== -1) {
if (isParsingValue) {
currentValue += css.slice(chunkStart, length);
} else {
currentProperty += css.slice(chunkStart, length);
}
}
const property = currentProperty.trim();
const value = currentValue.trim();
if (property !== '' && value !== '') {
styles[property] = value;
}
return styles;
}
function setDOMStyleProperty(
domStyle: CSSStyleDeclaration,
property: string,
value: string,
): void {
// Detect (and strip) a trailing `!important` flag using plain string
// operations. A regexp such as `/\s*!important\s*$/i` runs in O(n^2) time on
// whitespace-heavy values (the leading `\s*` backtracks from every starting
// offset), whereas `trimEnd` + `slice` is linear.
const trimmedValue = value.trimEnd();
const flagStart = trimmedValue.length - IMPORTANT_FLAG.length;
const hasImportant =
flagStart >= 0 &&
trimmedValue.slice(flagStart).toLowerCase() === IMPORTANT_FLAG;
if (hasImportant) {
domStyle.setProperty(
property,
trimmedValue.slice(0, flagStart).trim(),
'important',
);
} else {
domStyle.setProperty(property, value, '');
}
}
/**
* Applies a style object to a DOM style declaration using
* `CSSStyleDeclaration.setProperty()`.
*
* Property names are expected to be kebab-case, such as `font-size`, and
* values are expected to include explicit units where needed, such as `12px`.
*/
export function setDOMStyleObject(
domStyle: CSSStyleDeclaration,
styleObject: Record<string, string | null | undefined>,
): void {
for (const property in styleObject) {
const value = styleObject[property];
if (value == null) {
domStyle.removeProperty(property);
} else {
setDOMStyleProperty(domStyle, property, value);
}
}
}
/**
* Applies inline CSS text to a DOM style declaration using
* `CSSStyleDeclaration.setProperty()`.
*
* Property names are expected to be kebab-case, such as `font-size`, and
* values are expected to include explicit units where needed, such as `12px`.
*/
export function setDOMStyleFromCSS(
domStyle: CSSStyleDeclaration,
cssText: string,
prevCSSText: string = '',
): void {
if (cssText === prevCSSText) {
return;
}
const prevCSS = getStyleObjectFromCSS(prevCSSText);
const nextCSS = getStyleObjectFromCSS(cssText);
for (const property in nextCSS) {
delete prevCSS[property];
setDOMStyleProperty(domStyle, property, nextCSS[property]);
}
for (const property in prevCSS) {
domStyle.removeProperty(property);
}
}