@toghen/figma 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +18 -2
- package/cli/bin.mjs +19 -0
- package/cli/bridge.ts +243 -0
- package/cli/check.ts +62 -0
- package/cli/figcraft.ts +764 -0
- package/cli/figcraftApply.ts +1067 -0
- package/cli/figcraftFile.ts +476 -0
- package/cli/figcraftLibrary.ts +147 -0
- package/cli/figcraftRecord.ts +121 -0
- package/cli/figcraftScene.ts +661 -0
- package/cli/main.ts +218 -0
- package/cli/members.ts +65 -0
- package/cli/run.ts +756 -0
- package/component-files.md +665 -0
- package/dist/code.js +24161 -0
- package/dist/manifest.json +23 -0
- package/dist/ui.html +6918 -0
- package/guide.md +985 -0
- package/main/code.ts +50 -0
- package/main/components/apply.ts +704 -0
- package/main/components/endOfRun.ts +168 -0
- package/main/components/errors.ts +17 -0
- package/main/components/fieldWriter.ts +497 -0
- package/main/components/file.ts +263 -0
- package/main/components/receipt.ts +228 -0
- package/main/components/resolve.ts +1193 -0
- package/main/components/run.ts +273 -0
- package/main/components/snapshot.ts +341 -0
- package/main/components/stamp.ts +312 -0
- package/main/components/update.ts +465 -0
- package/main/dispatcher.ts +33 -0
- package/main/engines/batchRunner.ts +200 -0
- package/main/engines/ioAdapter.ts +132 -0
- package/main/engines/operationExecutor.ts +512 -0
- package/main/engines/propertyMapper.ts +221 -0
- package/main/engines/queryEngine.ts +45 -0
- package/main/engines/styleSyncEngine.ts +444 -0
- package/main/engines/variableSyncEngine.ts +561 -0
- package/main/handlers/auditSelection.ts +60 -0
- package/main/handlers/boundLayers.ts +51 -0
- package/main/handlers/cancelOperation.ts +18 -0
- package/main/handlers/executeApplyBindings.ts +69 -0
- package/main/handlers/executeScaffold.ts +59 -0
- package/main/handlers/executeStyleSync.ts +121 -0
- package/main/handlers/executeVariableSync.ts +82 -0
- package/main/handlers/exportDocument.ts +114 -0
- package/main/handlers/getSelection.ts +43 -0
- package/main/handlers/index.ts +30 -0
- package/main/handlers/inspectCorrespondence.ts +172 -0
- package/main/handlers/loadConventions.ts +86 -0
- package/main/handlers/loadDocument.ts +253 -0
- package/main/handlers/lock.ts +270 -0
- package/main/handlers/planApply.ts +157 -0
- package/main/handlers/planApplyBindings.ts +79 -0
- package/main/handlers/planScaffold.ts +84 -0
- package/main/handlers/planStyleSync.ts +49 -0
- package/main/handlers/planVariableSync.ts +49 -0
- package/main/handlers/readSelectionBindings.ts +160 -0
- package/main/handlers/resizeUi.ts +105 -0
- package/main/handlers/runComponent.ts +65 -0
- package/main/handlers/templates.ts +462 -0
- package/main/handlers/tokenGraph.ts +122 -0
- package/main/runtimeState.ts +66 -0
- package/main/utils/abortController.ts +11 -0
- package/main/utils/boundVariables.ts +84 -0
- package/main/utils/buildPayloads.ts +85 -0
- package/main/utils/correspondence.ts +658 -0
- package/main/utils/designSource.ts +122 -0
- package/main/utils/figmaCollectionIndex.ts +120 -0
- package/main/utils/figmaStyleAdapter.ts +268 -0
- package/main/utils/figmaTokenAdapter.ts +255 -0
- package/main/utils/flattenDesignSystem.ts +38 -0
- package/main/utils/folds.ts +203 -0
- package/main/utils/fontStack.ts +57 -0
- package/main/utils/lists.ts +4 -0
- package/main/utils/lockStore.ts +72 -0
- package/main/utils/moves.ts +67 -0
- package/main/utils/pageRoots.ts +58 -0
- package/main/utils/planSymbols.ts +96 -0
- package/main/utils/purpose.ts +351 -0
- package/main/utils/recentStore.ts +166 -0
- package/main/utils/resolverModes.ts +271 -0
- package/main/utils/rootRecord.ts +137 -0
- package/main/utils/shardedData.ts +142 -0
- package/main/utils/standing.ts +106 -0
- package/main/utils/styleTarget.ts +51 -0
- package/main/utils/sum.ts +612 -0
- package/main/utils/symbolicIdRegistry.ts +26 -0
- package/main/utils/syncState.ts +133 -0
- package/main/utils/templateStore.ts +73 -0
- package/main/utils/tokenPath.ts +28 -0
- package/main/utils/tokenStamps.ts +91 -0
- package/manifest.json +15 -0
- package/package.json +64 -4
- package/reference.md +1609 -0
- package/shared/components/artboard.ts +126 -0
- package/shared/components/axes.ts +420 -0
- package/shared/components/bindings.ts +64 -0
- package/shared/components/blueprint.ts +248 -0
- package/shared/components/check.ts +705 -0
- package/shared/components/diff.ts +668 -0
- package/shared/components/fields.ts +404 -0
- package/shared/components/groups.ts +306 -0
- package/shared/components/index.ts +140 -0
- package/shared/components/members.ts +280 -0
- package/shared/components/model.ts +273 -0
- package/shared/components/order.ts +53 -0
- package/shared/components/read.ts +480 -0
- package/shared/components/stack.ts +124 -0
- package/shared/components/steps.ts +186 -0
- package/shared/components/validate.ts +565 -0
- package/shared/conventions.ts +28 -0
- package/shared/inputs.ts +40 -0
- package/shared/operations.ts +233 -0
- package/shared/pendingNames.ts +39 -0
- package/shared/protocol.ts +588 -0
- package/shared/tokenTypeMap.ts +21 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FigCraft's layers in Figma's words: the read side of the FigCraft target's components.
|
|
3
|
+
*
|
|
4
|
+
* FigCraft keeps Figma's layer model, spelled its own way: `node.get` answers a layer with its auto layout under
|
|
5
|
+
* `layout`, its bindings as one flat map (`fills/0/color`, `gap`), its paints as hex colours. The component engine's
|
|
6
|
+
* reads (the snapshot, resolution, the receipts, the end of a run) are written against Figma's nodes, so this module
|
|
7
|
+
* gives each FigCraft layer as a node those reads understand, the way `figmaView` gives FigCraft's variables. They then
|
|
8
|
+
* run as they do in Figma: the same stamps, the same receipts, the same plan (specs.md, *FigCraft*, *Components*).
|
|
9
|
+
*
|
|
10
|
+
* The nodes are read-only but for what the reads themselves write back: shared plugin data (a stamp), `remove()`, a
|
|
11
|
+
* position (`x`, `y`) and a set's size (`resizeWithoutConstraints`), as the layout of a set's members writes them. Those are kept as writes (`Writes`) that the target sends to FigCraft; nothing here talks to it.
|
|
12
|
+
*
|
|
13
|
+
* Two fields FigCraft keeps but does not report: a layer's wiring to a component property, and a frame's variable
|
|
14
|
+
* modes. What Toghen wrote of them is kept in the layer's own data (`UNREPORTED_KEY`) and read back from there: a hand
|
|
15
|
+
* change to either is not seen until FigCraft reports them (plan, *Answers*, 2).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { FIELDS } from '../shared/components/fields.js';
|
|
19
|
+
import { transformOf } from './figcraftFile.js';
|
|
20
|
+
|
|
21
|
+
/** A layer as `node.get` answers it, with the fields this module reads. */
|
|
22
|
+
export interface FcNode {
|
|
23
|
+
id: number;
|
|
24
|
+
type: string;
|
|
25
|
+
name: string;
|
|
26
|
+
x?: number;
|
|
27
|
+
y?: number;
|
|
28
|
+
width?: number;
|
|
29
|
+
height?: number;
|
|
30
|
+
visible?: boolean;
|
|
31
|
+
opacity?: number;
|
|
32
|
+
rotation?: number;
|
|
33
|
+
children?: FcNode[];
|
|
34
|
+
pluginData?: Record<string, string>;
|
|
35
|
+
layout?: {
|
|
36
|
+
mode?: string;
|
|
37
|
+
gap?: number;
|
|
38
|
+
crossGap?: number;
|
|
39
|
+
padding?: number[];
|
|
40
|
+
mainAlign?: string;
|
|
41
|
+
crossAlign?: string;
|
|
42
|
+
wrap?: boolean;
|
|
43
|
+
contentAlign?: string;
|
|
44
|
+
columnTracks?: { sizing: string; value?: number }[];
|
|
45
|
+
rowTracks?: { sizing: string; value?: number }[];
|
|
46
|
+
};
|
|
47
|
+
gridCell?: { column?: number; row?: number; columnSpan?: number; rowSpan?: number; alignH?: string | null; alignV?: string | null };
|
|
48
|
+
constraints?: { horizontal?: string; vertical?: string };
|
|
49
|
+
dashes?: number[];
|
|
50
|
+
strokeJoin?: string;
|
|
51
|
+
strokeCap?: string;
|
|
52
|
+
blend?: string;
|
|
53
|
+
cornerSmoothing?: number;
|
|
54
|
+
listSpacing?: number;
|
|
55
|
+
leadingTrim?: string;
|
|
56
|
+
maxLines?: number;
|
|
57
|
+
paragraphSpacing?: number;
|
|
58
|
+
paragraphIndent?: number;
|
|
59
|
+
/** A slot layer's property name: a frame FigCraft made a slot (`component.makeSlot`). */
|
|
60
|
+
slot?: string;
|
|
61
|
+
sizing?: { horizontal?: string; vertical?: string };
|
|
62
|
+
absolute?: boolean;
|
|
63
|
+
minWidth?: number;
|
|
64
|
+
maxWidth?: number;
|
|
65
|
+
minHeight?: number;
|
|
66
|
+
maxHeight?: number;
|
|
67
|
+
cornerRadius?: number | number[];
|
|
68
|
+
strokeWeight?: number;
|
|
69
|
+
strokeSides?: number[];
|
|
70
|
+
strokeAlign?: string;
|
|
71
|
+
clip?: boolean;
|
|
72
|
+
fills?: FcPaint[];
|
|
73
|
+
strokes?: FcPaint[];
|
|
74
|
+
effects?: FcEffect[];
|
|
75
|
+
grids?: FcGrid[];
|
|
76
|
+
boundVariables?: Record<string, string>;
|
|
77
|
+
styles?: { text?: string | null; effect?: string | null };
|
|
78
|
+
characters?: string;
|
|
79
|
+
fontFamily?: string;
|
|
80
|
+
fontStyle?: string;
|
|
81
|
+
fontSize?: number;
|
|
82
|
+
fontWeight?: number;
|
|
83
|
+
lineHeight?: string | number;
|
|
84
|
+
letterSpacing?: string | number;
|
|
85
|
+
textAlign?: string;
|
|
86
|
+
textAlignVertical?: string;
|
|
87
|
+
textAutoResize?: string;
|
|
88
|
+
textCase?: string;
|
|
89
|
+
textDecoration?: string;
|
|
90
|
+
mainComponent?: number;
|
|
91
|
+
componentProperties?: Record<string, string | boolean | number>;
|
|
92
|
+
componentPropertyDefinitions?: Record<string, { type: string; default?: unknown; preferred?: unknown[] }>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FcPaint {
|
|
96
|
+
type: string;
|
|
97
|
+
/** A solid's, as hex. */
|
|
98
|
+
color?: string;
|
|
99
|
+
opacity?: number;
|
|
100
|
+
visible?: boolean;
|
|
101
|
+
/** A gradient's: `linear`, `radial`, `angular`, `diamond`, its stops and its ramp's ends in the layer. */
|
|
102
|
+
kind?: string;
|
|
103
|
+
stops?: { color: RGBA; position: number }[];
|
|
104
|
+
from?: [number, number];
|
|
105
|
+
to?: [number, number];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An effect as `node.get` answers it: a shadow whole, a blur by its radius. */
|
|
109
|
+
export interface FcEffect {
|
|
110
|
+
type: string;
|
|
111
|
+
color?: RGBA;
|
|
112
|
+
offset?: [number, number];
|
|
113
|
+
radius: number;
|
|
114
|
+
spread?: number;
|
|
115
|
+
visible?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A layout grid as `node.get` answers it, under `grids`. */
|
|
119
|
+
export interface FcGrid {
|
|
120
|
+
kind: string;
|
|
121
|
+
align?: string;
|
|
122
|
+
count?: number;
|
|
123
|
+
gutter?: number;
|
|
124
|
+
offset?: number;
|
|
125
|
+
size?: number;
|
|
126
|
+
color?: RGBA;
|
|
127
|
+
visible?: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The layer data key that holds what Toghen wrote of the two fields FigCraft does not report. */
|
|
131
|
+
export const UNREPORTED_KEY = 'toghen.unreported';
|
|
132
|
+
|
|
133
|
+
/** What `UNREPORTED_KEY` holds: a layer's wiring by field, and a frame's modes by collection id. */
|
|
134
|
+
export interface Unreported {
|
|
135
|
+
references?: Record<string, string>;
|
|
136
|
+
modes?: Record<string, string>;
|
|
137
|
+
/** Fields FigCraft takes and does not report, as Figma would hold them (`UNREPORTED_FIELDS` in `figcraftApply.ts`). */
|
|
138
|
+
fields?: Record<string, unknown>;
|
|
139
|
+
/** On a set or a component: each slot property's description and settings, which FigCraft's slots do not hold. */
|
|
140
|
+
slots?: Record<string, { description?: string; slotSettings?: SlotSettings }>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A slot property's settings, as Figma's `editComponentProperty` takes them. */
|
|
144
|
+
type SlotSettings = Record<string, number | boolean>;
|
|
145
|
+
|
|
146
|
+
/** Shared plugin data as FigCraft keeps it: one flat key, `namespace.key`. */
|
|
147
|
+
export const dataKey = (namespace: string, key: string): string => `${namespace}.${key}`;
|
|
148
|
+
|
|
149
|
+
/** What the reads wrote back, in the order they wrote it, for the target to send. */
|
|
150
|
+
export interface Writes {
|
|
151
|
+
data: Map<number, Record<string, string>>;
|
|
152
|
+
removed: number[];
|
|
153
|
+
moved: Map<number, { x: number; y: number }>;
|
|
154
|
+
sized: Map<number, { width: number; height: number }>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const noWrites = (): Writes => ({ data: new Map(), removed: [], moved: new Map(), sized: new Map() });
|
|
158
|
+
export const hasWrites = (writes: Writes): boolean =>
|
|
159
|
+
writes.data.size > 0 || writes.removed.length > 0 || writes.moved.size > 0 || writes.sized.size > 0;
|
|
160
|
+
|
|
161
|
+
/** Empties the writes once they are sent. */
|
|
162
|
+
export function clearWrites(writes: Writes): void {
|
|
163
|
+
writes.data.clear();
|
|
164
|
+
writes.moved.clear();
|
|
165
|
+
writes.sized.clear();
|
|
166
|
+
writes.removed.length = 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The writes as FigCraft commands: the data, then the moves and sizes, then the removals. */
|
|
170
|
+
export function writeSteps(writes: Writes): Array<{ command: string; params: Record<string, unknown> }> {
|
|
171
|
+
return [
|
|
172
|
+
...[...writes.data].map(([id, pluginData]) => ({ command: 'node.set', params: { ids: [id], props: { pluginData } } })),
|
|
173
|
+
...[...writes.moved].map(([id, { x, y }]) => ({ command: 'node.set', params: { ids: [id], props: { x, y } } })),
|
|
174
|
+
...[...writes.sized].map(([id, { width, height }]) => ({ command: 'node.set', params: { ids: [id], props: { width, height } } })),
|
|
175
|
+
...(writes.removed.length > 0 ? [{ command: 'edit.delete', params: { ids: writes.removed } }] : []),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const upper = (value: string | undefined): string | undefined => value?.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
|
|
180
|
+
|
|
181
|
+
/** A hex colour, `#rrggbb` or `#rrggbbaa`, as Figma's colour and a paint's opacity. */
|
|
182
|
+
function rgbOf(hex: string): { color: RGB; alpha: number } {
|
|
183
|
+
const channel = (at: number): number => parseInt(hex.slice(at, at + 2), 16) / 255;
|
|
184
|
+
return { color: { r: channel(1), g: channel(3), b: channel(5) }, alpha: hex.length === 9 ? channel(7) : 1 };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const alias = (id: string): VariableAlias => ({ type: 'VARIABLE_ALIAS', id });
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A paint as Figma holds it, its colour's variable on it as Figma's `setBoundVariableForPaint` leaves it; a gradient
|
|
191
|
+
* with each stop's variable on the stop. `bound` reads the paint's bindings by the field after its index.
|
|
192
|
+
*/
|
|
193
|
+
function paintOf(paint: FcPaint, bound: (part: string) => string | undefined): Paint {
|
|
194
|
+
if (paint.type === 'gradient' && paint.kind) {
|
|
195
|
+
const gradientStops = (paint.stops ?? []).map((stop, j) => {
|
|
196
|
+
const variable = bound(`stops/${j}/color`);
|
|
197
|
+
return {
|
|
198
|
+
position: stop.position,
|
|
199
|
+
color: stop.color,
|
|
200
|
+
...(variable !== undefined ? { boundVariables: { color: alias(variable) } } : {}),
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
return {
|
|
204
|
+
type: `GRADIENT_${upper(paint.kind)}`,
|
|
205
|
+
gradientStops,
|
|
206
|
+
gradientTransform: transformOf(paint),
|
|
207
|
+
opacity: paint.opacity ?? 1,
|
|
208
|
+
visible: paint.visible ?? true,
|
|
209
|
+
} as unknown as GradientPaint;
|
|
210
|
+
}
|
|
211
|
+
if (paint.type !== 'solid' || paint.color === undefined) return { type: (upper(paint.type) ?? 'UNKNOWN') as Paint['type'] } as Paint;
|
|
212
|
+
const variable = bound('color');
|
|
213
|
+
const { color, alpha } = rgbOf(paint.color);
|
|
214
|
+
// A bound paint's colour is its variable's in the layer's mode, alpha included: the paint's opacity is its own.
|
|
215
|
+
const opacity = (paint.opacity ?? 1) * (variable !== undefined ? 1 : alpha);
|
|
216
|
+
return {
|
|
217
|
+
type: 'SOLID',
|
|
218
|
+
color,
|
|
219
|
+
opacity,
|
|
220
|
+
visible: paint.visible ?? true,
|
|
221
|
+
...(variable !== undefined ? { boundVariables: { color: alias(variable) } } : {}),
|
|
222
|
+
} as SolidPaint;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The parts of the `index`th item of a list bound to a variable, by part: `effects/0/radius` is `{ radius }`. */
|
|
226
|
+
function partsBound(node: FcNode, list: string, index: number, names: Record<string, string> = {}): Record<string, VariableAlias> {
|
|
227
|
+
const prefix = `${list}/${index}/`;
|
|
228
|
+
const out: Record<string, VariableAlias> = {};
|
|
229
|
+
for (const [field, id] of Object.entries(node.boundVariables ?? {}))
|
|
230
|
+
if (field.startsWith(prefix)) out[names[field.slice(prefix.length)] ?? field.slice(prefix.length)] = alias(id);
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** An effect as Figma holds it. */
|
|
235
|
+
function effectOf(node: FcNode, effect: FcEffect, index: number): Effect {
|
|
236
|
+
const boundVariables = partsBound(node, 'effects', index);
|
|
237
|
+
if (effect.type === 'layerBlur' || effect.type === 'backgroundBlur')
|
|
238
|
+
return {
|
|
239
|
+
type: upper(effect.type),
|
|
240
|
+
blurType: 'NORMAL',
|
|
241
|
+
radius: effect.radius,
|
|
242
|
+
visible: effect.visible ?? true,
|
|
243
|
+
boundVariables,
|
|
244
|
+
} as unknown as Effect;
|
|
245
|
+
return {
|
|
246
|
+
type: upper(effect.type),
|
|
247
|
+
color: effect.color,
|
|
248
|
+
offset: { x: effect.offset?.[0] ?? 0, y: effect.offset?.[1] ?? 0 },
|
|
249
|
+
radius: effect.radius,
|
|
250
|
+
spread: effect.spread ?? 0,
|
|
251
|
+
visible: effect.visible ?? true,
|
|
252
|
+
blendMode: 'NORMAL',
|
|
253
|
+
boundVariables,
|
|
254
|
+
} as unknown as Effect;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** FigCraft's names for a layout grid's bound parts, as Figma's. */
|
|
258
|
+
const GRID_NAMES: Record<string, string> = { count: 'count', gutter: 'gutterSize', offset: 'offset', size: 'sectionSize' };
|
|
259
|
+
|
|
260
|
+
/** A layout grid as Figma holds it. */
|
|
261
|
+
function gridOf(node: FcNode, grid: FcGrid, index: number): LayoutGrid {
|
|
262
|
+
const out: Record<string, unknown> = {
|
|
263
|
+
pattern: grid.kind.toUpperCase(),
|
|
264
|
+
visible: grid.visible ?? true,
|
|
265
|
+
color: grid.color,
|
|
266
|
+
boundVariables: partsBound(node, 'layoutGrids', index, GRID_NAMES),
|
|
267
|
+
};
|
|
268
|
+
if (grid.kind !== 'grid') out.alignment = (grid.align ?? 'stretch').toUpperCase();
|
|
269
|
+
for (const [fc, figma] of Object.entries(GRID_NAMES)) if (grid[fc as keyof FcGrid] !== undefined) out[figma] = grid[fc as keyof FcGrid];
|
|
270
|
+
return out as unknown as LayoutGrid;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `node.get`'s length, `"Auto"`, `"150%"` or a number of pixels, as Figma's `LineHeight` or `LetterSpacing`. */
|
|
274
|
+
function lengthOf(value: string | number | undefined): { unit: string; value?: number } | undefined {
|
|
275
|
+
if (value === undefined) return undefined;
|
|
276
|
+
if (typeof value === 'number') return { unit: 'PIXELS', value };
|
|
277
|
+
if (/^auto$/i.test(value)) return { unit: 'AUTO' };
|
|
278
|
+
if (value.endsWith('%')) return { unit: 'PERCENT', value: Number(value.slice(0, -1)) };
|
|
279
|
+
return { unit: 'PIXELS', value: Number(value) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const MAIN_ALIGN: Record<string, string> = {
|
|
283
|
+
min: 'MIN',
|
|
284
|
+
center: 'CENTER',
|
|
285
|
+
max: 'MAX',
|
|
286
|
+
spaceBetween: 'SPACE_BETWEEN',
|
|
287
|
+
spaceEvenly: 'SPACE_EVENLY',
|
|
288
|
+
spaceAround: 'SPACE_AROUND',
|
|
289
|
+
};
|
|
290
|
+
const RESIZE: Record<string, string> = { widthAndHeight: 'WIDTH_AND_HEIGHT', height: 'HEIGHT', none: 'NONE', truncate: 'TRUNCATE' };
|
|
291
|
+
const CORNERS = ['topLeftRadius', 'topRightRadius', 'bottomRightRadius', 'bottomLeftRadius'];
|
|
292
|
+
const SIDES = ['strokeTopWeight', 'strokeRightWeight', 'strokeBottomWeight', 'strokeLeftWeight'];
|
|
293
|
+
/** FigCraft's binding names that differ from Figma's; in a grid, `gap` is the column gap and `crossGap` the row gap. */
|
|
294
|
+
const BOUND_AS: Record<string, string> = { gap: 'itemSpacing', crossGap: 'counterAxisSpacing' };
|
|
295
|
+
const BOUND_IN_GRID: Record<string, string> = { gap: 'gridColumnGap', crossGap: 'gridRowGap' };
|
|
296
|
+
|
|
297
|
+
/** FigCraft's words back into Figma's: a constraint per axis, a blend mode, a track's sizing. */
|
|
298
|
+
const CONSTRAINT_OF: Record<string, string> = {
|
|
299
|
+
left: 'MIN',
|
|
300
|
+
top: 'MIN',
|
|
301
|
+
right: 'MAX',
|
|
302
|
+
bottom: 'MAX',
|
|
303
|
+
leftRight: 'STRETCH',
|
|
304
|
+
topBottom: 'STRETCH',
|
|
305
|
+
center: 'CENTER',
|
|
306
|
+
scale: 'SCALE',
|
|
307
|
+
};
|
|
308
|
+
const BLEND_OF: Record<string, string> = { 'pass through': 'PASS_THROUGH', plusDarker: 'LINEAR_BURN', plusLighter: 'LINEAR_DODGE' };
|
|
309
|
+
const CAP_OF: Record<string, string> = { lineArrow: 'ARROW_LINES', triangleArrow: 'ARROW_EQUILATERAL' };
|
|
310
|
+
const TRACK_OF: Record<string, string> = { fixed: 'FIXED', fill: 'FLEX', hug: 'HUG' };
|
|
311
|
+
const trackOf = (track: { sizing: string; value?: number }) => ({ type: TRACK_OF[track.sizing] ?? 'FIXED', value: track.value ?? 0 });
|
|
312
|
+
|
|
313
|
+
/** A node's bindings as Figma reports them: a corner radius and a stroke weight by their parts, a text field as a range. */
|
|
314
|
+
function boundOf(node: FcNode): Record<string, unknown> {
|
|
315
|
+
const out: Record<string, unknown> = {};
|
|
316
|
+
for (const [field, id] of Object.entries(node.boundVariables ?? {})) {
|
|
317
|
+
if (/^(fills?|strokes?|effects|layoutGrids)(\/|$)/.test(field)) continue;
|
|
318
|
+
if (field.startsWith('componentProperties/')) {
|
|
319
|
+
out.componentProperties = { ...(out.componentProperties as object), [field.slice('componentProperties/'.length)]: alias(id) };
|
|
320
|
+
} else if (field === 'cornerRadius') {
|
|
321
|
+
for (const corner of CORNERS) out[corner] ??= alias(id);
|
|
322
|
+
} else if (field === 'strokeWeight') {
|
|
323
|
+
for (const side of SIDES) out[side] ??= alias(id);
|
|
324
|
+
} else if (node.type === 'TEXT' && FIELDS[field]?.token?.bind?.call === 'text') {
|
|
325
|
+
out[field] = [alias(id)];
|
|
326
|
+
} else {
|
|
327
|
+
out[(node.layout?.mode === 'grid' ? BOUND_IN_GRID[field] : undefined) ?? BOUND_AS[field] ?? field] = alias(id);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** What a paint's part is bound to: FigCraft says `fill` for the first fill's colour bound whole, `fills/0/color` by index. */
|
|
334
|
+
const paintBinding =
|
|
335
|
+
(node: FcNode, list: 'fills' | 'strokes', index: number) =>
|
|
336
|
+
(part: string): string | undefined =>
|
|
337
|
+
node.boundVariables?.[`${list}/${index}/${part}`] ??
|
|
338
|
+
(index === 0 && part === 'color' ? node.boundVariables?.[list === 'fills' ? 'fill' : 'stroke'] : undefined);
|
|
339
|
+
|
|
340
|
+
/** Figma's message for a set that cannot be read: what the engine's snapshot recognises. */
|
|
341
|
+
const brokenSet = (name: string): Error => new Error(`Component set "${name}" has existing errors`);
|
|
342
|
+
|
|
343
|
+
/** Each axis and its value, from a member's name `Axis=value, Axis=value`, or undefined when it does not read so. */
|
|
344
|
+
const combinationOf = (name: string): Array<[string, string]> | undefined => {
|
|
345
|
+
const pairs = name.split(',').map((part) => part.split('=').map((half) => half.trim()));
|
|
346
|
+
return pairs.every((pair) => pair.length === 2 && pair[0] !== '') ? (pairs as Array<[string, string]>) : undefined;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A property's definition, as Figma's, from the way FigCraft lists it on a member. A slot's description and settings are
|
|
351
|
+
* what Toghen wrote of them (`recorded`), which FigCraft does not hold. Preferred values are FigCraft's component and set
|
|
352
|
+
* ids, each read as Figma reports one (`offered`): by the key the view gives that component or set.
|
|
353
|
+
*/
|
|
354
|
+
function definitionOf(
|
|
355
|
+
definition: { type: string; default?: unknown; preferred?: unknown[] },
|
|
356
|
+
offered: (id: unknown) => InstanceSwapPreferredValue,
|
|
357
|
+
recorded?: { description?: string; slotSettings?: SlotSettings },
|
|
358
|
+
): ComponentPropertyDefinitions[string] {
|
|
359
|
+
const type = definition.type === 'instanceSwap' ? 'INSTANCE_SWAP' : (upper(definition.type) as ComponentPropertyType);
|
|
360
|
+
const preferredValues = (definition.preferred ?? []).map(offered);
|
|
361
|
+
if (type === 'SLOT')
|
|
362
|
+
return {
|
|
363
|
+
type,
|
|
364
|
+
preferredValues,
|
|
365
|
+
...(recorded?.description !== undefined ? { description: recorded.description } : {}),
|
|
366
|
+
...(recorded?.slotSettings ? { slotSettings: recorded.slotSettings } : {}),
|
|
367
|
+
} as unknown as ComponentPropertyDefinitions[string];
|
|
368
|
+
const defaultValue = type === 'INSTANCE_SWAP' ? String(definition.default) : definition.default;
|
|
369
|
+
return { type, defaultValue, ...(type === 'INSTANCE_SWAP' ? { preferredValues } : {}) } as ComponentPropertyDefinitions[string];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export interface Scene {
|
|
373
|
+
pages: SceneNode[];
|
|
374
|
+
byId(id: string): SceneNode | undefined;
|
|
375
|
+
/** A library's copy read after the scene was: viewed as the others are, found by id, on no page. */
|
|
376
|
+
adopt(node: FcNode, key: string): SceneNode;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A library's copies of components in the file, on no page: what each was imported from is `keyOf` its id. They are
|
|
381
|
+
* the library's, never the file's: their data (the library's stamps, which come along) is not read as Toghen's here.
|
|
382
|
+
*/
|
|
383
|
+
export interface Copies {
|
|
384
|
+
nodes: FcNode[];
|
|
385
|
+
keyOf(id: string): string | undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* The pages of a FigCraft file, each with its layers, as Figma nodes. `pages` are `node.get` of each page, children
|
|
390
|
+
* to the depth the layers have. What the nodes write back goes into `writes`.
|
|
391
|
+
*/
|
|
392
|
+
export function sceneOf(pages: FcNode[], writes: Writes, copies: Copies = { nodes: [], keyOf: () => undefined }): Scene {
|
|
393
|
+
const index = new Map<string, SceneNode>();
|
|
394
|
+
const views = pages.map((page) => view(page, null, false));
|
|
395
|
+
// A copy's own data is the library's, as an instance's is its main's.
|
|
396
|
+
for (const copy of copies.nodes) view(copy, null, true, copies.keyOf(String(copy.id)));
|
|
397
|
+
return { pages: views, byId: (id) => index.get(id), adopt: (node, key) => view(node, null, true, key) };
|
|
398
|
+
|
|
399
|
+
function offered(id: unknown): InstanceSwapPreferredValue {
|
|
400
|
+
const node = index.get(String(id)) as (SceneNode & { key?: string }) | undefined;
|
|
401
|
+
return { type: node?.type === 'COMPONENT_SET' ? 'COMPONENT_SET' : 'COMPONENT', key: node?.key ?? String(id) };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function view(node: FcNode, parent: SceneNode | null, inInstance: boolean, copyOf?: string): SceneNode {
|
|
405
|
+
const id = String(node.id);
|
|
406
|
+
const data = { ...node.pluginData };
|
|
407
|
+
const unreported = (): Unreported => {
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse((own(UNREPORTED_KEY) && data[UNREPORTED_KEY]) || '{}') as Unreported;
|
|
410
|
+
} catch (_unreadable) {
|
|
411
|
+
return {};
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
// An instance is made with a copy of its main's layer data, which is the main's, not its own (measured): a layer
|
|
415
|
+
// inside an instance is never Toghen's, and neither is an instance stamped as a member, which only a main can be.
|
|
416
|
+
const own = (key: string): boolean => {
|
|
417
|
+
if (!key.startsWith('toghen.')) return true;
|
|
418
|
+
if (inInstance) return false;
|
|
419
|
+
if (node.type !== 'INSTANCE') return true;
|
|
420
|
+
try {
|
|
421
|
+
return (JSON.parse(data['toghen.stamp'] ?? '{}') as { is?: { member?: string } }).is?.member === undefined;
|
|
422
|
+
} catch (_unreadable) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const written = (key: string, value: string): void => {
|
|
427
|
+
data[key] = value;
|
|
428
|
+
writes.data.set(node.id, { ...writes.data.get(node.id), [key]: value });
|
|
429
|
+
};
|
|
430
|
+
const layout = node.layout ?? {};
|
|
431
|
+
const recorded = (field: string): any => unreported().fields?.[field];
|
|
432
|
+
const padding = layout.padding ?? [0, 0, 0, 0];
|
|
433
|
+
const corners = Array.isArray(node.cornerRadius)
|
|
434
|
+
? node.cornerRadius
|
|
435
|
+
: [0, 1, 2, 3].map(() => (node.cornerRadius as number | undefined) ?? 0);
|
|
436
|
+
const sides = node.strokeSides ?? [0, 1, 2, 3].map(() => node.strokeWeight ?? 0);
|
|
437
|
+
let x = node.x ?? 0;
|
|
438
|
+
let y = node.y ?? 0;
|
|
439
|
+
const out: Record<string, unknown> = {
|
|
440
|
+
id,
|
|
441
|
+
// A frame FigCraft made a slot is Figma's SLOT, in a main and in its instances alike.
|
|
442
|
+
type: node.slot !== undefined ? 'SLOT' : node.type,
|
|
443
|
+
name: node.name,
|
|
444
|
+
parent,
|
|
445
|
+
removed: false,
|
|
446
|
+
get x() {
|
|
447
|
+
return x;
|
|
448
|
+
},
|
|
449
|
+
set x(value: number) {
|
|
450
|
+
if (value === x) return;
|
|
451
|
+
x = value;
|
|
452
|
+
writes.moved.set(node.id, { x, y });
|
|
453
|
+
},
|
|
454
|
+
get y() {
|
|
455
|
+
return y;
|
|
456
|
+
},
|
|
457
|
+
set y(value: number) {
|
|
458
|
+
if (value === y) return;
|
|
459
|
+
y = value;
|
|
460
|
+
writes.moved.set(node.id, { x, y });
|
|
461
|
+
},
|
|
462
|
+
width: node.width ?? 0,
|
|
463
|
+
height: node.height ?? 0,
|
|
464
|
+
visible: node.visible ?? true,
|
|
465
|
+
opacity: node.opacity ?? 1,
|
|
466
|
+
rotation: node.rotation ?? 0,
|
|
467
|
+
layoutMode: layout.mode ? upper(layout.mode) : 'NONE',
|
|
468
|
+
itemSpacing: layout.gap ?? 0,
|
|
469
|
+
counterAxisSpacing: layout.crossGap ?? 0,
|
|
470
|
+
paddingTop: padding[0],
|
|
471
|
+
paddingRight: padding[1],
|
|
472
|
+
paddingBottom: padding[2],
|
|
473
|
+
paddingLeft: padding[3],
|
|
474
|
+
primaryAxisAlignItems: MAIN_ALIGN[layout.mainAlign ?? 'min'],
|
|
475
|
+
counterAxisAlignItems: upper(layout.crossAlign ?? 'min'),
|
|
476
|
+
layoutWrap: layout.wrap ? 'WRAP' : 'NO_WRAP',
|
|
477
|
+
layoutSizingHorizontal: upper(node.sizing?.horizontal ?? 'fixed'),
|
|
478
|
+
layoutSizingVertical: upper(node.sizing?.vertical ?? 'fixed'),
|
|
479
|
+
layoutPositioning: node.absolute ? 'ABSOLUTE' : 'AUTO',
|
|
480
|
+
minWidth: node.minWidth ?? null,
|
|
481
|
+
maxWidth: node.maxWidth ?? null,
|
|
482
|
+
minHeight: node.minHeight ?? null,
|
|
483
|
+
maxHeight: node.maxHeight ?? null,
|
|
484
|
+
cornerRadius: corners.every((corner) => corner === corners[0]) ? corners[0] : Symbol.for('figma.mixed'),
|
|
485
|
+
...Object.fromEntries(CORNERS.map((corner, at) => [corner, corners[at]])),
|
|
486
|
+
strokeWeight: node.strokeWeight ?? 0,
|
|
487
|
+
...Object.fromEntries(SIDES.map((side, at) => [side, sides[at]])),
|
|
488
|
+
strokeAlign: upper(node.strokeAlign ?? 'inside'),
|
|
489
|
+
clipsContent: node.clip ?? false,
|
|
490
|
+
fills: (node.fills ?? []).map((paint, at) => paintOf(paint, paintBinding(node, 'fills', at))),
|
|
491
|
+
strokes: (node.strokes ?? []).map((paint, at) => paintOf(paint, paintBinding(node, 'strokes', at))),
|
|
492
|
+
effects: (node.effects ?? []).map((effect, at) => effectOf(node, effect, at)),
|
|
493
|
+
layoutGrids: (node.grids ?? []).map((grid, at) => gridOf(node, grid, at)),
|
|
494
|
+
counterAxisAlignContent: upper(layout.contentAlign ?? 'auto'),
|
|
495
|
+
gridColumnSizes: (layout.columnTracks ?? []).map(trackOf),
|
|
496
|
+
gridRowSizes: (layout.rowTracks ?? []).map(trackOf),
|
|
497
|
+
gridColumnGap: layout.gap ?? 0,
|
|
498
|
+
gridRowGap: layout.crossGap ?? 0,
|
|
499
|
+
gridRowAnchorIndex: node.gridCell?.row ?? 0,
|
|
500
|
+
gridColumnAnchorIndex: node.gridCell?.column ?? 0,
|
|
501
|
+
gridRowSpan: node.gridCell?.rowSpan ?? 1,
|
|
502
|
+
gridColumnSpan: node.gridCell?.columnSpan ?? 1,
|
|
503
|
+
gridChildHorizontalAlign: upper(node.gridCell?.alignH ?? 'auto'),
|
|
504
|
+
gridChildVerticalAlign: upper(node.gridCell?.alignV ?? 'auto'),
|
|
505
|
+
dashPattern: node.dashes ?? [],
|
|
506
|
+
strokeJoin: upper(node.strokeJoin ?? 'miter'),
|
|
507
|
+
// Reported where FigCraft reports them; else what Toghen wrote (`Unreported`).
|
|
508
|
+
constraints: node.constraints
|
|
509
|
+
? { horizontal: CONSTRAINT_OF[node.constraints.horizontal ?? 'left'], vertical: CONSTRAINT_OF[node.constraints.vertical ?? 'top'] }
|
|
510
|
+
: (recorded('constraints') ?? { horizontal: 'MIN', vertical: 'MIN' }),
|
|
511
|
+
blendMode: node.blend ? (BLEND_OF[node.blend] ?? upper(node.blend)) : (recorded('blendMode') ?? 'PASS_THROUGH'),
|
|
512
|
+
cornerSmoothing: node.cornerSmoothing ?? recorded('cornerSmoothing') ?? 0,
|
|
513
|
+
strokeCap: node.strokeCap ? (CAP_OF[node.strokeCap] ?? upper(node.strokeCap)) : (recorded('strokeCap') ?? 'NONE'),
|
|
514
|
+
isExposedInstance: recorded('isExposedInstance') ?? false,
|
|
515
|
+
boundVariables: boundOf(node),
|
|
516
|
+
effectStyleId: node.styles?.effect ?? '',
|
|
517
|
+
get componentPropertyReferences() {
|
|
518
|
+
return unreported().references;
|
|
519
|
+
},
|
|
520
|
+
get explicitVariableModes() {
|
|
521
|
+
return unreported().modes ?? {};
|
|
522
|
+
},
|
|
523
|
+
getSharedPluginData: (namespace: string, key: string): string =>
|
|
524
|
+
own(dataKey(namespace, key)) ? (data[dataKey(namespace, key)] ?? '') : '',
|
|
525
|
+
setSharedPluginData: (namespace: string, key: string, value: string): void => written(dataKey(namespace, key), value),
|
|
526
|
+
getSharedPluginDataKeys: (namespace: string): string[] =>
|
|
527
|
+
Object.keys(data)
|
|
528
|
+
.filter((key) => key.startsWith(`${namespace}.`) && own(key) && data[key] !== '')
|
|
529
|
+
.map((key) => key.slice(namespace.length + 1)),
|
|
530
|
+
resizeWithoutConstraints: (width: number, height: number): void => {
|
|
531
|
+
if (width === out.width && height === out.height) return;
|
|
532
|
+
out.width = width;
|
|
533
|
+
out.height = height;
|
|
534
|
+
writes.sized.set(node.id, { width, height });
|
|
535
|
+
},
|
|
536
|
+
remove: (): void => {
|
|
537
|
+
out.removed = true;
|
|
538
|
+
writes.removed.push(node.id);
|
|
539
|
+
},
|
|
540
|
+
loadAsync: async (): Promise<void> => {},
|
|
541
|
+
findAllWithCriteria: (criteria: { types?: string[]; sharedPluginData?: { namespace: string; keys?: string[] } }): SceneNode[] => {
|
|
542
|
+
const found: SceneNode[] = [];
|
|
543
|
+
const visit = (at: SceneNode): void => {
|
|
544
|
+
for (const child of (at as unknown as { children?: SceneNode[] }).children ?? []) {
|
|
545
|
+
const shared = criteria.sharedPluginData;
|
|
546
|
+
const typed = !criteria.types || criteria.types.includes(child.type);
|
|
547
|
+
const held =
|
|
548
|
+
!shared ||
|
|
549
|
+
(shared.keys ?? child.getSharedPluginDataKeys(shared.namespace)).some(
|
|
550
|
+
(key) => child.getSharedPluginData(shared.namespace, key) !== '',
|
|
551
|
+
);
|
|
552
|
+
if (typed && held) found.push(child);
|
|
553
|
+
visit(child);
|
|
554
|
+
}
|
|
555
|
+
};
|
|
556
|
+
visit(out as unknown as SceneNode);
|
|
557
|
+
return found;
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
if (node.type === 'TEXT') {
|
|
561
|
+
Object.assign(out, {
|
|
562
|
+
characters: node.characters ?? '',
|
|
563
|
+
fontName: { family: node.fontFamily ?? '', style: node.fontStyle ?? '' },
|
|
564
|
+
fontSize: node.fontSize,
|
|
565
|
+
fontWeight: node.fontWeight,
|
|
566
|
+
lineHeight: lengthOf(node.lineHeight ?? 'auto'),
|
|
567
|
+
letterSpacing: lengthOf(node.letterSpacing ?? 0),
|
|
568
|
+
textAlignHorizontal: upper(node.textAlign ?? 'left'),
|
|
569
|
+
textAlignVertical: upper(node.textAlignVertical ?? 'top'),
|
|
570
|
+
textAutoResize: RESIZE[node.textAutoResize ?? 'none'] ?? 'NONE',
|
|
571
|
+
textCase: upper(node.textCase ?? 'original'),
|
|
572
|
+
textDecoration: upper(node.textDecoration ?? 'none'),
|
|
573
|
+
textStyleId: node.styles?.text ?? '',
|
|
574
|
+
listSpacing: node.listSpacing ?? 0,
|
|
575
|
+
leadingTrim: upper(node.leadingTrim ?? 'none'),
|
|
576
|
+
maxLines: node.maxLines ?? recorded('maxLines') ?? null,
|
|
577
|
+
paragraphSpacing: node.paragraphSpacing ?? recorded('paragraphSpacing') ?? 0,
|
|
578
|
+
paragraphIndent: node.paragraphIndent ?? recorded('paragraphIndent') ?? 0,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
// A library's copy is remote, as an imported component is in Figma, under the key it was imported by; a variant in it
|
|
582
|
+
// has none of its own here, its set's being the one the lock and the swap compare.
|
|
583
|
+
if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
|
|
584
|
+
const remote =
|
|
585
|
+
copyOf !== undefined ||
|
|
586
|
+
(inInstance && parent?.type === 'COMPONENT_SET' && (parent as unknown as { remote?: boolean }).remote === true);
|
|
587
|
+
Object.assign(out, { key: copyOf ?? (remote ? `${(parent as unknown as { key: string }).key}/${id}` : id), remote, description: '' });
|
|
588
|
+
}
|
|
589
|
+
if (node.type === 'INSTANCE') {
|
|
590
|
+
out.getMainComponentAsync = async (): Promise<SceneNode | null> =>
|
|
591
|
+
node.mainComponent === undefined ? null : (index.get(String(node.mainComponent)) ?? null);
|
|
592
|
+
// A getter, read when asked: its main may be viewed after it (a library's copy is), which `Object.assign` would not wait for.
|
|
593
|
+
Object.defineProperty(out, 'componentProperties', {
|
|
594
|
+
enumerable: true,
|
|
595
|
+
get() {
|
|
596
|
+
const main =
|
|
597
|
+
node.mainComponent === undefined
|
|
598
|
+
? undefined
|
|
599
|
+
: (index.get(String(node.mainComponent)) as unknown as { definitions(): ComponentPropertyDefinitions } | undefined);
|
|
600
|
+
const definitions = main?.definitions() ?? {};
|
|
601
|
+
const values: ComponentProperties = {};
|
|
602
|
+
for (const [name, value] of Object.entries(node.componentProperties ?? {})) {
|
|
603
|
+
values[name] = {
|
|
604
|
+
type: definitions[name]?.type ?? 'TEXT',
|
|
605
|
+
value: typeof value === 'number' ? String(value) : value,
|
|
606
|
+
} as ComponentProperties[string];
|
|
607
|
+
}
|
|
608
|
+
for (const [axis, value] of combinationOf((main as unknown as SceneNode | undefined)?.name ?? '') ?? [])
|
|
609
|
+
values[axis] = { type: 'VARIANT', value };
|
|
610
|
+
return values;
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
// What fills an instance's slot is the instance's own, with ids of its own (measured 23 October 2026), and Toghen
|
|
615
|
+
// stamps what it placed there: inside a slot, a layer is no longer inside the instance.
|
|
616
|
+
const inside = node.slot !== undefined && inInstance ? false : inInstance || node.type === 'INSTANCE';
|
|
617
|
+
const children = (node.children ?? []).map((child) => view(child, out as unknown as SceneNode, inside));
|
|
618
|
+
out.children = children;
|
|
619
|
+
// A member's definitions, Figma's way: read on the set, not on a member of one.
|
|
620
|
+
const definitions = (slots?: Unreported['slots']): ComponentPropertyDefinitions => {
|
|
621
|
+
const own: ComponentPropertyDefinitions = {};
|
|
622
|
+
const recordedSlots = slots ?? unreported().slots;
|
|
623
|
+
for (const [name, definition] of Object.entries(node.componentPropertyDefinitions ?? {}))
|
|
624
|
+
own[name] = definitionOf(definition, offered, recordedSlots?.[name]);
|
|
625
|
+
return own;
|
|
626
|
+
};
|
|
627
|
+
out.definitions = definitions;
|
|
628
|
+
if (node.type === 'COMPONENT_SET') {
|
|
629
|
+
const members = children.filter((child) => child.type === 'COMPONENT');
|
|
630
|
+
Object.defineProperty(out, 'componentPropertyDefinitions', {
|
|
631
|
+
get() {
|
|
632
|
+
// A set two members of one combination share, or one of several whose name is not one, cannot be read, as in Figma.
|
|
633
|
+
const names = members.map((member) => member.name);
|
|
634
|
+
if (new Set(names).size !== names.length || (names.length > 1 && names.some((name) => !combinationOf(name))))
|
|
635
|
+
throw brokenSet(node.name);
|
|
636
|
+
const first = members[0] as unknown as { definitions(slots?: Unreported['slots']): ComponentPropertyDefinitions } | undefined;
|
|
637
|
+
const all: ComponentPropertyDefinitions = { ...first?.definitions(unreported().slots) };
|
|
638
|
+
const axes = new Map<string, string[]>();
|
|
639
|
+
for (const member of members)
|
|
640
|
+
for (const [axis, value] of combinationOf(member.name) ?? []) axes.set(axis, [...new Set([...(axes.get(axis) ?? []), value])]);
|
|
641
|
+
for (const [axis, values] of axes)
|
|
642
|
+
all[axis] = { type: 'VARIANT', defaultValue: values[0], variantOptions: values } as ComponentPropertyDefinitions[string];
|
|
643
|
+
return all;
|
|
644
|
+
},
|
|
645
|
+
});
|
|
646
|
+
// FigCraft's default variant is the one in the set's top-left corner.
|
|
647
|
+
Object.defineProperty(out, 'defaultVariant', {
|
|
648
|
+
get: () => [...members].sort((a, b) => a.y - b.y || a.x - b.x)[0],
|
|
649
|
+
});
|
|
650
|
+
} else if (node.type === 'COMPONENT') {
|
|
651
|
+
Object.defineProperty(out, 'componentPropertyDefinitions', {
|
|
652
|
+
get() {
|
|
653
|
+
if (parent?.type === 'COMPONENT_SET') throw new Error('Can only get definitions of a component set or non-variant component');
|
|
654
|
+
return definitions();
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
index.set(id, out as unknown as SceneNode);
|
|
659
|
+
return out as unknown as SceneNode;
|
|
660
|
+
}
|
|
661
|
+
}
|