@dannote/figma-use 0.6.2 → 0.7.0
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/CHANGELOG.md +77 -1
- package/README.md +114 -256
- package/SKILL.md +82 -20
- package/dist/cli/index.js +3947 -380
- package/dist/proxy/index.js +8 -6
- package/package.json +8 -1
- package/packages/cli/src/render/component-set.tsx +7 -3
- package/packages/cli/src/render/components.tsx +16 -47
- package/packages/cli/src/render/icon.ts +163 -0
- package/packages/cli/src/render/index.ts +7 -1
- package/packages/cli/src/render/reconciler.ts +96 -34
- package/packages/cli/src/render/shorthands.ts +129 -0
- package/packages/cli/src/render/vars.ts +4 -5
- package/packages/plugin/dist/main.js +127 -33
package/dist/proxy/index.js
CHANGED
|
@@ -180461,11 +180461,13 @@ function encodeNodeChangeWithVariables(nodeChange) {
|
|
|
180461
180461
|
}
|
|
180462
180462
|
const baseBytes = compiledSchema.encodeNodeChange(cleanNodeChange);
|
|
180463
180463
|
let hex4 = Buffer.from(baseBytes).toString("hex");
|
|
180464
|
-
|
|
180465
|
-
|
|
180464
|
+
const fillBinding = nodeChange.fillPaints?.[0]?.colorVariableBinding;
|
|
180465
|
+
if (hasFillBinding && fillBinding) {
|
|
180466
|
+
hex4 = injectVariableBinding(hex4, "2601", fillBinding);
|
|
180466
180467
|
}
|
|
180467
|
-
|
|
180468
|
-
|
|
180468
|
+
const strokeBinding = nodeChange.strokePaints?.[0]?.colorVariableBinding;
|
|
180469
|
+
if (hasStrokeBinding && strokeBinding) {
|
|
180470
|
+
hex4 = injectVariableBinding(hex4, "2701", strokeBinding);
|
|
180469
180471
|
}
|
|
180470
180472
|
return new Uint8Array(hex4.match(/.{2}/g).map((b2) => parseInt(b2, 16)));
|
|
180471
180473
|
}
|
|
@@ -209254,7 +209256,7 @@ async function executeCommand(command, args, timeoutMs) {
|
|
|
209254
209256
|
return result;
|
|
209255
209257
|
}
|
|
209256
209258
|
var mcpSessions = new Map;
|
|
209257
|
-
async function handleMcpRequest(req,
|
|
209259
|
+
async function handleMcpRequest(req, _sessionId) {
|
|
209258
209260
|
const { id, method, params } = req;
|
|
209259
209261
|
try {
|
|
209260
209262
|
switch (method) {
|
|
@@ -209414,7 +209416,7 @@ new Elysia().ws("/plugin", {
|
|
|
209414
209416
|
pluginConnected: sendToPlugin !== null,
|
|
209415
209417
|
multiplayer: getConnectionStatus()
|
|
209416
209418
|
})).post("/render", async ({ body }) => {
|
|
209417
|
-
const { fileKey, nodeChanges
|
|
209419
|
+
const { fileKey, nodeChanges } = body;
|
|
209418
209420
|
if (!fileKey) {
|
|
209419
209421
|
return { error: "fileKey is required" };
|
|
209420
209422
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dannote/figma-use",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Control Figma from the command line. Full read/write access for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -56,11 +56,18 @@
|
|
|
56
56
|
"elysia": "^1.2.25"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
+
"@iconify/core": "^4.1.0",
|
|
60
|
+
"@iconify/types": "^2.0.0",
|
|
61
|
+
"@iconify/utils": "^3.1.0",
|
|
59
62
|
"@types/bun": "^1.3.6",
|
|
63
|
+
"@types/pngjs": "^6.0.5",
|
|
64
|
+
"@types/ws": "^8.18.1",
|
|
60
65
|
"esbuild": "^0.25.4",
|
|
61
66
|
"kiwi-schema": "^0.5.0",
|
|
62
67
|
"oxfmt": "^0.24.0",
|
|
63
68
|
"oxlint": "^1.39.0",
|
|
69
|
+
"pixelmatch": "^7.1.0",
|
|
70
|
+
"pngjs": "^7.0.0",
|
|
64
71
|
"react": "19",
|
|
65
72
|
"typescript": "^5.8.3"
|
|
66
73
|
}
|
|
@@ -44,7 +44,11 @@ interface ComponentSetDef<V extends VariantDef> {
|
|
|
44
44
|
symbol: symbol
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// Use global registry to avoid module duplication issues between bundled CLI and source imports
|
|
48
|
+
const REGISTRY_KEY = '__figma_use_component_set_registry__'
|
|
49
|
+
const componentSetRegistry: Map<symbol, ComponentSetDef<VariantDef>> =
|
|
50
|
+
(globalThis as Record<string, unknown>)[REGISTRY_KEY] as Map<symbol, ComponentSetDef<VariantDef>> ||
|
|
51
|
+
((globalThis as Record<string, unknown>)[REGISTRY_KEY] = new Map<symbol, ComponentSetDef<VariantDef>>())
|
|
48
52
|
|
|
49
53
|
export function resetComponentSetRegistry() {
|
|
50
54
|
componentSetRegistry.clear()
|
|
@@ -115,8 +119,8 @@ export function generateVariantCombinations<V extends VariantDef>(
|
|
|
115
119
|
return
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
const key = keys[index]
|
|
119
|
-
for (const value of variants[key]) {
|
|
122
|
+
const key = keys[index]!
|
|
123
|
+
for (const value of variants[key]!) {
|
|
120
124
|
combine(index + 1, { ...current, [key]: value })
|
|
121
125
|
}
|
|
122
126
|
}
|
|
@@ -70,53 +70,11 @@ export function defineComponent<P extends BaseProps = BaseProps>(
|
|
|
70
70
|
return ComponentInstance
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// Style types
|
|
74
|
-
|
|
75
|
-
width?: number | string
|
|
76
|
-
height?: number | string
|
|
77
|
-
x?: number
|
|
78
|
-
y?: number
|
|
79
|
-
flexDirection?: 'row' | 'column'
|
|
80
|
-
justifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between'
|
|
81
|
-
alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch'
|
|
82
|
-
gap?: number
|
|
83
|
-
padding?: number
|
|
84
|
-
paddingTop?: number
|
|
85
|
-
paddingRight?: number
|
|
86
|
-
paddingBottom?: number
|
|
87
|
-
paddingLeft?: number
|
|
88
|
-
backgroundColor?: string
|
|
89
|
-
opacity?: number
|
|
90
|
-
borderRadius?: number
|
|
91
|
-
borderTopLeftRadius?: number
|
|
92
|
-
borderTopRightRadius?: number
|
|
93
|
-
borderBottomLeftRadius?: number
|
|
94
|
-
borderBottomRightRadius?: number
|
|
95
|
-
borderWidth?: number
|
|
96
|
-
borderColor?: string
|
|
97
|
-
}
|
|
73
|
+
// Style types - use StyleProps from shorthands which includes both full and shorthand names
|
|
74
|
+
import type { StyleProps } from './shorthands.ts'
|
|
98
75
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
fontFamily?: string
|
|
102
|
-
fontWeight?:
|
|
103
|
-
| 'normal'
|
|
104
|
-
| 'bold'
|
|
105
|
-
| '100'
|
|
106
|
-
| '200'
|
|
107
|
-
| '300'
|
|
108
|
-
| '400'
|
|
109
|
-
| '500'
|
|
110
|
-
| '600'
|
|
111
|
-
| '700'
|
|
112
|
-
| '800'
|
|
113
|
-
| '900'
|
|
114
|
-
fontStyle?: 'normal' | 'italic'
|
|
115
|
-
color?: string
|
|
116
|
-
textAlign?: 'left' | 'center' | 'right'
|
|
117
|
-
lineHeight?: number
|
|
118
|
-
letterSpacing?: number
|
|
119
|
-
}
|
|
76
|
+
type Style = StyleProps
|
|
77
|
+
type TextStyle = StyleProps
|
|
120
78
|
|
|
121
79
|
interface BaseProps {
|
|
122
80
|
name?: string
|
|
@@ -140,6 +98,15 @@ interface InstanceProps extends BaseProps {
|
|
|
140
98
|
componentId?: string
|
|
141
99
|
}
|
|
142
100
|
|
|
101
|
+
interface IconProps extends BaseProps {
|
|
102
|
+
/** Icon name in format "prefix:name" (e.g., "mdi:home", "lucide:star") */
|
|
103
|
+
icon: string
|
|
104
|
+
/** Icon size in pixels (default: 24) */
|
|
105
|
+
size?: number
|
|
106
|
+
/** Icon color (hex) */
|
|
107
|
+
color?: string
|
|
108
|
+
}
|
|
109
|
+
|
|
143
110
|
// Component factory - creates intrinsic element wrapper
|
|
144
111
|
const c =
|
|
145
112
|
<T extends BaseProps>(type: string): React.FC<T> =>
|
|
@@ -160,6 +127,7 @@ export const Instance = c<InstanceProps>('instance')
|
|
|
160
127
|
export const Group = c<BaseProps>('group')
|
|
161
128
|
export const Page = c<BaseProps>('page')
|
|
162
129
|
export const View = Frame
|
|
130
|
+
export const Icon = c<IconProps>('icon')
|
|
163
131
|
|
|
164
132
|
// All component names for JSX transform
|
|
165
133
|
export const INTRINSIC_ELEMENTS = [
|
|
@@ -175,5 +143,6 @@ export const INTRINSIC_ELEMENTS = [
|
|
|
175
143
|
'Instance',
|
|
176
144
|
'Group',
|
|
177
145
|
'Page',
|
|
178
|
-
'View'
|
|
146
|
+
'View',
|
|
147
|
+
'Icon'
|
|
179
148
|
] as const
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { loadIcon } from '@iconify/core/lib/api/icons'
|
|
2
|
+
import { setAPIModule } from '@iconify/core/lib/api/modules'
|
|
3
|
+
import { fetchAPIModule } from '@iconify/core/lib/api/modules/fetch'
|
|
4
|
+
import { iconToSVG } from '@iconify/utils'
|
|
5
|
+
import type { IconifyIcon } from '@iconify/types'
|
|
6
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
// Initialize API module
|
|
9
|
+
setAPIModule('', fetchAPIModule)
|
|
10
|
+
|
|
11
|
+
export interface IconData {
|
|
12
|
+
svg: string
|
|
13
|
+
width: number
|
|
14
|
+
height: number
|
|
15
|
+
body: string
|
|
16
|
+
viewBox: { left: number; top: number; width: number; height: number }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const iconCache = new Map<string, IconData>()
|
|
20
|
+
|
|
21
|
+
// Raw icon data cache (before size transformation)
|
|
22
|
+
const rawIconCache = new Map<string, IconifyIcon>()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load raw icon data (without size transformation)
|
|
26
|
+
*/
|
|
27
|
+
async function loadRawIcon(name: string): Promise<IconifyIcon | null> {
|
|
28
|
+
if (rawIconCache.has(name)) {
|
|
29
|
+
return rawIconCache.get(name)!
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const icon = await loadIcon(name)
|
|
33
|
+
if (!icon) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
rawIconCache.set(name, icon)
|
|
38
|
+
return icon
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Load icon from Iconify and return SVG string
|
|
43
|
+
* @param name Icon name in format "prefix:name" (e.g., "mdi:home", "lucide:star")
|
|
44
|
+
* @param size Optional size (default: 24)
|
|
45
|
+
*/
|
|
46
|
+
export async function loadIconSvg(name: string, size: number = 24): Promise<IconData | null> {
|
|
47
|
+
const cacheKey = `${name}@${size}`
|
|
48
|
+
|
|
49
|
+
if (iconCache.has(cacheKey)) {
|
|
50
|
+
return iconCache.get(cacheKey)!
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const icon = await loadRawIcon(name)
|
|
54
|
+
if (!icon) {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = iconToSVG(icon, { height: size, width: size })
|
|
59
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" ${Object.entries(result.attributes).map(([k, v]) => `${k}="${v}"`).join(' ')}>${result.body}</svg>`
|
|
60
|
+
|
|
61
|
+
const data: IconData = {
|
|
62
|
+
svg,
|
|
63
|
+
width: size,
|
|
64
|
+
height: size,
|
|
65
|
+
body: result.body,
|
|
66
|
+
viewBox: {
|
|
67
|
+
left: result.viewBox[0],
|
|
68
|
+
top: result.viewBox[1],
|
|
69
|
+
width: result.viewBox[2],
|
|
70
|
+
height: result.viewBox[3]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
iconCache.set(cacheKey, data)
|
|
75
|
+
return data
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get cached icon data (synchronous, for use in React components)
|
|
80
|
+
* Returns null if icon not preloaded
|
|
81
|
+
*/
|
|
82
|
+
export function getIconData(name: string, size: number = 24): IconData | null {
|
|
83
|
+
return iconCache.get(`${name}@${size}`) || null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Preload icons for use in JSX render
|
|
88
|
+
* Call before rendering to ensure icons are available synchronously
|
|
89
|
+
*/
|
|
90
|
+
export async function preloadIcons(icons: Array<{ name: string; size?: number }>): Promise<void> {
|
|
91
|
+
await Promise.all(icons.map(({ name, size }) => loadIconSvg(name, size || 24)))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get list of popular icon sets
|
|
96
|
+
*/
|
|
97
|
+
export const iconSets = {
|
|
98
|
+
mdi: 'Material Design Icons',
|
|
99
|
+
lucide: 'Lucide',
|
|
100
|
+
heroicons: 'Heroicons',
|
|
101
|
+
'heroicons-outline': 'Heroicons Outline',
|
|
102
|
+
'heroicons-solid': 'Heroicons Solid',
|
|
103
|
+
tabler: 'Tabler Icons',
|
|
104
|
+
'fa-solid': 'Font Awesome Solid',
|
|
105
|
+
'fa-regular': 'Font Awesome Regular',
|
|
106
|
+
'fa-brands': 'Font Awesome Brands',
|
|
107
|
+
ri: 'Remix Icon',
|
|
108
|
+
ph: 'Phosphor',
|
|
109
|
+
'ph-bold': 'Phosphor Bold',
|
|
110
|
+
'ph-fill': 'Phosphor Fill',
|
|
111
|
+
carbon: 'Carbon',
|
|
112
|
+
fluent: 'Fluent UI',
|
|
113
|
+
ion: 'Ionicons',
|
|
114
|
+
bi: 'Bootstrap Icons'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Recursively collect Icon primitives from React element tree
|
|
119
|
+
*/
|
|
120
|
+
export function collectIcons(element: ReactElement): Array<{ name: string; size?: number }> {
|
|
121
|
+
const icons: Array<{ name: string; size?: number }> = []
|
|
122
|
+
|
|
123
|
+
function traverse(node: ReactNode): void {
|
|
124
|
+
if (!node || typeof node !== 'object') return
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(node)) {
|
|
127
|
+
node.forEach(traverse)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const el = node as ReactElement
|
|
132
|
+
if (!el.type) return
|
|
133
|
+
|
|
134
|
+
if (el.type === 'icon') {
|
|
135
|
+
const props = el.props as { icon?: string; size?: number }
|
|
136
|
+
if (props.icon) {
|
|
137
|
+
icons.push({ name: props.icon, size: props.size })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (typeof el.type === 'function') {
|
|
142
|
+
try {
|
|
143
|
+
const Component = el.type as (props: Record<string, unknown>) => ReactNode
|
|
144
|
+
const rendered = Component(el.props as Record<string, unknown>)
|
|
145
|
+
if (rendered) traverse(rendered)
|
|
146
|
+
} catch {
|
|
147
|
+
// Ignore render errors during collection
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const props = el.props as { children?: ReactNode }
|
|
152
|
+
if (props.children) {
|
|
153
|
+
if (Array.isArray(props.children)) {
|
|
154
|
+
props.children.forEach(traverse)
|
|
155
|
+
} else {
|
|
156
|
+
traverse(props.children)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
traverse(element)
|
|
162
|
+
return icons
|
|
163
|
+
}
|
|
@@ -7,9 +7,12 @@ export {
|
|
|
7
7
|
resetRenderedComponents,
|
|
8
8
|
getPendingComponentSetInstances,
|
|
9
9
|
clearPendingComponentSetInstances,
|
|
10
|
+
getPendingIcons,
|
|
11
|
+
clearPendingIcons,
|
|
10
12
|
type RenderOptions,
|
|
11
13
|
type RenderResult,
|
|
12
|
-
type PendingComponentSetInstance
|
|
14
|
+
type PendingComponentSetInstance,
|
|
15
|
+
type PendingIcon
|
|
13
16
|
} from './reconciler.ts'
|
|
14
17
|
export {
|
|
15
18
|
Frame,
|
|
@@ -25,6 +28,7 @@ export {
|
|
|
25
28
|
Group,
|
|
26
29
|
Page,
|
|
27
30
|
View,
|
|
31
|
+
Icon,
|
|
28
32
|
INTRINSIC_ELEMENTS,
|
|
29
33
|
// Variable bindings (StyleX-inspired)
|
|
30
34
|
defineVars,
|
|
@@ -39,6 +43,8 @@ export {
|
|
|
39
43
|
getComponentRegistry
|
|
40
44
|
} from './components.tsx'
|
|
41
45
|
|
|
46
|
+
export { preloadIcons, loadIconSvg, getIconData, collectIcons } from './icon.ts'
|
|
47
|
+
|
|
42
48
|
export {
|
|
43
49
|
// ComponentSet (variants)
|
|
44
50
|
defineComponentSet,
|
|
@@ -34,14 +34,16 @@ import Reconciler from 'react-reconciler'
|
|
|
34
34
|
import { consola } from 'consola'
|
|
35
35
|
import type { NodeChange, Paint } from '../multiplayer/codec.ts'
|
|
36
36
|
import { parseColor } from '../color.ts'
|
|
37
|
-
import { isVariable, resolveVariable
|
|
37
|
+
import { isVariable, resolveVariable } from './vars.ts'
|
|
38
38
|
import { getComponentRegistry } from './components.tsx'
|
|
39
|
+
import { normalizeStyle, type StyleProps } from './shorthands.ts'
|
|
39
40
|
import {
|
|
40
41
|
getComponentSetRegistry,
|
|
41
42
|
generateVariantCombinations,
|
|
42
43
|
buildVariantName,
|
|
43
44
|
buildStateGroupPropertyValueOrders
|
|
44
45
|
} from './component-set.tsx'
|
|
46
|
+
import { getIconData } from './icon.ts'
|
|
45
47
|
|
|
46
48
|
// Track rendered components: symbol -> GUID
|
|
47
49
|
const renderedComponents = new Map<symbol, { sessionID: number; localID: number }>()
|
|
@@ -73,6 +75,25 @@ export function clearPendingComponentSetInstances() {
|
|
|
73
75
|
pendingComponentSetInstances.length = 0
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
// Pending Icon imports to create via Plugin API
|
|
79
|
+
export interface PendingIcon {
|
|
80
|
+
svg: string
|
|
81
|
+
parentGUID: { sessionID: number; localID: number }
|
|
82
|
+
childIndex: number
|
|
83
|
+
x: number
|
|
84
|
+
y: number
|
|
85
|
+
name: string
|
|
86
|
+
}
|
|
87
|
+
const pendingIcons: PendingIcon[] = []
|
|
88
|
+
|
|
89
|
+
export function getPendingIcons(): PendingIcon[] {
|
|
90
|
+
return [...pendingIcons]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function clearPendingIcons() {
|
|
94
|
+
pendingIcons.length = 0
|
|
95
|
+
}
|
|
96
|
+
|
|
76
97
|
export interface RenderOptions {
|
|
77
98
|
sessionID: number
|
|
78
99
|
parentGUID: { sessionID: number; localID: number }
|
|
@@ -107,7 +128,7 @@ function styleToNodeChange(
|
|
|
107
128
|
position: string,
|
|
108
129
|
textContent?: string
|
|
109
130
|
): NodeChange {
|
|
110
|
-
const style = (props.style || {}) as
|
|
131
|
+
const style = normalizeStyle((props.style || {}) as StyleProps)
|
|
111
132
|
const name = (props.name as string) || type
|
|
112
133
|
|
|
113
134
|
const nodeChange: NodeChange = {
|
|
@@ -119,6 +140,11 @@ function styleToNodeChange(
|
|
|
119
140
|
visible: true,
|
|
120
141
|
opacity: typeof style.opacity === 'number' ? style.opacity : 1
|
|
121
142
|
}
|
|
143
|
+
|
|
144
|
+
// Disable clipsContent for FRAME (Figma default is true which hides overflowing content)
|
|
145
|
+
if (mapType(type) === 'FRAME') {
|
|
146
|
+
nodeChange.clipsContent = false
|
|
147
|
+
}
|
|
122
148
|
|
|
123
149
|
// Size
|
|
124
150
|
const width = style.width ?? props.width
|
|
@@ -254,10 +280,8 @@ function styleToNodeChange(
|
|
|
254
280
|
const pb = style.paddingBottom ?? style.padding
|
|
255
281
|
const pl = style.paddingLeft ?? style.padding
|
|
256
282
|
|
|
257
|
-
if (pt !== undefined)
|
|
258
|
-
|
|
259
|
-
if (pl !== undefined)
|
|
260
|
-
(nodeChange as unknown as Record<string, unknown>).stackHorizontalPadding = Number(pl)
|
|
283
|
+
if (pt !== undefined) nodeChange.stackVerticalPadding = Number(pt)
|
|
284
|
+
if (pl !== undefined) nodeChange.stackHorizontalPadding = Number(pl)
|
|
261
285
|
if (pr !== undefined) nodeChange.stackPaddingRight = Number(pr)
|
|
262
286
|
if (pb !== undefined) nodeChange.stackPaddingBottom = Number(pb)
|
|
263
287
|
|
|
@@ -298,27 +322,26 @@ function styleToNodeChange(
|
|
|
298
322
|
// Text-specific
|
|
299
323
|
if (type.toLowerCase() === 'text' && textContent) {
|
|
300
324
|
// Text content via textData.characters
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
nc.textAlignVertical = 'TOP' // Required for text height calculation
|
|
325
|
+
nodeChange.textData = { characters: textContent }
|
|
326
|
+
nodeChange.textAutoResize = 'WIDTH_AND_HEIGHT'
|
|
327
|
+
nodeChange.textAlignVertical = 'TOP' // Required for text height calculation
|
|
305
328
|
|
|
306
|
-
if (style.fontSize)
|
|
329
|
+
if (style.fontSize) nodeChange.fontSize = Number(style.fontSize)
|
|
307
330
|
// CRITICAL: lineHeight MUST be { value: 100, units: 'PERCENT' } for text to have height
|
|
308
331
|
// Without this, TEXT nodes render with height=0 and are invisible
|
|
309
332
|
// Discovered via sniffing Figma's own text creation - see scripts/sniff-text.ts
|
|
310
|
-
|
|
333
|
+
nodeChange.lineHeight = { value: 100, units: 'PERCENT' }
|
|
311
334
|
// fontName is ALWAYS required for TEXT nodes, even without explicit fontFamily
|
|
312
335
|
const family = (style.fontFamily as string) || 'Inter'
|
|
313
336
|
const fontStyle = mapFontWeight(style.fontWeight as string)
|
|
314
|
-
|
|
337
|
+
nodeChange.fontName = {
|
|
315
338
|
family,
|
|
316
339
|
style: fontStyle,
|
|
317
340
|
postscript: `${family}-${fontStyle}`.replace(/\s+/g, '')
|
|
318
341
|
}
|
|
319
342
|
if (style.textAlign) {
|
|
320
343
|
const map: Record<string, string> = { left: 'LEFT', center: 'CENTER', right: 'RIGHT' }
|
|
321
|
-
|
|
344
|
+
nodeChange.textAlignHorizontal = map[style.textAlign as string] || 'LEFT'
|
|
322
345
|
}
|
|
323
346
|
if (style.color) {
|
|
324
347
|
const textColor = style.color
|
|
@@ -354,11 +377,10 @@ function styleToNodeChange(
|
|
|
354
377
|
if (type.toLowerCase() === 'instance' && props.componentId) {
|
|
355
378
|
const match = String(props.componentId).match(/(\d+):(\d+)/)
|
|
356
379
|
if (match) {
|
|
357
|
-
|
|
358
|
-
nc.symbolData = {
|
|
380
|
+
nodeChange.symbolData = {
|
|
359
381
|
symbolID: {
|
|
360
|
-
sessionID: parseInt(match[1]
|
|
361
|
-
localID: parseInt(match[2]
|
|
382
|
+
sessionID: parseInt(match[1]!, 10),
|
|
383
|
+
localID: parseInt(match[2]!, 10)
|
|
362
384
|
}
|
|
363
385
|
}
|
|
364
386
|
}
|
|
@@ -408,9 +430,47 @@ function collectNodeChanges(
|
|
|
408
430
|
sessionID: number,
|
|
409
431
|
parentGUID: { sessionID: number; localID: number },
|
|
410
432
|
position: string,
|
|
433
|
+
childIndex: number,
|
|
411
434
|
result: NodeChange[],
|
|
412
435
|
container: Container
|
|
413
436
|
): void {
|
|
437
|
+
// Handle Icon primitive
|
|
438
|
+
if (instance.type === 'icon') {
|
|
439
|
+
const props = instance.props as {
|
|
440
|
+
icon: string
|
|
441
|
+
size?: number
|
|
442
|
+
color?: string
|
|
443
|
+
name?: string
|
|
444
|
+
style?: Record<string, unknown>
|
|
445
|
+
}
|
|
446
|
+
const { icon: iconName, size = 24, color, name: nodeName, style = {} } = props
|
|
447
|
+
|
|
448
|
+
const iconData = getIconData(iconName, size)
|
|
449
|
+
if (!iconData) {
|
|
450
|
+
consola.error(`Icon "${iconName}" not found. Did you call preloadIcons()?`)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Replace currentColor with specified color
|
|
455
|
+
let svg = iconData.svg
|
|
456
|
+
if (color) {
|
|
457
|
+
svg = svg.replace(/currentColor/g, color)
|
|
458
|
+
} else {
|
|
459
|
+
svg = svg.replace(/currentColor/g, '#000000')
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Add to pending icons for Plugin API import
|
|
463
|
+
pendingIcons.push({
|
|
464
|
+
svg,
|
|
465
|
+
parentGUID,
|
|
466
|
+
childIndex,
|
|
467
|
+
x: (style.x as number) || 0,
|
|
468
|
+
y: (style.y as number) || 0,
|
|
469
|
+
name: nodeName || iconName.replace(':', '/')
|
|
470
|
+
})
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
|
|
414
474
|
// Handle defineComponent instances
|
|
415
475
|
if (instance.type === '__component_instance__') {
|
|
416
476
|
const sym = instance.props.__componentSymbol as symbol
|
|
@@ -443,8 +503,8 @@ function collectNodeChanges(
|
|
|
443
503
|
container.localIDCounter = componentResult.nextLocalID
|
|
444
504
|
|
|
445
505
|
// Change first node to be SYMBOL type and add to results
|
|
446
|
-
|
|
447
|
-
|
|
506
|
+
const rootChange = componentResult.nodeChanges[0]
|
|
507
|
+
if (rootChange) {
|
|
448
508
|
const originalRootGUID = { ...rootChange.guid }
|
|
449
509
|
|
|
450
510
|
// Replace root node's guid with componentGUID
|
|
@@ -457,6 +517,7 @@ function collectNodeChanges(
|
|
|
457
517
|
for (let i = 1; i < componentResult.nodeChanges.length; i++) {
|
|
458
518
|
const child = componentResult.nodeChanges[i]
|
|
459
519
|
if (
|
|
520
|
+
child &&
|
|
460
521
|
child.parentIndex?.guid.localID === originalRootGUID.localID &&
|
|
461
522
|
child.parentIndex?.guid.sessionID === originalRootGUID.sessionID
|
|
462
523
|
) {
|
|
@@ -493,8 +554,7 @@ function collectNodeChanges(
|
|
|
493
554
|
}
|
|
494
555
|
|
|
495
556
|
// Link to component
|
|
496
|
-
|
|
497
|
-
nc.symbolData = { symbolID: componentGUID }
|
|
557
|
+
instanceChange.symbolData = { symbolID: componentGUID }
|
|
498
558
|
|
|
499
559
|
result.push(instanceChange)
|
|
500
560
|
}
|
|
@@ -538,13 +598,12 @@ function collectNodeChanges(
|
|
|
538
598
|
opacity: 1,
|
|
539
599
|
size: { x: 1, y: 1 } // Will be auto-sized
|
|
540
600
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
setNc.stackCounterSizing = 'RESIZE_TO_FIT'
|
|
601
|
+
setChange.isStateGroup = true
|
|
602
|
+
setChange.stateGroupPropertyValueOrders = buildStateGroupPropertyValueOrders(variants)
|
|
603
|
+
setChange.stackMode = 'HORIZONTAL'
|
|
604
|
+
setChange.stackSpacing = 20
|
|
605
|
+
setChange.stackPrimarySizing = 'RESIZE_TO_FIT'
|
|
606
|
+
setChange.stackCounterSizing = 'RESIZE_TO_FIT'
|
|
548
607
|
|
|
549
608
|
result.push(setChange)
|
|
550
609
|
|
|
@@ -564,8 +623,8 @@ function collectNodeChanges(
|
|
|
564
623
|
})
|
|
565
624
|
container.localIDCounter = variantResult.nextLocalID
|
|
566
625
|
|
|
567
|
-
|
|
568
|
-
|
|
626
|
+
const rootChange = variantResult.nodeChanges[0]
|
|
627
|
+
if (rootChange) {
|
|
569
628
|
const originalRootGUID = { ...rootChange.guid }
|
|
570
629
|
|
|
571
630
|
rootChange.guid = variantGUID
|
|
@@ -580,6 +639,7 @@ function collectNodeChanges(
|
|
|
580
639
|
for (let j = 1; j < variantResult.nodeChanges.length; j++) {
|
|
581
640
|
const child = variantResult.nodeChanges[j]
|
|
582
641
|
if (
|
|
642
|
+
child &&
|
|
583
643
|
child.parentIndex?.guid.localID === originalRootGUID.localID &&
|
|
584
644
|
child.parentIndex?.guid.sessionID === originalRootGUID.sessionID
|
|
585
645
|
) {
|
|
@@ -641,7 +701,7 @@ function collectNodeChanges(
|
|
|
641
701
|
const thisGUID = { sessionID, localID: instance.localID }
|
|
642
702
|
instance.children.forEach((child, i) => {
|
|
643
703
|
const childPosition = String.fromCharCode(33 + (i % 90))
|
|
644
|
-
collectNodeChanges(child, sessionID, thisGUID, childPosition, result, container)
|
|
704
|
+
collectNodeChanges(child, sessionID, thisGUID, childPosition, i, result, container)
|
|
645
705
|
})
|
|
646
706
|
}
|
|
647
707
|
|
|
@@ -845,6 +905,7 @@ export function renderToNodeChanges(
|
|
|
845
905
|
options.sessionID,
|
|
846
906
|
options.parentGUID,
|
|
847
907
|
position,
|
|
908
|
+
i,
|
|
848
909
|
nodeChanges,
|
|
849
910
|
container
|
|
850
911
|
)
|
|
@@ -860,8 +921,9 @@ export function renderToNodeChanges(
|
|
|
860
921
|
function getDefaultVariants(variants: Record<string, readonly string[]>): Record<string, string> {
|
|
861
922
|
const defaults: Record<string, string> = {}
|
|
862
923
|
for (const [key, values] of Object.entries(variants)) {
|
|
863
|
-
|
|
864
|
-
|
|
924
|
+
const firstValue = values[0]
|
|
925
|
+
if (firstValue !== undefined) {
|
|
926
|
+
defaults[key] = firstValue
|
|
865
927
|
}
|
|
866
928
|
}
|
|
867
929
|
return defaults
|