Unify remote element style declarations (#23263)
Front components run in a Web Worker with a fake DOM. Until now the worker had a hand-rolled `style` object for remote elements and the host had its own separate CSS-string parser: two implementations of the same parsing that kept drifting apart (several review rounds fixed edge cases in one copy but not the other). What changed: - One shared `createStyleProxy` now backs `element.style` in the worker, and one shared `parseCssDeclarations` feeds both the worker proxy and the host's `parseCssString`. Most of the diff is existing logic split out of `installStylePropertyOnRemoteElements` into small single-purpose utils (`splitCssDeclarations`, `stripImportantPriorityFromCssValue`, `normalizeCssPropertyName`, `formatCssValue`, ...), not new behavior. - `!important` is stripped from values instead of tracked. Nothing ever read priorities back, and the host applies styles through React inline styles, which cannot express `!important`. Rendering note: `color: red !important` used to reach React as an invalid value (property silently not applied); it now applies, without the priority. - Style writes flush to the host synchronously, exactly as on main. - The parser handles quotes, escapes and parentheses; CSS comments inside hand-written `cssText` are not supported. This shared proxy is also the base for the worker `getComputedStyle` stub in the geometry PR. Second of three PRs splitting the geometry mirror work.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
const UNITLESS_CSS_PROPERTY_BASE_NAMES = [
|
||||
'animationIterationCount',
|
||||
'aspectRatio',
|
||||
'borderImageOutset',
|
||||
'borderImageSlice',
|
||||
'borderImageWidth',
|
||||
'boxFlex',
|
||||
'boxFlexGroup',
|
||||
'boxOrdinalGroup',
|
||||
'columnCount',
|
||||
'columns',
|
||||
'flex',
|
||||
'flexGrow',
|
||||
'flexPositive',
|
||||
'flexShrink',
|
||||
'flexNegative',
|
||||
'flexOrder',
|
||||
'gridArea',
|
||||
'gridRow',
|
||||
'gridRowEnd',
|
||||
'gridRowSpan',
|
||||
'gridRowStart',
|
||||
'gridColumn',
|
||||
'gridColumnEnd',
|
||||
'gridColumnSpan',
|
||||
'gridColumnStart',
|
||||
'fontWeight',
|
||||
'lineClamp',
|
||||
'lineHeight',
|
||||
'opacity',
|
||||
'order',
|
||||
'orphans',
|
||||
'scale',
|
||||
'tabSize',
|
||||
'widows',
|
||||
'zIndex',
|
||||
'zoom',
|
||||
'fillOpacity',
|
||||
'floodOpacity',
|
||||
'stopOpacity',
|
||||
'strokeDasharray',
|
||||
'strokeDashoffset',
|
||||
'strokeMiterlimit',
|
||||
'strokeOpacity',
|
||||
'strokeWidth',
|
||||
];
|
||||
|
||||
const UNITLESS_CSS_PROPERTY_VENDOR_PREFIXES = ['Webkit', 'Moz', 'ms', 'O'];
|
||||
|
||||
const withVendorPrefixedAliases = (propertyName: string): string[] => [
|
||||
propertyName,
|
||||
...UNITLESS_CSS_PROPERTY_VENDOR_PREFIXES.map(
|
||||
(vendorPrefix) =>
|
||||
`${vendorPrefix}${propertyName[0].toUpperCase()}${propertyName.slice(1)}`,
|
||||
),
|
||||
];
|
||||
|
||||
export const UNITLESS_CSS_PROPERTY_NAMES = new Set(
|
||||
UNITLESS_CSS_PROPERTY_BASE_NAMES.flatMap(withVendorPrefixedAliases),
|
||||
);
|
||||
+21
-7
@@ -1,9 +1,9 @@
|
||||
import { parseCssString } from '../parseCssString';
|
||||
|
||||
describe('parseCssString', () => {
|
||||
it('should return the input unchanged when it is not a non-empty string', () => {
|
||||
it('should return undefined when the input is not a non-empty string', () => {
|
||||
expect(parseCssString(undefined)).toBeUndefined();
|
||||
expect(parseCssString('')).toBe('');
|
||||
expect(parseCssString('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should convert kebab-case properties to camelCase', () => {
|
||||
@@ -23,13 +23,27 @@ describe('parseCssString', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip declarations without a colon', () => {
|
||||
expect(parseCssString('color: red; invalid')).toEqual({ color: 'red' });
|
||||
it('should keep semicolons inside url() values', () => {
|
||||
expect(
|
||||
parseCssString(
|
||||
'background-image: url(data:image/png;base64,abc); color: red',
|
||||
),
|
||||
).toEqual({
|
||||
backgroundImage: 'url(data:image/png;base64,abc)',
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
|
||||
it('should only split on the first colon so values may contain colons', () => {
|
||||
expect(parseCssString('background: url(http://example.com)')).toEqual({
|
||||
background: 'url(http://example.com)',
|
||||
it('should strip an important priority from values', () => {
|
||||
expect(parseCssString('color: red !important; width: 10px')).toEqual({
|
||||
color: 'red',
|
||||
width: '10px',
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep an earlier important declaration over a later normal duplicate', () => {
|
||||
expect(parseCssString('color: red !important; color: blue')).toEqual({
|
||||
color: 'red',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type CSSProperties } from 'react';
|
||||
import { kebabToCamelCase } from 'twenty-shared/utils';
|
||||
|
||||
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
|
||||
import { parseCssDeclarations } from '@/utils/parseCssDeclarations';
|
||||
|
||||
export const parseCssString = (
|
||||
styleString: string | undefined,
|
||||
): CSSProperties | undefined => {
|
||||
if (!isNonEmptyString(styleString)) {
|
||||
return styleString as CSSProperties | undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const style: Record<string, string> = {};
|
||||
const declarations = styleString.split(';').filter(Boolean);
|
||||
const reactStyleProperties: Record<string, string> = {};
|
||||
|
||||
for (const declaration of declarations) {
|
||||
const colonIndex = declaration.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
for (const { cssPropertyName, cssValue } of parseCssDeclarations(
|
||||
styleString,
|
||||
)) {
|
||||
const reactStylePropertyName = isCssCustomPropertyName(cssPropertyName)
|
||||
? cssPropertyName
|
||||
: kebabToCamelCase(cssPropertyName);
|
||||
|
||||
const property = declaration.slice(0, colonIndex).trim();
|
||||
const value = declaration.slice(colonIndex + 1).trim();
|
||||
|
||||
const isCssCustomProperty = property.startsWith('--');
|
||||
|
||||
const key = isCssCustomProperty
|
||||
? property
|
||||
: property.replace(/-([a-z])/g, (_, letter: string) =>
|
||||
letter.toUpperCase(),
|
||||
);
|
||||
|
||||
style[key] = value;
|
||||
reactStyleProperties[reactStylePropertyName] = cssValue;
|
||||
}
|
||||
|
||||
return style;
|
||||
return reactStyleProperties;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export type StyleProxy = {
|
||||
cssText: string;
|
||||
setProperty: (
|
||||
cssPropertyName: string,
|
||||
value: string | null,
|
||||
priority?: string,
|
||||
) => void;
|
||||
removeProperty: (cssPropertyName: string) => string;
|
||||
getPropertyValue: (cssPropertyName: string) => string;
|
||||
[stylePropertyName: string]: unknown;
|
||||
};
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { createStyleProxy } from '../createStyleProxy';
|
||||
|
||||
describe('createStyleProxy', () => {
|
||||
it('should round trip a property through setProperty and getPropertyValue', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('color', 'red');
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('red');
|
||||
expect(style.cssText).toBe('color:red');
|
||||
});
|
||||
|
||||
it('should flush every mutation synchronously with the serialized cssText', () => {
|
||||
const flush = jest.fn();
|
||||
const style = createStyleProxy(flush);
|
||||
|
||||
style.setProperty('color', 'red');
|
||||
style.width = 10;
|
||||
style.removeProperty('color');
|
||||
|
||||
expect(
|
||||
flush.mock.calls.map(([serializedCssText]) => serializedCssText),
|
||||
).toEqual(['color:red', 'color:red;width:10px', 'width:10px']);
|
||||
});
|
||||
|
||||
it('should accept an important priority and store the plain value', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('color', 'red', 'IMPORTANT');
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('red');
|
||||
expect(style.cssText).toBe('color:red');
|
||||
});
|
||||
|
||||
it('should ignore setProperty with an invalid priority', () => {
|
||||
const flush = jest.fn();
|
||||
const style = createStyleProxy(flush);
|
||||
|
||||
style.setProperty('color', 'red', 'loud');
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('');
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore setProperty with a whitespace-padded important priority', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('color', 'red', ' important ');
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('');
|
||||
});
|
||||
|
||||
it('should remove the property when the value is empty even with an invalid priority', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('color', 'red');
|
||||
style.setProperty('color', '', 'loud');
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('');
|
||||
expect(style.cssText).toBe('');
|
||||
});
|
||||
|
||||
it('should normalize the property name passed to setProperty and getPropertyValue', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('COLOR', 'red');
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('red');
|
||||
expect(style.color).toBe('red');
|
||||
});
|
||||
|
||||
it('should replace all declarations when cssText is assigned', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('color', 'red');
|
||||
style.cssText = 'width: 10px; height: 20px';
|
||||
|
||||
expect(style.getPropertyValue('color')).toBe('');
|
||||
expect(style.cssText).toBe('width:10px;height:20px');
|
||||
});
|
||||
|
||||
it('should resolve direct camelCase assignment to the css property name', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.backgroundColor = 'red';
|
||||
|
||||
expect(style.getPropertyValue('background-color')).toBe('red');
|
||||
expect(style.backgroundColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should map the cssFloat alias to the float property', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.cssFloat = 'left';
|
||||
|
||||
expect(style.cssText).toBe('float:left');
|
||||
});
|
||||
|
||||
it('should remove a property on empty direct assignment', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.color = 'red';
|
||||
style.color = '';
|
||||
|
||||
expect(style.cssText).toBe('');
|
||||
});
|
||||
|
||||
it('should append px to numeric values while keeping unitless properties unitless', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.width = 10;
|
||||
style.aspectRatio = 2;
|
||||
|
||||
expect(style.getPropertyValue('width')).toBe('10px');
|
||||
expect(style.getPropertyValue('aspect-ratio')).toBe('2');
|
||||
});
|
||||
|
||||
it('should preserve the exact custom property key on direct assignment', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style['--myVar'] = '2px';
|
||||
|
||||
expect(style.cssText).toBe('--myVar:2px');
|
||||
expect(style['--myVar']).toBe('2px');
|
||||
});
|
||||
|
||||
it('should keep Object.prototype methods callable', () => {
|
||||
const style = createStyleProxy(() => {});
|
||||
|
||||
style.setProperty('color', 'red');
|
||||
|
||||
expect(style.hasOwnProperty('color')).toBe(true);
|
||||
expect(style.hasOwnProperty('background')).toBe(false);
|
||||
expect(() => `${style}`).not.toThrow();
|
||||
expect(String(style)).toBe('[object Object]');
|
||||
});
|
||||
});
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { formatCssValue } from '../formatCssValue';
|
||||
|
||||
describe('formatCssValue', () => {
|
||||
it('should append px to a numeric length', () => {
|
||||
expect(formatCssValue(10, 'width')).toBe('10px');
|
||||
});
|
||||
|
||||
it('should keep a unitless numeric property unitless', () => {
|
||||
expect(formatCssValue(2, 'aspectRatio')).toBe('2');
|
||||
});
|
||||
|
||||
it('should keep a vendor-prefixed unitless property unitless', () => {
|
||||
expect(formatCssValue(3, 'WebkitLineClamp')).toBe('3');
|
||||
});
|
||||
|
||||
it('should never append px to a custom property', () => {
|
||||
expect(formatCssValue(4, '--gap')).toBe('4');
|
||||
});
|
||||
|
||||
it('should keep zero unitless', () => {
|
||||
expect(formatCssValue(0, 'width')).toBe('0');
|
||||
});
|
||||
|
||||
it('should stringify non-numeric values', () => {
|
||||
expect(formatCssValue('10px', 'width')).toBe('10px');
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { parseCssTextIntoStyleDeclarations } from '../parseCssTextIntoStyleDeclarations';
|
||||
|
||||
describe('parseCssTextIntoStyleDeclarations', () => {
|
||||
it('should parse declarations into values keyed by css property name', () => {
|
||||
expect(
|
||||
parseCssTextIntoStyleDeclarations('color: red !important; width: 10px'),
|
||||
).toEqual({ color: 'red', width: '10px' });
|
||||
});
|
||||
|
||||
it('should let the last duplicate win', () => {
|
||||
expect(
|
||||
parseCssTextIntoStyleDeclarations('color: red; color: blue'),
|
||||
).toEqual({ color: 'blue' });
|
||||
});
|
||||
|
||||
it('should keep an earlier important declaration over a later normal duplicate', () => {
|
||||
expect(
|
||||
parseCssTextIntoStyleDeclarations('color: red !important; color: blue'),
|
||||
).toEqual({ color: 'red' });
|
||||
});
|
||||
|
||||
it('should lowercase standard property names while preserving custom ones', () => {
|
||||
expect(
|
||||
parseCssTextIntoStyleDeclarations('COLOR: red; --My-Var: 1px'),
|
||||
).toEqual({ color: 'red', '--My-Var': '1px' });
|
||||
});
|
||||
|
||||
it('should skip declarations without a property name or value', () => {
|
||||
expect(
|
||||
parseCssTextIntoStyleDeclarations(': red; color: ; width: 10px'),
|
||||
).toEqual({ width: '10px' });
|
||||
});
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { resolveCssPropertyNameFromJsPropertyName } from '../resolveCssPropertyNameFromJsPropertyName';
|
||||
|
||||
describe('resolveCssPropertyNameFromJsPropertyName', () => {
|
||||
it('should convert a camelCase property name to kebab-case', () => {
|
||||
expect(resolveCssPropertyNameFromJsPropertyName('backgroundColor')).toBe(
|
||||
'background-color',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve a custom property name verbatim', () => {
|
||||
expect(resolveCssPropertyNameFromJsPropertyName('--myVar')).toBe('--myVar');
|
||||
});
|
||||
|
||||
it('should map the cssFloat alias to the float property', () => {
|
||||
expect(resolveCssPropertyNameFromJsPropertyName('cssFloat')).toBe('float');
|
||||
});
|
||||
|
||||
it('should not resolve Object.prototype keys to inherited values', () => {
|
||||
expect(resolveCssPropertyNameFromJsPropertyName('constructor')).toBe(
|
||||
'constructor',
|
||||
);
|
||||
expect(resolveCssPropertyNameFromJsPropertyName('__proto__')).toBe(
|
||||
'__proto__',
|
||||
);
|
||||
});
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { serializeStyleDeclarationsToCssText } from '../serializeStyleDeclarationsToCssText';
|
||||
|
||||
describe('serializeStyleDeclarationsToCssText', () => {
|
||||
it('should join declarations with semicolons', () => {
|
||||
expect(
|
||||
serializeStyleDeclarationsToCssText({ color: 'red', width: '10px' }),
|
||||
).toBe('color:red;width:10px');
|
||||
});
|
||||
|
||||
it('should return an empty string for no declarations', () => {
|
||||
expect(serializeStyleDeclarationsToCssText({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { isNonEmptyString, isString } from '@sniptt/guards';
|
||||
|
||||
import { type StyleProxy } from '@/polyfills/dom/types/StyleProxy';
|
||||
import { formatCssValue } from '@/polyfills/dom/utils/formatCssValue';
|
||||
import { isImportantPriorityKeyword } from '@/polyfills/dom/utils/isImportantPriorityKeyword';
|
||||
import { isObjectPrototypeMember } from '@/polyfills/dom/utils/isObjectPrototypeMember';
|
||||
import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName';
|
||||
import { parseCssTextIntoStyleDeclarations } from '@/polyfills/dom/utils/parseCssTextIntoStyleDeclarations';
|
||||
import { resolveCssPropertyNameFromJsPropertyName } from '@/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName';
|
||||
import { serializeStyleDeclarationsToCssText } from '@/polyfills/dom/utils/serializeStyleDeclarationsToCssText';
|
||||
|
||||
export const createStyleProxy = (
|
||||
flushSerializedCssTextToHost: (serializedCssText: string) => void,
|
||||
): StyleProxy => {
|
||||
const cssValueByCssPropertyName: Record<string, string> = {};
|
||||
|
||||
const flushToHost = (): void => {
|
||||
flushSerializedCssTextToHost(
|
||||
serializeStyleDeclarationsToCssText(cssValueByCssPropertyName),
|
||||
);
|
||||
};
|
||||
|
||||
const replaceAllDeclarationsFromCssText = (cssText: string): void => {
|
||||
for (const cssPropertyName of Object.keys(cssValueByCssPropertyName)) {
|
||||
delete cssValueByCssPropertyName[cssPropertyName];
|
||||
}
|
||||
|
||||
Object.assign(
|
||||
cssValueByCssPropertyName,
|
||||
parseCssTextIntoStyleDeclarations(cssText),
|
||||
);
|
||||
};
|
||||
|
||||
const setPropertyValue = (
|
||||
cssPropertyName: string,
|
||||
value: string | null,
|
||||
priority?: string,
|
||||
): void => {
|
||||
const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName);
|
||||
|
||||
if (value === null || value === '') {
|
||||
delete cssValueByCssPropertyName[normalizedCssPropertyName];
|
||||
flushToHost();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNonEmptyString(priority) && !isImportantPriorityKeyword(priority)) {
|
||||
return;
|
||||
}
|
||||
|
||||
cssValueByCssPropertyName[normalizedCssPropertyName] = String(value);
|
||||
flushToHost();
|
||||
};
|
||||
|
||||
const removePropertyValue = (cssPropertyName: string): string => {
|
||||
const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName);
|
||||
const previousValue =
|
||||
cssValueByCssPropertyName[normalizedCssPropertyName] ?? '';
|
||||
|
||||
delete cssValueByCssPropertyName[normalizedCssPropertyName];
|
||||
flushToHost();
|
||||
|
||||
return previousValue;
|
||||
};
|
||||
|
||||
const readPropertyValue = (cssPropertyName: string): string =>
|
||||
cssValueByCssPropertyName[normalizeCssPropertyName(cssPropertyName)] ?? '';
|
||||
|
||||
return new Proxy(cssValueByCssPropertyName, {
|
||||
get: (target, property) => {
|
||||
if (property === 'cssText') {
|
||||
return serializeStyleDeclarationsToCssText(target);
|
||||
}
|
||||
|
||||
if (property === 'setProperty') {
|
||||
return setPropertyValue;
|
||||
}
|
||||
|
||||
if (property === 'removeProperty') {
|
||||
return removePropertyValue;
|
||||
}
|
||||
|
||||
if (property === 'getPropertyValue') {
|
||||
return readPropertyValue;
|
||||
}
|
||||
|
||||
if (isObjectPrototypeMember(property)) {
|
||||
return Reflect.get(Object.prototype, property);
|
||||
}
|
||||
|
||||
if (isString(property)) {
|
||||
return target[resolveCssPropertyNameFromJsPropertyName(property)] ?? '';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
set: (target, property, value) => {
|
||||
if (!isString(property)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (property === 'cssText') {
|
||||
replaceAllDeclarationsFromCssText(String(value));
|
||||
flushToHost();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const cssPropertyName =
|
||||
resolveCssPropertyNameFromJsPropertyName(property);
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete target[cssPropertyName];
|
||||
flushToHost();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
target[cssPropertyName] = formatCssValue(value, property);
|
||||
flushToHost();
|
||||
|
||||
return true;
|
||||
},
|
||||
}) as unknown as StyleProxy;
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isNumber } from '@sniptt/guards';
|
||||
|
||||
import { UNITLESS_CSS_PROPERTY_NAMES } from '@/constants/UnitlessCssPropertyNames';
|
||||
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
|
||||
|
||||
export const formatCssValue = (
|
||||
value: unknown,
|
||||
stylePropertyName: string,
|
||||
): string => {
|
||||
const shouldAppendPixelUnitToNumber =
|
||||
isNumber(value) &&
|
||||
value !== 0 &&
|
||||
!isCssCustomPropertyName(stylePropertyName) &&
|
||||
!UNITLESS_CSS_PROPERTY_NAMES.has(stylePropertyName);
|
||||
|
||||
return shouldAppendPixelUnitToNumber ? `${value}px` : String(value);
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const isImportantPriorityKeyword = (priority: string): boolean =>
|
||||
priority.toLowerCase() === 'important';
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export const isObjectPrototypeMember = (
|
||||
propertyName: string | symbol,
|
||||
): boolean => propertyName in Object.prototype;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName';
|
||||
import { parseCssDeclarations } from '@/utils/parseCssDeclarations';
|
||||
|
||||
export const parseCssTextIntoStyleDeclarations = (
|
||||
cssText: string,
|
||||
): Record<string, string> => {
|
||||
const cssValueByCssPropertyName: Record<string, string> = {};
|
||||
|
||||
for (const { cssPropertyName, cssValue } of parseCssDeclarations(cssText)) {
|
||||
cssValueByCssPropertyName[normalizeCssPropertyName(cssPropertyName)] =
|
||||
cssValue;
|
||||
}
|
||||
|
||||
return cssValueByCssPropertyName;
|
||||
};
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { camelToKebab } from 'twenty-shared/utils';
|
||||
|
||||
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
|
||||
|
||||
const CSS_PROPERTY_NAME_BY_CSSOM_ALIAS = new Map<string, string>([
|
||||
['cssFloat', 'float'],
|
||||
]);
|
||||
|
||||
export const resolveCssPropertyNameFromJsPropertyName = (
|
||||
jsPropertyName: string,
|
||||
): string => {
|
||||
if (isCssCustomPropertyName(jsPropertyName)) {
|
||||
return jsPropertyName;
|
||||
}
|
||||
|
||||
const aliasedCssPropertyName =
|
||||
CSS_PROPERTY_NAME_BY_CSSOM_ALIAS.get(jsPropertyName);
|
||||
|
||||
if (aliasedCssPropertyName !== undefined) {
|
||||
return aliasedCssPropertyName;
|
||||
}
|
||||
|
||||
return camelToKebab(jsPropertyName);
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const serializeStyleDeclarationsToCssText = (
|
||||
cssValueByCssPropertyName: Record<string, string>,
|
||||
): string =>
|
||||
Object.entries(cssValueByCssPropertyName)
|
||||
.map(([cssPropertyName, cssValue]) => `${cssPropertyName}:${cssValue}`)
|
||||
.join(';');
|
||||
+32
-190
@@ -1,210 +1,52 @@
|
||||
import { isNonEmptyString, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { ALLOWED_HTML_ELEMENTS } from '@/constants/AllowedHtmlElements';
|
||||
|
||||
const camelToKebab = (property: string): string =>
|
||||
property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
||||
|
||||
const UNITLESS_CSS_PROPERTIES = new Set([
|
||||
'animationIterationCount',
|
||||
'borderImageOutset',
|
||||
'borderImageSlice',
|
||||
'borderImageWidth',
|
||||
'boxFlex',
|
||||
'boxFlexGroup',
|
||||
'boxOrdinalGroup',
|
||||
'columnCount',
|
||||
'columns',
|
||||
'flex',
|
||||
'flexGrow',
|
||||
'flexPositive',
|
||||
'flexShrink',
|
||||
'flexNegative',
|
||||
'flexOrder',
|
||||
'gridArea',
|
||||
'gridRow',
|
||||
'gridRowEnd',
|
||||
'gridRowSpan',
|
||||
'gridRowStart',
|
||||
'gridColumn',
|
||||
'gridColumnEnd',
|
||||
'gridColumnSpan',
|
||||
'gridColumnStart',
|
||||
'fontWeight',
|
||||
'lineClamp',
|
||||
'lineHeight',
|
||||
'opacity',
|
||||
'order',
|
||||
'orphans',
|
||||
'tabSize',
|
||||
'widows',
|
||||
'zIndex',
|
||||
'zoom',
|
||||
'fillOpacity',
|
||||
'floodOpacity',
|
||||
'stopOpacity',
|
||||
'strokeDasharray',
|
||||
'strokeDashoffset',
|
||||
'strokeMiterlimit',
|
||||
'strokeOpacity',
|
||||
'strokeWidth',
|
||||
]);
|
||||
|
||||
type FlushFn = (cssText: string) => void;
|
||||
|
||||
const createStyleProxy = (flush: FlushFn): Record<string, unknown> => {
|
||||
const styleStore: Record<string, string> = {};
|
||||
|
||||
const flushToRemoteProperty = (): void => {
|
||||
const cssText = Object.entries(styleStore)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';');
|
||||
|
||||
flush(cssText);
|
||||
};
|
||||
|
||||
return new Proxy(styleStore, {
|
||||
get: (target, property) => {
|
||||
if (property === 'cssText') {
|
||||
return Object.entries(target)
|
||||
.map(([key, value]) => `${key}:${value}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
if (property === 'setProperty') {
|
||||
return (name: string, value: string | null) => {
|
||||
if (value === null || value === '') {
|
||||
delete target[name];
|
||||
} else {
|
||||
target[name] = String(value);
|
||||
}
|
||||
|
||||
flushToRemoteProperty();
|
||||
};
|
||||
}
|
||||
|
||||
if (property === 'removeProperty') {
|
||||
return (name: string): string => {
|
||||
const oldValue = target[name] ?? '';
|
||||
|
||||
delete target[name];
|
||||
flushToRemoteProperty();
|
||||
|
||||
return oldValue;
|
||||
};
|
||||
}
|
||||
|
||||
if (property === 'getPropertyValue') {
|
||||
return (name: string): string => target[name] ?? '';
|
||||
}
|
||||
|
||||
if (typeof property === 'string') {
|
||||
const kebabKey = camelToKebab(property);
|
||||
|
||||
return target[kebabKey] ?? '';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
set: (target, property, value) => {
|
||||
if (property === 'cssText') {
|
||||
for (const key of Object.keys(target)) {
|
||||
delete target[key];
|
||||
}
|
||||
|
||||
String(value)
|
||||
.split(';')
|
||||
.forEach((pair) => {
|
||||
const colonIndex = pair.indexOf(':');
|
||||
|
||||
if (colonIndex > 0) {
|
||||
const key = pair.slice(0, colonIndex).trim();
|
||||
const val = pair.slice(colonIndex + 1).trim();
|
||||
|
||||
if (key && val) {
|
||||
target[key] = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
flushToRemoteProperty();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof property === 'string') {
|
||||
const kebabKey = camelToKebab(property);
|
||||
|
||||
if (value === null || value === undefined || value === '') {
|
||||
delete target[kebabKey];
|
||||
} else {
|
||||
let stringValue = String(value);
|
||||
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
value !== 0 &&
|
||||
!UNITLESS_CSS_PROPERTIES.has(property)
|
||||
) {
|
||||
stringValue = `${value}px`;
|
||||
}
|
||||
|
||||
target[kebabKey] = stringValue;
|
||||
}
|
||||
|
||||
flushToRemoteProperty();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
import { type StyleProxy } from '@/polyfills/dom/types/StyleProxy';
|
||||
import { createStyleProxy } from '@/polyfills/dom/utils/createStyleProxy';
|
||||
|
||||
type RemoteElementLike = Element & {
|
||||
updateRemoteProperty: (name: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
const createRemoteStyleProxy = (element: RemoteElementLike): StyleProxy =>
|
||||
createStyleProxy((serializedCssText) => {
|
||||
element.updateRemoteProperty(
|
||||
'style',
|
||||
isNonEmptyString(serializedCssText) ? serializedCssText : undefined,
|
||||
);
|
||||
});
|
||||
|
||||
export const installStylePropertyOnRemoteElements = (): void => {
|
||||
const styleProxies = new WeakMap<Element, StyleProxy>();
|
||||
|
||||
const resolveStyleProxy = (element: RemoteElementLike): StyleProxy => {
|
||||
const existingProxy = styleProxies.get(element);
|
||||
|
||||
if (isDefined(existingProxy)) {
|
||||
return existingProxy;
|
||||
}
|
||||
|
||||
const createdProxy = createRemoteStyleProxy(element);
|
||||
styleProxies.set(element, createdProxy);
|
||||
|
||||
return createdProxy;
|
||||
};
|
||||
|
||||
for (const elementConfig of ALLOWED_HTML_ELEMENTS) {
|
||||
const elementConstructor = customElements.get(elementConfig.tag);
|
||||
|
||||
if (!elementConstructor) {
|
||||
if (!isDefined(elementConstructor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const styleProxies = new WeakMap<Element, Record<string, unknown>>();
|
||||
|
||||
Object.defineProperty(elementConstructor.prototype, 'style', {
|
||||
get(this: RemoteElementLike) {
|
||||
let proxy = styleProxies.get(this);
|
||||
|
||||
if (!proxy) {
|
||||
const element = this;
|
||||
|
||||
const flush: FlushFn = (cssText: string) => {
|
||||
element.updateRemoteProperty('style', cssText || undefined);
|
||||
};
|
||||
|
||||
proxy = createStyleProxy(flush);
|
||||
styleProxies.set(this, proxy);
|
||||
}
|
||||
|
||||
return proxy;
|
||||
return resolveStyleProxy(this);
|
||||
},
|
||||
set(this: RemoteElementLike, value: unknown) {
|
||||
let proxy = styleProxies.get(this);
|
||||
|
||||
if (!proxy) {
|
||||
const element = this;
|
||||
const flush: FlushFn = (cssText: string) => {
|
||||
element.updateRemoteProperty('style', cssText || undefined);
|
||||
};
|
||||
|
||||
proxy = createStyleProxy(flush);
|
||||
styleProxies.set(this, proxy);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
(proxy as Record<string, unknown>).cssText = value;
|
||||
if (isString(value)) {
|
||||
resolveStyleProxy(this).cssText = value;
|
||||
}
|
||||
},
|
||||
configurable: true,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CssDeclaration = {
|
||||
cssPropertyName: string;
|
||||
cssValue: string;
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { isCssCustomPropertyName } from '../isCssCustomPropertyName';
|
||||
|
||||
describe('isCssCustomPropertyName', () => {
|
||||
it('should detect a custom property name', () => {
|
||||
expect(isCssCustomPropertyName('--gap')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject a standard property name', () => {
|
||||
expect(isCssCustomPropertyName('color')).toBe(false);
|
||||
});
|
||||
});
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { parseCssDeclarations } from '../parseCssDeclarations';
|
||||
|
||||
describe('parseCssDeclarations', () => {
|
||||
it('should parse declarations into property names and values', () => {
|
||||
expect(parseCssDeclarations('color: red; width: 10px')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'red' },
|
||||
{ cssPropertyName: 'width', cssValue: '10px' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should strip an important priority from values', () => {
|
||||
expect(parseCssDeclarations('color: red !important')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'red' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should let the last duplicate win among normal declarations', () => {
|
||||
expect(parseCssDeclarations('color: red; color: blue')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'blue' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep an earlier important declaration over a later normal duplicate', () => {
|
||||
expect(parseCssDeclarations('color: red !important; color: blue')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'red' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should let a later important duplicate replace an earlier important one', () => {
|
||||
expect(
|
||||
parseCssDeclarations('color: red !important; color: blue !important'),
|
||||
).toEqual([{ cssPropertyName: 'color', cssValue: 'blue' }]);
|
||||
});
|
||||
|
||||
it('should share priority state between case variants of a property name', () => {
|
||||
expect(parseCssDeclarations('COLOR: red !important; color: blue')).toEqual([
|
||||
{ cssPropertyName: 'COLOR', cssValue: 'red' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep case-sensitive custom properties as separate declarations', () => {
|
||||
expect(parseCssDeclarations('--My-Var: 1px; --my-var: 2px')).toEqual([
|
||||
{ cssPropertyName: '--My-Var', cssValue: '1px' },
|
||||
{ cssPropertyName: '--my-var', cssValue: '2px' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip declarations without a colon', () => {
|
||||
expect(parseCssDeclarations('color: red; invalid')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'red' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip a declaration starting with a colon', () => {
|
||||
expect(parseCssDeclarations(': red; color: blue')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'blue' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip declarations whose value is empty', () => {
|
||||
expect(
|
||||
parseCssDeclarations('color: !important; width: ; height: 10px'),
|
||||
).toEqual([{ cssPropertyName: 'height', cssValue: '10px' }]);
|
||||
});
|
||||
|
||||
it('should only split on the first colon so values may contain colons', () => {
|
||||
expect(parseCssDeclarations('background: url(http://example.com)')).toEqual(
|
||||
[{ cssPropertyName: 'background', cssValue: 'url(http://example.com)' }],
|
||||
);
|
||||
});
|
||||
|
||||
it('should tolerate a trailing semicolon', () => {
|
||||
expect(parseCssDeclarations('color: red;')).toEqual([
|
||||
{ cssPropertyName: 'color', cssValue: 'red' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { splitCssDeclarations } from '../splitCssDeclarations';
|
||||
|
||||
describe('splitCssDeclarations', () => {
|
||||
it('should split plain declarations on semicolons', () => {
|
||||
expect(splitCssDeclarations('color: red; font-size: 14px')).toEqual([
|
||||
'color: red',
|
||||
' font-size: 14px',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep semicolons inside quoted strings', () => {
|
||||
expect(splitCssDeclarations('content: "a;b"; color: red')).toEqual([
|
||||
'content: "a;b"',
|
||||
' color: red',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep semicolons inside single quoted strings', () => {
|
||||
expect(splitCssDeclarations("content: 'a;b'")).toEqual(["content: 'a;b'"]);
|
||||
});
|
||||
|
||||
it('should keep semicolons after an escaped quote inside a string', () => {
|
||||
expect(splitCssDeclarations('content: "a\\";b"; color: red')).toEqual([
|
||||
'content: "a\\";b"',
|
||||
' color: red',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should keep semicolons inside url parentheses', () => {
|
||||
expect(
|
||||
splitCssDeclarations(
|
||||
'background: url(data:image/png;base64,abc); color: red',
|
||||
),
|
||||
).toEqual(['background: url(data:image/png;base64,abc)', ' color: red']);
|
||||
});
|
||||
|
||||
it('should keep slash star sequences as plain characters', () => {
|
||||
expect(
|
||||
splitCssDeclarations(
|
||||
'background: url(http://example.com/a/*/b.png); color: red',
|
||||
),
|
||||
).toEqual(['background: url(http://example.com/a/*/b.png)', ' color: red']);
|
||||
});
|
||||
|
||||
it('should handle nested parentheses', () => {
|
||||
expect(
|
||||
splitCssDeclarations('width: calc(min(10px; 2px)); color: red'),
|
||||
).toEqual(['width: calc(min(10px; 2px))', ' color: red']);
|
||||
});
|
||||
|
||||
it('should return the whole text when there is no top level semicolon', () => {
|
||||
expect(splitCssDeclarations('color: red')).toEqual(['color: red']);
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { stripImportantPriorityFromCssValue } from '../stripImportantPriorityFromCssValue';
|
||||
|
||||
describe('stripImportantPriorityFromCssValue', () => {
|
||||
it('should strip a trailing important priority', () => {
|
||||
expect(stripImportantPriorityFromCssValue('red !important')).toBe('red');
|
||||
});
|
||||
|
||||
it('should keep a value without an important priority unchanged', () => {
|
||||
expect(stripImportantPriorityFromCssValue('red')).toBe('red');
|
||||
});
|
||||
|
||||
it('should ignore the word important inside a quoted value', () => {
|
||||
expect(stripImportantPriorityFromCssValue('"hello !important"')).toBe(
|
||||
'"hello !important"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip a real priority following a quoted value', () => {
|
||||
expect(stripImportantPriorityFromCssValue('"hello" !important')).toBe(
|
||||
'"hello"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const isCssCustomPropertyName = (propertyName: string): boolean =>
|
||||
propertyName.startsWith('--');
|
||||
@@ -0,0 +1,6 @@
|
||||
import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName';
|
||||
|
||||
export const normalizeCssPropertyName = (cssPropertyName: string): string =>
|
||||
isCssCustomPropertyName(cssPropertyName)
|
||||
? cssPropertyName
|
||||
: cssPropertyName.toLowerCase();
|
||||
@@ -0,0 +1,49 @@
|
||||
import { type CssDeclaration } from '@/types/CssDeclaration';
|
||||
import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName';
|
||||
import { splitCssDeclarations } from '@/utils/splitCssDeclarations';
|
||||
import { stripImportantPriorityFromCssValue } from '@/utils/stripImportantPriorityFromCssValue';
|
||||
|
||||
export const parseCssDeclarations = (cssText: string): CssDeclaration[] => {
|
||||
const declarationsByNormalizedCssPropertyName = new Map<
|
||||
string,
|
||||
CssDeclaration
|
||||
>();
|
||||
const importantNormalizedCssPropertyNames = new Set<string>();
|
||||
|
||||
for (const declaration of splitCssDeclarations(cssText)) {
|
||||
const propertyNameEndIndex = declaration.indexOf(':');
|
||||
|
||||
if (propertyNameEndIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cssPropertyName = declaration.slice(0, propertyNameEndIndex).trim();
|
||||
const rawCssValue = declaration.slice(propertyNameEndIndex + 1).trim();
|
||||
const cssValue = stripImportantPriorityFromCssValue(rawCssValue);
|
||||
|
||||
if (cssPropertyName === '' || cssValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName);
|
||||
const hasImportantPriority = cssValue !== rawCssValue;
|
||||
|
||||
if (
|
||||
!hasImportantPriority &&
|
||||
importantNormalizedCssPropertyNames.has(normalizedCssPropertyName)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasImportantPriority) {
|
||||
importantNormalizedCssPropertyNames.add(normalizedCssPropertyName);
|
||||
}
|
||||
|
||||
declarationsByNormalizedCssPropertyName.set(normalizedCssPropertyName, {
|
||||
cssPropertyName,
|
||||
cssValue,
|
||||
});
|
||||
}
|
||||
|
||||
return [...declarationsByNormalizedCssPropertyName.values()];
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
export const splitCssDeclarations = (cssText: string): string[] => {
|
||||
const declarations: string[] = [];
|
||||
|
||||
let currentDeclaration = '';
|
||||
let quoteCharacter: string | null = null;
|
||||
let isEscaped = false;
|
||||
let parenthesisDepth = 0;
|
||||
|
||||
for (const character of cssText) {
|
||||
if (quoteCharacter !== null) {
|
||||
currentDeclaration += character;
|
||||
|
||||
if (isEscaped) {
|
||||
isEscaped = false;
|
||||
} else if (character === '\\') {
|
||||
isEscaped = true;
|
||||
} else if (character === quoteCharacter) {
|
||||
quoteCharacter = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quoteCharacter = character;
|
||||
currentDeclaration += character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(') {
|
||||
parenthesisDepth += 1;
|
||||
currentDeclaration += character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ')') {
|
||||
if (parenthesisDepth > 0) {
|
||||
parenthesisDepth -= 1;
|
||||
}
|
||||
currentDeclaration += character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ';' && parenthesisDepth === 0) {
|
||||
declarations.push(currentDeclaration);
|
||||
currentDeclaration = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
currentDeclaration += character;
|
||||
}
|
||||
|
||||
declarations.push(currentDeclaration);
|
||||
|
||||
return declarations;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
const CSS_IMPORTANT_PRIORITY_PATTERN = /\s*!\s*important\s*$/i;
|
||||
|
||||
export const stripImportantPriorityFromCssValue = (
|
||||
rawCssValue: string,
|
||||
): string => rawCssValue.replace(CSS_IMPORTANT_PRIORITY_PATTERN, '');
|
||||
Reference in New Issue
Block a user