@dannote/figma-use 0.5.0 → 0.5.2
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 +19 -0
- package/SKILL.md +88 -209
- package/dist/cli/index.js +38 -5
- package/dist/proxy/index.js +21 -32
- package/package.json +7 -1
- package/packages/cli/src/render/component-set.tsx +138 -0
- package/packages/cli/src/render/components.tsx +155 -0
- package/packages/cli/src/render/index.ts +47 -0
- package/packages/cli/src/render/reconciler.ts +845 -0
- package/packages/cli/src/render/vars.ts +185 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Reconciler that outputs Figma NodeChanges directly
|
|
3
|
+
*
|
|
4
|
+
* Renders React components directly to NodeChanges array
|
|
5
|
+
* ready for multiplayer WebSocket transmission.
|
|
6
|
+
*
|
|
7
|
+
* ## Key implementation notes (discovered via protocol sniffing)
|
|
8
|
+
*
|
|
9
|
+
* ### TEXT nodes require specific fields
|
|
10
|
+
* - fontName: { family, style, postscript } - ALWAYS required, even without explicit font
|
|
11
|
+
* - textAlignVertical: 'TOP' - required for height calculation
|
|
12
|
+
* - lineHeight: { value: 100, units: 'PERCENT' } - CRITICAL! Without this, height=0
|
|
13
|
+
* - textData: { characters: '...' } - text content wrapper
|
|
14
|
+
*
|
|
15
|
+
* ### Auto-layout field names differ from Plugin API
|
|
16
|
+
* - justifyContent → stackPrimaryAlignItems (not stackJustify)
|
|
17
|
+
* - alignItems → stackCounterAlignItems (not stackCounterAlign)
|
|
18
|
+
* - Valid values: 'MIN', 'CENTER', 'MAX', 'SPACE_EVENLY' (not 'SPACE_BETWEEN')
|
|
19
|
+
*
|
|
20
|
+
* ### Sizing modes for auto-layout
|
|
21
|
+
* - stackPrimarySizing/stackCounterSizing = 'FIXED' when explicit size given
|
|
22
|
+
* - stackPrimarySizing/stackCounterSizing = 'RESIZE_TO_FIT' for hug contents
|
|
23
|
+
* - Setting RESIZE_TO_FIT via multiplayer doesn't work; use Plugin API trigger-layout
|
|
24
|
+
*
|
|
25
|
+
* ### Component types
|
|
26
|
+
* - SYMBOL (15) = Component in Figma (historical name)
|
|
27
|
+
* - INSTANCE (16) = Component instance
|
|
28
|
+
* - ComponentSet = FRAME with isStateGroup=true (field 225)
|
|
29
|
+
*
|
|
30
|
+
* See also: component-set.tsx for ComponentSet/Instance linking issues
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import Reconciler from 'react-reconciler'
|
|
34
|
+
import { consola } from 'consola'
|
|
35
|
+
import type { NodeChange, Paint } from '../multiplayer/codec.ts'
|
|
36
|
+
import { parseColor } from '../color.ts'
|
|
37
|
+
import { isVariable, resolveVariable, type FigmaVariable } from './vars.ts'
|
|
38
|
+
import { getComponentRegistry } from './components.tsx'
|
|
39
|
+
import {
|
|
40
|
+
getComponentSetRegistry,
|
|
41
|
+
generateVariantCombinations,
|
|
42
|
+
buildVariantName,
|
|
43
|
+
buildStateGroupPropertyValueOrders
|
|
44
|
+
} from './component-set.tsx'
|
|
45
|
+
|
|
46
|
+
// Track rendered components: symbol -> GUID
|
|
47
|
+
const renderedComponents = new Map<symbol, { sessionID: number; localID: number }>()
|
|
48
|
+
|
|
49
|
+
// Track rendered ComponentSets: symbol -> ComponentSet GUID
|
|
50
|
+
const renderedComponentSets = new Map<symbol, { sessionID: number; localID: number }>()
|
|
51
|
+
// Track variant component IDs within each ComponentSet
|
|
52
|
+
const renderedComponentSetVariants = new Map<symbol, Map<string, { sessionID: number; localID: number }>>()
|
|
53
|
+
|
|
54
|
+
// Pending ComponentSet instances to create via Plugin API
|
|
55
|
+
export interface PendingComponentSetInstance {
|
|
56
|
+
componentSetName: string
|
|
57
|
+
variantName: string
|
|
58
|
+
parentGUID: { sessionID: number; localID: number }
|
|
59
|
+
position: string
|
|
60
|
+
x: number
|
|
61
|
+
y: number
|
|
62
|
+
}
|
|
63
|
+
const pendingComponentSetInstances: PendingComponentSetInstance[] = []
|
|
64
|
+
|
|
65
|
+
export function getPendingComponentSetInstances(): PendingComponentSetInstance[] {
|
|
66
|
+
return [...pendingComponentSetInstances]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function clearPendingComponentSetInstances() {
|
|
70
|
+
pendingComponentSetInstances.length = 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RenderOptions {
|
|
74
|
+
sessionID: number
|
|
75
|
+
parentGUID: { sessionID: number; localID: number }
|
|
76
|
+
startLocalID?: number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface RenderResult {
|
|
80
|
+
nodeChanges: NodeChange[]
|
|
81
|
+
nextLocalID: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Instance {
|
|
85
|
+
type: string
|
|
86
|
+
props: Record<string, unknown>
|
|
87
|
+
localID: number
|
|
88
|
+
children: Instance[]
|
|
89
|
+
textContent?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Container {
|
|
93
|
+
options: RenderOptions
|
|
94
|
+
localIDCounter: number
|
|
95
|
+
children: Instance[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
function styleToNodeChange(
|
|
101
|
+
type: string,
|
|
102
|
+
props: Record<string, unknown>,
|
|
103
|
+
localID: number,
|
|
104
|
+
sessionID: number,
|
|
105
|
+
parentGUID: { sessionID: number; localID: number },
|
|
106
|
+
position: string,
|
|
107
|
+
textContent?: string
|
|
108
|
+
): NodeChange {
|
|
109
|
+
const style = (props.style || {}) as Record<string, unknown>
|
|
110
|
+
const name = (props.name as string) || type
|
|
111
|
+
|
|
112
|
+
const nodeChange: NodeChange = {
|
|
113
|
+
guid: { sessionID, localID },
|
|
114
|
+
phase: 'CREATED',
|
|
115
|
+
parentIndex: { guid: parentGUID, position },
|
|
116
|
+
type: mapType(type),
|
|
117
|
+
name,
|
|
118
|
+
visible: true,
|
|
119
|
+
opacity: typeof style.opacity === 'number' ? style.opacity : 1,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Size
|
|
123
|
+
const width = style.width ?? props.width
|
|
124
|
+
const height = style.height ?? props.height
|
|
125
|
+
if (width !== undefined && height !== undefined) {
|
|
126
|
+
nodeChange.size = { x: Number(width), y: Number(height) }
|
|
127
|
+
} else if (width !== undefined) {
|
|
128
|
+
nodeChange.size = { x: Number(width), y: 1 } // minimal height for auto-sizing
|
|
129
|
+
} else if (height !== undefined) {
|
|
130
|
+
nodeChange.size = { x: 1, y: Number(height) } // minimal width for auto-sizing
|
|
131
|
+
} else if (type !== 'TEXT') {
|
|
132
|
+
// Minimal size for auto-layout to expand from
|
|
133
|
+
nodeChange.size = { x: 1, y: 1 }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Position (transform)
|
|
137
|
+
const x = Number(style.x ?? props.x ?? 0)
|
|
138
|
+
const y = Number(style.y ?? props.y ?? 0)
|
|
139
|
+
nodeChange.transform = {
|
|
140
|
+
m00: 1, m01: 0, m02: x,
|
|
141
|
+
m10: 0, m11: 1, m12: y,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Background color → fill (supports Figma variables)
|
|
145
|
+
if (style.backgroundColor) {
|
|
146
|
+
const bgColor = style.backgroundColor
|
|
147
|
+
if (isVariable(bgColor)) {
|
|
148
|
+
const resolved = resolveVariable(bgColor)
|
|
149
|
+
// Use explicit value as fallback, or black
|
|
150
|
+
const fallback = bgColor.value ? parseColor(bgColor.value) : { r: 0, g: 0, b: 0, a: 1 }
|
|
151
|
+
nodeChange.fillPaints = [{
|
|
152
|
+
type: 'SOLID',
|
|
153
|
+
color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
|
|
154
|
+
opacity: 1,
|
|
155
|
+
visible: true,
|
|
156
|
+
colorVariableBinding: {
|
|
157
|
+
variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
|
|
158
|
+
}
|
|
159
|
+
} as Paint]
|
|
160
|
+
} else {
|
|
161
|
+
const color = parseColor(bgColor as string)
|
|
162
|
+
nodeChange.fillPaints = [{
|
|
163
|
+
type: 'SOLID',
|
|
164
|
+
color: { r: color.r, g: color.g, b: color.b, a: color.a },
|
|
165
|
+
opacity: color.a,
|
|
166
|
+
visible: true,
|
|
167
|
+
}]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Border → stroke (supports Figma variables)
|
|
172
|
+
if (style.borderColor) {
|
|
173
|
+
const borderColor = style.borderColor
|
|
174
|
+
if (isVariable(borderColor)) {
|
|
175
|
+
const resolved = resolveVariable(borderColor)
|
|
176
|
+
const fallback = borderColor.value ? parseColor(borderColor.value) : { r: 0, g: 0, b: 0, a: 1 }
|
|
177
|
+
nodeChange.strokePaints = [{
|
|
178
|
+
type: 'SOLID',
|
|
179
|
+
color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
|
|
180
|
+
opacity: 1,
|
|
181
|
+
visible: true,
|
|
182
|
+
colorVariableBinding: {
|
|
183
|
+
variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
|
|
184
|
+
}
|
|
185
|
+
} as Paint]
|
|
186
|
+
} else {
|
|
187
|
+
const color = parseColor(borderColor as string)
|
|
188
|
+
nodeChange.strokePaints = [{
|
|
189
|
+
type: 'SOLID',
|
|
190
|
+
color: { r: color.r, g: color.g, b: color.b, a: color.a },
|
|
191
|
+
opacity: color.a,
|
|
192
|
+
visible: true,
|
|
193
|
+
}]
|
|
194
|
+
}
|
|
195
|
+
nodeChange.strokeWeight = Number(style.borderWidth ?? 1)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Corner radius
|
|
199
|
+
if (style.borderRadius !== undefined) {
|
|
200
|
+
nodeChange.cornerRadius = Number(style.borderRadius)
|
|
201
|
+
}
|
|
202
|
+
if (style.borderTopLeftRadius !== undefined) {
|
|
203
|
+
nodeChange.rectangleTopLeftCornerRadius = Number(style.borderTopLeftRadius)
|
|
204
|
+
nodeChange.rectangleCornerRadiiIndependent = true
|
|
205
|
+
}
|
|
206
|
+
if (style.borderTopRightRadius !== undefined) {
|
|
207
|
+
nodeChange.rectangleTopRightCornerRadius = Number(style.borderTopRightRadius)
|
|
208
|
+
nodeChange.rectangleCornerRadiiIndependent = true
|
|
209
|
+
}
|
|
210
|
+
if (style.borderBottomLeftRadius !== undefined) {
|
|
211
|
+
nodeChange.rectangleBottomLeftCornerRadius = Number(style.borderBottomLeftRadius)
|
|
212
|
+
nodeChange.rectangleCornerRadiiIndependent = true
|
|
213
|
+
}
|
|
214
|
+
if (style.borderBottomRightRadius !== undefined) {
|
|
215
|
+
nodeChange.rectangleBottomRightCornerRadius = Number(style.borderBottomRightRadius)
|
|
216
|
+
nodeChange.rectangleCornerRadiiIndependent = true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Auto-layout
|
|
220
|
+
if (style.flexDirection) {
|
|
221
|
+
nodeChange.stackMode = style.flexDirection === 'row' ? 'HORIZONTAL' : 'VERTICAL'
|
|
222
|
+
// Sizing mode determines if frame hugs content or has fixed size
|
|
223
|
+
// IMPORTANT: RESIZE_TO_FIT via multiplayer sets the MODE but doesn't recalculate size
|
|
224
|
+
// The actual resize happens in trigger-layout via Plugin API
|
|
225
|
+
// If explicit size given → FIXED, otherwise → RESIZE_TO_FIT (hug contents)
|
|
226
|
+
const isRow = style.flexDirection === 'row'
|
|
227
|
+
const primarySize = isRow ? width : height
|
|
228
|
+
const counterSize = isRow ? height : width
|
|
229
|
+
nodeChange.stackPrimarySizing = primarySize !== undefined ? 'FIXED' : 'RESIZE_TO_FIT'
|
|
230
|
+
nodeChange.stackCounterSizing = counterSize !== undefined ? 'FIXED' : 'RESIZE_TO_FIT'
|
|
231
|
+
}
|
|
232
|
+
if (style.gap !== undefined) {
|
|
233
|
+
nodeChange.stackSpacing = Number(style.gap)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Padding
|
|
237
|
+
const pt = style.paddingTop ?? style.padding
|
|
238
|
+
const pr = style.paddingRight ?? style.padding
|
|
239
|
+
const pb = style.paddingBottom ?? style.padding
|
|
240
|
+
const pl = style.paddingLeft ?? style.padding
|
|
241
|
+
|
|
242
|
+
if (pt !== undefined) (nodeChange as unknown as Record<string, unknown>).stackVerticalPadding = Number(pt)
|
|
243
|
+
if (pl !== undefined) (nodeChange as unknown as Record<string, unknown>).stackHorizontalPadding = Number(pl)
|
|
244
|
+
if (pr !== undefined) nodeChange.stackPaddingRight = Number(pr)
|
|
245
|
+
if (pb !== undefined) nodeChange.stackPaddingBottom = Number(pb)
|
|
246
|
+
|
|
247
|
+
// Alignment - NOTE: field names differ from Plugin API!
|
|
248
|
+
// Plugin API uses primaryAxisAlignItems/counterAxisAlignItems
|
|
249
|
+
// Multiplayer uses stackPrimaryAlignItems/stackCounterAlignItems
|
|
250
|
+
// Also: 'SPACE_BETWEEN' doesn't exist in multiplayer, only 'SPACE_EVENLY'
|
|
251
|
+
if (style.justifyContent) {
|
|
252
|
+
const validValues: Record<string, string> = {
|
|
253
|
+
'flex-start': 'MIN', 'center': 'CENTER', 'flex-end': 'MAX', 'space-evenly': 'SPACE_EVENLY'
|
|
254
|
+
}
|
|
255
|
+
const mapped = validValues[style.justifyContent as string]
|
|
256
|
+
if (mapped) {
|
|
257
|
+
nodeChange.stackPrimaryAlignItems = mapped
|
|
258
|
+
} else {
|
|
259
|
+
consola.warn(`justifyContent: "${style.justifyContent}" not supported, using "flex-start"`)
|
|
260
|
+
nodeChange.stackPrimaryAlignItems = 'MIN'
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (style.alignItems) {
|
|
264
|
+
const validValues: Record<string, string> = {
|
|
265
|
+
'flex-start': 'MIN', 'center': 'CENTER', 'flex-end': 'MAX', 'stretch': 'STRETCH'
|
|
266
|
+
}
|
|
267
|
+
const mapped = validValues[style.alignItems as string]
|
|
268
|
+
if (mapped) {
|
|
269
|
+
nodeChange.stackCounterAlignItems = mapped
|
|
270
|
+
} else {
|
|
271
|
+
consola.warn(`alignItems: "${style.alignItems}" not supported, using "flex-start"`)
|
|
272
|
+
nodeChange.stackCounterAlignItems = 'MIN'
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Text-specific
|
|
277
|
+
if (type.toLowerCase() === 'text' && textContent) {
|
|
278
|
+
// Text content via textData.characters
|
|
279
|
+
const nc = nodeChange as unknown as Record<string, unknown>
|
|
280
|
+
nc.textData = { characters: textContent }
|
|
281
|
+
nc.textAutoResize = 'WIDTH_AND_HEIGHT'
|
|
282
|
+
nc.textAlignVertical = 'TOP' // Required for text height calculation
|
|
283
|
+
|
|
284
|
+
if (style.fontSize) nc.fontSize = Number(style.fontSize)
|
|
285
|
+
// CRITICAL: lineHeight MUST be { value: 100, units: 'PERCENT' } for text to have height
|
|
286
|
+
// Without this, TEXT nodes render with height=0 and are invisible
|
|
287
|
+
// Discovered via sniffing Figma's own text creation - see scripts/sniff-text.ts
|
|
288
|
+
nc.lineHeight = { value: 100, units: 'PERCENT' }
|
|
289
|
+
// fontName is ALWAYS required for TEXT nodes, even without explicit fontFamily
|
|
290
|
+
const family = (style.fontFamily as string) || 'Inter'
|
|
291
|
+
const fontStyle = mapFontWeight(style.fontWeight as string)
|
|
292
|
+
nc.fontName = {
|
|
293
|
+
family,
|
|
294
|
+
style: fontStyle,
|
|
295
|
+
postscript: `${family}-${fontStyle}`.replace(/\s+/g, ''),
|
|
296
|
+
}
|
|
297
|
+
if (style.textAlign) {
|
|
298
|
+
const map: Record<string, string> = { 'left': 'LEFT', 'center': 'CENTER', 'right': 'RIGHT' }
|
|
299
|
+
nc.textAlignHorizontal = map[style.textAlign as string] || 'LEFT'
|
|
300
|
+
}
|
|
301
|
+
if (style.color) {
|
|
302
|
+
const textColor = style.color
|
|
303
|
+
if (isVariable(textColor)) {
|
|
304
|
+
const resolved = resolveVariable(textColor)
|
|
305
|
+
const fallback = textColor.value ? parseColor(textColor.value) : { r: 0, g: 0, b: 0, a: 1 }
|
|
306
|
+
nodeChange.fillPaints = [{
|
|
307
|
+
type: 'SOLID',
|
|
308
|
+
color: { r: fallback.r, g: fallback.g, b: fallback.b, a: fallback.a },
|
|
309
|
+
opacity: 1,
|
|
310
|
+
visible: true,
|
|
311
|
+
colorVariableBinding: {
|
|
312
|
+
variableID: { sessionID: resolved.sessionID, localID: resolved.localID }
|
|
313
|
+
}
|
|
314
|
+
} as Paint]
|
|
315
|
+
} else {
|
|
316
|
+
const color = parseColor(textColor as string)
|
|
317
|
+
nodeChange.fillPaints = [{
|
|
318
|
+
type: 'SOLID',
|
|
319
|
+
color: { r: color.r, g: color.g, b: color.b, a: color.a },
|
|
320
|
+
opacity: color.a,
|
|
321
|
+
visible: true,
|
|
322
|
+
}]
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Instance - link to component
|
|
328
|
+
if (type.toLowerCase() === 'instance' && props.componentId) {
|
|
329
|
+
const match = String(props.componentId).match(/(\d+):(\d+)/)
|
|
330
|
+
if (match) {
|
|
331
|
+
const nc = nodeChange as unknown as Record<string, unknown>
|
|
332
|
+
nc.symbolData = {
|
|
333
|
+
symbolID: {
|
|
334
|
+
sessionID: parseInt(match[1], 10),
|
|
335
|
+
localID: parseInt(match[2], 10),
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return nodeChange
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function mapType(type: string): string {
|
|
345
|
+
const map: Record<string, string> = {
|
|
346
|
+
frame: 'FRAME',
|
|
347
|
+
rectangle: 'RECTANGLE',
|
|
348
|
+
ellipse: 'ELLIPSE',
|
|
349
|
+
text: 'TEXT',
|
|
350
|
+
line: 'LINE',
|
|
351
|
+
star: 'STAR',
|
|
352
|
+
polygon: 'REGULAR_POLYGON',
|
|
353
|
+
vector: 'VECTOR',
|
|
354
|
+
component: 'SYMBOL', // Figma internally calls components "symbols"
|
|
355
|
+
instance: 'INSTANCE',
|
|
356
|
+
group: 'GROUP',
|
|
357
|
+
page: 'CANVAS',
|
|
358
|
+
}
|
|
359
|
+
return map[type.toLowerCase()] || 'FRAME'
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function mapFontWeight(weight?: string): string {
|
|
363
|
+
if (!weight) return 'Regular'
|
|
364
|
+
const map: Record<string, string> = {
|
|
365
|
+
'normal': 'Regular',
|
|
366
|
+
'bold': 'Bold',
|
|
367
|
+
'100': 'Thin',
|
|
368
|
+
'200': 'Extra Light',
|
|
369
|
+
'300': 'Light',
|
|
370
|
+
'400': 'Regular',
|
|
371
|
+
'500': 'Medium',
|
|
372
|
+
'600': 'Semi Bold',
|
|
373
|
+
'700': 'Bold',
|
|
374
|
+
'800': 'Extra Bold',
|
|
375
|
+
'900': 'Black',
|
|
376
|
+
}
|
|
377
|
+
return map[weight] || 'Regular'
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function collectNodeChanges(
|
|
381
|
+
instance: Instance,
|
|
382
|
+
sessionID: number,
|
|
383
|
+
parentGUID: { sessionID: number; localID: number },
|
|
384
|
+
position: string,
|
|
385
|
+
result: NodeChange[],
|
|
386
|
+
container: Container
|
|
387
|
+
): void {
|
|
388
|
+
// Handle defineComponent instances
|
|
389
|
+
if (instance.type === '__component_instance__') {
|
|
390
|
+
const sym = instance.props.__componentSymbol as symbol
|
|
391
|
+
const name = instance.props.__componentName as string
|
|
392
|
+
const registry = getComponentRegistry()
|
|
393
|
+
const def = registry.get(sym)
|
|
394
|
+
|
|
395
|
+
if (!def) {
|
|
396
|
+
consola.error(`Component "${name}" not found in registry`)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Check if component already rendered
|
|
401
|
+
let componentGUID = renderedComponents.get(sym)
|
|
402
|
+
|
|
403
|
+
if (!componentGUID) {
|
|
404
|
+
// First instance: render as Component
|
|
405
|
+
const componentLocalID = container.localIDCounter++
|
|
406
|
+
componentGUID = { sessionID, localID: componentLocalID }
|
|
407
|
+
renderedComponents.set(sym, componentGUID)
|
|
408
|
+
|
|
409
|
+
// Render the component's element tree
|
|
410
|
+
const componentResult = renderToNodeChanges(def.element, {
|
|
411
|
+
sessionID,
|
|
412
|
+
parentGUID, // Will be fixed below
|
|
413
|
+
startLocalID: container.localIDCounter,
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// Update counter
|
|
417
|
+
container.localIDCounter = componentResult.nextLocalID
|
|
418
|
+
|
|
419
|
+
// Change first node to be SYMBOL type and add to results
|
|
420
|
+
if (componentResult.nodeChanges.length > 0) {
|
|
421
|
+
const rootChange = componentResult.nodeChanges[0]
|
|
422
|
+
const originalRootGUID = { ...rootChange.guid }
|
|
423
|
+
|
|
424
|
+
// Replace root node's guid with componentGUID
|
|
425
|
+
rootChange.guid = componentGUID
|
|
426
|
+
rootChange.type = 'SYMBOL'
|
|
427
|
+
rootChange.name = name
|
|
428
|
+
rootChange.parentIndex = { guid: parentGUID, position }
|
|
429
|
+
|
|
430
|
+
// Fix children's parentIndex to point to componentGUID instead of original root
|
|
431
|
+
for (let i = 1; i < componentResult.nodeChanges.length; i++) {
|
|
432
|
+
const child = componentResult.nodeChanges[i]
|
|
433
|
+
if (child.parentIndex?.guid.localID === originalRootGUID.localID &&
|
|
434
|
+
child.parentIndex?.guid.sessionID === originalRootGUID.sessionID) {
|
|
435
|
+
child.parentIndex.guid = componentGUID
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Merge style props from instance onto component
|
|
440
|
+
const style = (instance.props.style || {}) as Record<string, unknown>
|
|
441
|
+
if (style.x !== undefined || style.y !== undefined) {
|
|
442
|
+
const x = Number(style.x ?? 0)
|
|
443
|
+
const y = Number(style.y ?? 0)
|
|
444
|
+
rootChange.transform = { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
result.push(...componentResult.nodeChanges)
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
// Subsequent instance: create Instance node
|
|
451
|
+
const instanceLocalID = container.localIDCounter++
|
|
452
|
+
const style = (instance.props.style || {}) as Record<string, unknown>
|
|
453
|
+
const x = Number(style.x ?? 0)
|
|
454
|
+
const y = Number(style.y ?? 0)
|
|
455
|
+
|
|
456
|
+
const instanceChange: NodeChange = {
|
|
457
|
+
guid: { sessionID, localID: instanceLocalID },
|
|
458
|
+
phase: 'CREATED',
|
|
459
|
+
parentIndex: { guid: parentGUID, position },
|
|
460
|
+
type: 'INSTANCE',
|
|
461
|
+
name,
|
|
462
|
+
visible: true,
|
|
463
|
+
opacity: 1,
|
|
464
|
+
transform: { m00: 1, m01: 0, m02: x, m10: 0, m11: 1, m12: y },
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Link to component
|
|
468
|
+
const nc = instanceChange as unknown as Record<string, unknown>
|
|
469
|
+
nc.symbolData = { symbolID: componentGUID }
|
|
470
|
+
|
|
471
|
+
result.push(instanceChange)
|
|
472
|
+
}
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Handle defineComponentSet instances
|
|
477
|
+
if (instance.type === '__component_set_instance__') {
|
|
478
|
+
const sym = instance.props.__componentSetSymbol as symbol
|
|
479
|
+
const name = instance.props.__componentSetName as string
|
|
480
|
+
const variantProps = (instance.props.__variantProps || {}) as Record<string, string>
|
|
481
|
+
const csRegistry = getComponentSetRegistry()
|
|
482
|
+
const csDef = csRegistry.get(sym)
|
|
483
|
+
|
|
484
|
+
if (!csDef) {
|
|
485
|
+
consola.error(`ComponentSet "${name}" not found in registry`)
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Check if ComponentSet already rendered
|
|
490
|
+
let componentSetGUID = renderedComponentSets.get(sym)
|
|
491
|
+
|
|
492
|
+
if (!componentSetGUID) {
|
|
493
|
+
// First instance: create ComponentSet with all variant components
|
|
494
|
+
const componentSetLocalID = container.localIDCounter++
|
|
495
|
+
componentSetGUID = { sessionID, localID: componentSetLocalID }
|
|
496
|
+
renderedComponentSets.set(sym, componentSetGUID)
|
|
497
|
+
|
|
498
|
+
const variants = csDef.variants
|
|
499
|
+
const combinations = generateVariantCombinations(variants)
|
|
500
|
+
const variantComponentIds = new Map<string, { sessionID: number; localID: number }>()
|
|
501
|
+
|
|
502
|
+
// Create ComponentSet node (FRAME with isStateGroup)
|
|
503
|
+
const setChange: NodeChange = {
|
|
504
|
+
guid: componentSetGUID,
|
|
505
|
+
phase: 'CREATED',
|
|
506
|
+
parentIndex: { guid: parentGUID, position },
|
|
507
|
+
type: 'FRAME',
|
|
508
|
+
name,
|
|
509
|
+
visible: true,
|
|
510
|
+
opacity: 1,
|
|
511
|
+
size: { x: 1, y: 1 }, // Will be auto-sized
|
|
512
|
+
}
|
|
513
|
+
const setNc = setChange as unknown as Record<string, unknown>
|
|
514
|
+
setNc.isStateGroup = true
|
|
515
|
+
setNc.stateGroupPropertyValueOrders = buildStateGroupPropertyValueOrders(variants)
|
|
516
|
+
setNc.stackMode = 'HORIZONTAL'
|
|
517
|
+
setNc.stackSpacing = 20
|
|
518
|
+
setNc.stackPrimarySizing = 'RESIZE_TO_FIT'
|
|
519
|
+
setNc.stackCounterSizing = 'RESIZE_TO_FIT'
|
|
520
|
+
|
|
521
|
+
result.push(setChange)
|
|
522
|
+
|
|
523
|
+
// Create Component for each variant combination
|
|
524
|
+
combinations.forEach((combo, i) => {
|
|
525
|
+
const variantName = buildVariantName(combo)
|
|
526
|
+
const variantLocalID = container.localIDCounter++
|
|
527
|
+
const variantGUID = { sessionID, localID: variantLocalID }
|
|
528
|
+
variantComponentIds.set(variantName, variantGUID)
|
|
529
|
+
|
|
530
|
+
// Render the variant's element
|
|
531
|
+
const variantElement = csDef.render(combo)
|
|
532
|
+
const variantResult = renderToNodeChanges(variantElement, {
|
|
533
|
+
sessionID,
|
|
534
|
+
parentGUID: componentSetGUID!,
|
|
535
|
+
startLocalID: container.localIDCounter,
|
|
536
|
+
})
|
|
537
|
+
container.localIDCounter = variantResult.nextLocalID
|
|
538
|
+
|
|
539
|
+
if (variantResult.nodeChanges.length > 0) {
|
|
540
|
+
const rootChange = variantResult.nodeChanges[0]
|
|
541
|
+
const originalRootGUID = { ...rootChange.guid }
|
|
542
|
+
|
|
543
|
+
rootChange.guid = variantGUID
|
|
544
|
+
rootChange.type = 'SYMBOL'
|
|
545
|
+
rootChange.name = variantName
|
|
546
|
+
rootChange.parentIndex = {
|
|
547
|
+
guid: componentSetGUID!,
|
|
548
|
+
position: String.fromCharCode(33 + i)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Fix children's parentIndex
|
|
552
|
+
for (let j = 1; j < variantResult.nodeChanges.length; j++) {
|
|
553
|
+
const child = variantResult.nodeChanges[j]
|
|
554
|
+
if (child.parentIndex?.guid.localID === originalRootGUID.localID &&
|
|
555
|
+
child.parentIndex?.guid.sessionID === originalRootGUID.sessionID) {
|
|
556
|
+
child.parentIndex.guid = variantGUID
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
result.push(...variantResult.nodeChanges)
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
// Store variant IDs for creating instances
|
|
565
|
+
renderedComponentSetVariants.set(sym, variantComponentIds)
|
|
566
|
+
|
|
567
|
+
// Store pending instances to create via Plugin API (multiplayer symbolData doesn't work for ComponentSet)
|
|
568
|
+
const requestedVariantName = buildVariantName({
|
|
569
|
+
...getDefaultVariants(variants),
|
|
570
|
+
...variantProps
|
|
571
|
+
})
|
|
572
|
+
const style = (instance.props.style || {}) as Record<string, unknown>
|
|
573
|
+
pendingComponentSetInstances.push({
|
|
574
|
+
componentSetName: name,
|
|
575
|
+
variantName: requestedVariantName,
|
|
576
|
+
parentGUID,
|
|
577
|
+
position: String.fromCharCode(34 + combinations.length),
|
|
578
|
+
x: Number(style.x ?? 0),
|
|
579
|
+
y: Number(style.y ?? 0),
|
|
580
|
+
})
|
|
581
|
+
} else {
|
|
582
|
+
// Subsequent instance: store for Plugin API creation
|
|
583
|
+
const requestedVariantName = buildVariantName({
|
|
584
|
+
...getDefaultVariants(csDef.variants),
|
|
585
|
+
...variantProps
|
|
586
|
+
})
|
|
587
|
+
const style = (instance.props.style || {}) as Record<string, unknown>
|
|
588
|
+
pendingComponentSetInstances.push({
|
|
589
|
+
componentSetName: name,
|
|
590
|
+
variantName: requestedVariantName,
|
|
591
|
+
parentGUID,
|
|
592
|
+
position,
|
|
593
|
+
x: Number(style.x ?? 0),
|
|
594
|
+
y: Number(style.y ?? 0),
|
|
595
|
+
})
|
|
596
|
+
}
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const nodeChange = styleToNodeChange(
|
|
601
|
+
instance.type,
|
|
602
|
+
instance.props,
|
|
603
|
+
instance.localID,
|
|
604
|
+
sessionID,
|
|
605
|
+
parentGUID,
|
|
606
|
+
position,
|
|
607
|
+
instance.textContent
|
|
608
|
+
)
|
|
609
|
+
result.push(nodeChange)
|
|
610
|
+
|
|
611
|
+
const thisGUID = { sessionID, localID: instance.localID }
|
|
612
|
+
instance.children.forEach((child, i) => {
|
|
613
|
+
const childPosition = String.fromCharCode(33 + (i % 90))
|
|
614
|
+
collectNodeChanges(child, sessionID, thisGUID, childPosition, result, container)
|
|
615
|
+
})
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
619
|
+
const hostConfig: any = {
|
|
620
|
+
supportsMutation: true,
|
|
621
|
+
supportsPersistence: false,
|
|
622
|
+
supportsHydration: false,
|
|
623
|
+
isPrimaryRenderer: true,
|
|
624
|
+
|
|
625
|
+
now: Date.now,
|
|
626
|
+
scheduleTimeout: setTimeout,
|
|
627
|
+
cancelTimeout: clearTimeout,
|
|
628
|
+
noTimeout: -1 as const,
|
|
629
|
+
|
|
630
|
+
getRootHostContext() {
|
|
631
|
+
return {}
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
getChildHostContext() {
|
|
635
|
+
return {}
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
shouldSetTextContent() {
|
|
639
|
+
return false
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
createInstance(
|
|
643
|
+
type: string,
|
|
644
|
+
props: Record<string, unknown>,
|
|
645
|
+
_rootContainer: Container,
|
|
646
|
+
): Instance {
|
|
647
|
+
const { children: _, ...rest } = props
|
|
648
|
+
return {
|
|
649
|
+
type,
|
|
650
|
+
props: rest,
|
|
651
|
+
localID: _rootContainer.localIDCounter++,
|
|
652
|
+
children: [],
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
createTextInstance(
|
|
657
|
+
text: string,
|
|
658
|
+
): Instance {
|
|
659
|
+
return {
|
|
660
|
+
type: '__text__',
|
|
661
|
+
props: {},
|
|
662
|
+
localID: -1,
|
|
663
|
+
children: [],
|
|
664
|
+
textContent: text,
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
appendInitialChild(parent: Instance, child: Instance): void {
|
|
669
|
+
if (child.type === '__text__') {
|
|
670
|
+
parent.textContent = (parent.textContent || '') + (child.textContent || '')
|
|
671
|
+
} else {
|
|
672
|
+
parent.children.push(child)
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
appendChild(parent: Instance, child: Instance): void {
|
|
677
|
+
if (child.type === '__text__') {
|
|
678
|
+
parent.textContent = (parent.textContent || '') + (child.textContent || '')
|
|
679
|
+
} else {
|
|
680
|
+
parent.children.push(child)
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
appendChildToContainer(container: Container, child: Instance): void {
|
|
685
|
+
if (child.type !== '__text__') {
|
|
686
|
+
container.children.push(child)
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
|
|
690
|
+
removeChild(parent: Instance, child: Instance): void {
|
|
691
|
+
const index = parent.children.indexOf(child)
|
|
692
|
+
if (index !== -1) parent.children.splice(index, 1)
|
|
693
|
+
},
|
|
694
|
+
|
|
695
|
+
removeChildFromContainer(container: Container, child: Instance): void {
|
|
696
|
+
const index = container.children.indexOf(child)
|
|
697
|
+
if (index !== -1) container.children.splice(index, 1)
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
insertBefore(parent: Instance, child: Instance, beforeChild: Instance): void {
|
|
701
|
+
if (child.type === '__text__') return
|
|
702
|
+
const index = parent.children.indexOf(beforeChild)
|
|
703
|
+
if (index !== -1) {
|
|
704
|
+
parent.children.splice(index, 0, child)
|
|
705
|
+
} else {
|
|
706
|
+
parent.children.push(child)
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
insertInContainerBefore(container: Container, child: Instance, beforeChild: Instance): void {
|
|
711
|
+
if (child.type === '__text__') return
|
|
712
|
+
const index = container.children.indexOf(beforeChild)
|
|
713
|
+
if (index !== -1) {
|
|
714
|
+
container.children.splice(index, 0, child)
|
|
715
|
+
} else {
|
|
716
|
+
container.children.push(child)
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
prepareForCommit(): Record<string, unknown> | null {
|
|
721
|
+
return null
|
|
722
|
+
},
|
|
723
|
+
|
|
724
|
+
resetAfterCommit(): void {},
|
|
725
|
+
|
|
726
|
+
clearContainer(container: Container): void {
|
|
727
|
+
container.children = []
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
finalizeInitialChildren() {
|
|
731
|
+
return false
|
|
732
|
+
},
|
|
733
|
+
|
|
734
|
+
prepareUpdate() {
|
|
735
|
+
return true
|
|
736
|
+
},
|
|
737
|
+
|
|
738
|
+
commitUpdate(
|
|
739
|
+
instance: Instance,
|
|
740
|
+
_updatePayload: unknown,
|
|
741
|
+
_type: string,
|
|
742
|
+
_prevProps: Record<string, unknown>,
|
|
743
|
+
nextProps: Record<string, unknown>
|
|
744
|
+
): void {
|
|
745
|
+
const { children: _, ...rest } = nextProps
|
|
746
|
+
instance.props = rest
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
commitTextUpdate(
|
|
750
|
+
textInstance: Instance,
|
|
751
|
+
_oldText: string,
|
|
752
|
+
newText: string
|
|
753
|
+
): void {
|
|
754
|
+
textInstance.textContent = newText
|
|
755
|
+
},
|
|
756
|
+
|
|
757
|
+
getPublicInstance(instance: Instance): Instance {
|
|
758
|
+
return instance
|
|
759
|
+
},
|
|
760
|
+
|
|
761
|
+
preparePortalMount() {},
|
|
762
|
+
|
|
763
|
+
getCurrentEventPriority() {
|
|
764
|
+
return 16 // DefaultEventPriority
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
getInstanceFromNode() {
|
|
768
|
+
return null
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
beforeActiveInstanceBlur() {},
|
|
772
|
+
|
|
773
|
+
afterActiveInstanceBlur() {},
|
|
774
|
+
|
|
775
|
+
prepareScopeUpdate() {},
|
|
776
|
+
|
|
777
|
+
getInstanceFromScope() {
|
|
778
|
+
return null
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
detachDeletedInstance() {},
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Render a React element directly to NodeChanges
|
|
786
|
+
*/
|
|
787
|
+
export function renderToNodeChanges(
|
|
788
|
+
element: React.ReactElement,
|
|
789
|
+
options: RenderOptions
|
|
790
|
+
): RenderResult {
|
|
791
|
+
const container: Container = {
|
|
792
|
+
options,
|
|
793
|
+
localIDCounter: options.startLocalID ?? 1,
|
|
794
|
+
children: [],
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const reconciler = Reconciler(hostConfig)
|
|
798
|
+
|
|
799
|
+
const root = reconciler.createContainer(
|
|
800
|
+
container,
|
|
801
|
+
0, // tag: LegacyRoot
|
|
802
|
+
null, // hydrationCallbacks
|
|
803
|
+
false, // isStrictMode
|
|
804
|
+
null, // concurrentUpdatesByDefaultOverride
|
|
805
|
+
'', // identifierPrefix
|
|
806
|
+
() => {}, // onUncaughtError
|
|
807
|
+
() => {}, // onCaughtError
|
|
808
|
+
() => {}, // onRecoverableError
|
|
809
|
+
() => {}, // onDefaultTransitionIndicator
|
|
810
|
+
null // transitionCallbacks
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
reconciler.updateContainer(element, root, null, () => {})
|
|
814
|
+
reconciler.flushSync(() => {})
|
|
815
|
+
|
|
816
|
+
const nodeChanges: NodeChange[] = []
|
|
817
|
+
container.children.forEach((child, i) => {
|
|
818
|
+
const position = String.fromCharCode(33 + (i % 90))
|
|
819
|
+
collectNodeChanges(child, options.sessionID, options.parentGUID, position, nodeChanges, container)
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
return {
|
|
823
|
+
nodeChanges,
|
|
824
|
+
nextLocalID: container.localIDCounter,
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Get default variant values (first value for each property)
|
|
829
|
+
function getDefaultVariants(variants: Record<string, readonly string[]>): Record<string, string> {
|
|
830
|
+
const defaults: Record<string, string> = {}
|
|
831
|
+
for (const [key, values] of Object.entries(variants)) {
|
|
832
|
+
if (values.length > 0) {
|
|
833
|
+
defaults[key] = values[0]
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return defaults
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Reset component tracking between renders
|
|
840
|
+
export function resetRenderedComponents() {
|
|
841
|
+
renderedComponents.clear()
|
|
842
|
+
renderedComponentSets.clear()
|
|
843
|
+
renderedComponentSetVariants.clear()
|
|
844
|
+
pendingComponentSetInstances.length = 0
|
|
845
|
+
}
|