Skip to content

Commit f052200

Browse files
authored
fix: preserve CSS variable and gradient references on paste (#5716)
Preserve var() references in pasted gradient shorthand (css-data/parse-css.ts) When expanding shorthand properties (e.g. background), unresolved var() tokens were lost. Now they're recovered by probing each longhand with a placeholder, mapping which longhands the var belongs to, and restoring it. Fix border-(--x) Tailwind v4 syntax not recognized (tailwind.ts) borde
1 parent 5ff6bb7 commit f052200

22 files changed

Lines changed: 21617 additions & 1797 deletions

apps/builder/app/shared/html.test.tsx

Lines changed: 324 additions & 50 deletions
Large diffs are not rendered by default.

apps/builder/app/shared/html.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import { richTextContentTags } from "./content-model";
2828
import { setIsSubsetOf } from "./shim";
2929
import { isAttributeNameSafe } from "@webstudio-is/react-sdk";
30+
import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk";
3031
import * as csstree from "css-tree";
3132
import { titleCase } from "title-case";
3233

@@ -194,13 +195,19 @@ const classifyRules = (
194195
): {
195196
classRules: Map<string, ParsedStyleDecl[]>;
196197
nestedClassRules: Map<string, NestedClassRule>;
198+
rootRules: ParsedStyleDecl[];
197199
hasNonClassRules: boolean;
198200
} => {
199201
const classRules = new Map<string, ParsedStyleDecl[]>();
200202
const nestedClassRules = new Map<string, NestedClassRule>();
203+
const rootRules: ParsedStyleDecl[] = [];
201204
let hasNonClassRules = false;
202205

203206
for (const decl of decls) {
207+
if (decl.selector === ":root") {
208+
rootRules.push(decl);
209+
continue;
210+
}
204211
const parsed = parseClassBasedSelector(decl.selector);
205212
if (parsed !== undefined) {
206213
const selectorState = parsed.states?.[0];
@@ -229,7 +236,7 @@ const classifyRules = (
229236
hasNonClassRules = true;
230237
}
231238
}
232-
return { classRules, nestedClassRules, hasNonClassRules };
239+
return { classRules, nestedClassRules, rootRules, hasNonClassRules };
233240
};
234241

235242
/**
@@ -245,12 +252,20 @@ const buildLeftoverCss = (cssText: string): string => {
245252
const parts: string[] = [];
246253

247254
/** Re-use parseClassBasedSelector as single source of truth */
248-
const isClassBasedSelector = (selector: csstree.CssNode): boolean =>
249-
selector.type === "Selector" &&
250-
parseClassBasedSelector(csstree.generate(selector)) !== undefined;
255+
const isClassBasedSelector = (selector: csstree.CssNode): boolean => {
256+
if (selector.type !== "Selector") {
257+
return false;
258+
}
259+
const text = csstree.generate(selector);
260+
// :root rules are extracted separately — treat as non-leftover
261+
if (text === ":root") {
262+
return true;
263+
}
264+
return parseClassBasedSelector(text) !== undefined;
265+
};
251266

252267
/**
253-
* Process a Rule: if all selectors are class-based, skip entirely.
268+
* Process a Rule: if all selectors are class-based or :root, skip entirely.
254269
* If none are, keep entirely. If mixed, keep only non-class selectors.
255270
*/
256271
const getLeftoverRule = (node: csstree.Rule): string | undefined => {
@@ -537,7 +552,7 @@ export const generateFragmentFromHtml = (
537552

538553
// Parse all CSS and classify rules
539554
const { styles: allDecls } = parseCss(allCssText, allCssVars);
540-
const { classRules, nestedClassRules } = classifyRules(allDecls);
555+
const { classRules, nestedClassRules, rootRules } = classifyRules(allDecls);
541556

542557
// Track which class names are used by elements — IDs will be assigned later
543558
const usedClassNames = new Set<string>();
@@ -561,19 +576,32 @@ export const generateFragmentFromHtml = (
561576
const {
562577
classRules: tagClassRules,
563578
nestedClassRules: tagNestedRules,
579+
rootRules: tagRootRules,
564580
hasNonClassRules: tagHasNonClass,
565581
} = classifyRules(parsedDecls);
566582

567583
if (
568584
parsedDecls.length === 0 &&
569585
tagClassRules.size === 0 &&
570-
tagNestedRules.size === 0
586+
tagNestedRules.size === 0 &&
587+
tagRootRules.length === 0
571588
) {
572589
// Unparseable CSS — keep original
573590
styleTagActions.push({ type: "keep-original" });
574-
} else if (tagClassRules.size === 0 && tagNestedRules.size === 0) {
575-
// Only non-class rules — keep original
591+
} else if (
592+
tagClassRules.size === 0 &&
593+
tagNestedRules.size === 0 &&
594+
tagRootRules.length === 0
595+
) {
596+
// Only non-class, non-root element rules — keep original HtmlEmbed
576597
styleTagActions.push({ type: "keep-original" });
598+
} else if (
599+
tagClassRules.size === 0 &&
600+
tagNestedRules.size === 0 &&
601+
!tagHasNonClass
602+
) {
603+
// Only :root rules — extracted to ROOT_INSTANCE_ID, skip HtmlEmbed
604+
styleTagActions.push({ type: "skip" });
577605
} else if (!tagHasNonClass) {
578606
// Only class rules — also check for unsupported media like @media print
579607
const leftover = buildLeftoverCss(text);
@@ -1030,6 +1058,25 @@ export const generateFragmentFromHtml = (
10301058
}
10311059
}
10321060

1061+
// Inject :root styles as a local style source on ROOT_INSTANCE_ID
1062+
if (rootRules.length > 0) {
1063+
const rootStyleSourceId = getNewId();
1064+
styleSources.push({ type: "local", id: rootStyleSourceId });
1065+
styleSourceSelections.push({
1066+
instanceId: ROOT_INSTANCE_ID,
1067+
values: [rootStyleSourceId],
1068+
});
1069+
for (const decl of rootRules) {
1070+
styles.push({
1071+
styleSourceId: rootStyleSourceId,
1072+
breakpointId: getBaseBreakpointId(),
1073+
property: camelCaseProperty(decl.property),
1074+
value: decl.value,
1075+
...(decl.state ? { state: decl.state } : {}),
1076+
});
1077+
}
1078+
}
1079+
10331080
// Create style source selections for instances that use tokens
10341081
const selectionsByInstance = new Map(
10351082
styleSourceSelections.map((sel) => [sel.instanceId, sel])
@@ -1041,7 +1088,7 @@ export const generateFragmentFromHtml = (
10411088
if (tokenIds.length > 0) {
10421089
const existingSelection = selectionsByInstance.get(instanceId);
10431090
if (existingSelection) {
1044-
existingSelection.values.push(...tokenIds);
1091+
existingSelection.values = [...tokenIds, ...existingSelection.values];
10451092
} else {
10461093
const newSelection: StyleSourceSelection = {
10471094
instanceId,

0 commit comments

Comments
 (0)