Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- the two-column display is only used if the container is wide enough, this way property name columns do not get too small
- in narrower containers, property name and value are displayed as stacked rows
- make the breakpoint configurable via SCSS (`$eccgui-propertyvalue-size-column-breakpoint-small`)
- `utils`
- CSS custom properties are read from the computed style of an element now instead of collecting them from the CSSOM, so returned values are always resolved values, e.g. `var()` references are already replaced
- `CssCustomProperties` no longer provides the CSSOM based helpers `listLocalStylesheets()`, `listLocalCssRules()`, `listLocalCssStyleRules()` and `listLocalCssStyleRuleProperties()`

### Fixed

Expand All @@ -25,6 +28,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- `<HandleDefault />`
- default handle class names were removed as soon as an `intent` was given
- fix runtime error if the element holding the handle tools is not available
- `utils`
- CSS custom properties are also found if the `:root` rule defining them is nested inside a cascade layer (`@layer`) or any other grouping rule; this affects `textToColorHash()`, `getEnabledColorsFromPalette()`, `getEnabledColorPropertiesFromPalette()` and `getColorConfiguration()`
- empty results are not cached anymore, this way they are read again if the stylesheets are loaded later on
- `minimalColorDistance` is part of the cache key of `getEnabledColorsFromPalette()` and `getEnabledColorPropertiesFromPalette()` now

## [26.0.0] - 2026-07-08

Expand Down
190 changes: 106 additions & 84 deletions src/common/utils/CssCustomProperties.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,57 @@
/**
* Based on CSS Tricks tutorial.
* @see https://css-tricks.com/how-to-get-all-custom-properties-on-a-page-in-javascript/
* Read CSS custom properties from the DOM.
*
* We do not collect them from the CSSOM (`document.styleSheets`) on purpose:
* declarations can be nested inside grouping rules (`@layer`, `@media`, `@supports`, `@container`),
* they can sit in cross origin stylesheets that must not be read at all, and their document order
* does not represent the cascade anymore (unlayered declarations win over layered ones).
* Reading the computed style of an element instead always returns the values the browser really
* applies, including already resolved `var()` references.
*/

type AllowedCSSRule = CSSStyleRule | CSSPageRule; // they have necessary `selectorText` and `style` properties
type CustomPropertyEntry = [string, string];

