@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,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies the steps that change a set that already exists.
|
|
3
|
+
*
|
|
4
|
+
* Identity is what makes this safe: a member keeps its node id, a layer is found
|
|
5
|
+
* by the address in its stamp rather than by name, and a set is never deleted and
|
|
6
|
+
* rebuilt, since that would change its key and break every instance of it. Only
|
|
7
|
+
* the difference is applied, in the order the diff planned it: properties first,
|
|
8
|
+
* so that a layer can be wired to a property the same run added.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { renameAxis } from '../../shared/components/diff.js';
|
|
12
|
+
import type { Layer, Member, Value } from '../../shared/components/model.js';
|
|
13
|
+
import type { Desired, Step } from '../../shared/components/steps.js';
|
|
14
|
+
import {
|
|
15
|
+
applyFields,
|
|
16
|
+
applyInstanceProperties,
|
|
17
|
+
artboardBuild,
|
|
18
|
+
buildChildren,
|
|
19
|
+
createNode,
|
|
20
|
+
defaultOf,
|
|
21
|
+
editProperties,
|
|
22
|
+
findKey,
|
|
23
|
+
keyedPreferred,
|
|
24
|
+
placeMembers,
|
|
25
|
+
nameOf,
|
|
26
|
+
orRemoveWhatItMade,
|
|
27
|
+
registerSlots,
|
|
28
|
+
shownDefault,
|
|
29
|
+
stampNew,
|
|
30
|
+
stampSet,
|
|
31
|
+
withResets,
|
|
32
|
+
type Build,
|
|
33
|
+
} from './apply.js';
|
|
34
|
+
import type { Resolved } from './resolve.js';
|
|
35
|
+
import { stampReceipts, type Written } from './receipt.js';
|
|
36
|
+
import { stampOn, stripNodes, updateStamp } from './stamp.js';
|
|
37
|
+
import { membersOf, stampedTree } from './snapshot.js';
|
|
38
|
+
|
|
39
|
+
export interface Updated {
|
|
40
|
+
created: string[];
|
|
41
|
+
updated: string[];
|
|
42
|
+
deleted: string[];
|
|
43
|
+
warnings: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Container = SceneNode & ChildrenMixin;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Each of Toghen's layers of a member by address, the root (the member itself) included: as the snapshot read them
|
|
50
|
+
* (`stampedTree`), so a person's copy of a layer is never taken for it.
|
|
51
|
+
*
|
|
52
|
+
* In an artboard an instance's slots are searched too, and each is kept under the key its layers name as their parent.
|
|
53
|
+
*/
|
|
54
|
+
async function indexMember(member: Container, slots = false): Promise<Map<string, SceneNode>> {
|
|
55
|
+
const layers = new Map<string, SceneNode>();
|
|
56
|
+
for (const { node, address, copyOf } of await stampedTree(member, slots)) {
|
|
57
|
+
if (address === undefined || copyOf) continue;
|
|
58
|
+
layers.set(address, node);
|
|
59
|
+
if (slots && node.type === 'INSTANCE') registerSlots(layers, address, node);
|
|
60
|
+
}
|
|
61
|
+
return layers;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The keys of the set's properties by name: what a wired layer references. */
|
|
65
|
+
function keysOf(set: ComponentSetNode): Map<string, string> {
|
|
66
|
+
const keys = new Map<string, string>();
|
|
67
|
+
for (const [key, definition] of Object.entries(set.componentPropertyDefinitions)) {
|
|
68
|
+
if (definition.type !== 'VARIANT') keys.set(nameOf(key), key);
|
|
69
|
+
}
|
|
70
|
+
return keys;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Puts a node in a parent after the layer that precedes it in the file, or first when nothing does. */
|
|
74
|
+
function placeAfter(parent: ChildrenMixin, node: SceneNode, after: SceneNode | undefined, named: boolean): void {
|
|
75
|
+
const siblings = parent.children;
|
|
76
|
+
// A predecessor the member does not have yet is not a reason to fail: the layer goes last.
|
|
77
|
+
let index = after ? siblings.indexOf(after) + 1 : named ? siblings.length : 0;
|
|
78
|
+
const current = siblings.indexOf(node);
|
|
79
|
+
if (current !== -1 && current < index) index -= 1;
|
|
80
|
+
parent.insertChild(index, node);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function pageOf(node: BaseNode): PageNode {
|
|
84
|
+
let up: BaseNode | null = node;
|
|
85
|
+
while (up && up.type !== 'PAGE') up = up.parent;
|
|
86
|
+
if (!up) throw new Error(`the node ${node.id} is not on a page`);
|
|
87
|
+
return up;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function applyUpdate(setId: string, steps: Step[], desired: Desired, resolved: Resolved): Promise<Updated> {
|
|
91
|
+
const result: Updated = { created: [], updated: [], deleted: [], warnings: [] };
|
|
92
|
+
const spec = desired.component;
|
|
93
|
+
|
|
94
|
+
const found = await figma.getNodeByIdAsync(setId);
|
|
95
|
+
if (found?.type !== 'COMPONENT_SET') throw new Error(`the component set ${setId} is no longer in the file`);
|
|
96
|
+
const set = found;
|
|
97
|
+
|
|
98
|
+
// New nodes are created on the current page and moved into place, so the page is the one the set is on.
|
|
99
|
+
await figma.setCurrentPageAsync(pageOf(set));
|
|
100
|
+
const hasText = (layer: Layer): boolean => layer.kind === 'TEXT' || layer.children.some(hasText);
|
|
101
|
+
if (desired.members.some((member) => hasText(member.tree))) await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
|
|
102
|
+
for (const style of resolved.textStyles()) await figma.loadFontAsync(style.fontName);
|
|
103
|
+
|
|
104
|
+
const defaults = new Map<string, string | boolean>();
|
|
105
|
+
for (const property of spec.properties)
|
|
106
|
+
if (property.type === 'BOOLEAN' || property.type === 'TEXT') defaults.set(property.name, shownDefault(resolved, property));
|
|
107
|
+
// A person's copy of a member, in the set, is neither renamed nor laid out.
|
|
108
|
+
const copies = new Set<SceneNode>((await membersOf(set)).copies.map(({ copy }) => copy));
|
|
109
|
+
|
|
110
|
+
const indexes = new Map<string, Map<string, SceneNode>>();
|
|
111
|
+
const memberNodes = new Map<string, ComponentNode>();
|
|
112
|
+
const member = async (id: string): Promise<{ node: ComponentNode; layers: Map<string, SceneNode> }> => {
|
|
113
|
+
let node = memberNodes.get(id);
|
|
114
|
+
if (!node) {
|
|
115
|
+
const looked = await figma.getNodeByIdAsync(id);
|
|
116
|
+
if (looked?.type !== 'COMPONENT') throw new Error(`the member ${id} is no longer in the file`);
|
|
117
|
+
node = looked;
|
|
118
|
+
memberNodes.set(id, node);
|
|
119
|
+
indexes.set(id, await indexMember(node));
|
|
120
|
+
}
|
|
121
|
+
return { node, layers: indexes.get(id)! };
|
|
122
|
+
};
|
|
123
|
+
// The set's keys, read once before the first new member is appended: from then on its members disagree on axes until the old ones are gone, and Figma will not be read.
|
|
124
|
+
let keys: Map<string, string> | undefined;
|
|
125
|
+
const written: Written[] = [];
|
|
126
|
+
const build = (node: ComponentNode): Build => ({
|
|
127
|
+
member: node,
|
|
128
|
+
host: set,
|
|
129
|
+
spec,
|
|
130
|
+
resolved,
|
|
131
|
+
keys: keys ?? keysOf(set),
|
|
132
|
+
defaults,
|
|
133
|
+
warnings: result.warnings,
|
|
134
|
+
written,
|
|
135
|
+
});
|
|
136
|
+
const updated = new Set<string>();
|
|
137
|
+
let madeSlot = false;
|
|
138
|
+
const layerContext: LayerContext = {
|
|
139
|
+
member,
|
|
140
|
+
build: (owner) => build(owner as ComponentNode),
|
|
141
|
+
updated,
|
|
142
|
+
artboard: false,
|
|
143
|
+
members: desired.members as Member[],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
for (const step of steps) {
|
|
147
|
+
switch (step.kind) {
|
|
148
|
+
case 'ADD_PROPERTY': {
|
|
149
|
+
const property = step.property;
|
|
150
|
+
if (property.type === 'BOOLEAN' || property.type === 'TEXT')
|
|
151
|
+
set.addComponentProperty(property.name, property.type, defaultOf(resolved, property));
|
|
152
|
+
else if (property.type === 'INSTANCE_SWAP')
|
|
153
|
+
set.addComponentProperty(property.name, 'INSTANCE_SWAP', resolved.component(property.default).id);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case 'EDIT_PROPERTY': {
|
|
158
|
+
const property = step.property;
|
|
159
|
+
const key = findKey(set.componentPropertyDefinitions, property.type, property.name);
|
|
160
|
+
if (key === undefined) break;
|
|
161
|
+
const edit: Parameters<ComponentSetNode['editComponentProperty']>[1] = {};
|
|
162
|
+
for (const change of step.changes) {
|
|
163
|
+
if (change === 'defaultValue' && property.type === 'INSTANCE_SWAP') edit.defaultValue = resolved.component(property.default).id;
|
|
164
|
+
else if (change === 'defaultValue' && (property.type === 'BOOLEAN' || property.type === 'TEXT'))
|
|
165
|
+
edit.defaultValue = defaultOf(resolved, property);
|
|
166
|
+
else if (change === 'preferredValues' && (property.type === 'INSTANCE_SWAP' || property.type === 'SLOT'))
|
|
167
|
+
edit.preferredValues = keyedPreferred(property.preferredValues ?? []);
|
|
168
|
+
else if (change === 'description' && property.type === 'SLOT') edit.description = property.description ?? '';
|
|
169
|
+
else if (change === 'slotSettings' && property.type === 'SLOT') edit.slotSettings = property.slotSettings ?? {};
|
|
170
|
+
}
|
|
171
|
+
set.editComponentProperty(key, edit);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'DELETE_PROPERTY': {
|
|
176
|
+
const definitions = set.componentPropertyDefinitions;
|
|
177
|
+
const key = Object.keys(definitions).find((k) => definitions[k].type !== 'VARIANT' && nameOf(k) === step.name);
|
|
178
|
+
if (key !== undefined) set.deleteComponentProperty(key);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case 'RENAME_AXIS': {
|
|
183
|
+
// Measured (measured in a real file through use_figma, 2026-10-14): one call renames the axis in every member's name and keeps each node and each instance's value; shared plugin data does not follow.
|
|
184
|
+
set.editComponentProperty(step.from, { name: step.to });
|
|
185
|
+
for (const child of set.children) {
|
|
186
|
+
const stamped = child.type === 'COMPONENT' && !copies.has(child) ? stampOn(child)?.is.member : undefined;
|
|
187
|
+
const renamed = stamped === undefined ? undefined : renameAxis(stamped, step.from, step.to);
|
|
188
|
+
if (renamed !== undefined) updateStamp(child, { is: { member: renamed } });
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case 'CREATE_MEMBER': {
|
|
194
|
+
// All the new members at the first of these steps: built away from the set, appended together, then wired. Once one
|
|
195
|
+
// is in, the set may disagree with itself on axes (Figma then refuses to be read) until the old members are deleted.
|
|
196
|
+
// Measured (measured in a real file through use_figma, 2026-09-29): appending is enough; the new member's slots join the set's properties, and its
|
|
197
|
+
// layers take the set's keys. It is built with no properties of its own and wired after it is in.
|
|
198
|
+
if (keys) break;
|
|
199
|
+
keys = keysOf(set);
|
|
200
|
+
// A failure removes every member this step made, appended or not, and what it left loose on the page, before it goes on.
|
|
201
|
+
const created: ComponentNode[] = [];
|
|
202
|
+
await orRemoveWhatItMade(pageOf(set), created, async () => {
|
|
203
|
+
const made: { node: ComponentNode; own: Build }[] = [];
|
|
204
|
+
for (const { member: file } of steps.filter((s): s is Extract<Step, { kind: 'CREATE_MEMBER' }> => s.kind === 'CREATE_MEMBER')) {
|
|
205
|
+
const node = figma.createComponent();
|
|
206
|
+
created.push(node);
|
|
207
|
+
node.name = file.name;
|
|
208
|
+
const own: Build = { ...build(node), host: node, deferred: [] };
|
|
209
|
+
stampNew(own, node, file.tree, { member: file.name });
|
|
210
|
+
await applyFields(own, node, file.tree, true);
|
|
211
|
+
await buildChildren(own, node, file.tree);
|
|
212
|
+
made.push({ node, own });
|
|
213
|
+
}
|
|
214
|
+
for (const { node } of made) set.appendChild(node);
|
|
215
|
+
for (const { node, own } of made) {
|
|
216
|
+
for (const assign of own.deferred!) assign();
|
|
217
|
+
result.created.push(node.name);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'DELETE_MEMBER': {
|
|
224
|
+
const gone = await figma.getNodeByIdAsync(step.memberId);
|
|
225
|
+
if (gone) gone.remove();
|
|
226
|
+
await stripNodes(step.strip);
|
|
227
|
+
result.deleted.push(step.name);
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
case 'CREATE_LAYER':
|
|
232
|
+
case 'REPLACE_LAYER':
|
|
233
|
+
case 'MOVE_LAYER':
|
|
234
|
+
case 'PATCH_LAYER':
|
|
235
|
+
case 'SWAP_COMPONENT':
|
|
236
|
+
case 'DELETE_LAYER':
|
|
237
|
+
if (await applyLayerStep(step, layerContext)) madeSlot = true;
|
|
238
|
+
break;
|
|
239
|
+
|
|
240
|
+
case 'RELAYOUT': {
|
|
241
|
+
placeMembers(set, desired.members as Member[], spec, copies);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
default:
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (spec.description !== undefined) set.description = spec.description;
|
|
251
|
+
// A slot the run made has its property's description and settings still to take.
|
|
252
|
+
if (madeSlot) editProperties(set, spec);
|
|
253
|
+
// Last: what Figma reports once every step is in.
|
|
254
|
+
stampSet(set, spec);
|
|
255
|
+
await stampReceipts(written);
|
|
256
|
+
result.updated.push(...updated);
|
|
257
|
+
return result;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Whether a layer replaced by `layer` hands what is inside it to the new node, so the layers under it outlive it. */
|
|
261
|
+
export const keepsChildren = (layer: Layer, old: BaseNode): boolean =>
|
|
262
|
+
(layer.kind === 'FRAME' || layer.kind === 'SLOT') && 'children' in old && old.type !== 'INSTANCE';
|
|
263
|
+
|
|
264
|
+
/** An instance whose component the locator now answers with another (the file named another, or the lock another key) is swapped to it. */
|
|
265
|
+
async function swapComponent(build: Build, instance: InstanceNode, layer: Layer): Promise<void> {
|
|
266
|
+
const value = layer.fields.component as Value | undefined;
|
|
267
|
+
if (!value || value.kind === 'property') return;
|
|
268
|
+
const wanted =
|
|
269
|
+
value.kind === 'key'
|
|
270
|
+
? build.resolved.component({ key: value.key })
|
|
271
|
+
: build.resolved.component({ name: String((value as { value: string }).value) });
|
|
272
|
+
const current = await instance.getMainComponentAsync();
|
|
273
|
+
if (current?.id !== wanted.id) instance.swapComponent(wanted);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** What the steps that change a layer need to find the member (or the artboard) they are in. */
|
|
277
|
+
interface LayerContext {
|
|
278
|
+
member(id: string): Promise<{ node: Container; layers: Map<string, SceneNode> }>;
|
|
279
|
+
build(owner: Container): Build;
|
|
280
|
+
/** The members, or for an artboard the artboard, that a step changed. */
|
|
281
|
+
updated: Set<string>;
|
|
282
|
+
artboard: boolean;
|
|
283
|
+
/** The file's members, where a step that carries no layer finds its own. */
|
|
284
|
+
members: Member[];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The file's layer at an address in a member, by the member's name. */
|
|
288
|
+
function layerAt(members: Member[], name: string, address: string): Layer | undefined {
|
|
289
|
+
const find = (layer: Layer): Layer | undefined => (layer.address === address ? layer : layer.children.map(find).find(Boolean));
|
|
290
|
+
const member = members.find((candidate) => candidate.name === name);
|
|
291
|
+
return member && find(member.tree);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Applies one step that creates, replaces, moves, patches or deletes a layer: those of a member and those of an artboard.
|
|
296
|
+
* Returns true when it made a `SLOT` layer, whose property still has its description and settings to take.
|
|
297
|
+
*/
|
|
298
|
+
async function applyLayerStep(step: Step, context: LayerContext): Promise<boolean> {
|
|
299
|
+
const { member, build, updated, artboard } = context;
|
|
300
|
+
const nameOfLayer = (layer: Layer): string => layer.name ?? layer.address;
|
|
301
|
+
// An instance has the slots its component gives it; the layers in them name those as their parent. Its properties are
|
|
302
|
+
// written before its fields: a variant switch may resize it, and the size the file states comes after.
|
|
303
|
+
const settle = async (
|
|
304
|
+
b: Build,
|
|
305
|
+
node: SceneNode,
|
|
306
|
+
layer: Layer,
|
|
307
|
+
layers: Map<string, SceneNode>,
|
|
308
|
+
only?: ReadonlySet<string>,
|
|
309
|
+
): Promise<void> => {
|
|
310
|
+
if (layer.kind !== 'INSTANCE') return;
|
|
311
|
+
await applyInstanceProperties(b, node, layer, only);
|
|
312
|
+
if (artboard) registerSlots(layers, layer.address, node);
|
|
313
|
+
};
|
|
314
|
+
let madeSlot = false;
|
|
315
|
+
|
|
316
|
+
switch (step.kind) {
|
|
317
|
+
case 'CREATE_LAYER': {
|
|
318
|
+
const { node: owner, layers } = await member(step.memberId);
|
|
319
|
+
const parent = layers.get(step.parent) as Container | undefined;
|
|
320
|
+
if (!parent) throw new Error(`member "${step.member}": the parent "${step.parent}" of "${step.layer.address}" does not exist`);
|
|
321
|
+
const b = build(owner);
|
|
322
|
+
const node = createNode(b, step.layer);
|
|
323
|
+
if (step.layer.kind === 'SLOT') madeSlot = true;
|
|
324
|
+
placeAfter(parent, node as SceneNode, step.after ? layers.get(step.after) : undefined, step.after !== undefined);
|
|
325
|
+
node.name = nameOfLayer(step.layer);
|
|
326
|
+
stampNew(b, node as SceneNode, step.layer);
|
|
327
|
+
await settle(b, node as SceneNode, step.layer, layers);
|
|
328
|
+
await applyFields(b, node as SceneNode, step.layer, false);
|
|
329
|
+
layers.set(step.layer.address, node as SceneNode);
|
|
330
|
+
updated.add(step.member);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
case 'REPLACE_LAYER': {
|
|
335
|
+
const { node: owner, layers } = await member(step.memberId);
|
|
336
|
+
const old = await figma.getNodeByIdAsync(step.nodeId);
|
|
337
|
+
const parent = layers.get(step.parent) as Container | undefined;
|
|
338
|
+
if (!old || !parent) throw new Error(`member "${step.member}": cannot replace "${step.layer.address}"`);
|
|
339
|
+
const b = build(owner);
|
|
340
|
+
const node = createNode(b, step.layer);
|
|
341
|
+
if (step.layer.kind === 'SLOT') madeSlot = true;
|
|
342
|
+
placeAfter(parent, node as SceneNode, step.after ? layers.get(step.after) : undefined, step.after !== undefined);
|
|
343
|
+
node.name = nameOfLayer(step.layer);
|
|
344
|
+
stampNew(b, node as SceneNode, step.layer);
|
|
345
|
+
await settle(b, node as SceneNode, step.layer, layers);
|
|
346
|
+
await applyFields(b, node as SceneNode, step.layer, false);
|
|
347
|
+
// What was inside comes along, so the layers under it stay the same nodes. A text or an instance holds none.
|
|
348
|
+
if (keepsChildren(step.layer, old)) {
|
|
349
|
+
for (const child of [...(old as BaseNode & ChildrenMixin).children]) node.appendChild(child as SceneNode);
|
|
350
|
+
}
|
|
351
|
+
old.remove();
|
|
352
|
+
await stripNodes(step.strip);
|
|
353
|
+
layers.set(step.layer.address, node as SceneNode);
|
|
354
|
+
updated.add(step.member);
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
case 'MOVE_LAYER': {
|
|
359
|
+
const { layers } = await member(step.memberId);
|
|
360
|
+
const moving = await figma.getNodeByIdAsync(step.nodeId);
|
|
361
|
+
const parent = layers.get(step.parent) as Container | undefined;
|
|
362
|
+
if (!moving || !parent) throw new Error(`member "${step.member}": cannot move "${step.address}"`);
|
|
363
|
+
placeAfter(parent, moving as SceneNode, step.after ? layers.get(step.after) : undefined, step.after !== undefined);
|
|
364
|
+
updated.add(step.member);
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case 'PATCH_LAYER': {
|
|
369
|
+
const { node: owner, layers } = await member(step.memberId);
|
|
370
|
+
const root = step.nodeId === step.memberId;
|
|
371
|
+
const node = root ? owner : ((await figma.getNodeByIdAsync(step.nodeId)) as SceneNode | null);
|
|
372
|
+
if (!node) throw new Error(`member "${step.member}": the layer "${step.layer.address}" is no longer in the file`);
|
|
373
|
+
const b = build(owner);
|
|
374
|
+
const only = withResets(step.layer, step.fields);
|
|
375
|
+
await settle(b, node as SceneNode, step.layer, layers, only);
|
|
376
|
+
await applyFields(b, node as SceneNode, step.layer, root, only);
|
|
377
|
+
// A layer a designer renamed is still found by stamp, and gets the file's name back.
|
|
378
|
+
node.name = root && !artboard ? step.member : nameOfLayer(step.layer);
|
|
379
|
+
// Its receipt follows once the run's writes are done: one the file stopped declaring was reported and leaves it.
|
|
380
|
+
b.written.push({ node: node as SceneNode, layer: step.layer, fields: [...only, ...(step.restamp ?? [])] });
|
|
381
|
+
layers.set(step.layer.address, node as SceneNode);
|
|
382
|
+
updated.add(step.member);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
case 'SWAP_COMPONENT': {
|
|
387
|
+
const { node: owner, layers } = await member(step.memberId);
|
|
388
|
+
const node = (await figma.getNodeByIdAsync(step.nodeId)) as InstanceNode | null;
|
|
389
|
+
const layer = layerAt(context.members, step.member, step.address);
|
|
390
|
+
if (!node || !layer) throw new Error(`member "${step.member}": the instance "${step.address}" is no longer in the file`);
|
|
391
|
+
const b = build(owner);
|
|
392
|
+
// As a patch of `component` did: its property references written again, then the swap where the component differs.
|
|
393
|
+
const only = new Set(['component']);
|
|
394
|
+
await applyFields(b, node, layer, false, only);
|
|
395
|
+
await swapComponent(b, node, layer);
|
|
396
|
+
// Its receipt follows once the run's writes are done, so a real run holds the new component's and the next plans nothing.
|
|
397
|
+
b.written.push({ node, layer, fields: ['component'] });
|
|
398
|
+
await settle(b, node, layer, layers, only);
|
|
399
|
+
updated.add(step.member);
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case 'DELETE_LAYER': {
|
|
404
|
+
const gone = await figma.getNodeByIdAsync(step.nodeId);
|
|
405
|
+
// A layer inside one that was deleted is already gone.
|
|
406
|
+
if (gone) gone.remove();
|
|
407
|
+
await stripNodes(step.strip);
|
|
408
|
+
updated.add(step.member);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
default:
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
return madeSlot;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface UpdatedArtboard {
|
|
419
|
+
/** The addresses of the layers made, patched and removed. */
|
|
420
|
+
created: string[];
|
|
421
|
+
updated: string[];
|
|
422
|
+
deleted: string[];
|
|
423
|
+
warnings: string[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Applies the steps that change an artboard that exists: the layers of its frame, in the order the diff planned them.
|
|
428
|
+
*
|
|
429
|
+
* The frame keeps its node id, and so does every layer the file still produces. A layer the file stopped producing is
|
|
430
|
+
* removed, which the run's gate has already put to the person who ran it.
|
|
431
|
+
*/
|
|
432
|
+
export async function applyArtboardUpdate(frameId: string, steps: Step[], desired: Desired, resolved: Resolved): Promise<UpdatedArtboard> {
|
|
433
|
+
const result: UpdatedArtboard = { created: [], updated: [], deleted: [], warnings: [] };
|
|
434
|
+
const found = await figma.getNodeByIdAsync(frameId);
|
|
435
|
+
if (found?.type !== 'FRAME') throw new Error(`the artboard ${frameId} is no longer in the file`);
|
|
436
|
+
const frame = found;
|
|
437
|
+
const [member] = desired.members;
|
|
438
|
+
|
|
439
|
+
// New nodes are created on the current page and moved into place, so the page is the one the artboard is on.
|
|
440
|
+
await figma.setCurrentPageAsync(pageOf(frame));
|
|
441
|
+
const hasText = (layer: Layer): boolean => layer.kind === 'TEXT' || layer.children.some(hasText);
|
|
442
|
+
if (hasText(member.tree)) await figma.loadFontAsync({ family: 'Inter', style: 'Regular' });
|
|
443
|
+
for (const style of resolved.textStyles()) await figma.loadFontAsync(style.fontName);
|
|
444
|
+
|
|
445
|
+
const layers = await indexMember(frame, true);
|
|
446
|
+
const written: Written[] = [];
|
|
447
|
+
const context: LayerContext = {
|
|
448
|
+
member: async () => ({ node: frame, layers }),
|
|
449
|
+
build: () => artboardBuild(frame, member.name, resolved, result.warnings, written),
|
|
450
|
+
updated: new Set<string>(),
|
|
451
|
+
artboard: true,
|
|
452
|
+
members: desired.members as Member[],
|
|
453
|
+
};
|
|
454
|
+
for (const step of steps) {
|
|
455
|
+
await applyLayerStep(step, context);
|
|
456
|
+
if (step.kind === 'CREATE_LAYER' || step.kind === 'REPLACE_LAYER') result.created.push(step.layer.address);
|
|
457
|
+
else if (step.kind === 'PATCH_LAYER' || step.kind === 'SWAP_COMPONENT') {
|
|
458
|
+
// A swapped instance with other fields to write is one layer updated, not two.
|
|
459
|
+
const address = step.kind === 'PATCH_LAYER' ? step.layer.address : step.address;
|
|
460
|
+
if (!result.updated.includes(address)) result.updated.push(address);
|
|
461
|
+
} else if (step.kind === 'DELETE_LAYER') result.deleted.push(step.address);
|
|
462
|
+
}
|
|
463
|
+
await stampReceipts(written);
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Command, Response } from '../shared/protocol.js';
|
|
2
|
+
import * as handlers from './handlers/index.js';
|
|
3
|
+
|
|
4
|
+
type HandlerFn = (payload: any) => Promise<void> | void;
|
|
5
|
+
|
|
6
|
+
export function setupDispatcher(): void {
|
|
7
|
+
figma.ui.onmessage = async (command: Command) => {
|
|
8
|
+
const handler = (handlers as unknown as Record<string, HandlerFn>)[command.type];
|
|
9
|
+
if (!handler) {
|
|
10
|
+
postError(`Unknown command: ${command.type}`, command.type);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await handler(command.payload);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
18
|
+
postError(message, command.type);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function postError(message: string, commandType: string): void {
|
|
24
|
+
const response: Response = {
|
|
25
|
+
type: 'ERROR',
|
|
26
|
+
payload: {
|
|
27
|
+
message,
|
|
28
|
+
code: 'FIGMA_API_ERROR',
|
|
29
|
+
commandType,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
figma.ui.postMessage(response);
|
|
33
|
+
}
|