Repository navigation
Expand file tree
/
Copy pathcode.js
More file actions
646 lines (646 loc) · 28.8 KB
/
Copy pathcode.js
File metadata and controls
646 lines (646 loc) · 28.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
"use strict";
/// <reference types="@figma/plugin-typings" />
/**
* Dimension Tool
* --------------
* Drops a well-formatted dimension (horizontal or vertical) onto the canvas.
*
* v2 architecture: the dimension is an AUTO-LAYOUT frame (not constraints), so
* Figma reflows it natively when the user resizes it — in both axes, with no
* rotation and no per-child constraint math. Structure mirrors the hand-built
* parametric version:
*
* Horizontal = vertical stack, top -> bottom:
* [ Text (hug, short fixed cross-height) ]
* [ Extension Outside (fixed band) ] overshoot stubs above the line
* [ Dim line (open vector, STRETCH) ] arrow stroke caps at both ends
* [ Extension Inside (GROWS) ] witness lines reaching the feature
*
* Vertical = the same stack rotated into a row:
* [ Extension Inside (GROWS) | Dim | Extension Outside | Text ]
*
* The growing inside band is what makes it parametric: resize the frame across
* the measured axis and the inside witness band absorbs the slack to reach the
* feature at any depth, while label / overshoot / line stay fixed.
*
* Arrows are native stroke caps (StrokeCap enum) — they render exactly like
* Figma's own, including the rounded line-arrow ends, and scale with weight.
* The label value is the only non-native piece; it recomputes live (while open)
* and on reopen, from params each frame stores in pluginData.
*/
const NS = 'hcd';
const K = {
isDim: `${NS}:isDimension`,
orient: `${NS}:orientation`,
labelStyle: `${NS}:labelStyle`,
flip: `${NS}:flip`,
dpi: `${NS}:dpi`,
unit: `${NS}:unit`,
scale: `${NS}:scale`,
decimals: `${NS}:decimals`,
showUnit: `${NS}:showUnit`,
role: `${NS}:role`,
};
const FONT = { family: 'Inter', style: 'Regular' };
const COLOR = { r: 0.11, g: 0.11, b: 0.12 };
// Unit magnitude per inch. Canvas inches = pixels / dpi (Figma baseline dpi = 72),
// so value = (pixels / dpi) * PER_INCH[unit] / scale.
const PER_INCH = {
in: 1,
ft: 1 / 12,
mm: 25.4,
cm: 2.54,
m: 0.0254,
};
// The label wrapper is given a fixed size SHORTER than the text so the text
// overflows toward the line, tightening the label-to-line gap. Expressed as a
// fraction of font size so it scales with the font. Starting heuristics — refine as
// needed (Jon flagged this may want a fuller calc, esp. for the vertical case).
const TEXT_TIGHTEN_H = 0.7; // H: wrapper height = fontSize * this (text spills down)
const TEXT_TIGHTEN_V = 0.4; // V: wrapper narrower than text by fontSize * this (text spills toward line)
const LEN = 240; // default measured span (the value tracks this)
const INSIDE = 40; // default inside-extension reach (grows on resize)
const DEFAULTS = {
thickness: 1.5,
arrowStyle: 'ARROW_EQUILATERAL',
labelStyle: 'standard',
flip: false,
witnessGap: 4,
witnessOvershoot: 8,
dpi: 72,
unit: 'in',
scale: 1,
fontFamily: 'Inter',
fontSize: 11,
decimals: 1,
showUnit: true,
live: true,
};
let settings = { ...DEFAULTS };
let liveOn = false;
let suppress = false; // guards the documentchange feedback loop
let availableFonts = []; // cached from listAvailableFontsAsync()
// ---------- geometry helpers ----------
/**
* The dimension line: a two-point OPEN path, so both ends are open and receive
* the arrow stroke cap. Length is provisional — as an auto-layout child with
* layoutAlign STRETCH, Figma stretches it along the measured axis, moving the
* far endpoint so the cap always sits at the true extremity.
*/
function dimLine(orient, length, thickness, style) {
const data = orient === 'H' ? `M 0 0 L ${length} 0` : `M 0 0 L 0 ${length}`;
const v = figma.createVector();
v.vectorPaths = [{ windingRule: 'NONE', data }]; // NONE = open path
v.fills = [];
v.strokes = [{ type: 'SOLID', color: COLOR }];
v.strokeWeight = thickness;
v.strokeCap = style; // applies to both open ends
v.setPluginData(K.role, 'line');
return v;
}
/**
* A single witness/extension line: an OPEN vector (like the dim line), so its
* thickness stays stroke-driven and hand-adjustable later without the plugin.
* Length is provisional — it FILLs its band's cross axis via layoutSizing.
* orient 'H' -> vertical line; orient 'V' -> horizontal line.
*/
function witnessBar(orient, thickness, roundEnds) {
const data = orient === 'H' ? 'M 0 0 L 0 100' : 'M 0 0 L 100 0';
const v = figma.createVector();
v.vectorPaths = [{ windingRule: 'NONE', data }];
v.fills = [];
v.strokes = [{ type: 'SOLID', color: COLOR }];
v.strokeWeight = thickness;
// Round the extension-line ends to match Figma's Line-arrow look (Line style only).
v.strokeCap = roundEnds ? 'ROUND' : 'NONE';
v.setPluginData(K.role, 'witness');
return v;
}
/**
* A single segment of an INLINE dimension line — the piece of the dim line on one
* side of the label. Built as a two-vertex vectorNetwork so the arrow strokeCap can be
* placed on the OUTER vertex ONLY (the inner end, by the text, stays clean). The node
* fills its band's length axis, stretching to reach the frame extremity where the arrow
* sits. capOnStart true -> arrow on the x0/y0 vertex; false -> on the far vertex.
*/
async function inlineSegment(orient, thickness, cap, capOnStart) {
const v = figma.createVector();
const far = orient === 'H' ? { x: LEN / 2, y: 0 } : { x: 0, y: LEN / 2 };
await v.setVectorNetworkAsync({
vertices: [
{ x: 0, y: 0, strokeCap: capOnStart ? cap : 'NONE' },
{ x: far.x, y: far.y, strokeCap: capOnStart ? 'NONE' : cap },
],
segments: [{ start: 0, end: 1 }],
});
v.fills = [];
v.strokes = [{ type: 'SOLID', color: COLOR }];
v.strokeWeight = thickness;
v.setPluginData(K.role, 'line');
return v;
}
/**
* An extension band: an auto-layout frame holding two witness vectors pushed to the
* two ends (SPACE_BETWEEN). Used for both the outside overshoot stubs and the inside
* witness lines.
* orient 'H' -> horizontal band, vertical bars at each end.
* orient 'V' -> vertical band, horizontal bars at top/bottom.
* Bars are appended here; their FILL sizing is applied by fillBars() AFTER the band
* itself has been parented and sized (Fill/Hug/Fixed must resolve outermost-first).
*/
function extensionBand(orient, thickness, roundEnds) {
const band = figma.createFrame();
band.name = 'Extension';
band.fills = [];
band.clipsContent = false;
band.layoutMode = orient === 'H' ? 'HORIZONTAL' : 'VERTICAL';
band.primaryAxisAlignItems = 'SPACE_BETWEEN';
band.counterAxisAlignItems = 'CENTER';
band.itemSpacing = 0;
band.setPluginData(K.role, 'extension');
band.appendChild(witnessBar(orient, thickness, roundEnds));
band.appendChild(witnessBar(orient, thickness, roundEnds));
return band;
}
/** Make each bar FILL the band's cross axis (its length) and stay 0 on thickness. */
function fillBars(band, orient) {
for (const bar of band.children) {
if (orient === 'H') {
bar.layoutSizingHorizontal = 'FIXED'; // 0-width path
bar.layoutSizingVertical = 'FILL';
}
else {
bar.layoutSizingVertical = 'FIXED'; // 0-height path
bar.layoutSizingHorizontal = 'FILL';
}
}
}
/**
* Force a nested band to recompute its Fill size. Built in one synchronous pass, a
* nested auto-layout frame keeps a STALE 0 on the axis that fills its parent's counter
* axis (verified: identical props to a hand-built band, but width 0 vs 250). The only
* fix is to resize it to a NONZERO fixed size and re-apply Fill — and it must run AFTER
* the whole tree is assembled, so this is a final "settle" pass, not done mid-build.
* grows = the band also fills its primary (length) axis (the inside band);
* overshoot = the fixed cross size to preserve for the non-growing outside band.
*/
function settleBand(band, orient, grows, overshoot) {
const fixed = Math.max(overshoot, 0.01);
if (orient === 'H') {
band.resize(10, grows ? 10 : fixed);
band.layoutSizingHorizontal = 'FILL';
if (grows)
band.layoutSizingVertical = 'FILL';
}
else {
band.resize(grows ? 10 : fixed, 10);
band.layoutSizingVertical = 'FILL';
if (grows)
band.layoutSizingHorizontal = 'FILL';
}
}
// ---------- value formatting ----------
function fmtFrom(frame, px) {
const dpi = parseFloat(frame.getPluginData(K.dpi) || '72') || 72;
const unit = frame.getPluginData(K.unit) || 'in';
const perInch = PER_INCH[unit] !== undefined ? PER_INCH[unit] : 1;
const scale = parseFloat(frame.getPluginData(K.scale) || '1') || 1;
const dec = parseInt(frame.getPluginData(K.decimals) || '1', 10);
const showU = frame.getPluginData(K.showUnit) === '1';
const v = (((px / dpi) * perInch) / scale).toFixed(dec);
return showU && unit ? `${v} ${unit}` : v;
}
/** Resolve a family name to a loadable FontName (prefer Regular, else first style). */
function resolveFont(family) {
const styles = availableFonts
.filter((f) => f.fontName.family === family)
.map((f) => f.fontName.style);
if (styles.length) {
const style = styles.indexOf('Regular') >= 0 ? 'Regular' : styles[0];
return { family, style };
}
return FONT; // fallback: Inter Regular
}
// ---------- build ----------
/**
* Build a fully-assembled dimension frame with the CURRENT settings for stylistic props,
* but an EXPLICIT orient/labelStyle/flip (so it serves both a fresh drop — which passes the
* settings' style — and an update/rebuild — which passes the existing dim's own stored
* style). Not positioned or selected; the caller places it.
*/
async function createDimensionRoot(orient, labelStyle, flip) {
const s = settings;
if (!availableFonts.length)
availableFonts = await figma.listAvailableFontsAsync();
let font = resolveFont(s.fontFamily);
try {
await figma.loadFontAsync(font);
}
catch (e) {
font = FONT; // family vanished / not loadable -> fall back to Inter
await figma.loadFontAsync(FONT);
}
const inline = labelStyle === 'inline';
const root = figma.createFrame();
root.name = `Dimension (${orient})${inline ? ' - Inline' : ''}${flip ? ' - Flip' : ''}`;
root.fills = [];
root.clipsContent = false;
root.itemSpacing = 0;
root.paddingTop = root.paddingBottom = root.paddingLeft = root.paddingRight = 0;
root.setPluginData(K.isDim, '1');
root.setPluginData(K.orient, orient);
root.setPluginData(K.labelStyle, labelStyle);
root.setPluginData(K.flip, flip ? '1' : '0');
root.setPluginData(K.dpi, String(s.dpi));
root.setPluginData(K.unit, s.unit);
root.setPluginData(K.scale, String(s.scale));
root.setPluginData(K.decimals, String(s.decimals));
root.setPluginData(K.showUnit, s.showUnit ? '1' : '0');
const label = figma.createText();
label.fontName = font;
label.fontSize = s.fontSize;
label.fills = [{ type: 'SOLID', color: COLOR }];
label.textAlignHorizontal = 'CENTER';
label.setPluginData(K.role, 'label');
label.characters = fmtFrom(root, LEN); // root pluginData already set
// Root is a fixed-size auto-layout box the user resizes freely; children Fill it.
root.layoutMode = orient === 'H' ? 'VERTICAL' : 'HORIZONTAL';
root.counterAxisAlignItems = 'CENTER';
root.primaryAxisSizingMode = 'FIXED';
root.counterAxisSizingMode = 'FIXED';
if (inline)
await assembleInline(root, orient, flip, s, label);
else
assembleStandard(root, orient, flip, s, label);
return root;
}
/** Fresh drop from a Variant-grid button: build with the settings' style, center, select. */
async function buildDimension(orient) {
const root = await createDimensionRoot(orient, settings.labelStyle, settings.flip);
root.x = Math.round(figma.viewport.center.x - root.width / 2);
root.y = Math.round(figma.viewport.center.y - root.height / 2);
figma.currentPage.selection = [root];
}
/**
* Re-style an existing dimension: rebuild it from its OWN stored orient/labelStyle/flip
* using the current settings, preserving footprint (position, parent/z-order, and measured
* span via width/height). Rebuild (rather than surgical mutation) guarantees every setting —
* including font size, which drives layout — is applied correctly. Returns the new node.
*/
async function updateDimension(old) {
if (old.getPluginData(K.isDim) !== '1')
return null;
const orient = old.getPluginData(K.orient) === 'V' ? 'V' : 'H';
const labelStyle = old.getPluginData(K.labelStyle) === 'inline' ? 'inline' : 'standard';
const flip = old.getPluginData(K.flip) === '1';
const { x, y, width, height } = old;
const parent = old.parent;
const idx = parent ? parent.children.indexOf(old) : -1;
const root = await createDimensionRoot(orient, labelStyle, flip);
root.resize(Math.max(width, 0.01), Math.max(height, 0.01)); // preserve the measured footprint
if (parent && idx >= 0)
parent.insertChild(idx, root); // same parent + z-order
root.x = x;
root.y = y;
old.remove();
await recalcLabel(root); // label reflects the preserved span under the new settings
return root;
}
/**
* Apply the witness gap: padding between the growing inside band and the frame edge on the
* FEATURE side, so the witness line stops `gap` px short of the feature it points at.
* Feature side follows orient + flip: H -> bottom (top when flipped); V -> left (right when
* flipped) — matching the inside (growing) band's position in each variant's child order.
*/
function featurePad(root, orient, flip, gap) {
if (orient === 'H') {
if (flip)
root.paddingTop = gap;
else
root.paddingBottom = gap;
}
else {
if (flip)
root.paddingRight = gap;
else
root.paddingLeft = gap;
}
}
/**
* Standard dimension: the label sits OFFSET from the line (above/right by default). The
* stack is [Text · Extension Outside · Dim · Extension Inside(grows)]; `flip` reverses it
* so the label lands on the opposite side (H below, V left) and the label's spill flips
* to keep it tightened toward the line.
*
* The per-child sizing (resize-then-Fill discipline, gotchas #1/#2) is identical whatever
* the child order, so each child's build+size is a closure keyed by role and run in the
* order the orientation + flip dictate.
*/
function assembleStandard(root, orient, flip, s, label) {
const roundEnds = s.arrowStyle === 'ARROW_LINES';
const line = dimLine(orient, LEN, s.thickness, s.arrowStyle);
const outside = extensionBand(orient, s.thickness, roundEnds);
const inside = extensionBand(orient, s.thickness, roundEnds);
const textWrap = figma.createFrame();
textWrap.name = 'Text';
textWrap.fills = [];
textWrap.clipsContent = false;
textWrap.itemSpacing = 0;
textWrap.layoutMode = 'HORIZONTAL';
textWrap.setPluginData(K.role, 'text');
const steps = {};
if (orient === 'H') {
featurePad(root, 'H', flip, s.witnessGap);
root.resize(LEN, Math.round(s.fontSize * TEXT_TIGHTEN_H) + s.witnessOvershoot + INSIDE + s.witnessGap);
steps.text = () => {
// Hug width, fixed SHORT height -> text aligns to the line side and spills toward it.
textWrap.counterAxisAlignItems = flip ? 'MAX' : 'MIN';
textWrap.primaryAxisAlignItems = 'CENTER';
textWrap.appendChild(label);
root.appendChild(textWrap);
textWrap.resize(Math.max(textWrap.width, 1), Math.max(Math.round(s.fontSize * TEXT_TIGHTEN_H), 1));
textWrap.layoutSizingHorizontal = 'HUG'; // last: re-hug width, keep fixed height
};
steps.outside = () => {
root.appendChild(outside);
outside.resize(Math.max(outside.width, 1), Math.max(s.witnessOvershoot, 0.01));
outside.layoutSizingHorizontal = 'FILL';
fillBars(outside, 'H');
};
steps.line = () => {
root.appendChild(line);
line.layoutSizingHorizontal = 'FILL';
};
steps.inside = () => {
root.appendChild(inside);
inside.layoutSizingHorizontal = 'FILL';
inside.layoutSizingVertical = 'FILL'; // grows -> reaches the feature
fillBars(inside, 'H');
};
const order = flip ? ['inside', 'line', 'outside', 'text'] : ['text', 'outside', 'line', 'inside'];
order.forEach((k) => steps[k]());
}
else {
featurePad(root, 'V', flip, s.witnessGap);
root.resize(INSIDE + s.witnessOvershoot + Math.max(label.width, 1) + s.witnessGap, LEN);
steps.inside = () => {
root.appendChild(inside);
inside.layoutSizingHorizontal = 'FILL'; // grows -> reaches the feature
inside.layoutSizingVertical = 'FILL';
fillBars(inside, 'V');
};
steps.line = () => {
root.appendChild(line);
line.layoutSizingVertical = 'FILL';
};
steps.outside = () => {
root.appendChild(outside);
outside.resize(Math.max(s.witnessOvershoot, 0.01), Math.max(outside.height, 1));
outside.layoutSizingVertical = 'FILL';
fillBars(outside, 'V');
};
steps.text = () => {
// Upright, hug height, fixed width NARROWER than the text so it spills toward the line.
textWrap.counterAxisAlignItems = 'CENTER';
textWrap.primaryAxisAlignItems = flip ? 'MIN' : 'MAX';
textWrap.appendChild(label);
root.appendChild(textWrap);
textWrap.resize(Math.max(label.width - Math.round(s.fontSize * TEXT_TIGHTEN_V), 1), Math.max(textWrap.height, 1));
textWrap.layoutSizingVertical = 'HUG'; // last: re-hug height, keep fixed width
};
const order = flip ? ['text', 'outside', 'line', 'inside'] : ['inside', 'line', 'outside', 'text'];
order.forEach((k) => steps[k]());
}
// Settle pass: the whole tree now exists, so re-apply Fill to the bands to clear the
// stale 0-size on their stretch axis (see settleBand). Must be after all appends.
settleBand(outside, orient, false, s.witnessOvershoot);
settleBand(inside, orient, true, s.witnessOvershoot);
}
/**
* Inline dimension: the label BREAKS the line. The middle band is a row/column of
* [witness ‖ segment -> · Text · <- segment ‖ witness]: two dim-line segments whose OUTER
* ends carry the arrows (inner ends clean), the label centered between them, and a witness
* vector at each extremity so the witness line stays continuous straight through the band.
* Around it, the same fixed outside-overshoot stub and growing inside band as standard.
* `flip` puts the feature (growing band) on the opposite side.
*/
async function assembleInline(root, orient, flip, s, label) {
const roundEnds = s.arrowStyle === 'ARROW_LINES';
const gap = Math.max(3, Math.round(s.fontSize * 0.35)); // clear space between line ends and text
// The inline band's cross-thickness is the label's SHORT dimension (height) in BOTH
// orientations — for H it's the band height, for V the band width. Using it (not the label
// width) is what keeps the witness-crossing a small stub instead of a text-wide line.
const textH = Math.max(label.height, 1);
// The band's near half already provides part of the overshoot; the stub tops it up so the
// total overshoot past the line stays ~= witnessOvershoot (consistent with the standard).
const stub = Math.max(s.witnessOvershoot - textH / 2, 1);
const textWrap = figma.createFrame();
textWrap.name = 'Text';
textWrap.fills = [];
textWrap.clipsContent = false;
textWrap.itemSpacing = 0;
textWrap.layoutMode = 'HORIZONTAL';
textWrap.primaryAxisAlignItems = 'CENTER';
textWrap.counterAxisAlignItems = 'CENTER';
textWrap.setPluginData(K.role, 'text');
textWrap.appendChild(label);
const band = figma.createFrame();
band.name = 'Inline';
band.fills = [];
band.clipsContent = false;
band.itemSpacing = 0;
band.layoutMode = orient === 'H' ? 'HORIZONTAL' : 'VERTICAL';
band.primaryAxisAlignItems = 'CENTER';
band.counterAxisAlignItems = 'CENTER';
band.setPluginData(K.role, 'inline');
const wA = witnessBar(orient, s.thickness, roundEnds); // crossing witness at the start extremity
const wB = witnessBar(orient, s.thickness, roundEnds); // ...and the end extremity
const segA = await inlineSegment(orient, s.thickness, s.arrowStyle, true); // arrow on the outer (start) end
const segB = await inlineSegment(orient, s.thickness, s.arrowStyle, false); // arrow on the outer (end) end
band.appendChild(wA);
band.appendChild(segA);
band.appendChild(textWrap);
band.appendChild(segB);
band.appendChild(wB);
const outside = extensionBand(orient, s.thickness, roundEnds);
const inside = extensionBand(orient, s.thickness, roundEnds);
if (orient === 'H') {
textWrap.paddingLeft = textWrap.paddingRight = gap;
featurePad(root, 'H', flip, s.witnessGap);
root.resize(LEN, stub + textH + INSIDE + s.witnessGap);
}
else {
textWrap.paddingTop = textWrap.paddingBottom = gap;
featurePad(root, 'V', flip, s.witnessGap);
root.resize(stub + textH + INSIDE + s.witnessGap, LEN);
}
// Feature (growing inside band) side must match standard: H feature bottom / V feature
// left when not flipped. The natural order differs by orientation (see assembleStandard).
const order = orient === 'H'
? flip ? [inside, band, outside] : [outside, band, inside]
: flip ? [outside, band, inside] : [inside, band, outside];
order.forEach((n) => root.appendChild(n));
if (orient === 'H') {
outside.resize(Math.max(outside.width, 1), Math.max(stub, 0.01));
outside.layoutSizingHorizontal = 'FILL';
fillBars(outside, 'H');
band.layoutSizingHorizontal = 'FILL';
band.layoutSizingVertical = 'HUG';
wA.layoutSizingHorizontal = 'FIXED';
wA.layoutSizingVertical = 'FILL';
wB.layoutSizingHorizontal = 'FIXED';
wB.layoutSizingVertical = 'FILL';
segA.layoutSizingHorizontal = 'FILL';
segB.layoutSizingHorizontal = 'FILL';
textWrap.layoutSizingHorizontal = 'HUG';
textWrap.layoutSizingVertical = 'HUG';
inside.layoutSizingHorizontal = 'FILL';
inside.layoutSizingVertical = 'FILL';
fillBars(inside, 'H');
}
else {
outside.resize(Math.max(stub, 0.01), Math.max(outside.height, 1));
outside.layoutSizingVertical = 'FILL';
fillBars(outside, 'V');
band.layoutSizingVertical = 'FILL';
band.layoutSizingHorizontal = 'HUG';
wA.layoutSizingVertical = 'FIXED';
wA.layoutSizingHorizontal = 'FILL';
wB.layoutSizingVertical = 'FIXED';
wB.layoutSizingHorizontal = 'FILL';
segA.layoutSizingVertical = 'FILL';
segB.layoutSizingVertical = 'FILL';
// Pin the text container width to textH (the band's cross-thickness), NOT the label width.
// HUG here would size to the label's WIDTH — the perpendicular axis for a vertical dim —
// ballooning the band and its witness-crossing. The label overflows this narrow box,
// centered on the line (clipsContent is off), which reads as a number straddling the line.
textWrap.resize(Math.max(textH, 1), Math.max(textWrap.height, 1));
textWrap.layoutSizingVertical = 'HUG'; // keep fixed width, re-hug height
inside.layoutSizingHorizontal = 'FILL';
inside.layoutSizingVertical = 'FILL';
fillBars(inside, 'V');
}
// Settle pass (gotcha #2): clear the stale 0 on each nested band's fill axis after the
// whole tree exists — the two extension bands and the inline band alike.
settleBand(outside, orient, false, stub);
settleBand(inside, orient, true, stub);
if (orient === 'H') {
band.resize(10, Math.max(textH, 1));
band.layoutSizingHorizontal = 'FILL';
band.layoutSizingVertical = 'HUG';
}
else {
band.resize(Math.max(textH, 1), 10);
band.layoutSizingVertical = 'FILL';
band.layoutSizingHorizontal = 'HUG';
}
}
// ---------- recompute ----------
async function recalcLabel(frame) {
if (frame.getPluginData(K.isDim) !== '1')
return;
const label = frame.findOne((n) => n.getPluginData(K.role) === 'label');
if (!label)
return;
const orient = frame.getPluginData(K.orient);
const span = orient === 'V' ? frame.height : frame.width;
const next = fmtFrom(frame, span);
if (label.characters === next)
return; // diff guard: no needless writes / loops
await figma.loadFontAsync(label.fontName);
suppress = true;
label.characters = next;
suppress = false;
}
async function recalcAll() {
const dims = figma.currentPage.findAll((n) => n.getPluginData(K.isDim) === '1');
for (const d of dims)
await recalcLabel(d);
}
// ---------- live mode ----------
// NOTE: under documentAccess: dynamic-page, figma.loadAllPagesAsync() MUST be
// awaited before this handler is registered (not just before it fires), so
// registration is deferred into init() after that await — never at top level.
async function onDocChange(e) {
if (!liveOn || suppress)
return;
const seen = new Set();
for (const c of e.documentChanges) {
if (c.type !== 'PROPERTY_CHANGE')
continue;
if (!(c.properties.includes('width') || c.properties.includes('height')))
continue;
const n = c.node;
if (!n || n.removed)
continue;
if (n.getPluginData && n.getPluginData(K.isDim) === '1' && !seen.has(n.id)) {
seen.add(n.id);
await recalcLabel(n);
}
}
}
// ---------- lifecycle ----------
const UI_WIDTH = 264;
// Initial height ~= the all-collapsed content; the UI posts its exact height on load and
// on every section toggle so the window tracks its content.
figma.showUI(__html__, { width: UI_WIDTH, height: 360, themeColors: true });
figma.ui.onmessage = async (msg) => {
if (msg.type === 'create') {
// The variant grid sends orient + style/flip together; fold them into settings (so they
// persist and land in the dropped frame's pluginData) before building.
if (msg.labelStyle === 'standard' || msg.labelStyle === 'inline')
settings.labelStyle = msg.labelStyle;
if (typeof msg.flip === 'boolean')
settings.flip = msg.flip;
await figma.clientStorage.setAsync('settings', settings);
await buildDimension(msg.orient);
}
else if (msg.type === 'settings') {
settings = { ...settings, ...msg.settings };
liveOn = settings.live;
await figma.clientStorage.setAsync('settings', settings);
// No loadAllPagesAsync here: init() already awaited it before registering
// the documentchange handler, so toggling live on just flips the flag.
}
else if (msg.type === 'selectAll') {
const dims = figma.currentPage.findAll((n) => n.getPluginData(K.isDim) === '1');
figma.currentPage.selection = dims;
figma.notify(dims.length ? `Selected ${dims.length} dimension${dims.length === 1 ? '' : 's'}` : 'No dimensions on this page');
}
else if (msg.type === 'updateSelected') {
const sel = figma.currentPage.selection.filter((n) => typeof n.getPluginData === 'function' && n.getPluginData(K.isDim) === '1');
if (!sel.length) {
figma.notify('Select one or more dimensions first');
}
else {
const next = [];
for (const d of sel) {
const r = await updateDimension(d);
if (r)
next.push(r);
}
figma.currentPage.selection = next;
figma.notify(`Updated ${next.length} dimension${next.length === 1 ? '' : 's'}`);
}
}
else if (msg.type === 'resize') {
// UI reports its content height so the window shrinks/grows as sections collapse/expand.
const h = Math.max(80, Math.min(1000, Math.round(msg.height || 0)));
figma.ui.resize(UI_WIDTH, h);
}
};
(async function init() {
const saved = (await figma.clientStorage.getAsync('settings'));
if (saved)
settings = { ...DEFAULTS, ...saved };
liveOn = settings.live;
availableFonts = await figma.listAvailableFontsAsync();
const families = Array.from(new Set(availableFonts.map((f) => f.fontName.family))).sort();
figma.ui.postMessage({ type: 'init', settings, fonts: families });
// Required before registering documentchange under dynamic-page access. Done
// unconditionally (not just when live is on) so the handler can be registered
// now; the handler itself early-returns when liveOn is false, so it's cheap.
await figma.loadAllPagesAsync().catch(() => { });
figma.on('documentchange', onDocChange);
await recalcAll(); // refresh any stale labels on reopen
})();