inkbridge 0.1.0-beta.10 → 0.1.0-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +266 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/frame-cache.ts +146 -0
- package/src/generated-node.ts +84 -0
- package/src/github.ts +1492 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +678 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +829 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +3188 -0
- package/src/story-layout.ts +38 -0
- package/src/symbol-instance-policy.ts +311 -0
- package/src/tailwind.ts +2382 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/tokens.ts +786 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +2182 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "inkbridge",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.11",
|
|
4
4
|
"description": "Figma plugin that generates a pixel-accurate design system from your Tailwind React components.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
11
|
"scanner/",
|
|
12
|
-
"src/
|
|
12
|
+
"src/",
|
|
13
13
|
"templates/",
|
|
14
14
|
"code.js",
|
|
15
15
|
"ui.html",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type BlobDimensions = {
|
|
2
|
+
vectorWidth: number;
|
|
3
|
+
vectorHeight: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type BlobPlacement = {
|
|
7
|
+
layoutLeft: number;
|
|
8
|
+
desiredCenterX: number;
|
|
9
|
+
desiredCenterY: number;
|
|
10
|
+
cssRotateDeg: number;
|
|
11
|
+
topOffset: number;
|
|
12
|
+
hasTranslateXHalf: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const LEFT_CALC_REGEX = /^left-\[calc\(50%([+-]\d+(?:\.\d+)?rem)?\)\]$/;
|
|
16
|
+
const WIDTH_REGEX = /^w-(\d+(?:\.\d+)?)$/;
|
|
17
|
+
const ASPECT_REGEX = /^aspect-(\d+)\/(\d+)$/;
|
|
18
|
+
const TOP_REGEX = /^(-?)top-(\d+(?:\.\d+)?)$/;
|
|
19
|
+
const ROTATE_SIMPLE_REGEX = /^(-?)rotate-(\d+(?:\.\d+)?)$/;
|
|
20
|
+
const ROTATE_ARBITRARY_REGEX = /^(-?)rotate-\[(-?\d+(?:\.\d+)?)deg\]$/;
|
|
21
|
+
|
|
22
|
+
function findLastClass(classes: string[], predicate: (cls: string) => boolean): string | null {
|
|
23
|
+
for (let i = classes.length - 1; i >= 0; i--) {
|
|
24
|
+
if (predicate(classes[i])) return classes[i];
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseRotateDegreesFromClasses(classes: string[]): number {
|
|
30
|
+
for (let i = classes.length - 1; i >= 0; i--) {
|
|
31
|
+
const cls = classes[i];
|
|
32
|
+
const simple = cls.match(ROTATE_SIMPLE_REGEX);
|
|
33
|
+
if (simple) {
|
|
34
|
+
const sign = simple[1] === '-' ? -1 : 1;
|
|
35
|
+
return sign * parseFloat(simple[2]);
|
|
36
|
+
}
|
|
37
|
+
const arbitrary = cls.match(ROTATE_ARBITRARY_REGEX);
|
|
38
|
+
if (arbitrary) {
|
|
39
|
+
const sign = arbitrary[1] === '-' ? -1 : 1;
|
|
40
|
+
return sign * parseFloat(arbitrary[2]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveBlobDimensions(
|
|
47
|
+
activeClasses: string[],
|
|
48
|
+
defaults: BlobDimensions = { vectorWidth: 578, vectorHeight: 340 },
|
|
49
|
+
): BlobDimensions {
|
|
50
|
+
let vectorWidth = defaults.vectorWidth;
|
|
51
|
+
let vectorHeight = defaults.vectorHeight;
|
|
52
|
+
|
|
53
|
+
const widthClass = findLastClass(activeClasses, cls => WIDTH_REGEX.test(cls));
|
|
54
|
+
if (widthClass) {
|
|
55
|
+
const widthMatch = widthClass.match(WIDTH_REGEX);
|
|
56
|
+
if (widthMatch) {
|
|
57
|
+
vectorWidth = parseFloat(widthMatch[1]) * 4;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const aspectClass = findLastClass(activeClasses, cls => ASPECT_REGEX.test(cls));
|
|
62
|
+
if (aspectClass) {
|
|
63
|
+
const aspectMatch = aspectClass.match(ASPECT_REGEX);
|
|
64
|
+
if (aspectMatch) {
|
|
65
|
+
const ratio = parseFloat(aspectMatch[1]) / parseFloat(aspectMatch[2]);
|
|
66
|
+
vectorHeight = vectorWidth / ratio;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { vectorWidth, vectorHeight };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveBlobPlacement(
|
|
74
|
+
activeClasses: string[],
|
|
75
|
+
containerWidth: number,
|
|
76
|
+
vectorWidth: number,
|
|
77
|
+
vectorHeight: number,
|
|
78
|
+
): BlobPlacement {
|
|
79
|
+
const hasTranslateXHalf = activeClasses.includes('-translate-x-1/2');
|
|
80
|
+
|
|
81
|
+
let layoutLeft = 0;
|
|
82
|
+
const leftClass = findLastClass(activeClasses, cls => LEFT_CALC_REGEX.test(cls));
|
|
83
|
+
if (leftClass) {
|
|
84
|
+
const leftMatch = leftClass.match(LEFT_CALC_REGEX);
|
|
85
|
+
const remOffset = leftMatch && leftMatch[1] ? parseFloat(leftMatch[1]) * 16 : 0;
|
|
86
|
+
layoutLeft = containerWidth * 0.5 + remOffset;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let topOffset = 0;
|
|
90
|
+
const topClass = findLastClass(activeClasses, cls => TOP_REGEX.test(cls));
|
|
91
|
+
if (topClass) {
|
|
92
|
+
const topMatch = topClass.match(TOP_REGEX);
|
|
93
|
+
if (topMatch) {
|
|
94
|
+
const sign = topMatch[1] === '-' ? -1 : 1;
|
|
95
|
+
topOffset = parseFloat(topMatch[2]) * 4 * sign;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const desiredCenterX = hasTranslateXHalf ? layoutLeft : (layoutLeft + vectorWidth / 2);
|
|
100
|
+
const desiredCenterY = vectorHeight / 2;
|
|
101
|
+
const cssRotateDeg = parseRotateDegreesFromClasses(activeClasses);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
layoutLeft,
|
|
105
|
+
desiredCenterX,
|
|
106
|
+
desiredCenterY,
|
|
107
|
+
cssRotateDeg,
|
|
108
|
+
topOffset,
|
|
109
|
+
hasTranslateXHalf,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { COMPONENT_DEFS } from './tokens';
|
|
2
|
+
import { extractFrameProperties, propsToTailwindClasses } from './tailwind';
|
|
3
|
+
|
|
4
|
+
interface ComponentFrame {
|
|
5
|
+
frame: any;
|
|
6
|
+
componentName: string;
|
|
7
|
+
variant: string;
|
|
8
|
+
state: string;
|
|
9
|
+
props: any;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ChangeDetail {
|
|
13
|
+
property: string;
|
|
14
|
+
figma: string;
|
|
15
|
+
code: string;
|
|
16
|
+
newClasses: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ComponentChange {
|
|
20
|
+
name: string;
|
|
21
|
+
type: string;
|
|
22
|
+
file: string;
|
|
23
|
+
changes: ChangeDetail[];
|
|
24
|
+
figmaProps: any;
|
|
25
|
+
suggestedClasses: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ClassUpdate {
|
|
29
|
+
property: string;
|
|
30
|
+
remove: string[];
|
|
31
|
+
add: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ComponentPatch {
|
|
35
|
+
component: string;
|
|
36
|
+
file: string;
|
|
37
|
+
classUpdates: ClassUpdate[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findComponentFrames(): ComponentFrame[] {
|
|
41
|
+
let dsPage: any = null;
|
|
42
|
+
for (let i = 0; i < (figma as any).root.children.length; i++) {
|
|
43
|
+
if ((figma as any).root.children[i].name === 'Design System') {
|
|
44
|
+
dsPage = (figma as any).root.children[i];
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!dsPage) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const components: ComponentFrame[] = [];
|
|
54
|
+
|
|
55
|
+
// Find component frames by name pattern: "ComponentName/variant/state" or "ComponentName States"
|
|
56
|
+
function searchFrames(node: any, depth: number): void {
|
|
57
|
+
if (depth > 5) return; // Limit depth
|
|
58
|
+
|
|
59
|
+
if (node.type === 'FRAME' || node.type === 'COMPONENT') {
|
|
60
|
+
// Check if this looks like a component instance
|
|
61
|
+
const nameParts = node.name.split('/');
|
|
62
|
+
if (nameParts.length >= 2) {
|
|
63
|
+
// This is a variant frame like "Button/default/hover"
|
|
64
|
+
components.push({
|
|
65
|
+
frame: node,
|
|
66
|
+
componentName: nameParts[0],
|
|
67
|
+
variant: nameParts[1],
|
|
68
|
+
state: nameParts[2] || 'default',
|
|
69
|
+
props: extractFrameProperties(node)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if ('children' in node) {
|
|
75
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
76
|
+
searchFrames(node.children[i], depth + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
searchFrames(dsPage, 0);
|
|
82
|
+
return components;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compare Figma component properties with code definitions
|
|
87
|
+
* Returns list of changed components with their differences
|
|
88
|
+
*/
|
|
89
|
+
export function detectComponentChanges(): { changes?: ComponentChange[]; error?: string } {
|
|
90
|
+
const figmaComponents = findComponentFrames();
|
|
91
|
+
const changes: ComponentChange[] = [];
|
|
92
|
+
|
|
93
|
+
// Group Figma frames by component name
|
|
94
|
+
const figmaByComponent: Record<string, ComponentFrame[]> = {};
|
|
95
|
+
for (let i = 0; i < figmaComponents.length; i++) {
|
|
96
|
+
const comp = figmaComponents[i];
|
|
97
|
+
if (!figmaByComponent[comp.componentName]) {
|
|
98
|
+
figmaByComponent[comp.componentName] = [];
|
|
99
|
+
}
|
|
100
|
+
figmaByComponent[comp.componentName].push(comp);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Compare with code definitions
|
|
104
|
+
if (!COMPONENT_DEFS || !(COMPONENT_DEFS as any).components) {
|
|
105
|
+
return { error: 'No component definitions loaded. Start dev server and refresh.' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (let j = 0; j < (COMPONENT_DEFS as any).components.length; j++) {
|
|
109
|
+
const codeDef = (COMPONENT_DEFS as any).components[j];
|
|
110
|
+
const figmaFrames = figmaByComponent[codeDef.name] || [];
|
|
111
|
+
|
|
112
|
+
if (figmaFrames.length === 0) {
|
|
113
|
+
continue; // Component not found in Figma
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Get the first frame as representative (usually default state)
|
|
117
|
+
const figmaFrame = figmaFrames[0];
|
|
118
|
+
const figmaProps = figmaFrame.props;
|
|
119
|
+
const figmaClasses = propsToTailwindClasses(figmaProps);
|
|
120
|
+
|
|
121
|
+
// Get code classes
|
|
122
|
+
let codeClasses: string[] = [];
|
|
123
|
+
if (codeDef.type === 'cva' && codeDef.baseClasses) {
|
|
124
|
+
codeClasses = codeDef.baseClasses;
|
|
125
|
+
} else if (codeDef.type === 'state' && codeDef.baseClasses) {
|
|
126
|
+
codeClasses = codeDef.baseClasses;
|
|
127
|
+
} else if (codeDef.type === 'simple' && codeDef.classes) {
|
|
128
|
+
codeClasses = codeDef.classes;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Find spacing/radius classes in code
|
|
132
|
+
const codeSpacingClasses = codeClasses.filter(function(cls: string) {
|
|
133
|
+
return /^(p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|rounded)/.test(cls);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Compare
|
|
137
|
+
let hasChanges = false;
|
|
138
|
+
const changeDetails: ChangeDetail[] = [];
|
|
139
|
+
|
|
140
|
+
// Check padding changes
|
|
141
|
+
const figmaPadding = figmaClasses.filter(function(c: string) { return c.startsWith('p'); });
|
|
142
|
+
const codePadding = codeSpacingClasses.filter(function(c: string) { return c.startsWith('p'); });
|
|
143
|
+
|
|
144
|
+
if (figmaPadding.join(' ') !== codePadding.join(' ')) {
|
|
145
|
+
hasChanges = true;
|
|
146
|
+
changeDetails.push({
|
|
147
|
+
property: 'padding',
|
|
148
|
+
figma: figmaPadding.join(' ') || 'none',
|
|
149
|
+
code: codePadding.join(' ') || 'none',
|
|
150
|
+
newClasses: figmaPadding
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check radius changes
|
|
155
|
+
const figmaRadius = figmaClasses.filter(function(c: string) { return c.startsWith('rounded'); });
|
|
156
|
+
const codeRadius = codeSpacingClasses.filter(function(c: string) { return c.startsWith('rounded'); });
|
|
157
|
+
|
|
158
|
+
if (figmaRadius.join(' ') !== codeRadius.join(' ')) {
|
|
159
|
+
hasChanges = true;
|
|
160
|
+
changeDetails.push({
|
|
161
|
+
property: 'borderRadius',
|
|
162
|
+
figma: figmaRadius.join(' ') || 'none',
|
|
163
|
+
code: codeRadius.join(' ') || 'none',
|
|
164
|
+
newClasses: figmaRadius
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (hasChanges) {
|
|
169
|
+
changes.push({
|
|
170
|
+
name: codeDef.name,
|
|
171
|
+
type: codeDef.type,
|
|
172
|
+
file: codeDef.file,
|
|
173
|
+
changes: changeDetails,
|
|
174
|
+
figmaProps: figmaProps,
|
|
175
|
+
suggestedClasses: figmaClasses
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { changes: changes };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generate code patch for a component
|
|
185
|
+
* Returns the class changes that need to be made
|
|
186
|
+
*/
|
|
187
|
+
export function generateComponentPatch(componentChange: ComponentChange): ComponentPatch {
|
|
188
|
+
const patch: ComponentPatch = {
|
|
189
|
+
component: componentChange.name,
|
|
190
|
+
file: componentChange.file,
|
|
191
|
+
classUpdates: []
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < componentChange.changes.length; i++) {
|
|
195
|
+
const change = componentChange.changes[i];
|
|
196
|
+
patch.classUpdates.push({
|
|
197
|
+
property: change.property,
|
|
198
|
+
remove: change.code.split(' ').filter(Boolean),
|
|
199
|
+
add: change.newClasses
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return patch;
|
|
204
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type JsxNode, type JsxElement, splitClassName } from './node-ir';
|
|
2
|
+
|
|
3
|
+
export type ComponentDefResolver = (name: string) => any | null;
|
|
4
|
+
|
|
5
|
+
export function mergeClasses(base: string[], extra: string[]): string[] {
|
|
6
|
+
const seen: Record<string, boolean> = {};
|
|
7
|
+
const out: string[] = [];
|
|
8
|
+
for (const cls of base || []) {
|
|
9
|
+
if (!cls || seen[cls]) continue;
|
|
10
|
+
seen[cls] = true;
|
|
11
|
+
out.push(cls);
|
|
12
|
+
}
|
|
13
|
+
for (const cls of extra || []) {
|
|
14
|
+
if (!cls || seen[cls]) continue;
|
|
15
|
+
if (cls === 'rounded' || cls.indexOf('rounded-') === 0) {
|
|
16
|
+
const nextOut: string[] = [];
|
|
17
|
+
for (const existing of out) {
|
|
18
|
+
if (existing === 'rounded' || existing.indexOf('rounded-') === 0) {
|
|
19
|
+
delete seen[existing];
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
nextOut.push(existing);
|
|
23
|
+
}
|
|
24
|
+
out.length = 0;
|
|
25
|
+
for (const existing of nextOut) out.push(existing);
|
|
26
|
+
}
|
|
27
|
+
seen[cls] = true;
|
|
28
|
+
out.push(cls);
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getCompoundClasses(def: any, tagName: string): string[] {
|
|
34
|
+
if (!def || def.type !== 'compound' || !def.subComponents) return [];
|
|
35
|
+
const target = tagName.toLowerCase();
|
|
36
|
+
let fallback: any = null;
|
|
37
|
+
for (const sub of def.subComponents) {
|
|
38
|
+
if (!sub || !sub.name) continue;
|
|
39
|
+
const subName = String(sub.name).toLowerCase();
|
|
40
|
+
if (subName === target) return sub.classes || [];
|
|
41
|
+
if (!fallback && (sub.slot === 'container' || subName === String(def.name || '').toLowerCase())) {
|
|
42
|
+
fallback = sub;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (fallback && String(def.name || '').toLowerCase() === target) {
|
|
46
|
+
return fallback.classes || [];
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function treeHasFullWidth(
|
|
52
|
+
node: JsxNode | undefined,
|
|
53
|
+
parentCompoundDef: any | null,
|
|
54
|
+
options: {
|
|
55
|
+
getComponentDefByName: ComponentDefResolver;
|
|
56
|
+
normalizeComponentDef: (def: any) => any;
|
|
57
|
+
hasWidthHintInClasses: (classes: string[]) => boolean;
|
|
58
|
+
propsContainWidthHint: (props: Record<string, any> | undefined) => boolean;
|
|
59
|
+
}
|
|
60
|
+
): boolean {
|
|
61
|
+
if (!node || node.type !== 'element') return false;
|
|
62
|
+
const el = node as JsxElement;
|
|
63
|
+
let classes = splitClassName(el.props && el.props.className);
|
|
64
|
+
let nextParentCompoundDef = parentCompoundDef || null;
|
|
65
|
+
|
|
66
|
+
if (el.isComponent) {
|
|
67
|
+
const compDef = options.getComponentDefByName(el.tagName);
|
|
68
|
+
if (compDef) {
|
|
69
|
+
const normalizedDef = options.normalizeComponentDef(compDef);
|
|
70
|
+
if (normalizedDef.type === 'compound') {
|
|
71
|
+
nextParentCompoundDef = normalizedDef;
|
|
72
|
+
const compoundClasses = getCompoundClasses(normalizedDef, el.tagName);
|
|
73
|
+
if (compoundClasses.length > 0) {
|
|
74
|
+
classes = mergeClasses(compoundClasses, classes);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const baseClasses = normalizedDef.baseClasses || normalizedDef.classes || [];
|
|
79
|
+
if (baseClasses.length > 0) {
|
|
80
|
+
classes = mergeClasses(baseClasses, classes);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const variants = normalizedDef.variantClasses || {};
|
|
84
|
+
const defaults = normalizedDef.defaultVariants || {};
|
|
85
|
+
for (const key in variants) {
|
|
86
|
+
const value = (el.props && el.props[key]) || defaults[key];
|
|
87
|
+
if (value && variants[key] && variants[key][value]) {
|
|
88
|
+
classes = mergeClasses(variants[key][value], classes);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} else if (parentCompoundDef) {
|
|
92
|
+
const inherited = getCompoundClasses(parentCompoundDef, el.tagName);
|
|
93
|
+
if (inherited.length > 0) {
|
|
94
|
+
classes = mergeClasses(inherited, classes);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.hasWidthHintInClasses(classes)) return true;
|
|
100
|
+
if (options.propsContainWidthHint(el.props)) return true;
|
|
101
|
+
for (const child of el.children || []) {
|
|
102
|
+
if (treeHasFullWidth(child, nextParentCompoundDef, options)) return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { getClassesForBreakpoint } from './responsive-analyzer';
|
|
2
|
+
import { applyTailwindStylesToFrame, markAbsoluteNode, markPositionInfo } from './tailwind';
|
|
3
|
+
import { parseUtilityClass } from './utility-resolver';
|
|
4
|
+
import { resolveBlobDimensions, resolveBlobPlacement } from './blob-placement';
|
|
5
|
+
import { centerPlacedRotationTransform } from './transform-math';
|
|
6
|
+
|
|
7
|
+
declare const figma: any;
|
|
8
|
+
|
|
9
|
+
const DEBUG_BLOB_PLACEMENT = false;
|
|
10
|
+
|
|
11
|
+
export type DecorativeClipPathParams = {
|
|
12
|
+
clipPathPolygon: string;
|
|
13
|
+
classes: string[];
|
|
14
|
+
contextMaxWidth?: number;
|
|
15
|
+
colorGroup: Record<string, string>;
|
|
16
|
+
radiusGroup: Record<string, string> | null;
|
|
17
|
+
theme: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function parseClipPathFromStyle(style: string | Record<string, string> | undefined): string | null {
|
|
21
|
+
if (!style) return null;
|
|
22
|
+
if (typeof style === 'object') {
|
|
23
|
+
const clipPath = (style as any).clipPath || (style as any)['clip-path'];
|
|
24
|
+
if (!clipPath || typeof clipPath !== 'string') return null;
|
|
25
|
+
const match = clipPath.match(/polygon\s*\(\s*([^)]+)\s*\)/);
|
|
26
|
+
if (!match) return null;
|
|
27
|
+
return match[1].trim();
|
|
28
|
+
}
|
|
29
|
+
const match = (style as string).match(/polygon\s*\(\s*([^)]+)\s*\)/);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
return match[1].trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildDecorativeClipPathNode(params: DecorativeClipPathParams): SceneNode | null {
|
|
35
|
+
const { clipPathPolygon, classes, contextMaxWidth, colorGroup, radiusGroup, theme } = params;
|
|
36
|
+
const activeClasses = (contextMaxWidth != null && contextMaxWidth >= 640)
|
|
37
|
+
? getClassesForBreakpoint(classes, 'sm')
|
|
38
|
+
: classes;
|
|
39
|
+
const { vectorWidth, vectorHeight } = resolveBlobDimensions(activeClasses);
|
|
40
|
+
const vector = createVectorFromPolygon(clipPathPolygon, vectorWidth, vectorHeight);
|
|
41
|
+
if (!vector) return null;
|
|
42
|
+
|
|
43
|
+
const blobNode: SceneNode = vector;
|
|
44
|
+
vector.name = 'gradient-blob';
|
|
45
|
+
const clippedClasses = stripBlurUtilities(activeClasses);
|
|
46
|
+
applyTailwindStylesToFrame(vector as any, clippedClasses, colorGroup, radiusGroup, theme);
|
|
47
|
+
|
|
48
|
+
const containerWidth = contextMaxWidth;
|
|
49
|
+
if (containerWidth) {
|
|
50
|
+
const placement = resolveBlobPlacement(activeClasses, containerWidth, vectorWidth, vectorHeight);
|
|
51
|
+
const cssRotateDeg = placement.cssRotateDeg;
|
|
52
|
+
const topOffset = placement.topOffset;
|
|
53
|
+
|
|
54
|
+
const mask = figma.createFrame();
|
|
55
|
+
mask.name = 'gradient-blob-mask';
|
|
56
|
+
mask.layoutMode = 'NONE';
|
|
57
|
+
mask.primaryAxisSizingMode = 'FIXED';
|
|
58
|
+
mask.counterAxisSizingMode = 'FIXED';
|
|
59
|
+
mask.fills = [];
|
|
60
|
+
mask.strokes = [];
|
|
61
|
+
mask.clipsContent = false;
|
|
62
|
+
mask.resize(containerWidth, vectorHeight);
|
|
63
|
+
|
|
64
|
+
const blobBox = figma.createFrame();
|
|
65
|
+
blobBox.name = 'gradient-blob-box';
|
|
66
|
+
blobBox.layoutMode = 'NONE';
|
|
67
|
+
blobBox.primaryAxisSizingMode = 'FIXED';
|
|
68
|
+
blobBox.counterAxisSizingMode = 'FIXED';
|
|
69
|
+
blobBox.fills = [];
|
|
70
|
+
blobBox.strokes = [];
|
|
71
|
+
blobBox.resize(vectorWidth, vectorHeight);
|
|
72
|
+
blobBox.appendChild(blobNode);
|
|
73
|
+
vector.x = 0;
|
|
74
|
+
vector.y = 0;
|
|
75
|
+
mask.appendChild(blobBox);
|
|
76
|
+
|
|
77
|
+
const usedMatrix = Math.abs(cssRotateDeg) > 0.001
|
|
78
|
+
? setTransformFromCenter(blobBox as SceneNode, vectorWidth, vectorHeight, placement.desiredCenterX, placement.desiredCenterY, cssRotateDeg)
|
|
79
|
+
: false;
|
|
80
|
+
if (!usedMatrix) {
|
|
81
|
+
blobBox.x = placement.desiredCenterX - vectorWidth / 2;
|
|
82
|
+
blobBox.y = 0;
|
|
83
|
+
if (Math.abs(cssRotateDeg) > 0.001) {
|
|
84
|
+
try {
|
|
85
|
+
blobBox.rotation = -cssRotateDeg;
|
|
86
|
+
} catch (_err) {
|
|
87
|
+
// ignore rotation errors
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (DEBUG_BLOB_PLACEMENT) {
|
|
92
|
+
console.log('[blob-place]', {
|
|
93
|
+
containerWidth,
|
|
94
|
+
vectorWidth,
|
|
95
|
+
vectorHeight,
|
|
96
|
+
desiredCenterX: placement.desiredCenterX,
|
|
97
|
+
desiredCenterY: placement.desiredCenterY,
|
|
98
|
+
topOffset,
|
|
99
|
+
cssRotateDeg,
|
|
100
|
+
usedMatrix,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return mask;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (contextMaxWidth == null) {
|
|
107
|
+
const placement = resolveBlobPlacement(activeClasses, 0, vectorWidth, vectorHeight);
|
|
108
|
+
const cssRotateDeg = placement.cssRotateDeg;
|
|
109
|
+
if (Math.abs(cssRotateDeg) > 0.001) {
|
|
110
|
+
try {
|
|
111
|
+
(blobNode as any).rotation = -cssRotateDeg;
|
|
112
|
+
} catch (_err) {
|
|
113
|
+
// ignore rotation errors
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
markAbsoluteNode(blobNode);
|
|
117
|
+
markPositionInfo(blobNode, { left: placement.desiredCenterX - vectorWidth / 2, top: placement.topOffset });
|
|
118
|
+
}
|
|
119
|
+
return blobNode;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function createVectorFromPolygon(
|
|
123
|
+
polygonStr: string,
|
|
124
|
+
width: number,
|
|
125
|
+
height: number
|
|
126
|
+
): VectorNode | null {
|
|
127
|
+
try {
|
|
128
|
+
const points = parsePolygonPoints(polygonStr, width, height);
|
|
129
|
+
if (!points || points.length < 3) return null;
|
|
130
|
+
|
|
131
|
+
let pathData = `M ${points[0].x} ${points[0].y}`;
|
|
132
|
+
for (let i = 1; i < points.length; i++) {
|
|
133
|
+
pathData += ` L ${points[i].x} ${points[i].y}`;
|
|
134
|
+
}
|
|
135
|
+
pathData += ' Z';
|
|
136
|
+
|
|
137
|
+
const vector = figma.createVector();
|
|
138
|
+
vector.vectorPaths = [{ windingRule: 'EVENODD', data: pathData }];
|
|
139
|
+
vector.resize(width, height);
|
|
140
|
+
vector.strokes = [];
|
|
141
|
+
vector.fills = [];
|
|
142
|
+
return vector;
|
|
143
|
+
} catch (_err) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parsePolygonPoints(
|
|
149
|
+
polygonStr: string,
|
|
150
|
+
width: number,
|
|
151
|
+
height: number
|
|
152
|
+
): { x: number; y: number }[] | null {
|
|
153
|
+
const points = polygonStr.split(',').map(p => {
|
|
154
|
+
const parts = p.trim().split(/\s+/);
|
|
155
|
+
if (parts.length < 2) return null;
|
|
156
|
+
const x = parseFloat(parts[0].replace('%', '')) / 100;
|
|
157
|
+
const y = parseFloat(parts[1].replace('%', '')) / 100;
|
|
158
|
+
if (isNaN(x) || isNaN(y)) return null;
|
|
159
|
+
return { x: x * width, y: y * height };
|
|
160
|
+
}).filter(Boolean) as { x: number; y: number }[];
|
|
161
|
+
return points.length >= 3 ? points : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function stripBlurUtilities(classes: string[]): string[] {
|
|
165
|
+
const next: string[] = [];
|
|
166
|
+
for (let i = 0; i < classes.length; i++) {
|
|
167
|
+
const cls = classes[i];
|
|
168
|
+
const atom = parseUtilityClass(cls);
|
|
169
|
+
if (!atom.utility) {
|
|
170
|
+
next.push(cls);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (/^blur(?:-.+)?$/.test(atom.utility)) continue;
|
|
174
|
+
next.push(cls);
|
|
175
|
+
}
|
|
176
|
+
return next;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function setTransformFromCenter(
|
|
180
|
+
node: SceneNode,
|
|
181
|
+
width: number,
|
|
182
|
+
height: number,
|
|
183
|
+
centerX: number,
|
|
184
|
+
centerY: number,
|
|
185
|
+
rotationDeg: number
|
|
186
|
+
): boolean {
|
|
187
|
+
const transform = centerPlacedRotationTransform(width, height, centerX, centerY, rotationDeg);
|
|
188
|
+
try {
|
|
189
|
+
(node as any).relativeTransform = transform;
|
|
190
|
+
return true;
|
|
191
|
+
} catch (_err) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|