@designid/tokens 1.2.14 → 1.2.16
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/bin/build.js +1 -1
- package/bin/editor.js +1 -1
- package/bin/watch.js +1 -1
- package/dist/figma/code.js +2 -0
- package/dist/figma/manifest.json +20 -0
- package/dist/figma/ui.html +3009 -0
- package/package.json +3 -2
|
@@ -0,0 +1,3009 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>@DesignID Tokens - Figma Sync</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
body {
|
|
13
|
+
margin: 0;
|
|
14
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
background: var(--figma-color-bg);
|
|
17
|
+
color: var(--figma-color-text);
|
|
18
|
+
overflow: hidden;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.container {
|
|
22
|
+
display: flex;
|
|
23
|
+
height: 100vh;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.header {
|
|
28
|
+
padding: 12px 16px;
|
|
29
|
+
border-bottom: 1px solid var(--figma-color-border);
|
|
30
|
+
background: var(--figma-color-bg);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.header h1 {
|
|
34
|
+
margin: 0 0 8px 0;
|
|
35
|
+
font-size: 14px;
|
|
36
|
+
font-weight: 600;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.header p {
|
|
40
|
+
margin: 0;
|
|
41
|
+
font-size: 11px;
|
|
42
|
+
color: var(--figma-color-text-secondary);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.actions {
|
|
46
|
+
padding: 0 16px;
|
|
47
|
+
border-bottom: 1px solid var(--figma-color-border);
|
|
48
|
+
display: flex;
|
|
49
|
+
gap: 8px;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: space-between;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.tabs {
|
|
55
|
+
display: flex;
|
|
56
|
+
gap: 4px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.tab {
|
|
60
|
+
padding: 8px 16px;
|
|
61
|
+
border: none;
|
|
62
|
+
background: transparent;
|
|
63
|
+
color: var(--figma-color-text-secondary);
|
|
64
|
+
border-radius: 0px;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
font-size: 12px;
|
|
67
|
+
font-weight: 500;
|
|
68
|
+
transition: all 0.2s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.tab:hover {
|
|
72
|
+
background: var(--figma-color-bg-hover);
|
|
73
|
+
color: var(--figma-color-text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.tab.active {
|
|
77
|
+
background: var(--figma-color-bg-selected);
|
|
78
|
+
color: var(--figma-color-text-onselected);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#actions-menu-btn {
|
|
82
|
+
border: none;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.context-menu-btn {
|
|
86
|
+
position: relative;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.context-menu {
|
|
90
|
+
position: absolute;
|
|
91
|
+
top: 100%;
|
|
92
|
+
right: 0;
|
|
93
|
+
margin-top: 4px;
|
|
94
|
+
background: var(--figma-color-bg);
|
|
95
|
+
border-radius: 4px;
|
|
96
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
97
|
+
min-width: 200px;
|
|
98
|
+
z-index: 1000;
|
|
99
|
+
display: none;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.context-menu.open {
|
|
103
|
+
display: block;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.context-menu-item {
|
|
107
|
+
padding: 10px 16px;
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
font-size: 12px;
|
|
110
|
+
transition: background 0.2s;
|
|
111
|
+
border: none;
|
|
112
|
+
background: transparent;
|
|
113
|
+
width: 100%;
|
|
114
|
+
text-align: left;
|
|
115
|
+
color: var(--figma-color-text);
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.context-menu-item:hover {
|
|
122
|
+
background: var(--figma-color-bg-hover);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.context-menu-item:first-child {
|
|
126
|
+
border-radius: 4px 4px 0 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.context-menu-item:last-child {
|
|
130
|
+
border-radius: 0 0 4px 4px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.context-menu-divider {
|
|
134
|
+
height: 1px;
|
|
135
|
+
background: var(--figma-color-border);
|
|
136
|
+
margin: 4px 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
button {
|
|
140
|
+
padding: 8px 12px;
|
|
141
|
+
border: 1px solid var(--figma-color-border);
|
|
142
|
+
background: var(--figma-color-bg);
|
|
143
|
+
color: var(--figma-color-text);
|
|
144
|
+
border-radius: 4px;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
font-size: 11px;
|
|
147
|
+
font-weight: 500;
|
|
148
|
+
transition: background 0.2s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
button:hover {
|
|
152
|
+
background: var(--figma-color-bg-hover);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
button:active {
|
|
156
|
+
background: var(--figma-color-bg-pressed);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
button.primary {
|
|
160
|
+
background: var(--figma-color-bg-brand);
|
|
161
|
+
color: white;
|
|
162
|
+
border-color: var(--figma-color-bg-brand);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
button.primary:hover {
|
|
166
|
+
background: var(--figma-color-bg-brand-hover);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
button:disabled {
|
|
170
|
+
opacity: 0.5;
|
|
171
|
+
cursor: not-allowed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.content {
|
|
175
|
+
flex: 1;
|
|
176
|
+
display: flex;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.sidebar {
|
|
181
|
+
width: 280px;
|
|
182
|
+
border-right: 1px solid var(--figma-color-border);
|
|
183
|
+
display: flex;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
background: var(--figma-color-bg);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.search-box {
|
|
189
|
+
padding: 12px;
|
|
190
|
+
border-bottom: 1px solid var(--figma-color-border);
|
|
191
|
+
position: relative;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.search-box input {
|
|
195
|
+
width: 100%;
|
|
196
|
+
padding: 6px 28px 6px 8px;
|
|
197
|
+
border: 1px solid var(--figma-color-border);
|
|
198
|
+
border-radius: 4px;
|
|
199
|
+
background: var(--figma-color-bg);
|
|
200
|
+
color: var(--figma-color-text);
|
|
201
|
+
font-size: 11px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.search-box input:focus {
|
|
205
|
+
outline: 2px solid var(--figma-color-bg-brand);
|
|
206
|
+
outline-offset: -1px;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.search-clear {
|
|
210
|
+
position: absolute;
|
|
211
|
+
right: 20px;
|
|
212
|
+
top: 50%;
|
|
213
|
+
transform: translateY(-50%);
|
|
214
|
+
padding: 2px 6px;
|
|
215
|
+
border: none;
|
|
216
|
+
background: transparent;
|
|
217
|
+
color: var(--figma-color-text-secondary);
|
|
218
|
+
cursor: pointer;
|
|
219
|
+
font-size: 16px;
|
|
220
|
+
line-height: 1;
|
|
221
|
+
display: none;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.search-clear.visible {
|
|
225
|
+
display: block;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.sidebar-controls {
|
|
229
|
+
padding: 8px 12px;
|
|
230
|
+
display: flex;
|
|
231
|
+
gap: 8px;
|
|
232
|
+
border-bottom: 1px solid var(--figma-color-border);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.sidebar-controls button {
|
|
236
|
+
flex: 1;
|
|
237
|
+
padding: 4px 8px;
|
|
238
|
+
font-size: 10px;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.token-tree {
|
|
242
|
+
flex: 1;
|
|
243
|
+
overflow-y: auto;
|
|
244
|
+
padding: 8px;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.tree-node {
|
|
248
|
+
user-select: none;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.tree-node-header {
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
padding: 4px 8px;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
border-radius: 4px;
|
|
257
|
+
margin-bottom: 2px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.tree-node-header:hover {
|
|
261
|
+
background: var(--figma-color-bg-hover);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.tree-node-header.selected {
|
|
265
|
+
background: var(--figma-color-bg-selected);
|
|
266
|
+
color: var(--figma-color-text-onselected);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.tree-node-icon {
|
|
270
|
+
width: 12px;
|
|
271
|
+
margin-right: 4px;
|
|
272
|
+
font-size: 10px;
|
|
273
|
+
color: var(--figma-color-text-secondary);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.tree-node-label {
|
|
277
|
+
flex: 1;
|
|
278
|
+
font-size: 11px;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.tree-node-type {
|
|
282
|
+
font-size: 9px;
|
|
283
|
+
color: var(--figma-color-text-secondary);
|
|
284
|
+
background: var(--figma-color-bg-secondary);
|
|
285
|
+
padding: 2px 6px;
|
|
286
|
+
border-radius: 3px;
|
|
287
|
+
margin-left: 4px;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.tree-node-value {
|
|
291
|
+
margin-left: 8px;
|
|
292
|
+
font-size: 11px;
|
|
293
|
+
color: #888;
|
|
294
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
gap: 6px;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.color-swatch {
|
|
301
|
+
width: 16px;
|
|
302
|
+
height: 16px;
|
|
303
|
+
border-radius: 50%;
|
|
304
|
+
border: 1px solid var(--figma-color-border);
|
|
305
|
+
display: inline-block;
|
|
306
|
+
flex-shrink: 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.color-swatch-group {
|
|
310
|
+
display: flex;
|
|
311
|
+
gap: 4px;
|
|
312
|
+
align-items: center;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.mode-swatch {
|
|
316
|
+
position: relative;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/* .mode-swatch::after {
|
|
320
|
+
content: attr(data-mode);
|
|
321
|
+
position: absolute;
|
|
322
|
+
bottom: -14px;
|
|
323
|
+
left: 50%;
|
|
324
|
+
transform: translateX(-50%);
|
|
325
|
+
font-size: 8px;
|
|
326
|
+
color: var(--figma-color-text-secondary);
|
|
327
|
+
white-space: nowrap;
|
|
328
|
+
} */
|
|
329
|
+
|
|
330
|
+
.token-reference-info {
|
|
331
|
+
margin-top: 4px;
|
|
332
|
+
padding: 4px 8px;
|
|
333
|
+
background: #e6f2ff;
|
|
334
|
+
border-radius: 4px;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.tree-node-children {
|
|
338
|
+
margin-left: 16px;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.tree-node-children.collapsed {
|
|
342
|
+
display: none;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.editor {
|
|
346
|
+
flex: 1;
|
|
347
|
+
padding: 16px;
|
|
348
|
+
overflow-y: auto;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.empty-state {
|
|
352
|
+
display: flex;
|
|
353
|
+
flex-direction: column;
|
|
354
|
+
align-items: center;
|
|
355
|
+
justify-content: center;
|
|
356
|
+
height: 100%;
|
|
357
|
+
text-align: center;
|
|
358
|
+
color: var(--figma-color-text-secondary);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.empty-state h3 {
|
|
362
|
+
margin: 0 0 8px 0;
|
|
363
|
+
font-size: 14px;
|
|
364
|
+
font-weight: 600;
|
|
365
|
+
color: var(--figma-color-text);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.empty-state p {
|
|
369
|
+
margin: 0 0 16px 0;
|
|
370
|
+
font-size: 11px;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.editor-form {
|
|
374
|
+
max-width: 500px;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.form-group {
|
|
378
|
+
margin-bottom: 16px;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.form-group label {
|
|
382
|
+
display: block;
|
|
383
|
+
margin-bottom: 4px;
|
|
384
|
+
font-size: 11px;
|
|
385
|
+
font-weight: 500;
|
|
386
|
+
color: var(--figma-color-text);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.form-group input,
|
|
390
|
+
.form-group select,
|
|
391
|
+
.form-group textarea {
|
|
392
|
+
width: 100%;
|
|
393
|
+
padding: 6px 8px;
|
|
394
|
+
border: 1px solid var(--figma-color-border);
|
|
395
|
+
border-radius: 4px;
|
|
396
|
+
background: var(--figma-color-bg);
|
|
397
|
+
color: var(--figma-color-text);
|
|
398
|
+
font-size: 11px;
|
|
399
|
+
font-family: inherit;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.form-group textarea {
|
|
403
|
+
min-height: 60px;
|
|
404
|
+
resize: vertical;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.form-group input:focus,
|
|
408
|
+
.form-group select:focus,
|
|
409
|
+
.form-group textarea:focus {
|
|
410
|
+
outline: 2px solid var(--figma-color-bg-brand);
|
|
411
|
+
outline-offset: -1px;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.form-actions {
|
|
415
|
+
display: flex;
|
|
416
|
+
gap: 8px;
|
|
417
|
+
margin-top: 16px;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.form-actions button {
|
|
421
|
+
flex: 1;
|
|
422
|
+
padding: 8px 16px;
|
|
423
|
+
border-radius: 4px;
|
|
424
|
+
cursor: pointer;
|
|
425
|
+
font-size: 11px;
|
|
426
|
+
font-weight: 500;
|
|
427
|
+
transition: all 0.2s;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.form-actions button.primary {
|
|
431
|
+
background: var(--figma-color-bg-brand);
|
|
432
|
+
color: white;
|
|
433
|
+
border: none;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.form-actions button.primary:hover {
|
|
437
|
+
opacity: 0.9;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.form-actions button:not(.primary) {
|
|
441
|
+
background: var(--figma-color-bg-secondary);
|
|
442
|
+
color: var(--figma-color-text);
|
|
443
|
+
border: 1px solid var(--figma-color-border);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.form-actions button:not(.primary):hover {
|
|
447
|
+
background: var(--figma-color-bg-hover);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.status-bar {
|
|
451
|
+
padding: 8px 16px;
|
|
452
|
+
border-top: 1px solid var(--figma-color-border);
|
|
453
|
+
font-size: 11px;
|
|
454
|
+
background: var(--figma-color-bg);
|
|
455
|
+
color: var(--figma-color-text-secondary);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
.status-bar.success {
|
|
459
|
+
background: #0d8a00;
|
|
460
|
+
color: white;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.status-bar.error {
|
|
464
|
+
background: #f24822;
|
|
465
|
+
color: white;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.loading {
|
|
469
|
+
padding: 16px;
|
|
470
|
+
text-align: center;
|
|
471
|
+
color: var(--figma-color-text-secondary);
|
|
472
|
+
font-size: 11px;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.token-preview {
|
|
476
|
+
margin-top: 8px;
|
|
477
|
+
padding: 12px;
|
|
478
|
+
background: var(--figma-color-bg-secondary);
|
|
479
|
+
border-radius: 4px;
|
|
480
|
+
border: 1px solid var(--figma-color-border);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.token-preview-label {
|
|
484
|
+
font-size: 10px;
|
|
485
|
+
color: var(--figma-color-text-secondary);
|
|
486
|
+
margin-bottom: 8px;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.token-preview-color {
|
|
490
|
+
width: 100%;
|
|
491
|
+
height: 40px;
|
|
492
|
+
border-radius: 4px;
|
|
493
|
+
border: 1px solid var(--figma-color-border);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.token-preview-modes {
|
|
497
|
+
display: flex;
|
|
498
|
+
gap: 12px;
|
|
499
|
+
margin-top: 8px;
|
|
500
|
+
flex-wrap: wrap;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
.token-preview-mode {
|
|
504
|
+
display: flex;
|
|
505
|
+
flex-direction: column;
|
|
506
|
+
align-items: center;
|
|
507
|
+
gap: 4px;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.token-preview-mode-label {
|
|
511
|
+
font-size: 9px;
|
|
512
|
+
color: var(--figma-color-text-secondary);
|
|
513
|
+
text-transform: uppercase;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.token-preview-mode-swatch {
|
|
517
|
+
width: 32px;
|
|
518
|
+
height: 32px;
|
|
519
|
+
border-radius: 50%;
|
|
520
|
+
border: 1px solid var(--figma-color-border);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
.progress-bar {
|
|
524
|
+
width: 100%;
|
|
525
|
+
height: 4px;
|
|
526
|
+
background: var(--figma-color-bg-secondary);
|
|
527
|
+
border-radius: 2px;
|
|
528
|
+
overflow: hidden;
|
|
529
|
+
margin-top: 8px;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.progress-bar-fill {
|
|
533
|
+
height: 100%;
|
|
534
|
+
background: var(--figma-color-bg-brand);
|
|
535
|
+
transition: width 0.3s;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/* Files View Styles */
|
|
539
|
+
.files-view {
|
|
540
|
+
display: none;
|
|
541
|
+
flex-direction: column;
|
|
542
|
+
height: 100%;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.files-view.active {
|
|
546
|
+
display: flex;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.files-list {
|
|
550
|
+
flex: 1;
|
|
551
|
+
overflow-y: auto;
|
|
552
|
+
padding: 16px;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
.file-item {
|
|
556
|
+
padding: 12px;
|
|
557
|
+
border: 1px solid var(--figma-color-border);
|
|
558
|
+
border-radius: 4px;
|
|
559
|
+
margin-bottom: 8px;
|
|
560
|
+
cursor: pointer;
|
|
561
|
+
transition: background 0.2s;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.file-item:hover {
|
|
565
|
+
background: var(--figma-color-bg-hover);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.file-item.selected {
|
|
569
|
+
background: var(--figma-color-bg-selected);
|
|
570
|
+
border-color: var(--figma-color-bg-brand);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
.file-item.has-changes {
|
|
574
|
+
border-left: 3px solid var(--figma-color-bg-brand);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.file-header {
|
|
578
|
+
display: flex;
|
|
579
|
+
align-items: center;
|
|
580
|
+
justify-content: space-between;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
.file-name {
|
|
584
|
+
font-size: 12px;
|
|
585
|
+
font-weight: 500;
|
|
586
|
+
color: var(--figma-color-text);
|
|
587
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.file-badge {
|
|
591
|
+
font-size: 9px;
|
|
592
|
+
padding: 2px 6px;
|
|
593
|
+
border-radius: 3px;
|
|
594
|
+
background: var(--figma-color-bg-brand);
|
|
595
|
+
color: white;
|
|
596
|
+
font-weight: 600;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.file-path {
|
|
600
|
+
font-size: 10px;
|
|
601
|
+
color: var(--figma-color-text-secondary);
|
|
602
|
+
margin-top: 4px;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
.file-detail {
|
|
606
|
+
display: none;
|
|
607
|
+
flex-direction: column;
|
|
608
|
+
height: 100%;
|
|
609
|
+
background: var(--figma-color-bg);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.file-detail.active {
|
|
613
|
+
display: flex;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.file-detail-header {
|
|
617
|
+
padding: 16px;
|
|
618
|
+
border-bottom: 1px solid var(--figma-color-border);
|
|
619
|
+
display: flex;
|
|
620
|
+
align-items: center;
|
|
621
|
+
justify-content: space-between;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
.file-detail-title {
|
|
625
|
+
flex: 1;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
.file-detail-title h3 {
|
|
629
|
+
margin: 0 0 4px 0;
|
|
630
|
+
font-size: 14px;
|
|
631
|
+
font-weight: 600;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.file-detail-title p {
|
|
635
|
+
margin: 0;
|
|
636
|
+
font-size: 11px;
|
|
637
|
+
color: var(--figma-color-text-secondary);
|
|
638
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.file-detail-actions {
|
|
642
|
+
display: flex;
|
|
643
|
+
gap: 8px;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.file-detail-content {
|
|
647
|
+
flex: 1;
|
|
648
|
+
overflow-y: auto;
|
|
649
|
+
padding: 16px;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
.diff-section {
|
|
653
|
+
margin-bottom: 24px;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.diff-section h4 {
|
|
657
|
+
margin: 0 0 8px 0;
|
|
658
|
+
font-size: 12px;
|
|
659
|
+
font-weight: 600;
|
|
660
|
+
color: var(--figma-color-text-secondary);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
.diff-item {
|
|
664
|
+
margin-bottom: 12px;
|
|
665
|
+
padding: 8px;
|
|
666
|
+
background: var(--figma-color-bg-secondary);
|
|
667
|
+
border-radius: 4px;
|
|
668
|
+
border-left: 3px solid var(--figma-color-border);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.diff-item.changed {
|
|
672
|
+
border-left-color: #ffcd29;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.diff-item.added {
|
|
676
|
+
border-left-color: #14ae5c;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.diff-item.removed {
|
|
680
|
+
border-left-color: #f24822;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.diff-path {
|
|
684
|
+
font-size: 11px;
|
|
685
|
+
font-weight: 600;
|
|
686
|
+
color: var(--figma-color-text);
|
|
687
|
+
margin-bottom: 4px;
|
|
688
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.diff-values {
|
|
692
|
+
display: flex;
|
|
693
|
+
gap: 12px;
|
|
694
|
+
font-size: 11px;
|
|
695
|
+
font-family: 'Monaco', 'Menlo', monospace;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
.diff-old {
|
|
699
|
+
flex: 1;
|
|
700
|
+
color: #f24822;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.diff-new {
|
|
704
|
+
flex: 1;
|
|
705
|
+
color: #14ae5c;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.diff-label {
|
|
709
|
+
font-size: 9px;
|
|
710
|
+
text-transform: uppercase;
|
|
711
|
+
color: var(--figma-color-text-secondary);
|
|
712
|
+
margin-bottom: 2px;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/* Find & Replace Modal */
|
|
716
|
+
#find-replace-modal.active {
|
|
717
|
+
display: flex !important;
|
|
718
|
+
}
|
|
719
|
+
</style>
|
|
720
|
+
</head>
|
|
721
|
+
<body>
|
|
722
|
+
<div class="container">
|
|
723
|
+
<div class="header">
|
|
724
|
+
<h1>@DesignID Tokens - Figma Sync</h1>
|
|
725
|
+
<p>Synchronize design tokens with Figma variables, styles, and effects <span id="modes-indicator" style="color: #0066ff; font-weight: 600;"></span></p>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
<div class="actions">
|
|
729
|
+
<div class="tabs">
|
|
730
|
+
<button class="tab active" id="tokens-tab">🎨 Tokens</button>
|
|
731
|
+
<button class="tab" id="files-tab">📄 Files</button>
|
|
732
|
+
<button class="tab" id="config-tab">⚙️ Config</button>
|
|
733
|
+
</div>
|
|
734
|
+
<div class="context-menu-btn">
|
|
735
|
+
<button id="actions-menu-btn">⋯ Actions</button>
|
|
736
|
+
<div class="context-menu" id="context-menu">
|
|
737
|
+
<button class="context-menu-item" id="sync-btn">
|
|
738
|
+
<span>↻</span>
|
|
739
|
+
<span id="sync-btn-text">Sync to Figma</span>
|
|
740
|
+
</button>
|
|
741
|
+
<button class="context-menu-item" id="load-btn">
|
|
742
|
+
<span>↓</span>
|
|
743
|
+
<span>Load from Figma</span>
|
|
744
|
+
</button>
|
|
745
|
+
<button class="context-menu-item" id="fix-references-btn">
|
|
746
|
+
<span>🔗</span>
|
|
747
|
+
<span>Fix Broken References</span>
|
|
748
|
+
</button>
|
|
749
|
+
<button class="context-menu-item" id="find-replace-btn">
|
|
750
|
+
<span>🔍</span>
|
|
751
|
+
<span>Find & Replace Variables</span>
|
|
752
|
+
</button>
|
|
753
|
+
<div class="context-menu-divider"></div>
|
|
754
|
+
<button class="context-menu-item" id="import-btn">
|
|
755
|
+
<span>📂</span>
|
|
756
|
+
<span>Import Tokens</span>
|
|
757
|
+
</button>
|
|
758
|
+
<button class="context-menu-item" id="export-btn">
|
|
759
|
+
<span>💾</span>
|
|
760
|
+
<span>Export JSON</span>
|
|
761
|
+
</button>
|
|
762
|
+
</div>
|
|
763
|
+
</div>
|
|
764
|
+
</div>
|
|
765
|
+
|
|
766
|
+
<div class="content">
|
|
767
|
+
<div class="sidebar" id="tokens-view">
|
|
768
|
+
<div class="search-box">
|
|
769
|
+
<input
|
|
770
|
+
type="text"
|
|
771
|
+
id="search-input"
|
|
772
|
+
placeholder="Search tokens..."
|
|
773
|
+
/>
|
|
774
|
+
<button class="search-clear" id="search-clear">×</button>
|
|
775
|
+
</div>
|
|
776
|
+
<div class="sidebar-controls">
|
|
777
|
+
<button id="new-token-btn" style="background: var(--figma-color-bg-brand); color: white; font-weight: 600;">+ New Token</button>
|
|
778
|
+
<button id="expand-all-btn">Expand All</button>
|
|
779
|
+
<button id="collapse-all-btn">Collapse All</button>
|
|
780
|
+
</div>
|
|
781
|
+
<div class="token-tree" id="token-tree">
|
|
782
|
+
<div class="loading">No tokens loaded. Click "Import Tokens" to get started.</div>
|
|
783
|
+
</div>
|
|
784
|
+
</div>
|
|
785
|
+
|
|
786
|
+
<div class="files-view" id="files-view">
|
|
787
|
+
<div class="files-list" id="files-list">
|
|
788
|
+
<div class="loading">No files imported yet</div>
|
|
789
|
+
</div>
|
|
790
|
+
</div>
|
|
791
|
+
|
|
792
|
+
<div class="files-view" id="config-view">
|
|
793
|
+
<div class="config-content" id="config-content">
|
|
794
|
+
<div class="loading">No configuration loaded</div>
|
|
795
|
+
</div>
|
|
796
|
+
</div>
|
|
797
|
+
|
|
798
|
+
<div class="editor" id="editor-content">
|
|
799
|
+
<div class="empty-state">
|
|
800
|
+
<h3>Welcome to Figma Token Sync</h3>
|
|
801
|
+
<ol style="text-align: left; max-width: 450px; margin: 12px auto; line-height: 1.6;">
|
|
802
|
+
<li>Click <strong>"📂 Import Tokens"</strong></li>
|
|
803
|
+
<li>Select your <code>tokens</code> folder from your design system</li>
|
|
804
|
+
<li>All .tokens.json files will be automatically loaded and merged</li>
|
|
805
|
+
</ol>
|
|
806
|
+
<p>
|
|
807
|
+
<button id="get-started-btn" class="primary" onclick="handleImport()">Import Tokens</button>
|
|
808
|
+
</p>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<div class="status-bar" id="status-bar">Ready</div>
|
|
814
|
+
|
|
815
|
+
<!-- Find & Replace Variables Modal -->
|
|
816
|
+
<div id="find-replace-modal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;">
|
|
817
|
+
<div style="background: var(--figma-color-bg); border-radius: 8px; padding: 20px; max-width: 450px; width: 90%; box-shadow: 0 4px 12px rgba(0,0,0,0.3); max-height: 80vh; overflow-y: auto;">
|
|
818
|
+
<h2 style="margin: 0 0 16px 0; font-size: 14px; font-weight: 600;">Find & Replace Variables</h2>
|
|
819
|
+
|
|
820
|
+
<div style="margin-bottom: 16px;">
|
|
821
|
+
<label style="display: flex; align-items: center; font-size: 11px; cursor: pointer; margin-bottom: 12px;">
|
|
822
|
+
<input type="checkbox" id="use-regex-pattern" style="margin-right: 6px;">
|
|
823
|
+
Use regex pattern matching
|
|
824
|
+
</label>
|
|
825
|
+
</div>
|
|
826
|
+
|
|
827
|
+
<div id="manual-mode" style="display: block;">
|
|
828
|
+
<div style="margin-bottom: 16px;">
|
|
829
|
+
<label style="display: block; margin-bottom: 6px; font-size: 11px; font-weight: 500;">Find Variable:</label>
|
|
830
|
+
<select id="search-variable-select" style="width: 100%; padding: 6px 8px; border: 1px solid var(--figma-color-border); border-radius: 4px; background: var(--figma-color-bg); color: var(--figma-color-text); font-size: 11px;">
|
|
831
|
+
<option value="">Select variable to find...</option>
|
|
832
|
+
</select>
|
|
833
|
+
</div>
|
|
834
|
+
|
|
835
|
+
<div style="margin-bottom: 16px;">
|
|
836
|
+
<label style="display: block; margin-bottom: 6px; font-size: 11px; font-weight: 500;">Replace With:</label>
|
|
837
|
+
<select id="replace-variable-select" style="width: 100%; padding: 6px 8px; border: 1px solid var(--figma-color-border); border-radius: 4px; background: var(--figma-color-bg); color: var(--figma-color-text); font-size: 11px;">
|
|
838
|
+
<option value="">Select replacement variable...</option>
|
|
839
|
+
</select>
|
|
840
|
+
</div>
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
<div id="regex-mode" style="display: none;">
|
|
844
|
+
<div style="margin-bottom: 16px;">
|
|
845
|
+
<label style="display: block; margin-bottom: 6px; font-size: 11px; font-weight: 500;">Find Pattern (regex):</label>
|
|
846
|
+
<input type="text" id="regex-search-pattern" placeholder="e.g. semantic/info" style="width: 100%; padding: 6px 8px; border: 1px solid var(--figma-color-border); border-radius: 4px; background: var(--figma-color-bg); color: var(--figma-color-text); font-size: 11px; font-family: 'Monaco', 'Menlo', monospace;">
|
|
847
|
+
<div style="margin-top: 4px; font-size: 10px; color: var(--figma-color-text-secondary);">Pattern will match against variable names</div>
|
|
848
|
+
</div>
|
|
849
|
+
|
|
850
|
+
<div style="margin-bottom: 16px;">
|
|
851
|
+
<label style="display: block; margin-bottom: 6px; font-size: 11px; font-weight: 500;">Replace Pattern:</label>
|
|
852
|
+
<input type="text" id="regex-replace-pattern" placeholder="e.g. sentiment/info" style="width: 100%; padding: 6px 8px; border: 1px solid var(--figma-color-border); border-radius: 4px; background: var(--figma-color-bg); color: var(--figma-color-text); font-size: 11px; font-family: 'Monaco', 'Menlo', monospace;">
|
|
853
|
+
<div style="margin-top: 4px; font-size: 10px; color: var(--figma-color-text-secondary);">Use $1, $2 for capture groups</div>
|
|
854
|
+
</div>
|
|
855
|
+
|
|
856
|
+
<div id="regex-preview" style="margin-bottom: 16px; padding: 8px; background: var(--figma-color-bg-secondary); border-radius: 4px; font-size: 10px; max-height: 150px; overflow-y: auto; display: none;">
|
|
857
|
+
<div style="font-weight: 600; margin-bottom: 4px; color: var(--figma-color-text);">Matching variables:</div>
|
|
858
|
+
<div id="regex-preview-list" style="font-family: 'Monaco', 'Menlo', monospace; color: var(--figma-color-text-secondary);"></div>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
|
|
862
|
+
<div style="margin-bottom: 16px;">
|
|
863
|
+
<label style="display: flex; align-items: center; font-size: 11px; cursor: pointer;">
|
|
864
|
+
<input type="checkbox" id="apply-to-whole-page" style="margin-right: 6px;">
|
|
865
|
+
Apply to whole page (otherwise only selected nodes)
|
|
866
|
+
</label>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
|
870
|
+
<button id="find-replace-cancel-btn" style="padding: 6px 12px; border: 1px solid var(--figma-color-border); border-radius: 4px; background: var(--figma-color-bg); color: var(--figma-color-text); cursor: pointer; font-size: 11px;">Cancel</button>
|
|
871
|
+
<button id="find-replace-execute-btn" class="primary" style="padding: 6px 12px;">Replace</button>
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
</div>
|
|
875
|
+
</div>
|
|
876
|
+
|
|
877
|
+
<script>
|
|
878
|
+
// State management
|
|
879
|
+
let tokens = {};
|
|
880
|
+
let tokenFiles = []; // Store original file structures: { path, name, tokens }
|
|
881
|
+
let config = { modes: [], basePixelSize: 16, collectionName: '@DesignID Tokens' }; // Default empty, will detect from tokens
|
|
882
|
+
let selectedTokenPath = null;
|
|
883
|
+
let expandedNodes = new Set();
|
|
884
|
+
let searchQuery = '';
|
|
885
|
+
let currentView = 'tokens'; // 'tokens', 'files', or 'config'
|
|
886
|
+
let selectedFileIndex = null;
|
|
887
|
+
let configData = null; // Store raw config file content
|
|
888
|
+
let newTokenFormData = null; // Store new token form state when switching views
|
|
889
|
+
|
|
890
|
+
// Utility: Check if value is a token reference
|
|
891
|
+
function isTokenReference(value) {
|
|
892
|
+
if (typeof value !== 'string' || !value.startsWith('{') || !value.endsWith('}')) {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
const innerContent = value.slice(1, -1);
|
|
896
|
+
return !innerContent.includes('{') && !innerContent.includes('}') && innerContent.trim() !== '';
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Utility: Format token value for display
|
|
900
|
+
function formatTokenValue(value) {
|
|
901
|
+
if (value === null || value === undefined) return '';
|
|
902
|
+
if (typeof value === 'string') return value;
|
|
903
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
904
|
+
return JSON.stringify(value);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Utility: Get display value (show reference or actual value)
|
|
908
|
+
function getDisplayValue(value) {
|
|
909
|
+
if (isTokenReference(value)) {
|
|
910
|
+
return value; // Show the reference as-is
|
|
911
|
+
}
|
|
912
|
+
return formatTokenValue(value);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Detect modes from token data
|
|
916
|
+
function detectModesFromTokens(tokensObj) {
|
|
917
|
+
const modes = new Set();
|
|
918
|
+
|
|
919
|
+
function traverse(obj) {
|
|
920
|
+
if (!obj || typeof obj !== 'object') return;
|
|
921
|
+
|
|
922
|
+
// Check if this is a token with $extensions.$mode
|
|
923
|
+
if (obj.$extensions && obj.$extensions.$mode) {
|
|
924
|
+
Object.keys(obj.$extensions.$mode).forEach(mode => modes.add(mode));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Recursively check nested objects
|
|
928
|
+
Object.values(obj).forEach(value => {
|
|
929
|
+
if (value && typeof value === 'object') {
|
|
930
|
+
traverse(value);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
traverse(tokensObj);
|
|
936
|
+
return Array.from(modes);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Utility: Resolve token reference to actual value
|
|
940
|
+
function resolveTokenReference(ref, mode = null) {
|
|
941
|
+
if (!isTokenReference(ref)) return ref;
|
|
942
|
+
const path = ref.slice(1, -1); // Remove { and }
|
|
943
|
+
const parts = path.split('.');
|
|
944
|
+
let current = tokens;
|
|
945
|
+
for (const part of parts) {
|
|
946
|
+
if (!current || typeof current !== 'object') return ref;
|
|
947
|
+
current = current[part];
|
|
948
|
+
}
|
|
949
|
+
if (current && typeof current === 'object' && '$value' in current) {
|
|
950
|
+
// If a mode is specified and the token has mode-specific values, use that
|
|
951
|
+
if (mode && current.$extensions && current.$extensions.$mode && current.$extensions.$mode[mode]) {
|
|
952
|
+
const modeValue = current.$extensions.$mode[mode];
|
|
953
|
+
// Recursively resolve if the mode value is also a reference
|
|
954
|
+
return isTokenReference(modeValue) ? resolveTokenReference(modeValue, mode) : modeValue;
|
|
955
|
+
}
|
|
956
|
+
// Otherwise use the default value
|
|
957
|
+
const defaultValue = current.$value;
|
|
958
|
+
// Recursively resolve if the default value is also a reference
|
|
959
|
+
return isTokenReference(defaultValue) ? resolveTokenReference(defaultValue, mode) : defaultValue;
|
|
960
|
+
}
|
|
961
|
+
return ref; // Return original if not found
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Get the referenced token object (not just its value)
|
|
965
|
+
function getReferencedToken(ref) {
|
|
966
|
+
if (!isTokenReference(ref)) return null;
|
|
967
|
+
const path = ref.slice(1, -1); // Remove { and }
|
|
968
|
+
const parts = path.split('.');
|
|
969
|
+
let current = tokens;
|
|
970
|
+
for (const part of parts) {
|
|
971
|
+
if (!current || typeof current !== 'object') return null;
|
|
972
|
+
current = current[part];
|
|
973
|
+
}
|
|
974
|
+
if (current && typeof current === 'object' && '$value' in current) {
|
|
975
|
+
return current;
|
|
976
|
+
}
|
|
977
|
+
return null;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Utility: Deep merge token objects
|
|
981
|
+
function deepMergeTokens(target, source) {
|
|
982
|
+
for (const key in source) {
|
|
983
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
984
|
+
if (!target[key]) target[key] = {};
|
|
985
|
+
deepMergeTokens(target[key], source[key]);
|
|
986
|
+
} else {
|
|
987
|
+
target[key] = source[key];
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// Utility: Remove icon tokens from token object
|
|
993
|
+
function removeIconTokens(obj) {
|
|
994
|
+
if (!obj || typeof obj !== 'object') return;
|
|
995
|
+
|
|
996
|
+
// Check all keys in the object
|
|
997
|
+
for (const key in obj) {
|
|
998
|
+
const value = obj[key];
|
|
999
|
+
if (value && typeof value === 'object') {
|
|
1000
|
+
// Check if this is an icon token (has $type === 'icon')
|
|
1001
|
+
if (value.$type === 'icon') {
|
|
1002
|
+
delete obj[key];
|
|
1003
|
+
} else {
|
|
1004
|
+
// Recurse into nested objects/groups
|
|
1005
|
+
removeIconTokens(value);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// Initialize
|
|
1012
|
+
window.onload = () => {
|
|
1013
|
+
setupEventListeners();
|
|
1014
|
+
loadInitialTokens();
|
|
1015
|
+
updateModesIndicator();
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
// Update modes indicator in header
|
|
1019
|
+
function updateModesIndicator() {
|
|
1020
|
+
const indicator = document.getElementById('modes-indicator');
|
|
1021
|
+
if (config.modes && config.modes.length > 0) {
|
|
1022
|
+
// Ensure default is always first and not duplicated
|
|
1023
|
+
const modes = config.modes.includes('default')
|
|
1024
|
+
? config.modes
|
|
1025
|
+
: ['default', ...config.modes];
|
|
1026
|
+
indicator.textContent = `• ${modes.length} mode${modes.length > 1 ? 's' : ''}: ${modes.join(', ')}`;
|
|
1027
|
+
} else {
|
|
1028
|
+
indicator.textContent = '• 1 mode: default';
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Setup event listeners
|
|
1033
|
+
function setupEventListeners() {
|
|
1034
|
+
// Tab navigation
|
|
1035
|
+
document.getElementById('tokens-tab').addEventListener('click', showTokensView);
|
|
1036
|
+
document.getElementById('files-tab').addEventListener('click', showFilesView);
|
|
1037
|
+
document.getElementById('config-tab').addEventListener('click', showConfigView);
|
|
1038
|
+
|
|
1039
|
+
// Context menu
|
|
1040
|
+
const menuBtn = document.getElementById('actions-menu-btn');
|
|
1041
|
+
const contextMenu = document.getElementById('context-menu');
|
|
1042
|
+
|
|
1043
|
+
menuBtn.addEventListener('click', (e) => {
|
|
1044
|
+
e.stopPropagation();
|
|
1045
|
+
contextMenu.classList.toggle('open');
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
// Close context menu when clicking outside
|
|
1049
|
+
document.addEventListener('click', () => {
|
|
1050
|
+
contextMenu.classList.remove('open');
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
// Menu actions
|
|
1054
|
+
document.getElementById('sync-btn').addEventListener('click', () => {
|
|
1055
|
+
contextMenu.classList.remove('open');
|
|
1056
|
+
handleSync();
|
|
1057
|
+
});
|
|
1058
|
+
document.getElementById('load-btn').addEventListener('click', () => {
|
|
1059
|
+
contextMenu.classList.remove('open');
|
|
1060
|
+
handleLoad();
|
|
1061
|
+
});
|
|
1062
|
+
document.getElementById('fix-references-btn').addEventListener('click', () => {
|
|
1063
|
+
contextMenu.classList.remove('open');
|
|
1064
|
+
handleFixBrokenReferences();
|
|
1065
|
+
});
|
|
1066
|
+
document.getElementById('find-replace-btn').addEventListener('click', () => {
|
|
1067
|
+
contextMenu.classList.remove('open');
|
|
1068
|
+
showFindReplaceModal();
|
|
1069
|
+
});
|
|
1070
|
+
document.getElementById('import-btn').addEventListener('click', () => {
|
|
1071
|
+
contextMenu.classList.remove('open');
|
|
1072
|
+
handleImport();
|
|
1073
|
+
});
|
|
1074
|
+
document.getElementById('export-btn').addEventListener('click', () => {
|
|
1075
|
+
contextMenu.classList.remove('open');
|
|
1076
|
+
handleExport();
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
document.getElementById('new-token-btn').addEventListener('click', showNewTokenEditor);
|
|
1080
|
+
document.getElementById('expand-all-btn').addEventListener('click', expandAll);
|
|
1081
|
+
document.getElementById('collapse-all-btn').addEventListener('click', collapseAll);
|
|
1082
|
+
|
|
1083
|
+
// Find/replace modal event listeners
|
|
1084
|
+
document.getElementById('find-replace-cancel-btn').addEventListener('click', closeFindReplaceModal);
|
|
1085
|
+
document.getElementById('find-replace-execute-btn').addEventListener('click', handleFindReplace);
|
|
1086
|
+
document.getElementById('use-regex-pattern').addEventListener('change', toggleFindReplaceMode);
|
|
1087
|
+
document.getElementById('regex-search-pattern').addEventListener('input', updateRegexPreview);
|
|
1088
|
+
document.getElementById('regex-replace-pattern').addEventListener('input', updateRegexPreview);
|
|
1089
|
+
|
|
1090
|
+
const searchInput = document.getElementById('search-input');
|
|
1091
|
+
searchInput.addEventListener('input', handleSearch);
|
|
1092
|
+
|
|
1093
|
+
document.getElementById('search-clear').addEventListener('click', clearSearch);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Handle messages from plugin code
|
|
1097
|
+
window.onmessage = async (event) => {
|
|
1098
|
+
const msg = event.data.pluginMessage;
|
|
1099
|
+
if (!msg) return;
|
|
1100
|
+
|
|
1101
|
+
console.log('UI received:', msg.type);
|
|
1102
|
+
|
|
1103
|
+
switch (msg.type) {
|
|
1104
|
+
case 'init':
|
|
1105
|
+
setStatus(`Connected to: ${msg.payload.documentName}`);
|
|
1106
|
+
// Auto-trigger import if no tokens are present after init
|
|
1107
|
+
setTimeout(() => {
|
|
1108
|
+
if (Object.keys(tokens).length === 0 && tokenFiles.length === 0) {
|
|
1109
|
+
handleImport();
|
|
1110
|
+
}
|
|
1111
|
+
}, 500);
|
|
1112
|
+
break;
|
|
1113
|
+
|
|
1114
|
+
case 'tokens-loaded':
|
|
1115
|
+
tokens = msg.payload.tokens;
|
|
1116
|
+
|
|
1117
|
+
// Detect modes from loaded tokens
|
|
1118
|
+
const detectedModes = detectModesFromTokens(tokens);
|
|
1119
|
+
if (detectedModes.length > 0) {
|
|
1120
|
+
config.modes = detectedModes;
|
|
1121
|
+
console.log('Detected modes from Figma tokens:', config.modes);
|
|
1122
|
+
updateModesIndicator();
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
renderTokenTree();
|
|
1126
|
+
setStatus('Tokens loaded from Figma', 'success');
|
|
1127
|
+
break;
|
|
1128
|
+
|
|
1129
|
+
case 'sync-progress':
|
|
1130
|
+
setStatus(msg.payload.message);
|
|
1131
|
+
break;
|
|
1132
|
+
|
|
1133
|
+
case 'sync-complete':
|
|
1134
|
+
const result = msg.payload.result;
|
|
1135
|
+
let message = msg.payload.message;
|
|
1136
|
+
if (result && result.removed > 0) {
|
|
1137
|
+
message += ` (${result.removed} obsolete variable${result.removed > 1 ? 's' : ''} removed)`;
|
|
1138
|
+
}
|
|
1139
|
+
setStatus(message, 'success');
|
|
1140
|
+
setSyncButtonState(false);
|
|
1141
|
+
break;
|
|
1142
|
+
|
|
1143
|
+
case 'token-updated':
|
|
1144
|
+
case 'token-added':
|
|
1145
|
+
setStatus(`Token "${msg.payload.path}" saved`, 'success');
|
|
1146
|
+
// Update local state
|
|
1147
|
+
setNestedValue(tokens, msg.payload.path, msg.payload.token);
|
|
1148
|
+
renderTokenTree();
|
|
1149
|
+
break;
|
|
1150
|
+
|
|
1151
|
+
case 'token-deleted':
|
|
1152
|
+
setStatus(`Token "${msg.payload.path}" deleted`, 'success');
|
|
1153
|
+
deleteNestedValue(tokens, msg.payload.path);
|
|
1154
|
+
renderTokenTree();
|
|
1155
|
+
showEmptyState();
|
|
1156
|
+
break;
|
|
1157
|
+
|
|
1158
|
+
case 'tokens-exported':
|
|
1159
|
+
downloadJSON(msg.payload.tokens, 'figma-tokens.json');
|
|
1160
|
+
setStatus('Tokens exported', 'success');
|
|
1161
|
+
break;
|
|
1162
|
+
|
|
1163
|
+
case 'fix-references-complete':
|
|
1164
|
+
setStatus(msg.payload.message, 'success');
|
|
1165
|
+
break;
|
|
1166
|
+
|
|
1167
|
+
case 'available-variables':
|
|
1168
|
+
// Populate the dropdowns with available variables
|
|
1169
|
+
populateVariableDropdowns(msg.payload.variables);
|
|
1170
|
+
// Show the modal
|
|
1171
|
+
document.getElementById('find-replace-modal').classList.add('active');
|
|
1172
|
+
setStatus('');
|
|
1173
|
+
break;
|
|
1174
|
+
|
|
1175
|
+
case 'find-replace-progress':
|
|
1176
|
+
setStatus(msg.payload.message);
|
|
1177
|
+
break;
|
|
1178
|
+
|
|
1179
|
+
case 'find-replace-complete':
|
|
1180
|
+
const replacedCount = msg.payload.replacedCount || 0;
|
|
1181
|
+
const scannedCount = msg.payload.scannedNodes || 0;
|
|
1182
|
+
setStatus(`Replaced ${replacedCount} reference${replacedCount !== 1 ? 's' : ''} in ${scannedCount} node${scannedCount !== 1 ? 's' : ''}`, 'success');
|
|
1183
|
+
break;
|
|
1184
|
+
|
|
1185
|
+
case 'error':
|
|
1186
|
+
setStatus(msg.payload.message, 'error');
|
|
1187
|
+
setSyncButtonState(false);
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
};
|
|
1191
|
+
|
|
1192
|
+
// Load initial tokens
|
|
1193
|
+
function loadInitialTokens() {
|
|
1194
|
+
parent.postMessage({ pluginMessage: { type: 'load-tokens' } }, '*');
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Handle sync to Figma
|
|
1198
|
+
function handleSync() {
|
|
1199
|
+
if (Object.keys(tokens).length === 0) {
|
|
1200
|
+
setStatus('No tokens to sync. Import token files first.', 'error');
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
setSyncButtonState(true);
|
|
1205
|
+
setStatus('Syncing tokens to Figma...');
|
|
1206
|
+
parent.postMessage({
|
|
1207
|
+
pluginMessage: {
|
|
1208
|
+
type: 'sync-tokens',
|
|
1209
|
+
payload: {
|
|
1210
|
+
tokens,
|
|
1211
|
+
config: {
|
|
1212
|
+
basePixelSize: config.basePixelSize,
|
|
1213
|
+
collectionName: config.collectionName
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}, '*');
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// Handle load from Figma
|
|
1221
|
+
function handleLoad() {
|
|
1222
|
+
const container = document.getElementById('token-tree');
|
|
1223
|
+
container.innerHTML = '<div class="loading">Loading tokens from Figma...</div>';
|
|
1224
|
+
setStatus('Loading tokens from Figma...');
|
|
1225
|
+
parent.postMessage({
|
|
1226
|
+
pluginMessage: {
|
|
1227
|
+
type: 'load-tokens',
|
|
1228
|
+
payload: {
|
|
1229
|
+
config: {
|
|
1230
|
+
basePixelSize: config.basePixelSize,
|
|
1231
|
+
collectionName: config.collectionName
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}, '*');
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Handle fix broken references
|
|
1239
|
+
function handleFixBrokenReferences() {
|
|
1240
|
+
if (Object.keys(tokens).length === 0) {
|
|
1241
|
+
setStatus('No tokens loaded. Import tokens first.', 'error');
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
setStatus('Scanning for broken references...');
|
|
1246
|
+
parent.postMessage({
|
|
1247
|
+
pluginMessage: {
|
|
1248
|
+
type: 'fix-broken-references',
|
|
1249
|
+
payload: {
|
|
1250
|
+
tokens,
|
|
1251
|
+
config: {
|
|
1252
|
+
basePixelSize: config.basePixelSize,
|
|
1253
|
+
collectionName: config.collectionName
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}, '*');
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// Show find/replace modal
|
|
1261
|
+
function showFindReplaceModal() {
|
|
1262
|
+
setStatus('Loading variables...');
|
|
1263
|
+
// Request available variables from the plugin
|
|
1264
|
+
parent.postMessage({
|
|
1265
|
+
pluginMessage: {
|
|
1266
|
+
type: 'get-available-variables'
|
|
1267
|
+
}
|
|
1268
|
+
}, '*');
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Store available variables for regex preview
|
|
1272
|
+
let availableVariables = [];
|
|
1273
|
+
|
|
1274
|
+
// Handle find and replace variables
|
|
1275
|
+
function handleFindReplace() {
|
|
1276
|
+
const useRegex = document.getElementById('use-regex-pattern').checked;
|
|
1277
|
+
const applyToWholePage = document.getElementById('apply-to-whole-page').checked;
|
|
1278
|
+
|
|
1279
|
+
if (useRegex) {
|
|
1280
|
+
// Regex mode
|
|
1281
|
+
const searchPattern = document.getElementById('regex-search-pattern').value;
|
|
1282
|
+
const replacePattern = document.getElementById('regex-replace-pattern').value;
|
|
1283
|
+
|
|
1284
|
+
if (!searchPattern) {
|
|
1285
|
+
setStatus('Please enter a search pattern', 'error');
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (!replacePattern) {
|
|
1290
|
+
setStatus('Please enter a replacement pattern', 'error');
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Close modal
|
|
1295
|
+
document.getElementById('find-replace-modal').classList.remove('active');
|
|
1296
|
+
|
|
1297
|
+
// Get library collection name from config if available
|
|
1298
|
+
const libraryCollectionName = config.collectionName || null;
|
|
1299
|
+
console.log('[UI] Sending find-replace-pattern with library collection:', libraryCollectionName);
|
|
1300
|
+
|
|
1301
|
+
setStatus('Replacing variables with pattern...');
|
|
1302
|
+
parent.postMessage({
|
|
1303
|
+
pluginMessage: {
|
|
1304
|
+
type: 'find-replace-pattern',
|
|
1305
|
+
payload: {
|
|
1306
|
+
searchPattern,
|
|
1307
|
+
replacePattern,
|
|
1308
|
+
applyToWholePage,
|
|
1309
|
+
libraryCollectionName
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}, '*');
|
|
1313
|
+
} else {
|
|
1314
|
+
// Manual mode
|
|
1315
|
+
const searchSelect = document.getElementById('search-variable-select');
|
|
1316
|
+
const replaceSelect = document.getElementById('replace-variable-select');
|
|
1317
|
+
|
|
1318
|
+
const searchVariableId = searchSelect.value;
|
|
1319
|
+
const replaceVariableId = replaceSelect.value;
|
|
1320
|
+
|
|
1321
|
+
if (!searchVariableId) {
|
|
1322
|
+
setStatus('Please select a variable to search for', 'error');
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (!replaceVariableId) {
|
|
1327
|
+
setStatus('Please select a replacement variable', 'error');
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (searchVariableId === replaceVariableId) {
|
|
1332
|
+
setStatus('Search and replace variables must be different', 'error');
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Close modal
|
|
1337
|
+
document.getElementById('find-replace-modal').classList.remove('active');
|
|
1338
|
+
|
|
1339
|
+
setStatus('Replacing variable references...');
|
|
1340
|
+
parent.postMessage({
|
|
1341
|
+
pluginMessage: {
|
|
1342
|
+
type: 'find-replace-variables',
|
|
1343
|
+
payload: {
|
|
1344
|
+
searchVariableId,
|
|
1345
|
+
replaceVariableId,
|
|
1346
|
+
applyToWholePage
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}, '*');
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Close find/replace modal
|
|
1354
|
+
function closeFindReplaceModal() {
|
|
1355
|
+
document.getElementById('find-replace-modal').classList.remove('active');
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Toggle between manual and regex mode
|
|
1359
|
+
function toggleFindReplaceMode() {
|
|
1360
|
+
const useRegex = document.getElementById('use-regex-pattern').checked;
|
|
1361
|
+
document.getElementById('manual-mode').style.display = useRegex ? 'none' : 'block';
|
|
1362
|
+
document.getElementById('regex-mode').style.display = useRegex ? 'block' : 'none';
|
|
1363
|
+
|
|
1364
|
+
if (useRegex) {
|
|
1365
|
+
updateRegexPreview();
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Update regex preview
|
|
1370
|
+
function updateRegexPreview() {
|
|
1371
|
+
const searchPattern = document.getElementById('regex-search-pattern').value;
|
|
1372
|
+
const replacePattern = document.getElementById('regex-replace-pattern').value;
|
|
1373
|
+
const previewContainer = document.getElementById('regex-preview');
|
|
1374
|
+
const previewList = document.getElementById('regex-preview-list');
|
|
1375
|
+
|
|
1376
|
+
if (!searchPattern || availableVariables.length === 0) {
|
|
1377
|
+
previewContainer.style.display = 'none';
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
try {
|
|
1382
|
+
const regex = new RegExp(searchPattern);
|
|
1383
|
+
const matches = availableVariables.filter(v => regex.test(v.name));
|
|
1384
|
+
|
|
1385
|
+
if (matches.length === 0) {
|
|
1386
|
+
previewList.innerHTML = '<div style="color: var(--figma-color-text-tertiary);">No matches found</div>';
|
|
1387
|
+
previewContainer.style.display = 'block';
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const preview = matches.slice(0, 20).map(v => {
|
|
1392
|
+
const newName = replacePattern ? v.name.replace(regex, replacePattern) : v.name;
|
|
1393
|
+
return '<div style="margin-bottom: 2px;">' +
|
|
1394
|
+
'<span style="color: #f24822;">' + v.name + '</span>' +
|
|
1395
|
+
' → ' +
|
|
1396
|
+
'<span style="color: #14ae5c;">' + newName + '</span>' +
|
|
1397
|
+
'</div>';
|
|
1398
|
+
}).join('');
|
|
1399
|
+
|
|
1400
|
+
const more = matches.length > 20 ? '<div style="margin-top: 4px; color: var(--figma-color-text-tertiary);">... and ' + (matches.length - 20) + ' more</div>' : '';
|
|
1401
|
+
|
|
1402
|
+
previewList.innerHTML = preview + more;
|
|
1403
|
+
previewContainer.style.display = 'block';
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
previewList.innerHTML = '<div style="color: #f24822;">Invalid regex pattern</div>';
|
|
1406
|
+
previewContainer.style.display = 'block';
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Populate variable dropdowns
|
|
1411
|
+
function populateVariableDropdowns(variables) {
|
|
1412
|
+
availableVariables = variables;
|
|
1413
|
+
const searchSelect = document.getElementById('search-variable-select');
|
|
1414
|
+
const replaceSelect = document.getElementById('replace-variable-select');
|
|
1415
|
+
|
|
1416
|
+
// Clear existing options
|
|
1417
|
+
searchSelect.innerHTML = '<option value="">Select variable to find...</option>';
|
|
1418
|
+
replaceSelect.innerHTML = '<option value="">Select replacement variable...</option>';
|
|
1419
|
+
|
|
1420
|
+
if (!variables || variables.length === 0) {
|
|
1421
|
+
searchSelect.innerHTML += '<option disabled>No variables found in this file</option>';
|
|
1422
|
+
replaceSelect.innerHTML += '<option disabled>No variables found in this file</option>';
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// Group variables by collection
|
|
1427
|
+
const variablesByCollection = {};
|
|
1428
|
+
variables.forEach(variable => {
|
|
1429
|
+
if (!variablesByCollection[variable.collectionName]) {
|
|
1430
|
+
variablesByCollection[variable.collectionName] = [];
|
|
1431
|
+
}
|
|
1432
|
+
variablesByCollection[variable.collectionName].push(variable);
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
// Add options grouped by collection
|
|
1436
|
+
Object.entries(variablesByCollection).forEach(([collectionName, vars]) => {
|
|
1437
|
+
// Add collection group
|
|
1438
|
+
const searchGroup = document.createElement('optgroup');
|
|
1439
|
+
searchGroup.label = collectionName;
|
|
1440
|
+
const replaceGroup = document.createElement('optgroup');
|
|
1441
|
+
replaceGroup.label = collectionName;
|
|
1442
|
+
|
|
1443
|
+
// Sort variables by name
|
|
1444
|
+
vars.sort((a, b) => a.name.localeCompare(b.name));
|
|
1445
|
+
|
|
1446
|
+
// Add each variable
|
|
1447
|
+
vars.forEach(variable => {
|
|
1448
|
+
// Format display text with type info
|
|
1449
|
+
const typeLabel = variable.resolvedType ? ` (${variable.resolvedType.toLowerCase()})` : '';
|
|
1450
|
+
const aliasLabel = variable.isAlias ? ' 🔗' : '';
|
|
1451
|
+
const libraryLabel = variable.isLibrary ? ' 📚' : '';
|
|
1452
|
+
const displayText = `${variable.name}${typeLabel}${aliasLabel}${libraryLabel}`;
|
|
1453
|
+
|
|
1454
|
+
const searchOption = document.createElement('option');
|
|
1455
|
+
searchOption.value = variable.id;
|
|
1456
|
+
searchOption.textContent = displayText;
|
|
1457
|
+
searchGroup.appendChild(searchOption);
|
|
1458
|
+
|
|
1459
|
+
const replaceOption = document.createElement('option');
|
|
1460
|
+
replaceOption.value = variable.id;
|
|
1461
|
+
const replaceDisplayText = `${variable.name}${typeLabel}${aliasLabel}${libraryLabel}`;
|
|
1462
|
+
replaceOption.textContent = replaceDisplayText;
|
|
1463
|
+
replaceGroup.appendChild(replaceOption);
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
searchSelect.appendChild(searchGroup);
|
|
1467
|
+
replaceSelect.appendChild(replaceGroup);
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Handle import tokens
|
|
1472
|
+
function handleImport() {
|
|
1473
|
+
const input = document.createElement('input');
|
|
1474
|
+
input.type = 'file';
|
|
1475
|
+
input.accept = '.json,.tokens.json,.ts,.mjs,.js';
|
|
1476
|
+
input.multiple = true;
|
|
1477
|
+
input.setAttribute('webkitdirectory', '');
|
|
1478
|
+
input.setAttribute('directory', '');
|
|
1479
|
+
input.onchange = async (e) => {
|
|
1480
|
+
const files = e.target.files;
|
|
1481
|
+
if (!files || files.length === 0) return;
|
|
1482
|
+
|
|
1483
|
+
// Look for config file first
|
|
1484
|
+
const configFile = Array.from(files).find(file =>
|
|
1485
|
+
file.name === 'designid.config.ts' || file.name === 'designid.config.mjs' || file.name === 'designid.config.js'
|
|
1486
|
+
);
|
|
1487
|
+
|
|
1488
|
+
// Filter for .tokens.json files only, excluding icon files (svg.*.tokens.json)
|
|
1489
|
+
const jsonFiles = Array.from(files).filter(file =>
|
|
1490
|
+
file.name.endsWith('.tokens.json') && !file.name.startsWith('svg.')
|
|
1491
|
+
);
|
|
1492
|
+
|
|
1493
|
+
if (jsonFiles.length === 0) {
|
|
1494
|
+
setStatus('No .tokens.json files found in the selected directory.', 'error');
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Read config file first if found
|
|
1499
|
+
if (configFile) {
|
|
1500
|
+
setStatus('Reading config file...');
|
|
1501
|
+
try {
|
|
1502
|
+
const configText = await readFileAsText(configFile);
|
|
1503
|
+
configData = { fileName: configFile.name, content: configText }; // Store for config view
|
|
1504
|
+
|
|
1505
|
+
// Extract $name from config for collection name
|
|
1506
|
+
const nameMatch = configText.match(/\$name\s*:\s*['"]([^'"]+)['"]/);
|
|
1507
|
+
if (nameMatch) {
|
|
1508
|
+
config.collectionName = nameMatch[1];
|
|
1509
|
+
console.log('Found collection name in config:', config.collectionName);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Extract $modes from config using regex
|
|
1513
|
+
const modesMatch = configText.match(/\$modes\s*:\s*\{([^}]+)\}/);
|
|
1514
|
+
if (modesMatch) {
|
|
1515
|
+
const modesStr = modesMatch[1];
|
|
1516
|
+
// Extract mode names (keys in the object) - keep all modes including default
|
|
1517
|
+
const modeNames = [];
|
|
1518
|
+
const keyMatches = modesStr.matchAll(/['"]?([\w-]+)['"]?\s*:/g);
|
|
1519
|
+
for (const match of keyMatches) {
|
|
1520
|
+
const modeName = match[1];
|
|
1521
|
+
modeNames.push(modeName);
|
|
1522
|
+
}
|
|
1523
|
+
config.modes = modeNames;
|
|
1524
|
+
console.log('Found modes in config:', config.modes);
|
|
1525
|
+
const totalModes = config.modes.length;
|
|
1526
|
+
setStatus(`✓ Config loaded with ${totalModes} mode${totalModes > 1 ? 's' : ''}: ${config.modes.join(', ')}`);
|
|
1527
|
+
updateModesIndicator();
|
|
1528
|
+
}
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
console.warn('Failed to parse config file:', error);
|
|
1531
|
+
setStatus('Config file found but could not be parsed', 'warning');
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// Now read token files
|
|
1536
|
+
setStatus(`Processing ${jsonFiles.length} token file${jsonFiles.length > 1 ? 's' : ''}...`);
|
|
1537
|
+
|
|
1538
|
+
let importedTokens = {};
|
|
1539
|
+
let tokenFilesFound = 0;
|
|
1540
|
+
tokenFiles = []; // Reset file tracking
|
|
1541
|
+
|
|
1542
|
+
for (const file of jsonFiles) {
|
|
1543
|
+
try {
|
|
1544
|
+
const text = await readFileAsText(file);
|
|
1545
|
+
const fileTokens = JSON.parse(text);
|
|
1546
|
+
|
|
1547
|
+
// Store file structure for later export
|
|
1548
|
+
tokenFiles.push({
|
|
1549
|
+
path: file.webkitRelativePath || file.name,
|
|
1550
|
+
name: file.name,
|
|
1551
|
+
tokens: JSON.parse(JSON.stringify(fileTokens)) // Deep clone
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
deepMergeTokens(importedTokens, fileTokens);
|
|
1555
|
+
tokenFilesFound++;
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
console.warn(`Failed to import ${file.name}:`, error);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (tokenFilesFound > 0) {
|
|
1562
|
+
// Remove icon tokens from imported data
|
|
1563
|
+
removeIconTokens(importedTokens);
|
|
1564
|
+
|
|
1565
|
+
tokens = importedTokens;
|
|
1566
|
+
|
|
1567
|
+
// If no config file was found or no modes detected from config, detect from tokens
|
|
1568
|
+
if (!config.modes || config.modes.length === 0) {
|
|
1569
|
+
const detectedModes = detectModesFromTokens(tokens);
|
|
1570
|
+
if (detectedModes.length > 0) {
|
|
1571
|
+
config.modes = detectedModes;
|
|
1572
|
+
console.log('Detected modes from tokens:', config.modes);
|
|
1573
|
+
updateModesIndicator();
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
renderTokenTree();
|
|
1578
|
+
const modeInfo = config.modes.length > 0 ? ` (${config.modes.length} mode${config.modes.length > 1 ? 's' : ''} available)` : ' (1 mode: default)';
|
|
1579
|
+
setStatus(`✓ Imported ${tokenFilesFound} token file${tokenFilesFound > 1 ? 's' : ''}${modeInfo}`, 'success');
|
|
1580
|
+
} else {
|
|
1581
|
+
setStatus('Failed to import token files', 'error');
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
input.click();
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Helper to read file as text (promisified)
|
|
1588
|
+
function readFileAsText(file) {
|
|
1589
|
+
return new Promise((resolve, reject) => {
|
|
1590
|
+
const reader = new FileReader();
|
|
1591
|
+
reader.onload = (e) => resolve(e.target.result);
|
|
1592
|
+
reader.onerror = (e) => reject(e);
|
|
1593
|
+
reader.readAsText(file);
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Handle export JSON
|
|
1598
|
+
function handleExport() {
|
|
1599
|
+
if (Object.keys(tokens).length === 0) {
|
|
1600
|
+
setStatus('No tokens to export', 'error');
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Show export options dialog
|
|
1605
|
+
showExportDialog();
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// Show export dialog with options
|
|
1609
|
+
function showExportDialog() {
|
|
1610
|
+
const dialog = document.createElement('div');
|
|
1611
|
+
dialog.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000;';
|
|
1612
|
+
|
|
1613
|
+
const content = document.createElement('div');
|
|
1614
|
+
content.style.cssText = 'background: var(--figma-color-bg); padding: 24px; border-radius: 8px; max-width: 400px; width: 90%;';
|
|
1615
|
+
|
|
1616
|
+
content.innerHTML = `
|
|
1617
|
+
<h3 style="margin: 0 0 16px 0;">Export Tokens</h3>
|
|
1618
|
+
<p style="margin: 0 0 16px 0; font-size: 11px; color: var(--figma-color-text-secondary);">
|
|
1619
|
+
Choose how to export your tokens:
|
|
1620
|
+
</p>
|
|
1621
|
+
<div style="display: flex; flex-direction: column; gap: 8px;">
|
|
1622
|
+
<button class="primary" onclick="exportCurrentTokens()" style="width: 100%; padding: 12px;">
|
|
1623
|
+
💾 Download as Single JSON
|
|
1624
|
+
</button>
|
|
1625
|
+
<button onclick="exportFromFigma()" style="width: 100%; padding: 12px;">
|
|
1626
|
+
↓ Export from Figma (Latest Values)
|
|
1627
|
+
</button>
|
|
1628
|
+
<button onclick="closeExportDialog()" style="width: 100%; padding: 12px;">
|
|
1629
|
+
Cancel
|
|
1630
|
+
</button>
|
|
1631
|
+
</div>
|
|
1632
|
+
<p style="margin: 16px 0 0 0; font-size: 10px; color: var(--figma-color-text-secondary);">
|
|
1633
|
+
<strong>Single JSON:</strong> Downloads all tokens in one file.<br>
|
|
1634
|
+
<strong>From Figma:</strong> Exports the latest values from Figma variables.
|
|
1635
|
+
</p>
|
|
1636
|
+
`;
|
|
1637
|
+
|
|
1638
|
+
dialog.appendChild(content);
|
|
1639
|
+
dialog.id = 'export-dialog';
|
|
1640
|
+
dialog.onclick = (e) => {
|
|
1641
|
+
if (e.target === dialog) closeExportDialog();
|
|
1642
|
+
};
|
|
1643
|
+
document.body.appendChild(dialog);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// Export current tokens in editor
|
|
1647
|
+
function exportCurrentTokens() {
|
|
1648
|
+
downloadJSON(tokens, 'tokens.json');
|
|
1649
|
+
setStatus('Tokens exported', 'success');
|
|
1650
|
+
closeExportDialog();
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Update token values recursively - updates fileTokens with values from mergedTokens
|
|
1654
|
+
function updateTokenValues(fileTokens, mergedTokens, path = []) {
|
|
1655
|
+
const result = {};
|
|
1656
|
+
|
|
1657
|
+
for (const key in fileTokens) {
|
|
1658
|
+
if (key.startsWith('$')) {
|
|
1659
|
+
// Copy metadata as-is
|
|
1660
|
+
result[key] = fileTokens[key];
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const value = fileTokens[key];
|
|
1665
|
+
|
|
1666
|
+
// Check if this token exists in merged tokens
|
|
1667
|
+
let current = mergedTokens;
|
|
1668
|
+
const fullPath = [...path, key];
|
|
1669
|
+
for (const part of fullPath) {
|
|
1670
|
+
if (!current || typeof current !== 'object') break;
|
|
1671
|
+
current = current[part];
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
if (value && typeof value === 'object') {
|
|
1675
|
+
if ('$type' in value && '$value' in value) {
|
|
1676
|
+
// This is a token - update it with current value
|
|
1677
|
+
if (current && typeof current === 'object' && '$type' in current && '$value' in current) {
|
|
1678
|
+
result[key] = { ...current };
|
|
1679
|
+
} else {
|
|
1680
|
+
result[key] = value; // Keep original if not found in merged
|
|
1681
|
+
}
|
|
1682
|
+
} else {
|
|
1683
|
+
// This is a group - recurse
|
|
1684
|
+
result[key] = updateTokenValues(value, mergedTokens, fullPath);
|
|
1685
|
+
}
|
|
1686
|
+
} else {
|
|
1687
|
+
result[key] = value;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
return result;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Export from Figma
|
|
1695
|
+
function exportFromFigma() {
|
|
1696
|
+
parent.postMessage({ pluginMessage: { type: 'export-tokens' } }, '*');
|
|
1697
|
+
closeExportDialog();
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// Close export dialog
|
|
1701
|
+
function closeExportDialog() {
|
|
1702
|
+
const dialog = document.getElementById('export-dialog');
|
|
1703
|
+
if (dialog) dialog.remove();
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// Download JSON file
|
|
1707
|
+
function downloadJSON(data, filename) {
|
|
1708
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
1709
|
+
const url = URL.createObjectURL(blob);
|
|
1710
|
+
const a = document.createElement('a');
|
|
1711
|
+
a.href = url;
|
|
1712
|
+
a.download = filename;
|
|
1713
|
+
a.click();
|
|
1714
|
+
URL.revokeObjectURL(url);
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Set sync button state
|
|
1718
|
+
function setSyncButtonState(loading) {
|
|
1719
|
+
const btn = document.getElementById('sync-btn');
|
|
1720
|
+
const text = document.getElementById('sync-btn-text');
|
|
1721
|
+
btn.disabled = loading;
|
|
1722
|
+
text.textContent = loading ? '⏳ Syncing...' : 'Sync to Figma';
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// Set status message
|
|
1726
|
+
function setStatus(message, type = '') {
|
|
1727
|
+
const statusBar = document.getElementById('status-bar');
|
|
1728
|
+
statusBar.textContent = message;
|
|
1729
|
+
statusBar.className = 'status-bar' + (type ? ' ' + type : '');
|
|
1730
|
+
|
|
1731
|
+
if (type) {
|
|
1732
|
+
setTimeout(() => {
|
|
1733
|
+
statusBar.className = 'status-bar';
|
|
1734
|
+
statusBar.textContent = 'Ready';
|
|
1735
|
+
}, 3000);
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// Render token tree
|
|
1740
|
+
function renderTokenTree() {
|
|
1741
|
+
const container = document.getElementById('token-tree');
|
|
1742
|
+
container.innerHTML = '';
|
|
1743
|
+
|
|
1744
|
+
if (Object.keys(tokens).length === 0) {
|
|
1745
|
+
container.innerHTML = '<div class="loading">No tokens loaded. Click "Import Tokens" to get started.</div>';
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const filtered = searchQuery ? filterTokens(tokens, searchQuery) : tokens;
|
|
1750
|
+
const tree = buildTree(filtered);
|
|
1751
|
+
container.appendChild(tree);
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// Build tree DOM
|
|
1755
|
+
function buildTree(obj, path = '') {
|
|
1756
|
+
const container = document.createElement('div');
|
|
1757
|
+
|
|
1758
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1759
|
+
if (key.startsWith('$')) continue;
|
|
1760
|
+
|
|
1761
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
1762
|
+
const isToken = value && typeof value === 'object' && '$type' in value && '$value' in value;
|
|
1763
|
+
const hasChildren = !isToken && typeof value === 'object';
|
|
1764
|
+
|
|
1765
|
+
const node = document.createElement('div');
|
|
1766
|
+
node.className = 'tree-node';
|
|
1767
|
+
|
|
1768
|
+
const header = document.createElement('div');
|
|
1769
|
+
header.className = 'tree-node-header';
|
|
1770
|
+
if (selectedTokenPath === currentPath) {
|
|
1771
|
+
header.classList.add('selected');
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const icon = document.createElement('span');
|
|
1775
|
+
icon.className = 'tree-node-icon';
|
|
1776
|
+
icon.textContent = isToken ? '●' : (expandedNodes.has(currentPath) ? '▼' : '▶');
|
|
1777
|
+
|
|
1778
|
+
const label = document.createElement('span');
|
|
1779
|
+
label.className = 'tree-node-label';
|
|
1780
|
+
label.textContent = key;
|
|
1781
|
+
|
|
1782
|
+
header.appendChild(icon);
|
|
1783
|
+
header.appendChild(label);
|
|
1784
|
+
|
|
1785
|
+
if (isToken) {
|
|
1786
|
+
const type = document.createElement('span');
|
|
1787
|
+
type.className = 'tree-node-type';
|
|
1788
|
+
type.textContent = value.$type;
|
|
1789
|
+
header.appendChild(type);
|
|
1790
|
+
|
|
1791
|
+
// Add value preview
|
|
1792
|
+
const preview = document.createElement('span');
|
|
1793
|
+
preview.className = 'tree-node-value';
|
|
1794
|
+
|
|
1795
|
+
if (value.$type === 'color') {
|
|
1796
|
+
// Show color swatches for color tokens
|
|
1797
|
+
const swatchGroup = document.createElement('div');
|
|
1798
|
+
swatchGroup.className = 'color-swatch-group';
|
|
1799
|
+
|
|
1800
|
+
// Default/light swatch
|
|
1801
|
+
const defaultSwatch = document.createElement('span');
|
|
1802
|
+
defaultSwatch.className = 'color-swatch';
|
|
1803
|
+
const resolvedColor = isTokenReference(value.$value) ? resolveTokenReference(value.$value) : value.$value;
|
|
1804
|
+
defaultSwatch.style.backgroundColor = resolvedColor;
|
|
1805
|
+
defaultSwatch.title = `light: ${getDisplayValue(value.$value)}`;
|
|
1806
|
+
swatchGroup.appendChild(defaultSwatch);
|
|
1807
|
+
|
|
1808
|
+
// Collect all mode names from both the current token and referenced token
|
|
1809
|
+
const allModes = new Set();
|
|
1810
|
+
|
|
1811
|
+
// Add modes from current token
|
|
1812
|
+
if (value.$extensions && value.$extensions.$mode) {
|
|
1813
|
+
Object.keys(value.$extensions.$mode).forEach(mode => allModes.add(mode));
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
// If the value is a reference, also check the referenced token's modes
|
|
1817
|
+
if (isTokenReference(value.$value)) {
|
|
1818
|
+
const referencedToken = getReferencedToken(value.$value);
|
|
1819
|
+
if (referencedToken && referencedToken.$extensions && referencedToken.$extensions.$mode) {
|
|
1820
|
+
Object.keys(referencedToken.$extensions.$mode).forEach(mode => allModes.add(mode));
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
// Show swatches for all modes
|
|
1825
|
+
if (allModes.size > 0) {
|
|
1826
|
+
for (const modeName of Array.from(allModes)) {
|
|
1827
|
+
const modeSwatch = document.createElement('span');
|
|
1828
|
+
modeSwatch.className = 'color-swatch mode-swatch';
|
|
1829
|
+
|
|
1830
|
+
// Try to get mode value from current token first
|
|
1831
|
+
let modeValue = value.$extensions && value.$extensions.$mode && value.$extensions.$mode[modeName];
|
|
1832
|
+
|
|
1833
|
+
// If not found and value is a reference, get it from the referenced token
|
|
1834
|
+
if (!modeValue && isTokenReference(value.$value)) {
|
|
1835
|
+
modeValue = value.$value; // Use the reference, it will resolve with mode
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
const modeColor = isTokenReference(modeValue) ? resolveTokenReference(modeValue, modeName) : modeValue;
|
|
1839
|
+
modeSwatch.style.backgroundColor = modeColor;
|
|
1840
|
+
modeSwatch.setAttribute('data-mode', modeName);
|
|
1841
|
+
modeSwatch.title = `${modeName}: ${modeValue ? getDisplayValue(modeValue) : 'from reference'}`;
|
|
1842
|
+
swatchGroup.appendChild(modeSwatch);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
preview.appendChild(swatchGroup);
|
|
1847
|
+
} else {
|
|
1848
|
+
// Show text for non-color tokens
|
|
1849
|
+
const displayValue = getDisplayValue(value.$value);
|
|
1850
|
+
let previewText = displayValue.length > 30 ? displayValue.substring(0, 27) + '...' : displayValue;
|
|
1851
|
+
|
|
1852
|
+
// Check if there are mode-specific values
|
|
1853
|
+
if (value.$extensions && value.$extensions.$mode) {
|
|
1854
|
+
const modeValues = value.$extensions.$mode;
|
|
1855
|
+
const modeNames = Object.keys(modeValues);
|
|
1856
|
+
if (modeNames.length > 0) {
|
|
1857
|
+
// Add mode indicators
|
|
1858
|
+
const modeIndicators = modeNames.map(modeName => {
|
|
1859
|
+
const modeValue = getDisplayValue(modeValues[modeName]);
|
|
1860
|
+
const shortModeValue = modeValue.length > 20 ? modeValue.substring(0, 17) + '...' : modeValue;
|
|
1861
|
+
return `${modeName}: ${shortModeValue}`;
|
|
1862
|
+
}).join(', ');
|
|
1863
|
+
previewText = `${previewText} | ${modeIndicators}`;
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
preview.textContent = previewText;
|
|
1868
|
+
if (isTokenReference(value.$value)) {
|
|
1869
|
+
preview.style.color = '#0066ff';
|
|
1870
|
+
preview.style.fontStyle = 'italic';
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
header.appendChild(preview);
|
|
1875
|
+
|
|
1876
|
+
header.addEventListener('click', () => selectToken(currentPath, value));
|
|
1877
|
+
} else if (hasChildren) {
|
|
1878
|
+
header.addEventListener('click', () => toggleNode(currentPath));
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
node.appendChild(header);
|
|
1882
|
+
|
|
1883
|
+
if (hasChildren) {
|
|
1884
|
+
const children = document.createElement('div');
|
|
1885
|
+
children.className = 'tree-node-children';
|
|
1886
|
+
if (!expandedNodes.has(currentPath)) {
|
|
1887
|
+
children.classList.add('collapsed');
|
|
1888
|
+
}
|
|
1889
|
+
children.appendChild(buildTree(value, currentPath));
|
|
1890
|
+
node.appendChild(children);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
container.appendChild(node);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
return container;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Toggle node expansion
|
|
1900
|
+
function toggleNode(path) {
|
|
1901
|
+
if (expandedNodes.has(path)) {
|
|
1902
|
+
expandedNodes.delete(path);
|
|
1903
|
+
} else {
|
|
1904
|
+
expandedNodes.add(path);
|
|
1905
|
+
}
|
|
1906
|
+
renderTokenTree();
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// Select token
|
|
1910
|
+
function selectToken(path, token) {
|
|
1911
|
+
selectedTokenPath = path;
|
|
1912
|
+
renderTokenTree();
|
|
1913
|
+
showTokenEditor(path, token);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// Navigate to a token by path
|
|
1917
|
+
function navigateToToken(path) {
|
|
1918
|
+
// Expand all parent nodes in the path
|
|
1919
|
+
const parts = path.split('.');
|
|
1920
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1921
|
+
const parentPath = parts.slice(0, i + 1).join('.');
|
|
1922
|
+
expandedNodes.add(parentPath);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// Get the token object
|
|
1926
|
+
const parts2 = path.split('.');
|
|
1927
|
+
let token = tokens;
|
|
1928
|
+
for (const part of parts2) {
|
|
1929
|
+
if (!token || typeof token !== 'object') return;
|
|
1930
|
+
token = token[part];
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
if (token && typeof token === 'object' && '$type' in token && '$value' in token) {
|
|
1934
|
+
selectToken(path, token);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// Show new token editor
|
|
1939
|
+
function showNewTokenEditor(savedFormData = null) {
|
|
1940
|
+
selectedTokenPath = null;
|
|
1941
|
+
const editor = document.getElementById('editor-content');
|
|
1942
|
+
|
|
1943
|
+
// Build file options from tokenFiles
|
|
1944
|
+
const fileOptions = tokenFiles.length > 0
|
|
1945
|
+
? tokenFiles.map((file, index) =>
|
|
1946
|
+
`<option value="${index}">${file.name || file.path}</option>`
|
|
1947
|
+
).join('')
|
|
1948
|
+
: '';
|
|
1949
|
+
|
|
1950
|
+
const form = `
|
|
1951
|
+
<div class="editor-form">
|
|
1952
|
+
<h3>Create New Token</h3>
|
|
1953
|
+
<div class="form-group">
|
|
1954
|
+
<label>Token Path *</label>
|
|
1955
|
+
<input type="text" id="token-path" placeholder="e.g., color.primary.base or spacing.md" />
|
|
1956
|
+
<small style="color: var(--figma-color-text-secondary); display: block; margin-top: 4px;">
|
|
1957
|
+
Use dot notation to create nested groups (e.g., color.primary.base)
|
|
1958
|
+
</small>
|
|
1959
|
+
</div>
|
|
1960
|
+
<div class="form-group">
|
|
1961
|
+
<label>Type *</label>
|
|
1962
|
+
<select id="token-type">
|
|
1963
|
+
<option value="color">color</option>
|
|
1964
|
+
<option value="dimension">dimension</option>
|
|
1965
|
+
<option value="fontFamily">fontFamily</option>
|
|
1966
|
+
<option value="fontWeight">fontWeight</option>
|
|
1967
|
+
<option value="fontSize">fontSize</option>
|
|
1968
|
+
<option value="lineHeight">lineHeight</option>
|
|
1969
|
+
<option value="letterSpacing">letterSpacing</option>
|
|
1970
|
+
<option value="number">number</option>
|
|
1971
|
+
<option value="string">string</option>
|
|
1972
|
+
<option value="duration">duration</option>
|
|
1973
|
+
<option value="shadow">shadow</option>
|
|
1974
|
+
<option value="border">border</option>
|
|
1975
|
+
<option value="typography">typography</option>
|
|
1976
|
+
</select>
|
|
1977
|
+
</div>
|
|
1978
|
+
<div class="form-group">
|
|
1979
|
+
<label>Value (default / light) *</label>
|
|
1980
|
+
<input type="text" id="token-value" placeholder="e.g., #FF0000 or {color.red.500}" />
|
|
1981
|
+
<small style="color: var(--figma-color-text-secondary); display: block; margin-top: 4px;">
|
|
1982
|
+
Use {token.path} syntax to reference other tokens
|
|
1983
|
+
</small>
|
|
1984
|
+
</div>
|
|
1985
|
+
${config.modes && config.modes.length > 0 ? config.modes.filter(mode => mode !== 'default').map(mode => `
|
|
1986
|
+
<div class="form-group">
|
|
1987
|
+
<label>Value (${mode} mode)</label>
|
|
1988
|
+
<input type="text" id="token-value-${mode}" placeholder="Leave empty to use default" />
|
|
1989
|
+
</div>
|
|
1990
|
+
`).join('') : ''}
|
|
1991
|
+
<div class="form-group">
|
|
1992
|
+
<label>Target File *</label>
|
|
1993
|
+
<select id="token-file">
|
|
1994
|
+
${fileOptions}
|
|
1995
|
+
<option value="new" ${tokenFiles.length === 0 ? 'selected' : ''}>+ Create New File</option>
|
|
1996
|
+
</select>
|
|
1997
|
+
</div>
|
|
1998
|
+
<div class="form-group" id="new-file-group" style="display: ${tokenFiles.length === 0 ? 'block' : 'none'};">
|
|
1999
|
+
<label>New File Name *</label>
|
|
2000
|
+
<input type="text" id="new-file-name" placeholder="e.g., colors.tokens.json" />
|
|
2001
|
+
<small style="color: var(--figma-color-text-secondary); display: block; margin-top: 4px;">
|
|
2002
|
+
File name should end with .tokens.json
|
|
2003
|
+
</small>
|
|
2004
|
+
</div>
|
|
2005
|
+
<div class="form-actions">
|
|
2006
|
+
<button id="save-new-token" class="primary">Create Token</button>
|
|
2007
|
+
<button id="cancel-edit">Cancel</button>
|
|
2008
|
+
</div>
|
|
2009
|
+
</div>
|
|
2010
|
+
`;
|
|
2011
|
+
|
|
2012
|
+
editor.innerHTML = form;
|
|
2013
|
+
|
|
2014
|
+
// Restore saved form data if available
|
|
2015
|
+
if (savedFormData) {
|
|
2016
|
+
if (savedFormData.path) document.getElementById('token-path').value = savedFormData.path;
|
|
2017
|
+
if (savedFormData.type) document.getElementById('token-type').value = savedFormData.type;
|
|
2018
|
+
if (savedFormData.value) document.getElementById('token-value').value = savedFormData.value;
|
|
2019
|
+
if (savedFormData.fileIndex) document.getElementById('token-file').value = savedFormData.fileIndex;
|
|
2020
|
+
if (savedFormData.newFileName) document.getElementById('new-file-name').value = savedFormData.newFileName;
|
|
2021
|
+
if (savedFormData.fileIndex === 'new') {
|
|
2022
|
+
document.getElementById('new-file-group').style.display = 'block';
|
|
2023
|
+
}
|
|
2024
|
+
if (savedFormData.modeValues) {
|
|
2025
|
+
Object.entries(savedFormData.modeValues).forEach(([mode, val]) => {
|
|
2026
|
+
const input = document.getElementById(`token-value-${mode}`);
|
|
2027
|
+
if (input) input.value = val;
|
|
2028
|
+
});
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Track form changes to persist state
|
|
2033
|
+
const saveFormState = () => {
|
|
2034
|
+
const modeValues = {};
|
|
2035
|
+
if (config.modes && config.modes.length > 0) {
|
|
2036
|
+
config.modes.filter(m => m !== 'default').forEach(mode => {
|
|
2037
|
+
const input = document.getElementById(`token-value-${mode}`);
|
|
2038
|
+
if (input && input.value) modeValues[mode] = input.value;
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
newTokenFormData = {
|
|
2043
|
+
path: document.getElementById('token-path').value,
|
|
2044
|
+
type: document.getElementById('token-type').value,
|
|
2045
|
+
value: document.getElementById('token-value').value,
|
|
2046
|
+
fileIndex: document.getElementById('token-file').value,
|
|
2047
|
+
newFileName: document.getElementById('new-file-name').value,
|
|
2048
|
+
modeValues
|
|
2049
|
+
};
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
// Add listeners to save state on input
|
|
2053
|
+
document.getElementById('token-path').addEventListener('input', saveFormState);
|
|
2054
|
+
document.getElementById('token-type').addEventListener('change', saveFormState);
|
|
2055
|
+
document.getElementById('token-value').addEventListener('input', saveFormState);
|
|
2056
|
+
document.getElementById('token-file').addEventListener('change', saveFormState);
|
|
2057
|
+
document.getElementById('new-file-name').addEventListener('input', saveFormState);
|
|
2058
|
+
config.modes.filter(m => m !== 'default').forEach(mode => {
|
|
2059
|
+
const input = document.getElementById(`token-value-${mode}`);
|
|
2060
|
+
if (input) input.addEventListener('input', saveFormState);
|
|
2061
|
+
});
|
|
2062
|
+
|
|
2063
|
+
// Handle file selection change
|
|
2064
|
+
const fileSelect = document.getElementById('token-file');
|
|
2065
|
+
const newFileGroup = document.getElementById('new-file-group');
|
|
2066
|
+
fileSelect.addEventListener('change', (e) => {
|
|
2067
|
+
if (e.target.value === 'new') {
|
|
2068
|
+
newFileGroup.style.display = 'block';
|
|
2069
|
+
} else {
|
|
2070
|
+
newFileGroup.style.display = 'none';
|
|
2071
|
+
}
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
// Save button
|
|
2075
|
+
document.getElementById('save-new-token').addEventListener('click', async () => {
|
|
2076
|
+
const path = document.getElementById('token-path').value.trim();
|
|
2077
|
+
const type = document.getElementById('token-type').value;
|
|
2078
|
+
const value = document.getElementById('token-value').value.trim();
|
|
2079
|
+
const fileIndex = document.getElementById('token-file').value;
|
|
2080
|
+
|
|
2081
|
+
if (!path) {
|
|
2082
|
+
alert('Token path is required');
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
if (!value) {
|
|
2087
|
+
alert('Token value is required');
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// Validate file selection
|
|
2092
|
+
let targetFileName;
|
|
2093
|
+
if (fileIndex === 'new') {
|
|
2094
|
+
targetFileName = document.getElementById('new-file-name').value.trim();
|
|
2095
|
+
if (!targetFileName) {
|
|
2096
|
+
alert('File name is required when creating a new file');
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (!targetFileName.endsWith('.tokens.json')) {
|
|
2100
|
+
targetFileName += '.tokens.json';
|
|
2101
|
+
}
|
|
2102
|
+
} else {
|
|
2103
|
+
if (tokenFiles.length === 0) {
|
|
2104
|
+
alert('No files available. Please specify a new file name.');
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
targetFileName = tokenFiles[parseInt(fileIndex)].name || tokenFiles[parseInt(fileIndex)].path;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// Build the new token object
|
|
2111
|
+
const newToken = {
|
|
2112
|
+
$type: type,
|
|
2113
|
+
$value: value
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
// Add mode-specific values if any
|
|
2117
|
+
const modeValues = {};
|
|
2118
|
+
if (config.modes && config.modes.length > 0) {
|
|
2119
|
+
for (const mode of config.modes.filter(m => m !== 'default')) {
|
|
2120
|
+
const modeInput = document.getElementById(`token-value-${mode}`);
|
|
2121
|
+
if (modeInput && modeInput.value.trim()) {
|
|
2122
|
+
modeValues[mode] = modeInput.value.trim();
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
if (Object.keys(modeValues).length > 0) {
|
|
2128
|
+
newToken.$extensions = { $mode: modeValues };
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// Add token to the tokens object using path
|
|
2132
|
+
const pathParts = path.split('.');
|
|
2133
|
+
let current = tokens;
|
|
2134
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
2135
|
+
const part = pathParts[i];
|
|
2136
|
+
if (!current[part]) {
|
|
2137
|
+
current[part] = {};
|
|
2138
|
+
}
|
|
2139
|
+
current = current[part];
|
|
2140
|
+
}
|
|
2141
|
+
current[pathParts[pathParts.length - 1]] = newToken;
|
|
2142
|
+
|
|
2143
|
+
// Update or create the file entry
|
|
2144
|
+
if (fileIndex === 'new') {
|
|
2145
|
+
// Create a new file entry
|
|
2146
|
+
const newFileTokens = {};
|
|
2147
|
+
let curr = newFileTokens;
|
|
2148
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
2149
|
+
curr[pathParts[i]] = {};
|
|
2150
|
+
curr = curr[pathParts[i]];
|
|
2151
|
+
}
|
|
2152
|
+
curr[pathParts[pathParts.length - 1]] = newToken;
|
|
2153
|
+
|
|
2154
|
+
tokenFiles.push({
|
|
2155
|
+
name: targetFileName,
|
|
2156
|
+
path: targetFileName,
|
|
2157
|
+
tokens: newFileTokens
|
|
2158
|
+
});
|
|
2159
|
+
} else {
|
|
2160
|
+
// Add to existing file
|
|
2161
|
+
const targetFile = tokenFiles[parseInt(fileIndex)];
|
|
2162
|
+
let curr = targetFile.tokens;
|
|
2163
|
+
for (let i = 0; i < pathParts.length - 1; i++) {
|
|
2164
|
+
const part = pathParts[i];
|
|
2165
|
+
if (!curr[part]) {
|
|
2166
|
+
curr[part] = {};
|
|
2167
|
+
}
|
|
2168
|
+
curr = curr[part];
|
|
2169
|
+
}
|
|
2170
|
+
curr[pathParts[pathParts.length - 1]] = newToken;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
// Re-render everything
|
|
2174
|
+
renderTokenTree();
|
|
2175
|
+
renderFilesView();
|
|
2176
|
+
|
|
2177
|
+
// Clear the saved form data
|
|
2178
|
+
newTokenFormData = null;
|
|
2179
|
+
|
|
2180
|
+
// Select and navigate to edit the new token
|
|
2181
|
+
selectedTokenPath = path;
|
|
2182
|
+
expandAllParents(path);
|
|
2183
|
+
selectToken(path, newToken);
|
|
2184
|
+
|
|
2185
|
+
setStatus(`Token "${path}" created in ${targetFileName}`, 'success');
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
// Cancel button
|
|
2189
|
+
document.getElementById('cancel-edit').addEventListener('click', () => {
|
|
2190
|
+
newTokenFormData = null;
|
|
2191
|
+
editor.innerHTML = '<div class="empty-state"><h3>Welcome to Figma Token Sync</h3><p>Select a token from the sidebar to edit, or click "+ New Token" to create one.</p></div>';
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
// Helper function to expand all parent nodes
|
|
2196
|
+
function expandAllParents(path) {
|
|
2197
|
+
const parts = path.split('.');
|
|
2198
|
+
for (let i = 0; i < parts.length; i++) {
|
|
2199
|
+
const parentPath = parts.slice(0, i + 1).join('.');
|
|
2200
|
+
expandedNodes.add(parentPath);
|
|
2201
|
+
}
|
|
2202
|
+
renderTokenTree();
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Show token editor
|
|
2206
|
+
function showTokenEditor(path, token) {
|
|
2207
|
+
const editor = document.getElementById('editor-content');
|
|
2208
|
+
|
|
2209
|
+
console.log('showTokenEditor - config.modes:', config.modes);
|
|
2210
|
+
console.log('showTokenEditor - token.$extensions:', token.$extensions);
|
|
2211
|
+
|
|
2212
|
+
const form = `
|
|
2213
|
+
<div class="editor-form">
|
|
2214
|
+
<h3>Edit Token</h3>
|
|
2215
|
+
<div class="form-group">
|
|
2216
|
+
<label>Path</label>
|
|
2217
|
+
<input type="text" id="token-path" value="${path}" readonly />
|
|
2218
|
+
</div>
|
|
2219
|
+
<div class="form-group">
|
|
2220
|
+
<label>Type</label>
|
|
2221
|
+
<select id="token-type">
|
|
2222
|
+
${['color', 'dimension', 'fontFamily', 'fontWeight', 'fontSize', 'lineHeight', 'letterSpacing', 'number', 'string', 'duration', 'shadow', 'border', 'typography'].map(t =>
|
|
2223
|
+
`<option value="${t}" ${token.$type === t ? 'selected' : ''}>${t}</option>`
|
|
2224
|
+
).join('')}
|
|
2225
|
+
</select>
|
|
2226
|
+
</div>
|
|
2227
|
+
<div class="form-group">
|
|
2228
|
+
<label>Value (default / light)</label>
|
|
2229
|
+
<input type="text" id="token-value" value="${formatTokenValue(token.$value).replace(/"/g, '"')}" />
|
|
2230
|
+
${isTokenReference(token.$value) ? `
|
|
2231
|
+
<div class="token-reference-info" style="cursor: pointer;" onclick="navigateToToken('${token.$value.slice(1, -1)}')">
|
|
2232
|
+
<small style="color: #0066ff;">📎 Token Reference - Click to navigate</small>
|
|
2233
|
+
</div>
|
|
2234
|
+
` : ''}
|
|
2235
|
+
</div>
|
|
2236
|
+
${config.modes && config.modes.length > 0 ? config.modes.filter(mode => mode !== 'default').map(mode => {
|
|
2237
|
+
const modeValue = token.$extensions?.$mode?.[mode] || '';
|
|
2238
|
+
console.log(`Mode ${mode} value:`, modeValue);
|
|
2239
|
+
return `
|
|
2240
|
+
<div class="form-group">
|
|
2241
|
+
<label>Value (${mode} mode)</label>
|
|
2242
|
+
<input type="text" id="token-value-${mode}" value="${modeValue ? formatTokenValue(modeValue).replace(/"/g, '"') : ''}" placeholder="Leave empty to use default" />
|
|
2243
|
+
${isTokenReference(modeValue) ? `
|
|
2244
|
+
<div class="token-reference-info" style="cursor: pointer;" onclick="navigateToToken('${modeValue.slice(1, -1)}')">
|
|
2245
|
+
<small style="color: #0066ff;">📎 Token Reference - Click to navigate</small>
|
|
2246
|
+
</div>
|
|
2247
|
+
` : ''}
|
|
2248
|
+
</div>
|
|
2249
|
+
`;
|
|
2250
|
+
}).join('') : '<div class="form-group"><small style="color: #999;">No modes configured. Import tokens with designid.config.ts to enable mode support.</small></div>'}
|
|
2251
|
+
<div class="form-group">
|
|
2252
|
+
<label>Description (optional)</label>
|
|
2253
|
+
<textarea id="token-description">${token.$description || ''}</textarea>
|
|
2254
|
+
</div>
|
|
2255
|
+
${token.$type === 'color' ? `
|
|
2256
|
+
<div class="token-preview">
|
|
2257
|
+
<div class="token-preview-label">Preview</div>
|
|
2258
|
+
<div class="token-preview-modes">
|
|
2259
|
+
<div class="token-preview-mode">
|
|
2260
|
+
<div class="token-preview-mode-label">Light (Default)</div>
|
|
2261
|
+
<div class="token-preview-mode-swatch" style="background-color: ${isTokenReference(token.$value) ? resolveTokenReference(token.$value) : token.$value}"></div>
|
|
2262
|
+
${isTokenReference(token.$value) ? `<small style="color: #666; font-size: 9px; cursor: pointer;" onclick="navigateToToken('${token.$value.slice(1, -1)}')">↪ ${token.$value}</small>` : ''}
|
|
2263
|
+
</div>
|
|
2264
|
+
${(() => {
|
|
2265
|
+
// Collect all modes from both current token and referenced token
|
|
2266
|
+
const allModes = new Set();
|
|
2267
|
+
if (token.$extensions && token.$extensions.$mode) {
|
|
2268
|
+
Object.keys(token.$extensions.$mode).forEach(m => allModes.add(m));
|
|
2269
|
+
}
|
|
2270
|
+
if (isTokenReference(token.$value)) {
|
|
2271
|
+
const refToken = getReferencedToken(token.$value);
|
|
2272
|
+
if (refToken && refToken.$extensions && refToken.$extensions.$mode) {
|
|
2273
|
+
Object.keys(refToken.$extensions.$mode).forEach(m => allModes.add(m));
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
return Array.from(allModes).map(modeName => {
|
|
2277
|
+
// Try to get mode value from current token first
|
|
2278
|
+
let modeValue = token.$extensions && token.$extensions.$mode && token.$extensions.$mode[modeName];
|
|
2279
|
+
// If not found and value is a reference, use the reference
|
|
2280
|
+
if (!modeValue && isTokenReference(token.$value)) {
|
|
2281
|
+
modeValue = token.$value;
|
|
2282
|
+
}
|
|
2283
|
+
const resolvedColor = isTokenReference(modeValue) ? resolveTokenReference(modeValue, modeName) : modeValue;
|
|
2284
|
+
return `
|
|
2285
|
+
<div class="token-preview-mode">
|
|
2286
|
+
<div class="token-preview-mode-label">${modeName}</div>
|
|
2287
|
+
<div class="token-preview-mode-swatch" style="background-color: ${resolvedColor}"></div>
|
|
2288
|
+
${isTokenReference(modeValue) ? `<small style="color: #666; font-size: 9px; cursor: pointer;" onclick="navigateToToken('${modeValue.slice(1, -1)}')">↪ ${modeValue}</small>` : ''}
|
|
2289
|
+
</div>
|
|
2290
|
+
`;
|
|
2291
|
+
}).join('');
|
|
2292
|
+
})()}
|
|
2293
|
+
</div>
|
|
2294
|
+
</div>
|
|
2295
|
+
` : ''}
|
|
2296
|
+
<div class="form-actions">
|
|
2297
|
+
<button onclick="saveToken()" style="flex: 1; background: var(--figma-color-bg-brand); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 500;">Save</button>
|
|
2298
|
+
<button onclick="duplicateToken('${path}')" style="flex: 1; background: var(--figma-color-bg-secondary); color: var(--figma-color-text); border: 1px solid var(--figma-color-border); padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 11px;">Duplicate</button>
|
|
2299
|
+
<button onclick="showEmptyState()" style="background: var(--figma-color-bg-secondary); color: var(--figma-color-text); border: 1px solid var(--figma-color-border); padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 11px;">Cancel</button>
|
|
2300
|
+
<button onclick="if(confirm('Are you sure you want to delete this token?')) deleteToken('${path}')" style="background: #f24822; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 11px;">Remove</button>
|
|
2301
|
+
</div>
|
|
2302
|
+
</div>
|
|
2303
|
+
`;
|
|
2304
|
+
|
|
2305
|
+
editor.innerHTML = form;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
// Show empty state
|
|
2309
|
+
function showEmptyState() {
|
|
2310
|
+
const editor = document.getElementById('editor-content');
|
|
2311
|
+
editor.innerHTML = `
|
|
2312
|
+
<div class="empty-state">
|
|
2313
|
+
<h3>Welcome to Figma Token Sync</h3>
|
|
2314
|
+
<p>Select a token from the sidebar to edit, or click "Sync to Figma" to synchronize tokens.</p>
|
|
2315
|
+
<p><strong>To import tokens:</strong></p>
|
|
2316
|
+
<ol style="text-align: left; max-width: 450px; margin: 12px auto; line-height: 1.6;">
|
|
2317
|
+
<li>Click <strong>"📂 Import Tokens"</strong></li>
|
|
2318
|
+
<li>Select your <code>tokens</code> folder from your design system</li>
|
|
2319
|
+
<li>All .tokens.json files will be automatically loaded and merged</li>
|
|
2320
|
+
</ol>
|
|
2321
|
+
</div>
|
|
2322
|
+
`;
|
|
2323
|
+
selectedTokenPath = null;
|
|
2324
|
+
renderTokenTree();
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// Save token changes
|
|
2328
|
+
function saveToken() {
|
|
2329
|
+
if (!selectedTokenPath) return;
|
|
2330
|
+
|
|
2331
|
+
const type = document.getElementById('token-type').value;
|
|
2332
|
+
const value = document.getElementById('token-value').value;
|
|
2333
|
+
const description = document.getElementById('token-description').value;
|
|
2334
|
+
|
|
2335
|
+
// Get mode values if any
|
|
2336
|
+
const modeValues = {};
|
|
2337
|
+
if (config.modes && config.modes.length > 0) {
|
|
2338
|
+
config.modes.forEach(mode => {
|
|
2339
|
+
const modeInput = document.getElementById(`token-value-${mode}`);
|
|
2340
|
+
if (modeInput && modeInput.value) {
|
|
2341
|
+
modeValues[mode] = modeInput.value;
|
|
2342
|
+
}
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// Build token object
|
|
2347
|
+
const token = {
|
|
2348
|
+
$type: type,
|
|
2349
|
+
$value: value
|
|
2350
|
+
};
|
|
2351
|
+
|
|
2352
|
+
// Add description if provided
|
|
2353
|
+
if (description) {
|
|
2354
|
+
token.$description = description;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// Add mode extensions if any mode values were provided
|
|
2358
|
+
if (Object.keys(modeValues).length > 0) {
|
|
2359
|
+
token.$extensions = {
|
|
2360
|
+
$mode: modeValues
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// Update token in the tokens object
|
|
2365
|
+
setNestedValue(tokens, selectedTokenPath, token);
|
|
2366
|
+
|
|
2367
|
+
// Update the token tree and show success
|
|
2368
|
+
renderTokenTree();
|
|
2369
|
+
setStatus('Token saved successfully', 'success');
|
|
2370
|
+
|
|
2371
|
+
// Keep the editor open with updated values
|
|
2372
|
+
showTokenEditor(selectedTokenPath, token);
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// Delete token
|
|
2376
|
+
function deleteToken(path) {
|
|
2377
|
+
deleteNestedValue(tokens, path);
|
|
2378
|
+
renderTokenTree();
|
|
2379
|
+
showEmptyState();
|
|
2380
|
+
setStatus('Token deleted', 'success');
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
// Duplicate token
|
|
2384
|
+
function duplicateToken(path) {
|
|
2385
|
+
// Get the current token
|
|
2386
|
+
const parts = path.split('.');
|
|
2387
|
+
let token = tokens;
|
|
2388
|
+
for (const part of parts) {
|
|
2389
|
+
if (!token || typeof token !== 'object') return;
|
|
2390
|
+
token = token[part];
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
if (!token || typeof token !== 'object' || !('$type' in token)) return;
|
|
2394
|
+
|
|
2395
|
+
// Get current form values
|
|
2396
|
+
const type = document.getElementById('token-type').value;
|
|
2397
|
+
const value = document.getElementById('token-value').value;
|
|
2398
|
+
const description = document.getElementById('token-description')?.value || '';
|
|
2399
|
+
|
|
2400
|
+
// Get mode values
|
|
2401
|
+
const modeValues = {};
|
|
2402
|
+
if (config.modes && config.modes.length > 0) {
|
|
2403
|
+
config.modes.filter(m => m !== 'default').forEach(mode => {
|
|
2404
|
+
const modeInput = document.getElementById(`token-value-${mode}`);
|
|
2405
|
+
if (modeInput && modeInput.value) {
|
|
2406
|
+
modeValues[mode] = modeInput.value;
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Create form data for the new token editor
|
|
2412
|
+
const formData = {
|
|
2413
|
+
path: path + '-copy',
|
|
2414
|
+
type: type,
|
|
2415
|
+
value: value,
|
|
2416
|
+
fileIndex: 'new',
|
|
2417
|
+
newFileName: '',
|
|
2418
|
+
modeValues: modeValues
|
|
2419
|
+
};
|
|
2420
|
+
|
|
2421
|
+
// Show new token editor with duplicated data
|
|
2422
|
+
newTokenFormData = formData;
|
|
2423
|
+
showNewTokenEditor(formData);
|
|
2424
|
+
|
|
2425
|
+
setStatus('Token duplicated. Update the path and save.', 'success');
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// Delete token
|
|
2429
|
+
function deleteToken(path) {
|
|
2430
|
+
deleteNestedValue(tokens, path);
|
|
2431
|
+
renderTokenTree();
|
|
2432
|
+
showEmptyState();
|
|
2433
|
+
setStatus('Token deleted', 'success');
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
// Handle search
|
|
2437
|
+
function handleSearch(e) {
|
|
2438
|
+
searchQuery = e.target.value.toLowerCase();
|
|
2439
|
+
const clearBtn = document.getElementById('search-clear');
|
|
2440
|
+
clearBtn.className = 'search-clear' + (searchQuery ? ' visible' : '');
|
|
2441
|
+
renderTokenTree();
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// Clear search
|
|
2445
|
+
function clearSearch() {
|
|
2446
|
+
document.getElementById('search-input').value = '';
|
|
2447
|
+
searchQuery = '';
|
|
2448
|
+
document.getElementById('search-clear').className = 'search-clear';
|
|
2449
|
+
renderTokenTree();
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// Filter tokens
|
|
2453
|
+
function filterTokens(obj, query) {
|
|
2454
|
+
const result = {};
|
|
2455
|
+
|
|
2456
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
2457
|
+
if (key.startsWith('$')) {
|
|
2458
|
+
result[key] = value;
|
|
2459
|
+
continue;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
if (key.toLowerCase().includes(query)) {
|
|
2463
|
+
result[key] = value;
|
|
2464
|
+
} else if (typeof value === 'object' && !('$type' in value)) {
|
|
2465
|
+
const filtered = filterTokens(value, query);
|
|
2466
|
+
if (Object.keys(filtered).length > 0) {
|
|
2467
|
+
result[key] = filtered;
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
return result;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// Expand all nodes
|
|
2476
|
+
function expandAll() {
|
|
2477
|
+
expandedNodes.clear();
|
|
2478
|
+
collectAllPaths(tokens, '').forEach(path => expandedNodes.add(path));
|
|
2479
|
+
renderTokenTree();
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// Collapse all nodes
|
|
2483
|
+
function collapseAll() {
|
|
2484
|
+
expandedNodes.clear();
|
|
2485
|
+
renderTokenTree();
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// Collect all paths
|
|
2489
|
+
function collectAllPaths(obj, path = '') {
|
|
2490
|
+
const paths = [];
|
|
2491
|
+
|
|
2492
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
2493
|
+
if (key.startsWith('$')) continue;
|
|
2494
|
+
|
|
2495
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
2496
|
+
const isToken = value && typeof value === 'object' && '$type' in value;
|
|
2497
|
+
|
|
2498
|
+
if (!isToken && typeof value === 'object') {
|
|
2499
|
+
paths.push(currentPath);
|
|
2500
|
+
paths.push(...collectAllPaths(value, currentPath));
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
return paths;
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// Set nested value
|
|
2508
|
+
function setNestedValue(obj, path, value) {
|
|
2509
|
+
const parts = path.split('.');
|
|
2510
|
+
let current = obj;
|
|
2511
|
+
|
|
2512
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2513
|
+
if (!current[parts[i]]) {
|
|
2514
|
+
current[parts[i]] = {};
|
|
2515
|
+
}
|
|
2516
|
+
current = current[parts[i]];
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
current[parts[parts.length - 1]] = value;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Delete nested value
|
|
2523
|
+
function deleteNestedValue(obj, path) {
|
|
2524
|
+
const parts = path.split('.');
|
|
2525
|
+
let current = obj;
|
|
2526
|
+
|
|
2527
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
2528
|
+
if (!current[parts[i]]) return;
|
|
2529
|
+
current = current[parts[i]];
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
delete current[parts[parts.length - 1]];
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
// View Navigation
|
|
2536
|
+
function showFilesView() {
|
|
2537
|
+
currentView = 'files';
|
|
2538
|
+
document.getElementById('tokens-tab').classList.remove('active');
|
|
2539
|
+
document.getElementById('files-tab').classList.add('active');
|
|
2540
|
+
document.getElementById('config-tab').classList.remove('active');
|
|
2541
|
+
document.getElementById('tokens-view').style.display = 'none';
|
|
2542
|
+
document.getElementById('files-view').classList.add('active');
|
|
2543
|
+
document.getElementById('config-view').classList.remove('active');
|
|
2544
|
+
document.getElementById('editor-content').innerHTML = '';
|
|
2545
|
+
selectedFileIndex = null;
|
|
2546
|
+
renderFilesList();
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function showTokensView() {
|
|
2550
|
+
currentView = 'tokens';
|
|
2551
|
+
document.getElementById('tokens-tab').classList.add('active');
|
|
2552
|
+
document.getElementById('files-tab').classList.remove('active');
|
|
2553
|
+
document.getElementById('config-tab').classList.remove('active');
|
|
2554
|
+
document.getElementById('tokens-view').style.display = 'flex';
|
|
2555
|
+
document.getElementById('files-view').classList.remove('active');
|
|
2556
|
+
document.getElementById('config-view').classList.remove('active');
|
|
2557
|
+
|
|
2558
|
+
// Restore new token form if it was in progress
|
|
2559
|
+
if (newTokenFormData) {
|
|
2560
|
+
showNewTokenEditor(newTokenFormData);
|
|
2561
|
+
} else {
|
|
2562
|
+
document.getElementById('editor-content').innerHTML = '';
|
|
2563
|
+
selectedTokenPath = null;
|
|
2564
|
+
renderTokenTree();
|
|
2565
|
+
showEmptyState();
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
function showConfigView() {
|
|
2570
|
+
currentView = 'config';
|
|
2571
|
+
document.getElementById('tokens-tab').classList.remove('active');
|
|
2572
|
+
document.getElementById('files-tab').classList.remove('active');
|
|
2573
|
+
document.getElementById('config-tab').classList.add('active');
|
|
2574
|
+
document.getElementById('tokens-view').style.display = 'none';
|
|
2575
|
+
document.getElementById('files-view').classList.remove('active');
|
|
2576
|
+
document.getElementById('config-view').classList.add('active');
|
|
2577
|
+
document.getElementById('editor-content').innerHTML = '';
|
|
2578
|
+
renderConfigView();
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
function renderFilesList() {
|
|
2582
|
+
const container = document.getElementById('files-list');
|
|
2583
|
+
|
|
2584
|
+
if (!tokenFiles || tokenFiles.length === 0) {
|
|
2585
|
+
container.innerHTML = '<div class="loading">No files imported yet. Click "Import Tokens" to get started.</div>';
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
container.innerHTML = '';
|
|
2590
|
+
|
|
2591
|
+
tokenFiles.forEach((fileInfo, index) => {
|
|
2592
|
+
const changes = detectFileChanges(fileInfo);
|
|
2593
|
+
const hasChanges = changes.length > 0;
|
|
2594
|
+
|
|
2595
|
+
const fileItem = document.createElement('div');
|
|
2596
|
+
fileItem.className = 'file-item' + (hasChanges ? ' has-changes' : '') + (selectedFileIndex === index ? ' selected' : '');
|
|
2597
|
+
fileItem.onclick = () => showFileDetail(index);
|
|
2598
|
+
|
|
2599
|
+
const header = document.createElement('div');
|
|
2600
|
+
header.className = 'file-header';
|
|
2601
|
+
|
|
2602
|
+
const name = document.createElement('div');
|
|
2603
|
+
name.className = 'file-name';
|
|
2604
|
+
name.textContent = fileInfo.name;
|
|
2605
|
+
header.appendChild(name);
|
|
2606
|
+
|
|
2607
|
+
if (hasChanges) {
|
|
2608
|
+
const badge = document.createElement('span');
|
|
2609
|
+
badge.className = 'file-badge';
|
|
2610
|
+
badge.textContent = `${changes.length} change${changes.length > 1 ? 's' : ''}`;
|
|
2611
|
+
header.appendChild(badge);
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
fileItem.appendChild(header);
|
|
2615
|
+
|
|
2616
|
+
const path = document.createElement('div');
|
|
2617
|
+
path.className = 'file-path';
|
|
2618
|
+
path.textContent = fileInfo.path;
|
|
2619
|
+
fileItem.appendChild(path);
|
|
2620
|
+
|
|
2621
|
+
container.appendChild(fileItem);
|
|
2622
|
+
});
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
// Alias for backward compatibility
|
|
2626
|
+
const renderFilesView = renderFilesList;
|
|
2627
|
+
|
|
2628
|
+
function renderConfigView() {
|
|
2629
|
+
const container = document.getElementById('config-content');
|
|
2630
|
+
|
|
2631
|
+
if (!configData) {
|
|
2632
|
+
container.innerHTML = `
|
|
2633
|
+
<div class="empty-state" style="padding: 40px;">
|
|
2634
|
+
<h3>No Configuration File</h3>
|
|
2635
|
+
<p>Import a directory with <code>designid.config.ts</code>, <code>designid.config.mjs</code>, or <code>designid.config.js</code> to view configuration settings.</p>
|
|
2636
|
+
</div>
|
|
2637
|
+
`;
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
const html = `
|
|
2642
|
+
<div style="padding: 24px;">
|
|
2643
|
+
<div style="margin-bottom: 32px;">
|
|
2644
|
+
<h2 style="margin: 0 0 8px 0; font-size: 18px; font-weight: 600;">⚙️ Configuration</h2>
|
|
2645
|
+
<p style="margin: 0; font-size: 11px; color: var(--figma-color-text-secondary); font-family: 'Monaco', 'Menlo', monospace;">
|
|
2646
|
+
${configData.fileName}
|
|
2647
|
+
</p>
|
|
2648
|
+
</div>
|
|
2649
|
+
|
|
2650
|
+
<div style="display: grid; gap: 24px;">
|
|
2651
|
+
<div style="background: var(--figma-color-bg-secondary); padding: 16px; border-radius: 8px; border-left: 3px solid #0066ff;">
|
|
2652
|
+
<h4 style="margin: 0 0 12px 0; font-size: 12px; font-weight: 600; color: var(--figma-color-text-secondary); text-transform: uppercase;">Collection Name</h4>
|
|
2653
|
+
<p style="margin: 0; font-size: 14px; font-weight: 500;">${config.collectionName}</p>
|
|
2654
|
+
<p style="margin: 8px 0 0 0; font-size: 10px; color: var(--figma-color-text-secondary);">The name used for the Figma variable collection</p>
|
|
2655
|
+
</div>
|
|
2656
|
+
|
|
2657
|
+
<div style="background: var(--figma-color-bg-secondary); padding: 16px; border-radius: 8px; border-left: 3px solid #14ae5c;">
|
|
2658
|
+
<h4 style="margin: 0 0 12px 0; font-size: 12px; font-weight: 600; color: var(--figma-color-text-secondary); text-transform: uppercase;">Modes</h4>
|
|
2659
|
+
${config.modes && config.modes.length > 0 ? `
|
|
2660
|
+
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;">
|
|
2661
|
+
${config.modes.map(mode => `
|
|
2662
|
+
<span style="padding: 4px 12px; background: var(--figma-color-bg-brand); color: white; border-radius: 12px; font-size: 11px; font-weight: 500; display: flex; align-items: center; gap: 6px;">
|
|
2663
|
+
${mode}
|
|
2664
|
+
${mode !== 'default' ? `<button onclick="removeMode('${mode}')" style="background: none; border: none; color: white; cursor: pointer; padding: 0; margin: 0; font-size: 14px; line-height: 1;">×</button>` : ''}
|
|
2665
|
+
</span>
|
|
2666
|
+
`).join('')}
|
|
2667
|
+
</div>
|
|
2668
|
+
<p style="margin: 8px 0 0 0; font-size: 10px; color: var(--figma-color-text-secondary);">${config.modes.length} mode${config.modes.length > 1 ? 's' : ''} configured</p>
|
|
2669
|
+
` : `
|
|
2670
|
+
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px;">
|
|
2671
|
+
<span style="padding: 4px 12px; background: var(--figma-color-bg-brand); color: white; border-radius: 12px; font-size: 11px; font-weight: 500;">default</span>
|
|
2672
|
+
</div>
|
|
2673
|
+
<p style="margin: 8px 0 0 0; font-size: 10px; color: var(--figma-color-text-secondary);">1 mode configured (default only)</p>
|
|
2674
|
+
`}
|
|
2675
|
+
<div style="margin-top: 12px; display: flex; gap: 8px;">
|
|
2676
|
+
<input type="text" id="new-mode-name" placeholder="Mode name (e.g., dark)" style="flex: 1; padding: 6px 8px; border: 1px solid var(--figma-color-border); border-radius: 4px; font-size: 11px;" />
|
|
2677
|
+
<button onclick="addMode()" style="padding: 6px 12px; background: var(--figma-color-bg-brand); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: 500;">Add Mode</button>
|
|
2678
|
+
</div>
|
|
2679
|
+
</div>
|
|
2680
|
+
|
|
2681
|
+
<div style="background: var(--figma-color-bg-secondary); padding: 16px; border-radius: 8px; border-left: 3px solid #ffcd29;">
|
|
2682
|
+
<h4 style="margin: 0 0 12px 0; font-size: 12px; font-weight: 600; color: var(--figma-color-text-secondary); text-transform: uppercase;">Base Pixel Size</h4>
|
|
2683
|
+
<p style="margin: 0; font-size: 14px; font-weight: 500;">${config.basePixelSize}px</p>
|
|
2684
|
+
<p style="margin: 8px 0 0 0; font-size: 10px; color: var(--figma-color-text-secondary);">Used for rem/px conversion in dimension tokens</p>
|
|
2685
|
+
</div>
|
|
2686
|
+
|
|
2687
|
+
<div style="background: var(--figma-color-bg-secondary); padding: 16px; border-radius: 8px; border-left: 3px solid #8b5cf6;">
|
|
2688
|
+
<h4 style="margin: 0 0 12px 0; font-size: 12px; font-weight: 600; color: var(--figma-color-text-secondary); text-transform: uppercase;">Token Files</h4>
|
|
2689
|
+
<p style="margin: 0 0 12px 0; font-size: 14px; font-weight: 500;">${tokenFiles.length} file${tokenFiles.length !== 1 ? 's' : ''} imported</p>
|
|
2690
|
+
${tokenFiles.length > 0 ? `
|
|
2691
|
+
<div style="display: flex; flex-direction: column; gap: 4px;">
|
|
2692
|
+
${tokenFiles.map(file => `
|
|
2693
|
+
<div style="font-size: 11px; font-family: 'Monaco', 'Menlo', monospace; color: var(--figma-color-text-secondary);">
|
|
2694
|
+
📄 ${file.name}
|
|
2695
|
+
</div>
|
|
2696
|
+
`).join('')}
|
|
2697
|
+
</div>
|
|
2698
|
+
` : ''}
|
|
2699
|
+
</div>
|
|
2700
|
+
|
|
2701
|
+
<div style="background: var(--figma-color-bg-secondary); padding: 16px; border-radius: 8px; border-left: 3px solid #f24822;">
|
|
2702
|
+
<h4 style="margin: 0 0 12px 0; font-size: 12px; font-weight: 600; color: var(--figma-color-text-secondary); text-transform: uppercase;">Raw Configuration</h4>
|
|
2703
|
+
<details style="cursor: pointer;">
|
|
2704
|
+
<summary style="font-size: 11px; margin-bottom: 12px; color: var(--figma-color-text);">View source code</summary>
|
|
2705
|
+
<pre style="margin: 12px 0 0 0; padding: 12px; background: var(--figma-color-bg); border-radius: 4px; font-size: 10px; font-family: 'Monaco', 'Menlo', monospace; overflow-x: auto; max-height: 400px; overflow-y: auto;">${configData.content.replace(/</g, '<').replace(/>/g, '>')}</pre>
|
|
2706
|
+
</details>
|
|
2707
|
+
</div>
|
|
2708
|
+
|
|
2709
|
+
</div>
|
|
2710
|
+
|
|
2711
|
+
<div style="margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--figma-color-border);">
|
|
2712
|
+
<button onclick="exportConfigFile()" style="width: 100%; padding: 12px; background: var(--figma-color-bg-brand); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500;">💾 Export Config File</button>
|
|
2713
|
+
</div>
|
|
2714
|
+
</div>
|
|
2715
|
+
`;
|
|
2716
|
+
|
|
2717
|
+
container.innerHTML = html;
|
|
2718
|
+
} function detectFileChanges(fileInfo) {
|
|
2719
|
+
const changes = [];
|
|
2720
|
+
const original = fileInfo.tokens;
|
|
2721
|
+
const current = tokens;
|
|
2722
|
+
|
|
2723
|
+
// Deep compare tokens
|
|
2724
|
+
function compareTokens(originalObj, currentObj, path = []) {
|
|
2725
|
+
for (const key in originalObj) {
|
|
2726
|
+
if (key.startsWith('$')) continue;
|
|
2727
|
+
|
|
2728
|
+
const fullPath = [...path, key];
|
|
2729
|
+
const originalValue = originalObj[key];
|
|
2730
|
+
|
|
2731
|
+
// Find current value in merged tokens
|
|
2732
|
+
let currentValue = current;
|
|
2733
|
+
for (const part of fullPath) {
|
|
2734
|
+
if (!currentValue || typeof currentValue !== 'object') {
|
|
2735
|
+
currentValue = undefined;
|
|
2736
|
+
break;
|
|
2737
|
+
}
|
|
2738
|
+
currentValue = currentValue[part];
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
if (originalValue && typeof originalValue === 'object' && '$type' in originalValue) {
|
|
2742
|
+
// This is a token - compare values (case-insensitive for strings)
|
|
2743
|
+
if (!currentValue) {
|
|
2744
|
+
changes.push({
|
|
2745
|
+
type: 'removed',
|
|
2746
|
+
path: fullPath.join('.'),
|
|
2747
|
+
oldValue: originalValue.$value,
|
|
2748
|
+
newValue: null
|
|
2749
|
+
});
|
|
2750
|
+
} else {
|
|
2751
|
+
// Normalize values for comparison (case-insensitive)
|
|
2752
|
+
const normalizeValue = (val) => {
|
|
2753
|
+
if (typeof val === 'string') {
|
|
2754
|
+
return val.toLowerCase();
|
|
2755
|
+
}
|
|
2756
|
+
if (val && typeof val === 'object') {
|
|
2757
|
+
const normalized = {};
|
|
2758
|
+
for (const k in val) {
|
|
2759
|
+
normalized[k] = normalizeValue(val[k]);
|
|
2760
|
+
}
|
|
2761
|
+
return normalized;
|
|
2762
|
+
}
|
|
2763
|
+
return val;
|
|
2764
|
+
};
|
|
2765
|
+
|
|
2766
|
+
const originalNormalized = JSON.stringify(normalizeValue(originalValue));
|
|
2767
|
+
const currentNormalized = JSON.stringify(normalizeValue(currentValue));
|
|
2768
|
+
|
|
2769
|
+
if (originalNormalized !== currentNormalized) {
|
|
2770
|
+
changes.push({
|
|
2771
|
+
type: 'changed',
|
|
2772
|
+
path: fullPath.join('.'),
|
|
2773
|
+
oldValue: originalValue.$value,
|
|
2774
|
+
newValue: currentValue.$value,
|
|
2775
|
+
oldToken: originalValue,
|
|
2776
|
+
newToken: currentValue
|
|
2777
|
+
});
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
} else if (originalValue && typeof originalValue === 'object') {
|
|
2781
|
+
// This is a group - recurse
|
|
2782
|
+
compareTokens(originalValue, currentObj, fullPath);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// Check for added tokens
|
|
2787
|
+
if (currentObj && typeof currentObj === 'object') {
|
|
2788
|
+
for (const key in currentObj) {
|
|
2789
|
+
if (key.startsWith('$')) continue;
|
|
2790
|
+
|
|
2791
|
+
const fullPath = [...path, key];
|
|
2792
|
+
const currentValue = currentObj[key];
|
|
2793
|
+
|
|
2794
|
+
if (currentValue && typeof currentValue === 'object' && '$type' in currentValue) {
|
|
2795
|
+
// Check if this token exists in original
|
|
2796
|
+
let originalValue = original;
|
|
2797
|
+
for (const part of fullPath) {
|
|
2798
|
+
if (!originalValue || typeof originalValue !== 'object') {
|
|
2799
|
+
originalValue = undefined;
|
|
2800
|
+
break;
|
|
2801
|
+
}
|
|
2802
|
+
originalValue = originalValue[part];
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if (!originalValue) {
|
|
2806
|
+
changes.push({
|
|
2807
|
+
type: 'added',
|
|
2808
|
+
path: fullPath.join('.'),
|
|
2809
|
+
oldValue: null,
|
|
2810
|
+
newValue: currentValue.$value,
|
|
2811
|
+
newToken: currentValue
|
|
2812
|
+
});
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
compareTokens(original, current);
|
|
2820
|
+
return changes;
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
// Add new mode
|
|
2824
|
+
function addMode() {
|
|
2825
|
+
const input = document.getElementById('new-mode-name');
|
|
2826
|
+
const modeName = input.value.trim();
|
|
2827
|
+
|
|
2828
|
+
if (!modeName) {
|
|
2829
|
+
alert('Please enter a mode name');
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
if (modeName === 'default') {
|
|
2834
|
+
alert('"default" is a reserved mode name');
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
if (config.modes.includes(modeName)) {
|
|
2839
|
+
alert(`Mode "${modeName}" already exists`);
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
config.modes.push(modeName);
|
|
2844
|
+
input.value = '';
|
|
2845
|
+
updateModesIndicator();
|
|
2846
|
+
renderConfigView();
|
|
2847
|
+
setStatus(`Mode "${modeName}" added`, 'success');
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// Remove mode
|
|
2851
|
+
function removeMode(modeName) {
|
|
2852
|
+
if (modeName === 'default') {
|
|
2853
|
+
alert('Cannot remove the default mode');
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
if (!confirm(`Remove mode "${modeName}"? This will not affect existing token values.`)) {
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
config.modes = config.modes.filter(m => m !== modeName);
|
|
2862
|
+
updateModesIndicator();
|
|
2863
|
+
renderConfigView();
|
|
2864
|
+
setStatus(`Mode "${modeName}" removed`, 'success');
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// Export config file
|
|
2868
|
+
function exportConfigFile() {
|
|
2869
|
+
if (!configData) {
|
|
2870
|
+
// Create a new config from current settings
|
|
2871
|
+
const newConfig = {
|
|
2872
|
+
$name: config.collectionName,
|
|
2873
|
+
$modes: {},
|
|
2874
|
+
basePixelSize: config.basePixelSize
|
|
2875
|
+
};
|
|
2876
|
+
|
|
2877
|
+
// Add all modes
|
|
2878
|
+
const allModes = config.modes.includes('default') ? config.modes : ['default', ...config.modes];
|
|
2879
|
+
allModes.forEach(mode => {
|
|
2880
|
+
newConfig.$modes[mode] = {};
|
|
2881
|
+
});
|
|
2882
|
+
|
|
2883
|
+
const configContent = `export default ${JSON.stringify(newConfig, null, 2)};`;
|
|
2884
|
+
|
|
2885
|
+
const blob = new Blob([configContent], { type: 'text/javascript' });
|
|
2886
|
+
const url = URL.createObjectURL(blob);
|
|
2887
|
+
const a = document.createElement('a');
|
|
2888
|
+
a.href = url;
|
|
2889
|
+
a.download = 'designid.config.mjs';
|
|
2890
|
+
a.click();
|
|
2891
|
+
URL.revokeObjectURL(url);
|
|
2892
|
+
|
|
2893
|
+
setStatus('Config file exported', 'success');
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
// Update existing config with current modes
|
|
2898
|
+
let updatedConfig = configData.content;
|
|
2899
|
+
|
|
2900
|
+
// Update $name
|
|
2901
|
+
updatedConfig = updatedConfig.replace(
|
|
2902
|
+
/\$name\s*:\s*['"][^'"]+['"]/,
|
|
2903
|
+
`\$name: '${config.collectionName}'`
|
|
2904
|
+
);
|
|
2905
|
+
|
|
2906
|
+
// Update $modes section
|
|
2907
|
+
const allModes = config.modes.includes('default') ? config.modes : ['default', ...config.modes];
|
|
2908
|
+
const modesObj = {};
|
|
2909
|
+
allModes.forEach(mode => {
|
|
2910
|
+
modesObj[mode] = {};
|
|
2911
|
+
});
|
|
2912
|
+
|
|
2913
|
+
const modesStr = JSON.stringify(modesObj, null, 4).replace(/^/gm, ' ');
|
|
2914
|
+
updatedConfig = updatedConfig.replace(
|
|
2915
|
+
/\$modes\s*:\s*\{[^}]+\}/s,
|
|
2916
|
+
`\$modes: ${modesStr.trim()}`
|
|
2917
|
+
);
|
|
2918
|
+
|
|
2919
|
+
const blob = new Blob([updatedConfig], { type: 'text/javascript' });
|
|
2920
|
+
const url = URL.createObjectURL(blob);
|
|
2921
|
+
const a = document.createElement('a');
|
|
2922
|
+
a.href = url;
|
|
2923
|
+
a.download = configData.fileName;
|
|
2924
|
+
a.click();
|
|
2925
|
+
URL.revokeObjectURL(url);
|
|
2926
|
+
|
|
2927
|
+
setStatus('Config file exported', 'success');
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
function showFileDetail(index) {
|
|
2931
|
+
selectedFileIndex = index;
|
|
2932
|
+
renderFilesList();
|
|
2933
|
+
|
|
2934
|
+
const fileInfo = tokenFiles[index];
|
|
2935
|
+
const changes = detectFileChanges(fileInfo);
|
|
2936
|
+
const editor = document.getElementById('editor-content');
|
|
2937
|
+
|
|
2938
|
+
const html = `
|
|
2939
|
+
<div class="file-detail active">
|
|
2940
|
+
<div class="file-detail-header">
|
|
2941
|
+
<div class="file-detail-title">
|
|
2942
|
+
<h3>${fileInfo.name}</h3>
|
|
2943
|
+
<p>${fileInfo.path}</p>
|
|
2944
|
+
</div>
|
|
2945
|
+
<div class="file-detail-actions">
|
|
2946
|
+
<button onclick="exportFile(${index})">💾 Export File</button>
|
|
2947
|
+
</div>
|
|
2948
|
+
</div>
|
|
2949
|
+
<div class="file-detail-content">
|
|
2950
|
+
${changes.length === 0 ? `
|
|
2951
|
+
<div class="empty-state">
|
|
2952
|
+
<h3>No Changes</h3>
|
|
2953
|
+
<p>This file has no changes from the original import.</p>
|
|
2954
|
+
</div>
|
|
2955
|
+
` : `
|
|
2956
|
+
<div class="diff-section">
|
|
2957
|
+
<h4>Changes (${changes.length})</h4>
|
|
2958
|
+
${changes.map(change => `
|
|
2959
|
+
<div class="diff-item ${change.type}">
|
|
2960
|
+
<div class="diff-path">${change.path}</div>
|
|
2961
|
+
<div class="diff-values">
|
|
2962
|
+
${change.oldValue !== null ? `
|
|
2963
|
+
<div class="diff-old">
|
|
2964
|
+
<div class="diff-label">Original</div>
|
|
2965
|
+
<div>${formatTokenValue(change.oldValue)}</div>
|
|
2966
|
+
${change.oldToken && change.oldToken.$extensions && change.oldToken.$extensions.$mode ? Object.entries(change.oldToken.$extensions.$mode).map(([mode, value]) => `
|
|
2967
|
+
<div style="margin-top: 4px; opacity: 0.7;">${mode}: ${formatTokenValue(value)}</div>
|
|
2968
|
+
`).join('') : ''}
|
|
2969
|
+
</div>
|
|
2970
|
+
` : '<div class="diff-old"><div class="diff-label">Original</div><div style="opacity: 0.5;">—</div></div>'}
|
|
2971
|
+
${change.newValue !== null ? `
|
|
2972
|
+
<div class="diff-new">
|
|
2973
|
+
<div class="diff-label">Current</div>
|
|
2974
|
+
<div>${formatTokenValue(change.newValue)}</div>
|
|
2975
|
+
${change.newToken && change.newToken.$extensions && change.newToken.$extensions.$mode ? Object.entries(change.newToken.$extensions.$mode).map(([mode, value]) => `
|
|
2976
|
+
<div style="margin-top: 4px; opacity: 0.7;">${mode}: ${formatTokenValue(value)}</div>
|
|
2977
|
+
`).join('') : ''}
|
|
2978
|
+
</div>
|
|
2979
|
+
` : '<div class="diff-new"><div class="diff-label">Current</div><div style="opacity: 0.5;">—</div></div>'}
|
|
2980
|
+
</div>
|
|
2981
|
+
</div>
|
|
2982
|
+
`).join('')}
|
|
2983
|
+
</div>
|
|
2984
|
+
`}
|
|
2985
|
+
</div>
|
|
2986
|
+
</div>
|
|
2987
|
+
`;
|
|
2988
|
+
|
|
2989
|
+
editor.innerHTML = html;
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
function exportFile(index) {
|
|
2993
|
+
const fileInfo = tokenFiles[index];
|
|
2994
|
+
const updatedTokens = updateTokenValues(fileInfo.tokens, tokens);
|
|
2995
|
+
const content = JSON.stringify(updatedTokens, null, 2);
|
|
2996
|
+
|
|
2997
|
+
const blob = new Blob([content], { type: 'application/json' });
|
|
2998
|
+
const url = URL.createObjectURL(blob);
|
|
2999
|
+
const a = document.createElement('a');
|
|
3000
|
+
a.href = url;
|
|
3001
|
+
a.download = fileInfo.name;
|
|
3002
|
+
a.click();
|
|
3003
|
+
URL.revokeObjectURL(url);
|
|
3004
|
+
|
|
3005
|
+
setStatus(`Exported ${fileInfo.name}`, 'success');
|
|
3006
|
+
}
|
|
3007
|
+
</script>
|
|
3008
|
+
</body>
|
|
3009
|
+
</html>
|