Skip to content

Commit 7dba865

Browse files
committed
css variable color on svg icons
1 parent 5d90773 commit 7dba865

File tree

5 files changed

+172
-16
lines changed

5 files changed

+172
-16
lines changed

packages/backend/src/altNodes/altNodeUtils.ts

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { AltNode } from "types";
22
import { curry } from "../common/curry";
33
import { exportAsyncProxy } from "../common/exportAsyncProxy";
44
import { addWarning } from "../common/commonConversionWarnings";
5+
import { getVariableNameFromColor } from "./jsonNodeConversion";
6+
import { htmlColor } from "../html/builderImpl/htmlColor";
57

68
export const overrideReadonlyProperty = curry(
79
<T, K extends keyof T>(prop: K, value: any, obj: T): T =>
@@ -53,20 +55,59 @@ export const isSVGNode = (node: SceneNode) => {
5355
};
5456

5557
export const renderAndAttachSVG = async (node: any) => {
56-
// const nodeName = `${node.type}:${node.id}`;
57-
// console.log(altNode);
5858
if (node.canBeFlattened) {
5959
if (node.svg) {
60-
// console.log(`SVG already rendered for ${nodeName}`);
6160
return node;
6261
}
6362

6463
try {
65-
// console.log(`${nodeName} can be flattened!`);
6664
const svg = (await exportAsyncProxy<string>(node, {
6765
format: "SVG_STRING",
6866
})) as string;
69-
node.svg = svg;
67+
68+
// Process the SVG to replace colors with variable references
69+
if (node.colorVariableMappings && node.colorVariableMappings.size > 0) {
70+
let processedSvg = svg;
71+
72+
// Replace fill="COLOR" or stroke="COLOR" patterns
73+
const colorAttributeRegex = /(fill|stroke)="([^"]*)"/g;
74+
75+
processedSvg = processedSvg.replace(colorAttributeRegex, (match, attribute, colorValue) => {
76+
// Clean up the color value and normalize it
77+
const normalizedColor = colorValue.toLowerCase().trim();
78+
79+
// Look up the color directly in our mappings
80+
const mapping = node.colorVariableMappings.get(normalizedColor);
81+
if (mapping) {
82+
// If we have a variable reference, use it with fallback to original
83+
return `${attribute}="var(--${mapping.variableName}, ${colorValue})"`;
84+
}
85+
86+
// Otherwise keep the original color
87+
return match;
88+
});
89+
90+
// Also handle style attributes with fill: or stroke: properties
91+
const styleRegex = /style="([^"]*)(?:(fill|stroke):\s*([^;"]*))(;|\s|")([^"]*)"/g;
92+
93+
processedSvg = processedSvg.replace(styleRegex, (match, prefix, property, colorValue, separator, suffix) => {
94+
// Clean up any extra spaces from the color value
95+
const normalizedColor = colorValue.toLowerCase().trim();
96+
97+
// Look up the color directly in our mappings
98+
const mapping = node.colorVariableMappings.get(normalizedColor);
99+
if (mapping) {
100+
// Replace just the color value with the variable and fallback
101+
return `style="${prefix}${property}: var(--${mapping.variableName}, ${colorValue})${separator}${suffix}"`;
102+
}
103+
104+
return match;
105+
});
106+
107+
node.svg = processedSvg;
108+
} else {
109+
node.svg = svg;
110+
}
70111
} catch (error) {
71112
addWarning(`Failed rendering SVG for ${node.name}`);
72113
console.error(`Error rendering SVG for ${node.type}:${node.id}`);

packages/backend/src/altNodes/jsonNodeConversion.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const nodeNameCounters: Map<string, number> = new Map();
2727

2828
const variableCache = new Map<string, string>();
2929

30+
/**
31+
* Maps variable IDs to color names and caches the result
32+
*/
3033
const memoizedVariableToColorName = async (
3134
variableId: string,
3235
): Promise<string> => {
@@ -41,6 +44,98 @@ const memoizedVariableToColorName = async (
4144
return variableCache.get(variableId)!;
4245
};
4346

47+
/**
48+
* Maps a color hex value to its variable name using node-specific color mappings
49+
*/
50+
export const getVariableNameFromColor = (
51+
hexColor: string,
52+
colorMappings?: Map<string, { variableId: string; variableName: string }>,
53+
): string | undefined => {
54+
if (!colorMappings) return undefined;
55+
56+
const normalizedColor = hexColor.toLowerCase();
57+
const mapping = colorMappings.get(normalizedColor);
58+
59+
if (mapping) {
60+
return mapping.variableName;
61+
}
62+
63+
return undefined;
64+
};
65+
66+
/**
67+
* Collects all color variables used in a node and its descendants
68+
*/
69+
const collectNodeColorVariables = async (
70+
node: any,
71+
): Promise<Map<string, { variableId: string; variableName: string }>> => {
72+
const colorMappings = new Map<
73+
string,
74+
{ variableId: string; variableName: string }
75+
>();
76+
77+
// Helper function to add a mapping from a paint object
78+
const addMappingFromPaint = (paint: any) => {
79+
// Ensure we have a solid paint, a resolved variable name, and color data
80+
if (
81+
paint.type === "SOLID" &&
82+
paint.variableColorName &&
83+
paint.color &&
84+
paint.boundVariables?.color
85+
) {
86+
// Prefer the actual variable name from the bound variable if available
87+
const variableName = paint.boundVariables.color.name || paint.variableColorName;
88+
89+
if (variableName) {
90+
const colorInfo = {
91+
variableId: paint.boundVariables.color.id,
92+
variableName: variableName.replace(/[^a-zA-Z0-9_-]/g, '-'), // Sanitize variable name for CSS
93+
};
94+
95+
// Create hex representation of the color
96+
const r = Math.round(paint.color.r * 255);
97+
const g = Math.round(paint.color.g * 255);
98+
const b = Math.round(paint.color.b * 255);
99+
100+
// Standard hex format for the color mapping
101+
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`.toLowerCase();
102+
colorMappings.set(hexColor, colorInfo);
103+
104+
// Also add named color equivalent if it matches standard colors
105+
if (r === 255 && g === 255 && b === 255) {
106+
colorMappings.set('white', colorInfo); // Add "white" mapping
107+
}
108+
else if (r === 0 && g === 0 && b === 0) {
109+
colorMappings.set('black', colorInfo); // Add "black" mapping
110+
}
111+
}
112+
}
113+
};
114+
115+
// Process fills
116+
if (node.fills && Array.isArray(node.fills)) {
117+
node.fills.forEach(addMappingFromPaint);
118+
}
119+
120+
// Process strokes
121+
if (node.strokes && Array.isArray(node.strokes)) {
122+
node.strokes.forEach(addMappingFromPaint);
123+
}
124+
125+
// Process children recursively
126+
if (node.children && Array.isArray(node.children)) {
127+
for (const child of node.children) {
128+
const childMappings = await collectNodeColorVariables(child);
129+
// Merge child mappings with this node's mappings
130+
childMappings.forEach((value, key) => {
131+
colorMappings.set(key, value);
132+
});
133+
}
134+
}
135+
136+
return colorMappings;
137+
};
138+
44139
/**
45140
* Process color variables in a paint style and add pre-computed variable names
46141
* @param paint The paint style to process (fill or stroke)
@@ -376,7 +471,14 @@ const processNodePair = async (
376471

377472
// Add canBeFlattened property
378473
if (settings.embedVectors && !parentNode?.canBeFlattened) {
379-
(jsonNode as any).canBeFlattened = isLikelyIcon(jsonNode as any);
474+
const isIcon = isLikelyIcon(jsonNode as any);
475+
(jsonNode as any).canBeFlattened = isIcon;
476+
477+
// If this node will be flattened to SVG, collect its color variables
478+
if (isIcon && settings.useColorVariables) {
479+
// Schedule color mapping collection after variable processing
480+
(jsonNode as any)._collectColorMappings = true;
481+
}
380482
} else {
381483
(jsonNode as any).canBeFlattened = false;
382484
}
@@ -496,6 +598,13 @@ const processNodePair = async (
496598
adjustChildrenOrder(jsonNode);
497599
}
498600

601+
// Collect color variables for SVG nodes after all processing is done
602+
if ((jsonNode as any)._collectColorMappings) {
603+
(jsonNode as any).colorVariableMappings =
604+
await collectNodeColorVariables(jsonNode);
605+
delete (jsonNode as any)._collectColorMappings;
606+
}
607+
499608
return jsonNode;
500609
};
501610

packages/backend/src/html/htmlDefaultBuilder.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -437,12 +437,12 @@ export class HtmlDefaultBuilder {
437437
if (this.name) {
438438
this.addData("layer", this.name.trim());
439439

440-
// if (mode !== "svelte" && mode !== "styled-components") {
441-
// const layerNameClass = stringToClassName(this.name.trim());
442-
// if (layerNameClass !== "") {
443-
// classNames.push(layerNameClass);
444-
// }
445-
// }
440+
if (mode !== "svelte" && mode !== "styled-components") {
441+
const layerNameClass = stringToClassName(this.name.trim());
442+
if (layerNameClass !== "") {
443+
classNames.push(layerNameClass);
444+
}
445+
}
446446
}
447447

448448
if ("componentProperties" in this.node && this.node.componentProperties) {

packages/backend/src/html/htmlMain.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,9 +439,15 @@ const htmlWrapSVG = (
439439
settings: HTMLSettings,
440440
): string => {
441441
if (node.svg === "") return "";
442+
442443
const builder = new HtmlDefaultBuilder(node, settings)
443444
.addData("svg-wrapper")
444445
.position();
446+
447+
// The SVG content already has the var() references, so we don't need
448+
// to add inline CSS variables in most cases. The browser will use the fallbacks
449+
// if the variables aren't defined in the CSS.
450+
445451
return `\n<div${builder.build()}>\n${node.svg ?? ""}</div>`;
446452
};
447453

packages/backend/src/tailwind/tailwindDefaultBuilder.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
getClassLabel,
3131
} from "../common/commonFormatAttributes";
3232
import { TailwindColorType, TailwindSettings } from "types";
33-
import { MinimalFillsTrait, Paint } from "../api_types";
33+
import { MinimalFillsTrait, MinimalStrokesTrait, Paint } from "../api_types";
3434

3535
const isNotEmpty = (s: string) => s !== "" && s !== null && s !== undefined;
3636
const dropEmptyStrings = (strings: string[]) => strings.filter(isNotEmpty);
@@ -262,9 +262,9 @@ export class TailwindDefaultBuilder {
262262
this.addAttributes(additionalAttr);
263263
}
264264

265-
// if (this.name !== "") {
266-
// this.prependAttributes(stringToClassName(this.name));
267-
// }
265+
if (this.name !== "") {
266+
this.prependAttributes(stringToClassName(this.name));
267+
}
268268
if (this.name) {
269269
this.addData("layer", this.name.trim());
270270
}

0 commit comments

Comments
 (0)