Repository navigation
Expand file tree
/
Copy pathcode.ts
More file actions
423 lines (370 loc) · 16.4 KB
/
Copy pathcode.ts
File metadata and controls
423 lines (370 loc) · 16.4 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
/// <reference types="@figma/plugin-typings" />
/**
* Units
* -----
* The inverse of Dims. Dims READS a node's pixel size and displays it as a
* real-world measurement; Units takes a real-world measurement as INPUT and
* SETS the node's pixels.
*
* Both plugins share one conversion model so they never drift:
*
* PER_INCH = { in: 1, ft: 1/12, mm: 25.4, cm: 2.54, m: 0.0254 }
* Dims (read): value = (pixels / dpi) * PER_INCH[unit]
* Units (write): pixels = (value / PER_INCH[unit]) * dpi
*
* v1 scope: rectangles, frames (and other resizable containers), and lines.
* A LINE's own `width` is its geometric length in local coordinates, so
* resize(len, 0) sets the true span along the line's own axis at any rotation
* — the bounding-box wrinkle Dims solved on read doesn't bite us on write.
*
* Stroke weight is set in the same units but treated as a PEN weight: it is
* converted at DPI only and ignores the drawing scale, the way a 0.35mm line
* stays 0.35mm on paper whether the drawing is 1:1 or 1/4" = 1'.
*
* Corner radius, by contrast, is part of the object's geometry (a 1/4" radius
* on a real part), so it converts exactly like width/height, scale included.
*
* Conventions inherited from Dims: TypeScript + standard plugin structure,
* `hcd:` pluginData namespace, DPI/unit persisted via clientStorage, Figma
* theme colors in the UI. No text is drawn, so there are no font concerns.
*/
const NS = 'hcd';
// Unit magnitude per inch. Canvas inches = pixels / dpi (Figma baseline dpi = 72).
// Kept byte-for-byte identical to Dims' PER_INCH so the two plugins stay consistent.
const PER_INCH: Record<string, number> = {
in: 1,
ft: 1 / 12,
mm: 25.4,
cm: 2.54,
m: 0.0254,
};
const MIN_PX = 0.01; // Figma's smallest legal node dimension
interface Settings {
dpi: number; // pixels per inch (Figma baseline = 72)
unit: string; // 'in' | 'ft' | 'mm' | 'cm' | 'm'
scale: number; // drawing scale: entered value = real size / scale
aspectLock: boolean; // set one dimension, scale the other proportionally
centerAnchor: boolean; // resize() pins top-left; re-center after resize
ignoreConstraints: boolean; // frames w/ children: resizeWithoutConstraints()
applyToAll: boolean; // apply the same dimension(s) to every selected node
}
const DEFAULTS: Settings = {
dpi: 72,
unit: 'in',
scale: 1,
aspectLock: false,
centerAnchor: false,
ignoreConstraints: false,
applyToAll: false,
};
let settings: Settings = { ...DEFAULTS };
// ---------- conversion ----------
/** Write formula: real-world value -> pixels. Inverse of Dims' read formula.
* Includes the drawing scale: pixels = (value / PER_INCH[unit]) * dpi * scale. */
function toPx(value: number, unit: string, dpi: number, scale: number): number {
const perInch = PER_INCH[unit] !== undefined ? PER_INCH[unit] : 1;
return (value / perInch) * dpi * scale;
}
/** Read formula (for prefilling fields from a node's current size).
* Matches Dims: value = (pixels / dpi) * PER_INCH[unit] / scale. */
function toUnit(px: number, unit: string, dpi: number, scale: number): number {
const perInch = PER_INCH[unit] !== undefined ? PER_INCH[unit] : 1;
return dpi && scale ? ((px / dpi) * perInch) / scale : 0;
}
// ---------- node classification ----------
type Resizable = SceneNode & {
resize: (w: number, h: number) => void;
resizeWithoutConstraints: (w: number, h: number) => void;
};
function isResizable(n: SceneNode): n is Resizable {
return typeof (n as { resize?: unknown }).resize === 'function';
}
function isLine(n: SceneNode): n is LineNode {
return n.type === 'LINE';
}
type Stroked = SceneNode & MinimalStrokesMixin;
/** Anything that carries a stroke weight (shapes, frames, text) — not the same
* set as resizable: groups and slices resize but have no stroke. */
function hasStrokes(n: SceneNode): n is Stroked {
return 'strokeWeight' in n;
}
type Cornered = SceneNode & CornerMixin;
/** Nodes with a corner radius worth setting. Ellipses carry the property too,
* but it does nothing visible on a closed ellipse, so they're left out. */
function hasCorners(n: SceneNode): n is Cornered {
return 'cornerRadius' in n && n.type !== 'ELLIPSE';
}
/** Something Units can act on: its size, stroke weight, or corner radius. */
function isTarget(n: SceneNode): boolean {
return isResizable(n) || hasStrokes(n) || hasCorners(n);
}
/** A container whose children carry constraints, so the resize() vs
* resizeWithoutConstraints() choice is meaningful. */
function hasConstrainedChildren(n: SceneNode): boolean {
const kids = (n as ChildrenMixin & SceneNode).children;
return Array.isArray(kids) && kids.length > 0;
}
// ---------- the setter ----------
interface ApplyMsg {
width: number | null; // in current unit; null = leave unchanged
height: number | null; // in current unit; null = leave unchanged
stroke: number | null; // in current unit, ignoring scale; null = leave unchanged
radius: number | null; // in current unit, with scale; null = leave unchanged
}
/**
* Resize one node to the requested real-world dimensions.
* - width/height are in the current unit; null means "leave this axis alone".
* - LINE nodes take width as their length and are always resized to height 0.
* - aspectLock scales the untouched axis proportionally when only one is given.
* - centerAnchor repositions after resize so the node grows about its center.
* - ignoreConstraints uses resizeWithoutConstraints() for containers w/ children.
* Returns true if the node was resized.
*/
function applyToNode(node: SceneNode, msg: ApplyMsg): boolean {
if (!isResizable(node)) return false;
const oldW = node.width;
const oldH = node.height;
const wantW = msg.width != null ? Math.max(toPx(msg.width, settings.unit, settings.dpi, settings.scale), MIN_PX) : null;
const wantH = msg.height != null ? Math.max(toPx(msg.height, settings.unit, settings.dpi, settings.scale), MIN_PX) : null;
// LINE: width is the geometric length; height stays 0. Aspect/center don't apply.
if (isLine(node)) {
if (wantW == null) return false; // only length is meaningful for a line
node.resize(wantW, 0);
return true;
}
let newW = wantW != null ? wantW : oldW;
let newH = wantH != null ? wantH : oldH;
// Aspect lock only kicks in when exactly one axis was provided — a single-field
// entry scales the other to preserve the node's current ratio.
if (settings.aspectLock && oldW > 0 && oldH > 0) {
if (wantW != null && wantH == null) newH = Math.max(wantW * (oldH / oldW), MIN_PX);
else if (wantH != null && wantW == null) newW = Math.max(wantH * (oldW / oldH), MIN_PX);
}
const useUnconstrained = settings.ignoreConstraints && hasConstrainedChildren(node);
if (useUnconstrained) node.resizeWithoutConstraints(newW, newH);
else node.resize(newW, newH);
// resize() pins the top-left; recentre so the node grows/shrinks about its middle.
if (settings.centerAnchor) {
node.x = node.x - (node.width - oldW) / 2;
node.y = node.y - (node.height - oldH) / 2;
}
return true;
}
/**
* Set a node's stroke weight from a value in the current unit. Converted at DPI
* only (scale = 1): stroke weight is a pen weight, not a scaled dimension.
* Returns false if the node has no stroke to weight.
*/
function applyStroke(node: SceneNode, value: number): boolean {
if (!hasStrokes(node) || !node.strokes.length) return false;
// The field prefills with the current weight rounded to 4 decimals, so
// re-applying it untouched would nudge the weight by that rounding error.
const cur = node.strokeWeight;
if (typeof cur === 'number' && tidy(toUnit(cur, settings.unit, settings.dpi, 1)) === value) return true;
const px = toPx(value, settings.unit, settings.dpi, 1);
node.strokeWeight = px;
// Rectangles/frames with per-side weights: make sure every side took it.
if (node.strokeWeight !== px && 'strokeTopWeight' in node) {
const sides = node as Stroked & IndividualStrokesMixin;
sides.strokeTopWeight = sides.strokeBottomWeight = sides.strokeLeftWeight = sides.strokeRightWeight = px;
}
return true;
}
/**
* Set a node's corner radius from a value in the current unit. Unlike stroke,
* radius is geometry, so the drawing scale applies. Returns false if the node
* has no corners to round.
*/
function applyRadius(node: SceneNode, value: number): boolean {
if (!hasCorners(node)) return false;
// Same prefill-rounding guard as applyStroke.
const cur = node.cornerRadius;
if (typeof cur === 'number' && tidy(toUnit(cur, settings.unit, settings.dpi, settings.scale)) === value) return true;
const px = toPx(value, settings.unit, settings.dpi, settings.scale);
node.cornerRadius = px;
// Rectangles/frames with per-corner radii: make sure every corner took it.
if (node.cornerRadius !== px && 'topLeftRadius' in node) {
const corners = node as Cornered & RectangleCornerMixin;
corners.topLeftRadius = corners.topRightRadius = corners.bottomLeftRadius = corners.bottomRightRadius = px;
}
return true;
}
// ---------- selection reporting ----------
/** Round to at most 4 decimals and drop trailing zeros, for clean field prefills. */
function tidy(n: number): number {
return Math.round(n * 10000) / 10000;
}
// Signature of what the UI currently shows, so repeated reports (from polling or
// documentchange) only post when something actually changed — otherwise a live
// poll would overwrite a value the user is mid-typing every tick.
let lastSig = '';
function strokeSignature(n: SceneNode): string {
if (!hasStrokes(n)) return '-';
// strokeWeight can be figma.mixed (a symbol), which can't go in a template.
const w = typeof n.strokeWeight === 'number' ? n.strokeWeight : 'mixed';
return `${n.strokes.length}/${w}`;
}
function radiusSignature(n: SceneNode): string {
if (!hasCorners(n)) return '-';
return typeof n.cornerRadius === 'number' ? String(n.cornerRadius) : 'mixed';
}
function selectionSignature(sel: SceneNode[]): string {
const parts = sel.map((n) => `${n.id}:${n.width}:${n.height}:${strokeSignature(n)}:${radiusSignature(n)}`);
// Include unit/dpi/scale so a settings change re-expresses the current size.
return `${settings.unit}|${settings.dpi}|${settings.scale}|${parts.join(',')}`;
}
/**
* Report the current selection to the UI: how many target nodes, whether any
* are lines or have a stroke or corners, and — for a single selection — its
* current size, stroke weight and radius in the chosen unit so the fields
* prefill (the inverse-of-Dims convenience). No-ops when nothing observable
* changed since the last report.
*/
function reportSelection() {
const sel = figma.currentPage.selection.filter(isTarget);
const sig = selectionSignature(sel);
if (sig === lastSig) return;
lastSig = sig;
const lineCount = sel.filter(isLine).length;
const hasStroke = sel.some((n) => hasStrokes(n) && n.strokes.length > 0);
const hasRadius = sel.some(hasCorners);
let width: number | null = null;
let height: number | null = null;
let stroke: number | 'mixed' | null = null;
let radius: number | 'mixed' | null = null;
let type: string | null = null;
if (sel.length === 1) {
const n = sel[0];
type = n.type;
if (isResizable(n)) {
width = tidy(toUnit(n.width, settings.unit, settings.dpi, settings.scale));
height = isLine(n) ? null : tidy(toUnit(n.height, settings.unit, settings.dpi, settings.scale));
}
if (hasStroke && hasStrokes(n)) {
// Per-side weights that differ read back as figma.mixed.
stroke = typeof n.strokeWeight === 'number'
? tidy(toUnit(n.strokeWeight, settings.unit, settings.dpi, 1))
: 'mixed';
}
if (hasCorners(n)) {
// Per-corner radii that differ (or per-vertex on vectors) read as figma.mixed.
radius = typeof n.cornerRadius === 'number'
? tidy(toUnit(n.cornerRadius, settings.unit, settings.dpi, settings.scale))
: 'mixed';
}
}
figma.ui.postMessage({
type: 'selection',
count: sel.length,
lineCount,
single: sel.length === 1,
nodeType: type,
width,
height,
hasStroke,
stroke,
hasRadius,
radius,
});
}
// ---------- lifecycle ----------
const UI_WIDTH = 264;
figma.showUI(__html__, { width: UI_WIDTH, height: 340, themeColors: true });
figma.ui.onmessage = async (msg) => {
if (msg.type === 'apply') {
const sel = figma.currentPage.selection.filter(isTarget);
if (!sel.length) {
figma.notify('Select a rectangle, frame, or line first');
return;
}
const sizeGiven = msg.width != null || msg.height != null;
if (!sizeGiven && msg.stroke == null && msg.radius == null) {
figma.notify('Enter a width, height, stroke weight, or radius');
return;
}
// Don't silently resize just one of many: multi-select requires an explicit
// "apply to all" so the intent is never ambiguous.
if (sel.length > 1 && !settings.applyToAll) {
figma.notify('Multiple objects selected — enable "Apply to all selected" or select one');
return;
}
const targets = settings.applyToAll ? sel : [sel[0]];
let done = 0;
let noStroke = 0;
let noCorners = 0;
for (const n of targets) {
let changed = false;
if (sizeGiven && applyToNode(n, msg)) changed = true;
if (msg.stroke != null) {
if (applyStroke(n, msg.stroke)) changed = true;
else noStroke++;
}
if (msg.radius != null) {
if (applyRadius(n, msg.radius)) changed = true;
else noCorners++;
}
if (changed) done++;
}
const suffix = settings.applyToAll && sel.length > 1 ? ` (of ${sel.length} selected)` : '';
const skipped = (noStroke ? ` · ${noStroke} without a stroke skipped` : '')
+ (noCorners ? ` · ${noCorners} without corners skipped` : '');
figma.notify((done ? `Updated ${done} object${done === 1 ? '' : 's'}${suffix}` : 'Nothing to update in selection') + skipped);
reportSelection(); // reflect the new sizes back into the fields
} else if (msg.type === 'settings') {
settings = { ...settings, ...msg.settings };
await figma.clientStorage.setAsync('settings', settings);
reportSelection(); // unit/dpi change -> re-express current size in the new unit
} else if (msg.type === 'requestSelection' || msg.type === 'poll') {
// 'poll' is the UI's timer tick — the reliable driver for live-tracking a
// drag-resize. reportSelection() dedupes, so idle ticks cost nothing.
reportSelection();
} else if (msg.type === 'coffee') {
// Open the tip link in the user's browser. URL is hardcoded here (not taken
// from the message) so nothing but this exact destination can be launched.
figma.openExternal('https://www.buymeacoffee.com/jonalling');
} else if (msg.type === 'resize') {
const h = Math.max(80, Math.min(1000, Math.round(msg.height || 0)));
figma.ui.resize(UI_WIDTH, h);
}
};
figma.on('selectionchange', reportSelection);
/**
* Live-refresh the fields when a selected node's size changes on the canvas
* (e.g. the user drags a resize handle). We only READ sizes here and post them
* to the UI — no document mutation — so unlike Dims' live label recalc this
* needs no reentrancy guard. Watches size, stroke and radius only; a plain
* move (x/y) is ignored since it changes none of them.
*/
const WATCHED: NodeChangeProperty[] = [
'width', 'height', 'strokeWeight', 'strokes',
'cornerRadius', 'topLeftRadius', 'topRightRadius', 'bottomLeftRadius', 'bottomRightRadius',
];
function onDocChange(e: DocumentChangeEvent) {
const selIds = new Set(figma.currentPage.selection.map((n) => n.id));
if (!selIds.size) return;
for (const c of e.documentChanges) {
if (c.type !== 'PROPERTY_CHANGE') continue;
if (!c.properties.some((p) => WATCHED.includes(p))) continue;
const n = c.node as SceneNode;
if (n && !n.removed && selIds.has(n.id)) {
reportSelection();
return;
}
}
}
(async function init() {
const saved = (await figma.clientStorage.getAsync('settings')) as Partial<Settings> | undefined;
if (saved) settings = { ...DEFAULTS, ...saved };
figma.ui.postMessage({ type: 'init', settings });
reportSelection();
// documentchange gives an instant update when it fires; the UI poll is the
// reliable fallback (esp. during an interactive drag). Registering the event
// requires loadAllPagesAsync under dynamic-page access — guard it so a failure
// here never takes down the poll-driven path.
try {
await figma.loadAllPagesAsync();
figma.on('documentchange', onDocChange);
} catch (e) {
/* poll still covers live updates */
}
})();