interface getLocalCssStyleRulesProps {
cssRuleType?: "CSSStyleRule";
/** Element that supports the CSS typed object model, we only need to iterate over the property names. */
type TypedOMElement = Element & {
computedStyleMap?: () => { forEach: (callback: (value: unknown, propertyName: string) => void) => void };
};

const rootSelectors = [":root", "html", ":root:root"];
const classSelectorPattern = /^(?:\.-?[_a-zA-Z][\w-]*)+$/;

interface getCustomPropertiesProps {
/**
* Selector of the element the custom properties are read from.
* `:root` and `html` are mapped to the root element of the document.
* For any other selector the first matching element is used, if nothing matches and the
* selector consists of class names only, then a temporary hidden element is created.
*/
selectorText?: string;
}
interface getLocalCssStyleRulePropertiesProps extends getLocalCssStyleRulesProps {
propertyType?: "all" | "normal" | "custom";
}
interface getCustomPropertiesProps extends getLocalCssStyleRulesProps {
/** Only return custom properties whose name (including the `--` prefix) passes this test. */
filterName?: (name: string) => boolean;
/** Remove the leading `--` from the returned property names. */
removeDashPrefix?: boolean;
/** Return an object instead of a list of name and value pairs. */
returnObject?: boolean;
}

export default class CssCustomProperties {
getterDefaultProps = {} as getCustomPropertiesProps;
customprops = {};
customprops = {} as CustomPropertyEntry[] | Record<string, string>;

constructor(props: getCustomPropertiesProps = {}) {
this.getterDefaultProps = props;
}

// Methods

customProperties = (props: getCustomPropertiesProps = {}): [string, string][] | Record<string, string> => {
customProperties = (props: getCustomPropertiesProps = {}): CustomPropertyEntry[] | Record<string, string> => {
// FIXME:
// in case of performance issues results should get saved at least into intern variables
// other cache strategies could be also tested
if (Object.keys(this.customprops).length > 1) {
// in case of performance issues other cache strategies could be also tested
if (Object.keys(this.customprops).length > 0) {
return this.customprops;
}
// an empty result is not cached, the stylesheets may be loaded later on
const customprops = CssCustomProperties.listCustomProperties({
...this.getterDefaultProps,
...props,
Expand All @@ -43,88 +60,93 @@ export default class CssCustomProperties {
return customprops;
};

static listLocalStylesheets = (): CSSStyleSheet[] => {
if (document && document.styleSheets) {
return (Array.from(document.styleSheets) as CSSStyleSheet[]).filter((stylesheet) => {
// is inline stylesheet or from same domain
if (!stylesheet.href) {
return true;
}
return stylesheet.href.indexOf(window.location.origin) === 0;
});
/**
* Return the element the custom properties of a selector can be read from.
* The second item of the returned tuple removes a temporarily created element again.
*/
static targetElement = (selectorText: string = ":root"): [Element | undefined, (() => void) | undefined] => {
if (typeof document === "undefined") {
return [undefined, undefined];
}

return [] as CSSStyleSheet[];
};
if (rootSelectors.includes(selectorText.trim().toLowerCase())) {
return [document.documentElement, undefined];
}

static listLocalCssRules = (): CSSRule[] => {
return CssCustomProperties.listLocalStylesheets()
.map((stylesheet) => {
return Array.from(stylesheet.cssRules);
})
.flat();
try {
const existingElement = document.querySelector(selectorText);
if (existingElement) {
return [existingElement, undefined];
}
} catch {
// selector cannot be used by the DOM API, we try to create a placeholder below
}

if (!classSelectorPattern.test(selectorText)) {
return [undefined, undefined];
}

// we need an element inside the DOM, otherwise the browser does not calculate the values for us
const placeholder = document.createElement("div");
placeholder.classList.add(...selectorText.split(".").filter(Boolean));
placeholder.setAttribute("style", "display: none");
(document.body ?? document.documentElement).appendChild(placeholder);

return [placeholder, () => placeholder.remove()];
};

static listLocalCssStyleRules = (filter: getLocalCssStyleRulesProps = {}): CSSStyleRule[] => {
const { cssRuleType = "CSSStyleRule", selectorText } = filter;
const cssStyleRules = CssCustomProperties.listLocalCssRules().filter((rule) => {
const cssrule = rule as AllowedCSSRule;
if (cssrule.style) {
if (cssrule.constructor.name !== cssRuleType) {
return false;
}
if (!!selectorText && cssrule.selectorText !== selectorText) {
return false;
}
return true;
} else {
return false;
static listElementCustomProperties = (element: Element): CustomPropertyEntry[] => {
const documentView = element.ownerDocument?.defaultView;
if (!documentView) {
return [];
}

const computedStyle = documentView.getComputedStyle(element);
const propertyNames = new Set<string>();

for (let i = 0; i < computedStyle.length; i++) {
const propertyName = computedStyle.item(i);
if (propertyName.startsWith("--")) {
propertyNames.add(propertyName);
}
});
return cssStyleRules as CSSStyleRule[];
};
}

static listLocalCssStyleRuleProperties = (filter: getLocalCssStyleRulePropertiesProps = {}): string[][] => {
const { propertyType = "all", ...otherFilters } = filter;
return CssCustomProperties.listLocalCssStyleRules(otherFilters)
.map((cssrule) => {
return [...(cssrule as CSSStyleRule).style].map((propertyname) => {
return [propertyname.trim(), (cssrule as CSSStyleRule).style.getPropertyValue(propertyname).trim()];
});
})
.flat()
.filter((declaration) => {
switch (propertyType) {
case "normal":
return declaration[0].indexOf("--") !== 0;
case "custom":
return declaration[0].indexOf("--") === 0;
const typedOMElement = element as TypedOMElement;
if (propertyNames.size === 0 && typeof typedOMElement.computedStyleMap === "function") {
// Chromium before v141 does not enumerate custom properties in `getComputedStyle()`,
// but they are available via the typed object model
typedOMElement.computedStyleMap().forEach((_value, propertyName) => {
if (propertyName.startsWith("--")) {
propertyNames.add(propertyName);
}
return true; // case "all"
});
}

return [...propertyNames].map((propertyName) => [
propertyName,
computedStyle.getPropertyValue(propertyName).trim(),
]);
};

static listCustomProperties = (
props: getCustomPropertiesProps = {},
): [string, string][] | Record<string, string> => {
const { removeDashPrefix = true, returnObject = true, filterName = () => true, ...filterProps } = props;

const customProperties = CssCustomProperties.listLocalCssStyleRuleProperties({
...filterProps,
propertyType: "custom",
})
.filter((declaration) => {
return filterName(declaration[0]);
})
.map((declaration) => {
if (removeDashPrefix) {
return [declaration[0].substr(2), declaration[1]];
}
return declaration;
});
): CustomPropertyEntry[] | Record<string, string> => {
const { selectorText = ":root", removeDashPrefix = true, returnObject = true, filterName = () => true } = props;

const [element, removePlaceholder] = CssCustomProperties.targetElement(selectorText);

return returnObject
? (Object.fromEntries(customProperties) as Record<string, string>)
: (customProperties as [string, string][]);
try {
const customProperties = (element ? CssCustomProperties.listElementCustomProperties(element) : [])
.filter(([propertyName]) => filterName(propertyName))
.map(([propertyName, value]): CustomPropertyEntry => {
return [removeDashPrefix ? propertyName.slice(2) : propertyName, value];
});

return returnObject
? (Object.fromEntries(customProperties) as Record<string, string>)
: (customProperties as CustomPropertyEntry[]);
} finally {
removePlaceholder?.();
}
};
}
25 changes: 15 additions & 10 deletions src/common/utils/colorHash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,23 @@ export function getEnabledColorsFromPalette(props: getEnabledColorsProps): Color
const configId = JSON.stringify({
includePaletteGroup: props.includePaletteGroup,
includeColorWeight: props.includeColorWeight,
minimalColorDistance: props.minimalColorDistance,
});

if (getEnabledColorsFromPaletteCache.has(configId)) {
return getEnabledColorsFromPaletteCache.get(configId)!;
}

const colorPropertiesFromPalette = Object.values(getEnabledColorPropertiesFromPalette(props));
const colorsFromPalette = getEnabledColorPropertiesFromPalette(props).map((color) => {
return Color(color[1]);
});

getEnabledColorsFromPaletteCache.set(
configId,
colorPropertiesFromPalette.map((color) => {
return Color(color[1]);
}),
);
if (colorsFromPalette.length > 0) {
// an empty result is not cached, the stylesheets may be loaded later on
getEnabledColorsFromPaletteCache.set(configId, colorsFromPalette);
}

return getEnabledColorsFromPaletteCache.get(configId)!;
return colorsFromPalette;
}

export function getEnabledColorPropertiesFromPalette({
Expand All @@ -54,6 +55,7 @@ export function getEnabledColorPropertiesFromPalette({
const configId = JSON.stringify({
includePaletteGroup,
includeColorWeight,
minimalColorDistance,
});

if (getEnabledColorPropertiesFromPaletteCache.has(configId)) {
Expand Down Expand Up @@ -93,9 +95,12 @@ export function getEnabledColorPropertiesFromPalette({
}, colorsFromPaletteValues)
: colorsFromPaletteValues;

getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance);
if (colorsFromPaletteWithEnoughDistance.length > 0) {
// an empty result is not cached, the stylesheets may be loaded later on
getEnabledColorPropertiesFromPaletteCache.set(configId, colorsFromPaletteWithEnoughDistance);
}

return getEnabledColorPropertiesFromPaletteCache.get(configId)!;
return colorsFromPaletteWithEnoughDistance;
}

function getColorcode(text: string): ColorOrFalse {
Expand Down
67 changes: 24 additions & 43 deletions src/common/utils/getColorConfiguration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,49 +17,30 @@ const colorConfigurationMemo = new Map<colorconfigs, Record<string, string>>();
const getColorConfiguration = (configId: colorconfigs): Record<string, string> => {
if (!colorConfigurationMemo.has(configId)) {
const selectorClass = `${eccgui}-configuration--colors__${configId}`;
colorConfigurationMemo.set(
configId,
Object.fromEntries(
(
new CssCustomProperties({
selectorText: `.${selectorClass}`,
removeDashPrefix: true,
returnObject: false,
}).customProperties() as string[][]
).map((setting) => {
// check if the value could be a color

let testColorValue = setting[1];
// check if value itself is a reference to another css custom property
if (testColorValue.slice(0, 3) === "var") {
// we currently only extract the first part and ignore any fallbacks
const customPropertyName = /var\(\s*(--[a-zA-Z0-9_-]+)/g.exec(testColorValue);
if (customPropertyName && customPropertyName[1]) {
let selectorElement = document.getElementsByClassName(selectorClass)[0];
if (!selectorElement) {
// we need to add an empty element that the JS API can read the value of the custom prop
selectorElement = document.createElement("div");
selectorElement.classList.add(selectorClass);
selectorElement.setAttribute("style", "display: none");
document.body.appendChild(selectorElement);
}
// only check 1 time, not recursive
testColorValue = getComputedStyle(selectorElement).getPropertyValue(customPropertyName[1]);
}
}

try {
if (Color(testColorValue)) {
return [setting[0], testColorValue];
} else {
return [setting[0], undefined];
}
} catch {
return [setting[0], undefined];
}
}),
) as Record<string, string>,
);
const colorConfiguration = Object.fromEntries(
(
new CssCustomProperties({
selectorText: `.${selectorClass}`,
removeDashPrefix: true,
returnObject: false,
}).customProperties() as string[][]
).map((setting) => {
// check if the value could be a color, `var()` references are already resolved
try {
Color(setting[1]);
return [setting[0], setting[1]];
} catch {
return [setting[0], undefined];
}
}),
) as Record<string, string>;

if (Object.keys(colorConfiguration).length === 0) {
// an empty result is not cached, the stylesheets may be loaded later on
return colorConfiguration;
}

colorConfigurationMemo.set(configId, colorConfiguration);
}
return colorConfigurationMemo.get(configId)!;
};
Expand Down
Loading