Files
webstudio/apps/builder/app/shared/style-object-model.ts
T
Bogdan Chadkin 02ca084a0c refactor: upgrade to csstree v3 (#5455)
The new version is aware of modern colors and other new css features.
2025-11-23 14:32:12 +03:00

493 lines
14 KiB
TypeScript

import { generate, walk, List, type CssNode } from "css-tree";
import type { HtmlTags } from "html-tags";
import {
camelCaseProperty,
cssTryParseValue,
html,
parseCssVar,
propertiesData,
} from "@webstudio-is/css-data";
import {
type StyleValue,
type VarFallback,
type VarValue,
type UnparsedValue,
type CssProperty,
toValue,
} from "@webstudio-is/css-engine";
import {
type Instance,
type StyleDecl,
type StyleSourceSelections,
type Styles,
getStyleDeclKey,
} from "@webstudio-is/sdk";
/**
*
* use cases where computing is called
*
* 1. compiler
*
* put the whole tree into the model once
* lazily compute each point in the tree reusing cached cascaded values
*
* 2. builder style panel
*
* property is updated within some point in the tree
* invalidate only changed properties in model
* lazily recompute changed properties
*
* custom property or color is updated within some point in the tree
* invalidate dependent properties in model
* lazily recompute changed and dependent properties
*
* any selection is changed
* invalidate model
* lazily recompute all properties
*
* 3. CSS Preview
*
* all properties are collected from instance selector
* property are iterated with own model and computed
*
*/
type InstanceSelector = string[];
export type StyleValueSourceColor =
| "default"
| "preset"
| "remote"
| "local"
| "overwritten";
export type StyleValueSource = {
name: StyleValueSourceColor;
instanceId?: Instance["id"];
styleSourceId?: StyleDecl["styleSourceId"];
state?: StyleDecl["state"];
breakpointId?: StyleDecl["breakpointId"];
};
/**
* model contains all data and cache of computed styles
* and manages reactive subscriptions
*/
export type StyleObjectModel = {
styles: Styles;
styleSourceSelections: StyleSourceSelections;
// component:tag:state:property
presetStyles: Map<string, StyleValue>;
instanceTags: Map<Instance["id"], HtmlTags>;
instanceComponents: Map<Instance["id"], Instance["component"]>;
/**:
* all currently matching and ordered breakpoints
*/
matchingBreakpoints: StyleDecl["breakpointId"][];
/**
* all currently matching and ordered breakpointsgg
*/
matchingStates: Set<string>;
};
export const getPresetStyleDeclKey = ({
component,
tag,
state,
property,
}: {
component: string;
tag: string;
state?: string;
property: CssProperty;
}) => `${component}:${tag}:${state ?? ""}:${property}`;
/**
*
* Standard specificity is defined as ID-CLASS-TYPE.
* Though webstudio does not rely on these and also
* cannot rely on order of declarations.
*
* Instead webstudio define own specificity format
* LAYER-STATE-BREAKPOINT-STYLESOURCE
*
* LAYER is similar to @layer and allows to group parts of styles like browser, preset, user styles
* and preset states are wrapped with :where to avoid specificity increasing
* in the future can be replaced with actual cascade laters
* Declaration with selected STATE gets 2, with any other STATE gets 1
* Declaration BREAKPOINT is its position in ordered list
* Declaration STYLESOURCE is its position in predefined list
* excluding everything after selected STYLESOURCE
*
*/
const getCascadedValue = ({
model,
instanceId,
styleSourceId: selectedStyleSourceId,
state: selectedState,
property,
}: {
model: StyleObjectModel;
instanceId: Instance["id"];
styleSourceId?: StyleDecl["styleSourceId"];
state?: StyleDecl["state"];
property: CssProperty;
}) => {
const {
styles,
styleSourceSelections,
presetStyles,
instanceTags,
instanceComponents,
matchingBreakpoints,
matchingStates,
} = model;
const tag = instanceTags.get(instanceId);
const component = instanceComponents.get(instanceId);
let selectedIndex = -1;
// store the source of latest value
let source: StyleValueSource = { name: "default" };
// https://drafts.csswg.org/css-cascade-5/#declared
const declaredValues: StyleValue[] = [];
// browser styles
if (tag) {
const key = `${tag}:${property}`;
const browserValue = html.get(key);
if (browserValue) {
declaredValues.push(browserValue);
}
}
const states = new Set<undefined | string>();
// allow stateless to be overwritten
states.add(undefined);
for (const state of matchingStates) {
states.add(state);
}
// move selected state in the end if already present in matching states
if (selectedState) {
states.delete(selectedState);
states.add(selectedState);
}
// preset component styles
if (component && tag) {
for (const state of states) {
const key = getPresetStyleDeclKey({
component,
tag,
state,
property,
});
const styleValue = presetStyles.get(key);
if (styleValue) {
source = { name: "preset", state, instanceId };
declaredValues.push(styleValue);
}
}
}
// user styles
const styleSourceIds = styleSourceSelections.get(instanceId)?.values ?? [];
selectedStyleSourceId ??= styleSourceIds.at(-1);
for (const state of states) {
for (const breakpointId of matchingBreakpoints) {
for (const styleSourceId of styleSourceIds) {
const key = getStyleDeclKey({
styleSourceId,
breakpointId,
state,
property: camelCaseProperty(property),
});
const styleDecl = styles.get(key);
if (
styleSourceId === selectedStyleSourceId &&
state === selectedState
) {
// reset selection from another state or breakpoint
selectedIndex = styleDecl ? declaredValues.length : -1;
}
if (styleDecl) {
source = {
name: "remote",
instanceId,
styleSourceId,
state,
breakpointId,
};
declaredValues.push(styleDecl.value);
}
}
}
}
// https://drafts.csswg.org/css-cascade-5/#cascaded
// when reset or unselected (-1) take last declared value
const cascadedValue = declaredValues.at(selectedIndex);
if (cascadedValue) {
if (selectedIndex > -1) {
// local when selected value is latest declared
if (selectedIndex === declaredValues.length - 1) {
source.name = "local";
} else {
source.name = "overwritten";
}
}
return { value: cascadedValue, source };
}
};
const matchKeyword = (styleValue: undefined | StyleValue, keyword: string) =>
styleValue?.type === "keyword" && styleValue.value.toLowerCase() === keyword;
/**
* stable invalid values to support caching
*/
const guaranteedInvalidValue: StyleValue = { type: "guaranteedInvalid" };
const invalidValue: StyleValue = { type: "invalid", value: "" };
const customPropertyData = {
inherited: true,
initial: guaranteedInvalidValue,
};
const invalidPropertyData = {
inherited: false,
initial: invalidValue,
};
const substituteVars = (
styleValue: StyleValue,
mapper: (value: VarValue) => StyleValue
): StyleValue => {
if (styleValue.type === "var") {
return mapper(styleValue);
}
if (styleValue.type === "shadow") {
const newShadowValue = { ...styleValue };
if (newShadowValue.offsetX.type === "var") {
newShadowValue.offsetX = mapper(newShadowValue.offsetX) as VarValue;
}
if (newShadowValue.offsetY.type === "var") {
newShadowValue.offsetY = mapper(newShadowValue.offsetY) as VarValue;
}
if (newShadowValue.blur?.type === "var") {
newShadowValue.blur = mapper(newShadowValue.blur) as VarValue;
}
if (newShadowValue.spread?.type === "var") {
newShadowValue.spread = mapper(newShadowValue.spread) as VarValue;
}
if (newShadowValue.color?.type === "var") {
newShadowValue.color = mapper(newShadowValue.color) as VarValue;
}
return newShadowValue;
}
// slightly optimize to not parse without variables
if (styleValue.type === "unparsed" && styleValue.value.includes("var(")) {
// parse, replace variables and serialize back to unparsed value
const ast = cssTryParseValue(styleValue.value);
if (ast) {
walk(ast, {
enter(node, item, list) {
if (node.type === "Function" && node.name === "var") {
const varValue = parseCssVar(node);
if (varValue && item) {
const newValue = mapper(varValue);
list?.replace(
item,
List.createItem<CssNode>({
type: "Raw",
value: toValue(newValue),
loc: null,
})
);
}
}
},
});
return {
type: "unparsed",
value: generate(ast),
};
}
return styleValue;
}
if (styleValue.type === "layers" || styleValue.type === "tuple") {
const newItems = styleValue.value.map((item) => {
return substituteVars(item, mapper) as UnparsedValue;
});
return { ...styleValue, value: newItems };
}
return styleValue;
};
export type ComputedStyleDecl = {
property: CssProperty;
source: StyleValueSource;
cascadedValue: StyleValue;
computedValue: StyleValue;
usedValue: StyleValue;
// @todo We will delete it once we have added additional filters to advanced panel and
// don't need to differentiate this any more.
listed?: boolean;
};
/**
* follow value processing specification
* https://drafts.csswg.org/css-cascade-5/#value-stages
*/
export const getComputedStyleDecl = ({
model,
instanceSelector = [],
styleSourceId,
state,
property,
customPropertiesGraph = new Map(),
}: {
model: StyleObjectModel;
instanceSelector?: InstanceSelector;
styleSourceId?: StyleDecl["styleSourceId"];
state?: StyleDecl["state"];
property: CssProperty;
/**
* for internal use only
*/
customPropertiesGraph?: Map<Instance["id"], Set<CssProperty>>;
}): ComputedStyleDecl => {
const isCustomProperty = property.startsWith("--");
const propertyData = isCustomProperty
? customPropertyData
: (propertiesData[property] ?? invalidPropertyData);
const inherited = propertyData.inherited;
const initialValue: StyleValue = propertyData.initial;
let computedValue: StyleValue = initialValue;
let cascadedValue: undefined | StyleValue;
let source: StyleValueSource = { name: "default" };
// start computing from the root
for (let index = instanceSelector.length - 1; index >= 0; index -= 1) {
const instanceId = instanceSelector[index];
let usedCustomProperties = customPropertiesGraph.get(instanceId);
if (usedCustomProperties === undefined) {
usedCustomProperties = new Set();
customPropertiesGraph.set(instanceId, usedCustomProperties);
}
// https://drafts.csswg.org/css-cascade-5/#inheriting
const inheritedValue: StyleValue = computedValue;
const inheritedSource: StyleValueSource =
source.name === "local" ? { ...source, name: "remote" } : source;
// https://drafts.csswg.org/css-cascade-5/#cascaded
const cascaded = getCascadedValue({
model,
instanceId,
styleSourceId,
state,
property,
});
const inheritedCascadedValue = cascadedValue;
cascadedValue = cascaded?.value;
source = cascaded?.source ?? { name: "default" };
// resolve specified value
// https://drafts.csswg.org/css-cascade-5/#specified
let specifiedValue: StyleValue = initialValue;
// explicit defaulting
// https://drafts.csswg.org/css-cascade-5/#defaulting-keywords
if (matchKeyword(cascadedValue, "initial")) {
specifiedValue = initialValue;
} else if (
matchKeyword(cascadedValue, "inherit") ||
// treat currentcolor as inherit when used on color property
// https://www.w3.org/TR/css-color-3/#currentColor-def
(property === "color" && matchKeyword(cascadedValue, "currentcolor"))
) {
specifiedValue = inheritedValue;
} else if (matchKeyword(cascadedValue, "unset")) {
if (inherited) {
specifiedValue = inheritedValue;
} else {
specifiedValue = initialValue;
}
} else if (cascadedValue) {
specifiedValue = cascadedValue;
}
// defaulting https://drafts.csswg.org/css-cascade-5/#defaulting
else if (inherited) {
specifiedValue = inheritedValue;
cascadedValue = inheritedCascadedValue;
source = inheritedSource;
} else {
specifiedValue = initialValue;
}
// https://drafts.csswg.org/css-cascade-5/#computed
computedValue = specifiedValue;
let invalid = false;
// check whether the property was used with parent node
// to support var(--var1), var(--var1) layers
const parentUsedCustomProperties = usedCustomProperties;
usedCustomProperties = new Set<CssProperty>(usedCustomProperties);
customPropertiesGraph.set(instanceId, usedCustomProperties);
computedValue = substituteVars(computedValue, (varValue) => {
const customProperty = `--${varValue.value}` as const;
// https://www.w3.org/TR/css-variables-1/#cycles
if (parentUsedCustomProperties.has(customProperty)) {
invalid = true;
return varValue;
}
usedCustomProperties.add(customProperty);
const fallback: undefined | VarFallback = varValue.fallback;
const customPropertyValue = getComputedStyleDecl({
model,
// resolve custom properties on instance they are defined
// instead of where they are accessed
instanceSelector: instanceSelector.slice(index),
property: customProperty,
customPropertiesGraph,
});
let replacement = customPropertyValue.computedValue;
// https://www.w3.org/TR/css-variables-1/#invalid-variables
if (
replacement.type === "guaranteedInvalid" ||
(isCustomProperty === false && replacement.type === "invalid")
) {
if (inherited) {
replacement = fallback ?? inheritedValue;
} else {
replacement = fallback ?? initialValue;
}
}
return replacement;
});
if (invalid) {
computedValue = invalidValue;
break;
}
}
// https://drafts.csswg.org/css-cascade-5/#used
let usedValue: StyleValue = computedValue;
// https://drafts.csswg.org/css-color-4/#resolving-other-colors
if (matchKeyword(computedValue, "currentcolor")) {
const currentColor = getComputedStyleDecl({
model,
instanceSelector,
property: "color",
});
usedValue = currentColor.usedValue;
}
// fallback to initial value
cascadedValue ??= initialValue;
return { property, source, cascadedValue, computedValue, usedValue };
};