@toghen/figma 0.0.0-stage → 0.1.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/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 +1058 -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 +24147 -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 +548 -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,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Members and a snapshot to steps. Pure: this is tested without Figma.
|
|
3
|
+
*
|
|
4
|
+
* The snapshot is what the Figma side found by stamp, as plain data; the diff is
|
|
5
|
+
* the design's table of situations turned into code. A dry run stops here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
Component,
|
|
10
|
+
ComponentRef,
|
|
11
|
+
Layer,
|
|
12
|
+
LayerKind,
|
|
13
|
+
Member,
|
|
14
|
+
PreferredValue,
|
|
15
|
+
Property,
|
|
16
|
+
SetField,
|
|
17
|
+
SlotSettings,
|
|
18
|
+
Value,
|
|
19
|
+
} from './model.js';
|
|
20
|
+
import type { SetSpec, Step } from './steps.js';
|
|
21
|
+
|
|
22
|
+
/** A node inside a member that carries no stamp: someone else's. */
|
|
23
|
+
export interface UnstampedNode {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
/** The address of the stamped layer it sits in, when it sits in one. */
|
|
27
|
+
parent?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LayerSnapshot {
|
|
31
|
+
id: string;
|
|
32
|
+
/** The layer's name in Figma now, which a designer may have changed. */
|
|
33
|
+
name: string;
|
|
34
|
+
address: string;
|
|
35
|
+
kind: LayerKind;
|
|
36
|
+
/** The address of its parent layer; absent for the root, which is the member itself. */
|
|
37
|
+
parent?: string;
|
|
38
|
+
index: number;
|
|
39
|
+
/** The fields Toghen owns on it, each with the hash of what Figma reported when Toghen last wrote it: its stamp's receipt. An empty hash is a field owned before receipts. */
|
|
40
|
+
receipt: Record<string, string>;
|
|
41
|
+
/** The same fields, each with the hash of what Figma reports now. */
|
|
42
|
+
reported: Record<string, string>;
|
|
43
|
+
/** Toghen's layer, moved: its stamp names another id, so it is patched, if only to write its stamp again with its own. */
|
|
44
|
+
moved?: true;
|
|
45
|
+
/**
|
|
46
|
+
* An instance's component as Figma holds it now, a variant by its set: its name, its key, and for a library's the
|
|
47
|
+
* library the lock holds its key under (empty when none). `reported.component` is then always read, receipt or not.
|
|
48
|
+
*/
|
|
49
|
+
main?: { name: string; key: string; library?: string };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MemberSnapshot {
|
|
53
|
+
id: string;
|
|
54
|
+
/** The combination, from its stamp. */
|
|
55
|
+
name: string;
|
|
56
|
+
layers: LayerSnapshot[];
|
|
57
|
+
unstamped: UnstampedNode[];
|
|
58
|
+
/** Layers a person copied from one of Toghen's: each carries the original's stamp, and is left alone. */
|
|
59
|
+
copies?: { id: string; name: string; address: string }[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A property of the set as Figma holds it, without the `VARIANT` axes, which follow the members' names. */
|
|
63
|
+
export interface PropertySnapshot {
|
|
64
|
+
key: string;
|
|
65
|
+
name: string;
|
|
66
|
+
type: 'BOOLEAN' | 'TEXT' | 'INSTANCE_SWAP' | 'SLOT';
|
|
67
|
+
defaultValue: string | boolean;
|
|
68
|
+
description?: string;
|
|
69
|
+
preferredValues?: PreferredValue[];
|
|
70
|
+
slotSettings?: SlotSettings;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SetSnapshot {
|
|
74
|
+
id: string;
|
|
75
|
+
/** Every property of the set that is not a variant axis, ours or not. */
|
|
76
|
+
definitions: PropertySnapshot[];
|
|
77
|
+
/** The names of the component properties Toghen added: the keys of the set's receipt. */
|
|
78
|
+
properties: string[];
|
|
79
|
+
members: MemberSnapshot[];
|
|
80
|
+
/** Children of the set that are not a stamped member. */
|
|
81
|
+
unstamped: { id: string; name: string }[];
|
|
82
|
+
/** Members a person copied from one of Toghen's, each with the combination of the original: left alone. */
|
|
83
|
+
copies?: { id: string; name: string; of: string }[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Snapshot {
|
|
87
|
+
/** The set stamped with this component's name. */
|
|
88
|
+
set?: SetSnapshot;
|
|
89
|
+
/** Sets of the same name that carry no stamp. */
|
|
90
|
+
foreign: { id: string; name: string }[];
|
|
91
|
+
/** Sets a person copied from Toghen's: they carry its stamp, and are left alone. */
|
|
92
|
+
copies?: { id: string; name: string }[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The frame stamped as this artboard, read like a member: its layers by stamp, and what is not ours. */
|
|
96
|
+
export interface ArtboardSnapshot {
|
|
97
|
+
artboard?: MemberSnapshot;
|
|
98
|
+
/** Frames a person copied from the artboard's: left alone. */
|
|
99
|
+
copies?: { id: string; name: string }[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The parent a layer in an instance's slot has: not the instance, which has the slot, but the slot of it.
|
|
104
|
+
* Nothing else has an address with this character in it.
|
|
105
|
+
*/
|
|
106
|
+
export const slotKey = (address: string, slot: string): string => `${address}\u0000${slot}`;
|
|
107
|
+
const slotPrefix = (address: string): string => `${address}\u0000`;
|
|
108
|
+
|
|
109
|
+
/** The fields a layer's receipt lists: its field names, and for an instance each property its `set` writes. */
|
|
110
|
+
export function fieldNames(layer: Layer): string[] {
|
|
111
|
+
const names: string[] = [];
|
|
112
|
+
for (const field of Object.keys(layer.fields)) {
|
|
113
|
+
if (field === 'set') for (const name of Object.keys(layer.fields.set as SetField)) names.push(`set ${name}`);
|
|
114
|
+
else names.push(field);
|
|
115
|
+
}
|
|
116
|
+
return names;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The same data with every object's keys in order, so two writes of it give one text. */
|
|
120
|
+
function stable(value: unknown): unknown {
|
|
121
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
122
|
+
if (value === null || typeof value !== 'object') return value;
|
|
123
|
+
const out: Record<string, unknown> = {};
|
|
124
|
+
for (const key of Object.keys(value).sort()) out[key] = stable((value as Record<string, unknown>)[key]);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A value in a few characters: a hash of its JSON with every object's keys in order, so key order does not change it.
|
|
130
|
+
* FNV-1a: not a defence against anything, only a way to tell two values apart that is short enough for a stamp.
|
|
131
|
+
*/
|
|
132
|
+
export function hashOf(value: unknown): string {
|
|
133
|
+
const text = JSON.stringify(stable(value)) ?? '';
|
|
134
|
+
let hash = 0x811c9dc5;
|
|
135
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
136
|
+
hash ^= text.charCodeAt(index);
|
|
137
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
138
|
+
}
|
|
139
|
+
return `${hash.toString(36)}-${text.length.toString(36)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What the diff is told about receipts: the receipt the file's value would leave on a layer (`expectedOf`), and whether drift is overwritten. */
|
|
143
|
+
export interface Receipts {
|
|
144
|
+
expected(layer: Layer): Record<string, string>;
|
|
145
|
+
overwriteDrift?: boolean;
|
|
146
|
+
/** What the locator answered for a component the file names: its name, the key it imports (or would), and the declared library it came from. */
|
|
147
|
+
component?(ref: ComponentRef): { name: string; key?: string; library?: string };
|
|
148
|
+
/** A preferred value as the target reports it: one by name as the key of what the locator answered, when it answered. */
|
|
149
|
+
preferred?(value: PreferredValue): PreferredValue;
|
|
150
|
+
/** The id an instance swap's default would be set to: that of the component the locator answers, when it answers. */
|
|
151
|
+
defaultComponent?(ref: ComponentRef): string | undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const specOf = (component: SetSpec): SetSpec => ({
|
|
155
|
+
name: component.name,
|
|
156
|
+
...(component.description !== undefined ? { description: component.description } : {}),
|
|
157
|
+
properties: component.properties,
|
|
158
|
+
...(component.across !== undefined ? { across: component.across } : {}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/** `Axis=value, Axis=value` as pairs, or undefined when a part does not read as one (a name no member of ours has). */
|
|
162
|
+
export function parseCombination(name: string): [string, string][] | undefined {
|
|
163
|
+
const pairs: [string, string][] = [];
|
|
164
|
+
for (const part of name.split(', ')) {
|
|
165
|
+
const at = part.indexOf('=');
|
|
166
|
+
if (at < 1) return undefined;
|
|
167
|
+
pairs.push([part.slice(0, at), part.slice(at + 1)]);
|
|
168
|
+
}
|
|
169
|
+
return pairs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A combination with one axis renamed, or undefined when the name does not read as one. */
|
|
173
|
+
export function renameAxis(name: string, from: string, to: string): string | undefined {
|
|
174
|
+
return parseCombination(name)
|
|
175
|
+
?.map(([axis, value]) => `${axis === from ? to : axis}=${value}`)
|
|
176
|
+
.join(', ');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* An axis the file renamed, when that is the only difference in the axes: the held members all have the same axes, the
|
|
181
|
+
* file's differ from them in one name at one position, the new name is not held, and the values on it are the old ones
|
|
182
|
+
* (as sets: the order is the layout's). Anything looser is a replacement, left to the members created and deleted.
|
|
183
|
+
*/
|
|
184
|
+
function inferRename(members: Member[], set: SetSnapshot): { from: string; to: string } | undefined {
|
|
185
|
+
const held = set.members.map((member) => parseCombination(member.name));
|
|
186
|
+
if (held.length === 0 || held.some((pairs) => pairs === undefined)) return undefined;
|
|
187
|
+
const axes = (held[0] as [string, string][]).map(([axis]) => axis);
|
|
188
|
+
if (held.some((pairs) => (pairs as [string, string][]).map(([axis]) => axis).join('\u0000') !== axes.join('\u0000'))) return undefined;
|
|
189
|
+
const wanted = members[0] ? Object.keys(members[0].combination) : [];
|
|
190
|
+
const at = axes.findIndex((axis, index) => axis !== wanted[index]);
|
|
191
|
+
if (
|
|
192
|
+
at === -1 ||
|
|
193
|
+
axes.length !== wanted.length ||
|
|
194
|
+
axes.some((axis, index) => index !== at && axis !== wanted[index]) ||
|
|
195
|
+
axes.includes(wanted[at])
|
|
196
|
+
)
|
|
197
|
+
return undefined;
|
|
198
|
+
const values = (list: string[]): string => [...new Set(list)].sort().join('\u0000');
|
|
199
|
+
const used = held.map((pairs) => (pairs as [string, string][])[at][1]);
|
|
200
|
+
return values(used) === values(members.map((member) => member.combination[wanted[at]])) ? { from: axes[at], to: wanted[at] } : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
204
|
+
|
|
205
|
+
/** The layer without its children: a step patches one layer, and its children are steps of their own. */
|
|
206
|
+
const bare = (layer: Layer): Layer => ({ ...layer, children: [] });
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Properties: what to add, edit and delete on the set.
|
|
210
|
+
*
|
|
211
|
+
* Ours are the names in the set's `properties` stamp. A property in the set that
|
|
212
|
+
* the stamp does not list is foreign and is left, and reported, even when the
|
|
213
|
+
* file declares the same name: the design never adopts by name.
|
|
214
|
+
*/
|
|
215
|
+
function diffProperties(component: SetSpec, set: SetSnapshot, receipts?: Receipts): Step[] {
|
|
216
|
+
const steps: Step[] = [];
|
|
217
|
+
const declared = component.properties.filter((p): p is Exclude<Property, { type: 'VARIANT' }> => p.type !== 'VARIANT');
|
|
218
|
+
const ours = new Set(set.properties);
|
|
219
|
+
const held = new Map(set.definitions.map((definition) => [definition.name, definition]));
|
|
220
|
+
|
|
221
|
+
// A property whose type changed cannot be edited into the new one: it is replaced, and instances lose it.
|
|
222
|
+
for (const property of declared) {
|
|
223
|
+
const definition = held.get(property.name);
|
|
224
|
+
if (definition && definition.type !== property.type && ours.has(property.name)) {
|
|
225
|
+
steps.push({
|
|
226
|
+
kind: 'DELETE_PROPERTY',
|
|
227
|
+
name: property.name,
|
|
228
|
+
breaks: `the ${definition.type} property "${property.name}" became a ${property.type}: instances lose its overrides`,
|
|
229
|
+
});
|
|
230
|
+
held.delete(property.name);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const property of declared) {
|
|
235
|
+
const definition = held.get(property.name);
|
|
236
|
+
if (!definition) {
|
|
237
|
+
// A SLOT property comes with its SLOT layer, which is made with `createSlot()`.
|
|
238
|
+
if (property.type !== 'SLOT') steps.push({ kind: 'ADD_PROPERTY', property });
|
|
239
|
+
} else if (!ours.has(property.name)) {
|
|
240
|
+
steps.push({
|
|
241
|
+
kind: 'LEAVE',
|
|
242
|
+
what: 'property',
|
|
243
|
+
message: `the set has a property "${property.name}" that Toghen did not add, and the file declares one of that name; it was left as it is`,
|
|
244
|
+
});
|
|
245
|
+
} else {
|
|
246
|
+
const changes: string[] = [];
|
|
247
|
+
// A default that is a token is bound to a variable, which only the Figma side can resolve: it is always re-set.
|
|
248
|
+
if ((property.type === 'BOOLEAN' || property.type === 'TEXT') && definition.defaultValue !== property.default) {
|
|
249
|
+
changes.push('defaultValue');
|
|
250
|
+
}
|
|
251
|
+
// An instance swap's default is the id of the component the file names, which only the target side can resolve:
|
|
252
|
+
// compared through the receipts, and re-set when they cannot answer.
|
|
253
|
+
if (property.type === 'INSTANCE_SWAP' && definition.defaultValue !== receipts?.defaultComponent?.(property.default))
|
|
254
|
+
changes.push('defaultValue');
|
|
255
|
+
// Figma reports a slot with no preferred values as `[]` (measured), and the file leaves them out: the same thing.
|
|
256
|
+
const preferred = (property.type === 'INSTANCE_SWAP' || property.type === 'SLOT' ? (property.preferredValues ?? []) : []).map(
|
|
257
|
+
(value) => receipts?.preferred?.(value) ?? value,
|
|
258
|
+
);
|
|
259
|
+
if ((property.type === 'INSTANCE_SWAP' || property.type === 'SLOT') && !same(definition.preferredValues ?? [], preferred)) {
|
|
260
|
+
changes.push('preferredValues');
|
|
261
|
+
}
|
|
262
|
+
if (property.type === 'SLOT') {
|
|
263
|
+
if (definition.description !== property.description) changes.push('description');
|
|
264
|
+
if (!same(definition.slotSettings, property.slotSettings)) changes.push('slotSettings');
|
|
265
|
+
}
|
|
266
|
+
if (changes.length > 0) steps.push({ kind: 'EDIT_PROPERTY', property, changes });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const declaredNames = new Set(declared.map((property) => property.name));
|
|
271
|
+
for (const definition of set.definitions) {
|
|
272
|
+
if (declaredNames.has(definition.name)) continue;
|
|
273
|
+
if (ours.has(definition.name)) {
|
|
274
|
+
steps.push({
|
|
275
|
+
kind: 'DELETE_PROPERTY',
|
|
276
|
+
name: definition.name,
|
|
277
|
+
breaks: `the ${definition.type} property "${definition.name}" is no longer declared: instances lose it`,
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
steps.push({
|
|
281
|
+
kind: 'LEAVE',
|
|
282
|
+
what: 'property',
|
|
283
|
+
message: `the set has a property "${definition.name}" that Toghen did not add; it was left as it is`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return steps;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
interface Placed {
|
|
291
|
+
layer: Layer;
|
|
292
|
+
/** The address of the layer it sits in, or for a layer in an instance's slot, `slotKey` of the instance and the slot. */
|
|
293
|
+
parent: string;
|
|
294
|
+
/** The address of the layer before it under its parent, in the file. */
|
|
295
|
+
after?: string;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The layers of a tree but the root, parents before their children. */
|
|
299
|
+
function placed(parent: Layer, out: Placed[] = []): Placed[] {
|
|
300
|
+
// Under one parent, the layers of each slot are a row of their own.
|
|
301
|
+
const previous = new Map<string, string>();
|
|
302
|
+
for (const layer of parent.children) {
|
|
303
|
+
const key = layer.slot === undefined ? parent.address : slotKey(parent.address, layer.slot);
|
|
304
|
+
const after = previous.get(key);
|
|
305
|
+
out.push({ layer, parent: key, ...(after !== undefined ? { after } : {}) });
|
|
306
|
+
previous.set(key, layer.address);
|
|
307
|
+
placed(layer, out);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* One member that exists: what to do to each of its layers.
|
|
314
|
+
*
|
|
315
|
+
* Layers are found by the address in their stamp, never by name, so a layer a
|
|
316
|
+
* designer renamed is still ours and is patched. A layer is moved when its
|
|
317
|
+
* parent changed, or when the order of the layers the file and the member share
|
|
318
|
+
* differs; a node Toghen does not own beside it does not count.
|
|
319
|
+
*
|
|
320
|
+
* A field is written only when its receipt says to (`patch`); without `receipts` every declared field is.
|
|
321
|
+
*/
|
|
322
|
+
function diffMember(member: Member, snap: MemberSnapshot, component: string, artboard = false, receipts?: Receipts): Step[] {
|
|
323
|
+
const steps: Step[] = [];
|
|
324
|
+
const context = { member: member.name, memberId: snap.id };
|
|
325
|
+
const byAddress = new Map(snap.layers.filter((layer) => layer.id !== snap.id).map((layer) => [layer.address, layer]));
|
|
326
|
+
const rootStamp = snap.layers.find((layer) => layer.id === snap.id);
|
|
327
|
+
const what = artboard ? 'artboard' : 'member';
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Which of a layer's fields to write, from the two comparisons with its receipt. Figma against the receipt: a field
|
|
331
|
+
* that differs was edited by hand since Toghen wrote it, and is drift, kept and said, or with `overwriteDrift` written
|
|
332
|
+
* and said; unless Figma already holds what the file asks for, which is agreement: nothing is written or said, and the
|
|
333
|
+
* receipt is taken again. The file against the receipt: a field that differs moved in the file, and is written. A
|
|
334
|
+
* field the receipt lacks, or holds no hash for, is new to Toghen and written. The layer is patched when a field is to
|
|
335
|
+
* be written or its receipt taken again, when in a set its name is not the file's, or when its receipt lists a field
|
|
336
|
+
* the file no longer declares, which is said once.
|
|
337
|
+
*/
|
|
338
|
+
const patch = (layer: Layer, existing: LayerSnapshot | undefined, nodeId: string, name: string): void => {
|
|
339
|
+
const declared = fieldNames(layer);
|
|
340
|
+
const expected = existing && receipts ? receipts.expected(layer) : {};
|
|
341
|
+
const fields: string[] = [];
|
|
342
|
+
const restamp: string[] = [];
|
|
343
|
+
for (const field of declared) {
|
|
344
|
+
const held = existing?.receipt[field];
|
|
345
|
+
if (!held) fields.push(field);
|
|
346
|
+
else if (existing.reported[field] !== held && existing.reported[field] === expected[field]) restamp.push(field);
|
|
347
|
+
else if (existing.reported[field] !== held) {
|
|
348
|
+
const at = `${what} "${member.name}", layer "${layer.address}": "${field}" was changed in Figma since Toghen wrote it`;
|
|
349
|
+
if (receipts?.overwriteDrift) {
|
|
350
|
+
steps.push({
|
|
351
|
+
kind: 'OVERWRITE',
|
|
352
|
+
member: member.name,
|
|
353
|
+
address: layer.address,
|
|
354
|
+
message: `${at}; the file's value is written over it`,
|
|
355
|
+
});
|
|
356
|
+
fields.push(field);
|
|
357
|
+
} else {
|
|
358
|
+
steps.push({
|
|
359
|
+
kind: 'LEAVE',
|
|
360
|
+
what: 'drift',
|
|
361
|
+
message: `${at}, and was left as it is; pass --overwrite-drift to write the file's value`,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
} else if (expected[field] !== held) fields.push(field);
|
|
365
|
+
}
|
|
366
|
+
const dropped = Object.keys(existing?.receipt ?? {}).filter((field) => !declared.includes(field));
|
|
367
|
+
for (const field of dropped) {
|
|
368
|
+
steps.push({
|
|
369
|
+
kind: 'LEAVE',
|
|
370
|
+
what: 'field',
|
|
371
|
+
message: `${what} "${member.name}", layer "${layer.address}": the file no longer declares "${field}", so its value was left as it is`,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// An instance whose component is to be written is swapped: a step of its own, so each swap is one row (R14).
|
|
375
|
+
const swapped = layer.fields.component as Value | undefined;
|
|
376
|
+
if (existing && layer.kind === 'INSTANCE' && fields.includes('component') && swapped && swapped.kind !== 'property') {
|
|
377
|
+
fields.splice(fields.indexOf('component'), 1);
|
|
378
|
+
const ref: ComponentRef = swapped.kind === 'key' ? { key: swapped.key } : { name: String((swapped as { value: string }).value) };
|
|
379
|
+
const to = receipts?.component?.(ref) ?? { name: 'name' in ref ? ref.name : `(key)"${ref.key}"` };
|
|
380
|
+
const shown = existing.reported.component;
|
|
381
|
+
// Figma already shows it: only the receipt is taken again. A dry run that would import it compares keys instead.
|
|
382
|
+
if (shown !== undefined && shown === expected.component) restamp.push('component');
|
|
383
|
+
else if (expected.component !== undefined || to.key === undefined || to.key !== existing.main?.key) {
|
|
384
|
+
steps.push({
|
|
385
|
+
kind: 'SWAP_COMPONENT',
|
|
386
|
+
...context,
|
|
387
|
+
nodeId,
|
|
388
|
+
address: layer.address,
|
|
389
|
+
component,
|
|
390
|
+
from: existing.main?.name ?? '',
|
|
391
|
+
...(existing.main?.library !== undefined ? { fromLibrary: existing.main.library } : {}),
|
|
392
|
+
to: to.name,
|
|
393
|
+
...(to.library !== undefined ? { library: to.library } : {}),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (
|
|
398
|
+
!existing ||
|
|
399
|
+
fields.length > 0 ||
|
|
400
|
+
restamp.length > 0 ||
|
|
401
|
+
dropped.length > 0 ||
|
|
402
|
+
existing.moved ||
|
|
403
|
+
(!artboard && existing.name !== name)
|
|
404
|
+
) {
|
|
405
|
+
steps.push({ kind: 'PATCH_LAYER', ...context, nodeId, layer: bare(layer), fields, ...(restamp.length > 0 ? { restamp } : {}) });
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
patch(member.tree, rootStamp, snap.id, member.name);
|
|
410
|
+
|
|
411
|
+
const layers = placed(member.tree);
|
|
412
|
+
const desiredSequence = new Map<string, string[]>();
|
|
413
|
+
for (const entry of layers) desiredSequence.set(entry.parent, [...(desiredSequence.get(entry.parent) ?? []), entry.layer.address]);
|
|
414
|
+
|
|
415
|
+
// A layer replaced by another kind, where either kind is an instance, loses what its slots held: those layers are made again.
|
|
416
|
+
const lost = new Set<string>();
|
|
417
|
+
if (artboard) {
|
|
418
|
+
const replaced = layers.filter(({ layer }) => {
|
|
419
|
+
const existing = byAddress.get(layer.address);
|
|
420
|
+
return existing && existing.kind !== layer.kind && (existing.kind === 'INSTANCE' || layer.kind === 'INSTANCE');
|
|
421
|
+
});
|
|
422
|
+
const isIn = (parent: string | undefined, address: string): boolean =>
|
|
423
|
+
parent !== undefined && (parent === address || parent.startsWith(slotPrefix(address)));
|
|
424
|
+
const frontier = replaced.map(({ layer }) => layer.address);
|
|
425
|
+
while (frontier.length > 0) {
|
|
426
|
+
const address = frontier.pop() as string;
|
|
427
|
+
for (const candidate of snap.layers) {
|
|
428
|
+
if (candidate.id !== snap.id && !lost.has(candidate.address) && isIn(candidate.parent, address)) {
|
|
429
|
+
lost.add(candidate.address);
|
|
430
|
+
frontier.push(candidate.address);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// The order of the children a parent shares between the file and the member.
|
|
437
|
+
const sharedOrder = (parent: string): { desired: string[]; actual: string[] } => {
|
|
438
|
+
const inFile = desiredSequence.get(parent) ?? [];
|
|
439
|
+
const inMember = snap.layers
|
|
440
|
+
.filter((layer) => layer.parent === parent && inFile.includes(layer.address) && !lost.has(layer.address))
|
|
441
|
+
.sort((a, b) => a.index - b.index)
|
|
442
|
+
.map((layer) => layer.address);
|
|
443
|
+
return { desired: inFile.filter((address) => inMember.includes(address)), actual: inMember };
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
for (const { layer, parent, after } of layers) {
|
|
447
|
+
const existing = lost.has(layer.address) ? undefined : byAddress.get(layer.address);
|
|
448
|
+
const place = after !== undefined ? { after } : {};
|
|
449
|
+
if (!existing) {
|
|
450
|
+
steps.push({ kind: 'CREATE_LAYER', ...context, layer: bare(layer), parent, ...place });
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (existing.kind !== layer.kind) {
|
|
454
|
+
steps.push({
|
|
455
|
+
kind: 'REPLACE_LAYER',
|
|
456
|
+
...context,
|
|
457
|
+
nodeId: existing.id,
|
|
458
|
+
layer: bare(layer),
|
|
459
|
+
parent,
|
|
460
|
+
...place,
|
|
461
|
+
breaks: `${what} "${member.name}", layer "${layer.address}" changed from ${existing.kind} to ${layer.kind}: overrides on it are lost`,
|
|
462
|
+
});
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const order = sharedOrder(parent);
|
|
466
|
+
const before = (list: string[]): string | undefined => list[list.indexOf(layer.address) - 1];
|
|
467
|
+
if (existing.parent !== parent || before(order.desired) !== before(order.actual)) {
|
|
468
|
+
steps.push({ kind: 'MOVE_LAYER', ...context, nodeId: existing.id, address: layer.address, parent, ...place });
|
|
469
|
+
}
|
|
470
|
+
patch(layer, existing, existing.id, layer.name ?? layer.address);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Deletions last: what a deleted layer held that the file still wants has been moved out by now.
|
|
474
|
+
const wanted = new Set(layers.map((entry) => entry.layer.address));
|
|
475
|
+
for (const layer of byAddress.values()) {
|
|
476
|
+
// A layer that is made again is not removed here: the old one went with what replaced its parent.
|
|
477
|
+
if (wanted.has(layer.address)) continue;
|
|
478
|
+
steps.push({
|
|
479
|
+
kind: 'DELETE_LAYER',
|
|
480
|
+
...context,
|
|
481
|
+
nodeId: layer.id,
|
|
482
|
+
address: layer.address,
|
|
483
|
+
...(artboard
|
|
484
|
+
? { breaks: `artboard "${member.name}", layer "${layer.address}" is no longer in the file: it is removed, with whatever it holds` }
|
|
485
|
+
: {}),
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
for (const node of snap.unstamped) {
|
|
489
|
+
steps.push({
|
|
490
|
+
kind: 'LEAVE',
|
|
491
|
+
what: 'unstamped-node',
|
|
492
|
+
message: `${what} "${member.name}": "${node.name}" (${node.id}) has no stamp and was left alone`,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
for (const node of snap.copies ?? []) {
|
|
496
|
+
steps.push({
|
|
497
|
+
kind: 'LEAVE',
|
|
498
|
+
what: 'copy',
|
|
499
|
+
message: `${what} "${member.name}": "${node.name}" (${node.id}) is a copy of Toghen's layer "${node.address}", left alone`,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return steps;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** A copy of a set or an artboard's frame, as its row says. */
|
|
506
|
+
const copyRows = (copies: { id: string; name: string }[] | undefined, what: string): Step[] =>
|
|
507
|
+
(copies ?? []).map((node) => ({
|
|
508
|
+
kind: 'LEAVE',
|
|
509
|
+
what: 'copy',
|
|
510
|
+
message: `"${node.name}" (${node.id}) is a copy of Toghen's ${what}, left alone`,
|
|
511
|
+
}));
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* What to do about an artboard, given what Figma holds: build it whole when it is not there, otherwise the difference.
|
|
515
|
+
*
|
|
516
|
+
* The frame is found by its stamp wherever a person moved it, and its layers by theirs. A layer without a stamp
|
|
517
|
+
* is left alone and said. Nothing is adopted by name, so a frame that merely has the artboard's name is not touched.
|
|
518
|
+
*/
|
|
519
|
+
export function diffArtboard(member: Member, snapshot: ArtboardSnapshot, receipts?: Receipts): Step[] {
|
|
520
|
+
if (!snapshot.artboard) return [{ kind: 'CREATE_ARTBOARD', member }];
|
|
521
|
+
return [...diffMember(member, snapshot.artboard, member.name, true, receipts), ...copyRows(snapshot.copies, `artboard "${member.name}"`)];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* What to do about a component, given what Figma holds.
|
|
526
|
+
*
|
|
527
|
+
* A foreign set is refused before anything else: two sets of one name are
|
|
528
|
+
* ambiguous for anyone, and Toghen never adopts by name. A set Toghen made is
|
|
529
|
+
* reconciled: only the difference between the file and the stamped nodes is
|
|
530
|
+
* planned, in the order it can be applied.
|
|
531
|
+
*/
|
|
532
|
+
export function diff(members: Member[], component: SetSpec, snapshot: Snapshot, receipts?: Receipts): Step[] {
|
|
533
|
+
if (snapshot.foreign.length > 0) {
|
|
534
|
+
const ids = snapshot.foreign.map((node) => node.id);
|
|
535
|
+
return [
|
|
536
|
+
{
|
|
537
|
+
kind: 'REFUSE_FOREIGN',
|
|
538
|
+
component: component.name,
|
|
539
|
+
nodeIds: ids,
|
|
540
|
+
message: `a component set named "${component.name}" already exists (${ids.join(', ')}) and Toghen did not make it; rename or delete it, or build on a page where it is not. Toghen never adopts a set by name`,
|
|
541
|
+
},
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
const set = snapshot.set;
|
|
545
|
+
if (!set) return [{ kind: 'CREATE_SET', component: specOf(component), members }];
|
|
546
|
+
|
|
547
|
+
const steps: Step[] = diffProperties(component, set, receipts);
|
|
548
|
+
// A renamed axis is renamed in place; the members are then paired by their new names, as any others.
|
|
549
|
+
const rename = inferRename(members, set);
|
|
550
|
+
if (rename) steps.push({ kind: 'RENAME_AXIS', ...rename });
|
|
551
|
+
const present = rename
|
|
552
|
+
? set.members.map((member) => ({ ...member, name: renameAxis(member.name, rename.from, rename.to) as string }))
|
|
553
|
+
: set.members;
|
|
554
|
+
const held = new Map(present.map((member) => [member.name, member]));
|
|
555
|
+
const wanted = new Set(members.map((member) => member.name));
|
|
556
|
+
|
|
557
|
+
for (const member of members) {
|
|
558
|
+
const existing = held.get(member.name);
|
|
559
|
+
if (existing) steps.push(...diffMember(member, existing, component.name, false, receipts));
|
|
560
|
+
}
|
|
561
|
+
for (const member of members) {
|
|
562
|
+
if (!held.has(member.name)) steps.push({ kind: 'CREATE_MEMBER', member });
|
|
563
|
+
}
|
|
564
|
+
for (const member of present) {
|
|
565
|
+
if (!wanted.has(member.name)) {
|
|
566
|
+
steps.push({
|
|
567
|
+
kind: 'DELETE_MEMBER',
|
|
568
|
+
memberId: member.id,
|
|
569
|
+
name: member.name,
|
|
570
|
+
breaks: `the member "${member.name}" is gone: instances of it lose their variant`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
for (const node of set.unstamped) {
|
|
575
|
+
steps.push({
|
|
576
|
+
kind: 'LEAVE',
|
|
577
|
+
what: 'unstamped-member',
|
|
578
|
+
message: `"${node.name}" (${node.id}) in the set has no member stamp and was left alone`,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
for (const node of set.copies ?? []) {
|
|
582
|
+
steps.push({
|
|
583
|
+
kind: 'LEAVE',
|
|
584
|
+
what: 'copy',
|
|
585
|
+
message: `"${node.name}" (${node.id}) in the set is a copy of Toghen's member "${node.of}", left alone`,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
steps.push(...copyRows(snapshot.copies, `set "${component.name}"`));
|
|
589
|
+
steps.push({ kind: 'RELAYOUT' });
|
|
590
|
+
return steps;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export interface MemberCell {
|
|
594
|
+
name: string;
|
|
595
|
+
row: number;
|
|
596
|
+
column: number;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The board of a set's members: where each sits, and the gap before each column and each row. */
|
|
600
|
+
export interface Board {
|
|
601
|
+
cells: MemberCell[];
|
|
602
|
+
/** `columnGaps[c]` is the gap between column `c - 1` and column `c`; the first is 0. One entry per column of the board, empty ones included. */
|
|
603
|
+
columnGaps: number[];
|
|
604
|
+
rowGaps: number[];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ponytail: a fixed gap between members; a design that wants another spacing makes it an option
|
|
608
|
+
/** The gap between two members inside the innermost group of an axis. */
|
|
609
|
+
export const MEMBER_GAP = 40;
|
|
610
|
+
|
|
611
|
+
type Axis = { name: string; values: string[] };
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The axes that go across. The file may name them (`across=` on the component line); otherwise the last axis goes
|
|
615
|
+
* across, and while the columns are fewer than the rows and more than one axis is down, the last of the down axes
|
|
616
|
+
* moves across too. Either way they keep the order they were declared in.
|
|
617
|
+
*/
|
|
618
|
+
function acrossAxes(axes: Axis[], across: string[] | undefined): Axis[] {
|
|
619
|
+
if (across) return axes.filter((axis) => across.includes(axis.name));
|
|
620
|
+
const product = (list: Axis[]): number => list.reduce((count, axis) => count * axis.values.length, 1);
|
|
621
|
+
let split = axes.length - 1;
|
|
622
|
+
while (split > 1 && product(axes.slice(split)) < product(axes.slice(0, split))) split -= 1;
|
|
623
|
+
return axes.slice(split);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* One direction of the board as a mixed-radix count: a member's index among the combinations of these axes (each
|
|
628
|
+
* axis's values in declared order, the earlier axis the more significant), and the gap before each index, which
|
|
629
|
+
* grows outward: 40 px times one plus the nesting level of the outermost axis whose value changes there.
|
|
630
|
+
*/
|
|
631
|
+
function count(axes: Axis[]): { indexOf(combination: Record<string, string>): number; gaps: number[] } {
|
|
632
|
+
const radix = axes.map((axis) => axis.values.length);
|
|
633
|
+
const stride = radix.map((_, at) => radix.slice(at + 1).reduce((product, r) => product * r, 1));
|
|
634
|
+
const total = radix.reduce((product, r) => product * r, 1);
|
|
635
|
+
const digit = (index: number, at: number): number => Math.floor(index / stride[at]) % radix[at];
|
|
636
|
+
const gaps = Array.from({ length: total }, (_, index) => {
|
|
637
|
+
if (index === 0) return 0;
|
|
638
|
+
const changed = axes.findIndex((_axis, at) => digit(index - 1, at) !== digit(index, at));
|
|
639
|
+
return MEMBER_GAP * (1 + (axes.length - 1 - changed));
|
|
640
|
+
});
|
|
641
|
+
return {
|
|
642
|
+
indexOf: (combination) => axes.reduce((sum, axis, at) => sum + axis.values.indexOf(combination[axis.name]) * stride[at], 0),
|
|
643
|
+
gaps,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Where each member sits on the set's board, the variant matrix designers draw: the axes are split into those that go
|
|
649
|
+
* across and those that go down, and within each direction they nest, an earlier axis being the outer group. The member
|
|
650
|
+
* of every first value is at the origin, which is how Figma picks a set's default variant. A combination the file
|
|
651
|
+
* excludes leaves its cell empty, so the columns and rows stay aligned.
|
|
652
|
+
*/
|
|
653
|
+
export function memberBoard(members: Member[], component: Pick<Component, 'properties' | 'across'>): Board {
|
|
654
|
+
const axes = component.properties.filter((p) => p.type === 'VARIANT') as Axis[];
|
|
655
|
+
const across = acrossAxes(axes, component.across);
|
|
656
|
+
const down = axes.filter((axis) => !across.includes(axis));
|
|
657
|
+
const columns = count(across);
|
|
658
|
+
const rows = count(down);
|
|
659
|
+
return {
|
|
660
|
+
cells: members.map((member) => ({
|
|
661
|
+
name: member.name,
|
|
662
|
+
row: rows.indexOf(member.combination),
|
|
663
|
+
column: columns.indexOf(member.combination),
|
|
664
|
+
})),
|
|
665
|
+
columnGaps: columns.gaps,
|
|
666
|
+
rowGaps: rows.gaps,
|
|
667
|
+
};
|
|
668
|
+
}
|