figma-plugin-utilities 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -1
- package/README.md +134 -26
- package/package.json +27 -2
- package/src/components/CodeExportModal.svelte +98 -0
- package/src/components/ConfirmModal.svelte +66 -0
- package/src/components/DataTable.svelte +349 -0
- package/src/components/FieldGrid.svelte +18 -0
- package/src/components/Footer.svelte +10 -0
- package/src/components/Header.svelte +6 -1
- package/src/components/LadderBadges.svelte +35 -0
- package/src/components/ListItem.svelte +53 -28
- package/src/components/MappingChip.svelte +150 -0
- package/src/components/RampCurve.svelte +383 -0
- package/src/components/Section.svelte +49 -0
- package/src/components/SteppedField.svelte +52 -0
- package/src/components/index.js +9 -0
- package/src/index.js +19 -3
- package/src/lib/colors.ts +99 -0
- package/src/lib/confirm.ts +55 -0
- package/src/lib/errorHandling.js +0 -39
- package/src/lib/figma-frame-builders.ts +2 -2
- package/src/lib/figma-helpers.ts +103 -59
- package/src/lib/figma-variables.ts +151 -0
- package/src/lib/format.ts +19 -0
- package/src/lib/index.js +13 -4
- package/src/lib/scale.ts +147 -0
- package/src/lib/colors.js +0 -80
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Color utilities for Figma plugins. No Figma API, so either thread may
|
|
3
|
+
* import them — but in a plugin that builds both threads in one pass, only
|
|
4
|
+
* one of them, or Vite splits this module into a chunk the sandbox can't load.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A color with channels 0–1, as Figma's RGB and RGBA. */
|
|
8
|
+
export type Rgb = { r: number; g: number; b: number; a?: number };
|
|
9
|
+
|
|
10
|
+
export type HexOptions = {
|
|
11
|
+
/** Lowercase digits; uppercase by default */
|
|
12
|
+
lowercase?: boolean;
|
|
13
|
+
/** Prefix with "#"; true by default */
|
|
14
|
+
hash?: boolean;
|
|
15
|
+
/** Append the alpha byte when `a` is below 1 */
|
|
16
|
+
alpha?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const byte = (channel: number) =>
|
|
20
|
+
Math.round(Math.min(1, Math.max(0, channel)) * 255)
|
|
21
|
+
.toString(16)
|
|
22
|
+
.padStart(2, "0");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert an RGB color (0–1 channels, clamped) to a HEX string, "#FF0000" by
|
|
26
|
+
* default.
|
|
27
|
+
*/
|
|
28
|
+
export function rgbToHex(
|
|
29
|
+
{ r, g, b, a }: Rgb,
|
|
30
|
+
{ lowercase = false, hash = true, alpha = false }: HexOptions = {},
|
|
31
|
+
): string {
|
|
32
|
+
let hex = byte(r) + byte(g) + byte(b);
|
|
33
|
+
if (alpha && a !== undefined && a < 1) hex += byte(a);
|
|
34
|
+
if (!lowercase) hex = hex.toUpperCase();
|
|
35
|
+
return hash ? `#${hex}` : hex;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether a string is a six-digit HEX color, in either case. The "#" is
|
|
40
|
+
* required unless `requireHash` is false.
|
|
41
|
+
*/
|
|
42
|
+
export function isValidHex(
|
|
43
|
+
value: string,
|
|
44
|
+
{ requireHash = true }: { requireHash?: boolean } = {},
|
|
45
|
+
): boolean {
|
|
46
|
+
return (requireHash ? /^#[0-9a-f]{6}$/i : /^#?[0-9a-f]{6}$/i).test(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert a HEX string ("#FF0000" or "FF0000") to an RGB color with 0–1
|
|
51
|
+
* channels, or null if it isn't six HEX digits.
|
|
52
|
+
*/
|
|
53
|
+
export function hexToRgb(
|
|
54
|
+
hex: string,
|
|
55
|
+
): { r: number; g: number; b: number } | null {
|
|
56
|
+
const cleanHex = hex.replace(/^#/, "");
|
|
57
|
+
if (!/^[0-9A-Fa-f]{6}$/.test(cleanHex)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const r = parseInt(cleanHex.substring(0, 2), 16) / 255;
|
|
61
|
+
const g = parseInt(cleanHex.substring(2, 4), 16) / 255;
|
|
62
|
+
const b = parseInt(cleanHex.substring(4, 6), 16) / 255;
|
|
63
|
+
return { r, g, b };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Relative luminance of a color (0–1), for contrast calculations. */
|
|
67
|
+
export function getLuminance({ r, g, b }: Rgb): number {
|
|
68
|
+
const adjust = (c: number) =>
|
|
69
|
+
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
70
|
+
return 0.2126 * adjust(r) + 0.7152 * adjust(g) + 0.0722 * adjust(b);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** WCAG contrast ratio between two colors (1–21). */
|
|
74
|
+
export function getContrastRatio(color1: Rgb, color2: Rgb): number {
|
|
75
|
+
const l1 = getLuminance(color1);
|
|
76
|
+
const l2 = getLuminance(color2);
|
|
77
|
+
const lighter = Math.max(l1, l2);
|
|
78
|
+
const darker = Math.min(l1, l2);
|
|
79
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether a contrast ratio meets a WCAG level. */
|
|
83
|
+
export function meetsContrastLevel(
|
|
84
|
+
ratio: number,
|
|
85
|
+
level: "AA" | "AAA" | "AA-large" | "AAA-large",
|
|
86
|
+
): boolean {
|
|
87
|
+
switch (level) {
|
|
88
|
+
case "AAA":
|
|
89
|
+
return ratio >= 7;
|
|
90
|
+
case "AAA-large":
|
|
91
|
+
return ratio >= 4.5;
|
|
92
|
+
case "AA":
|
|
93
|
+
return ratio >= 4.5;
|
|
94
|
+
case "AA-large":
|
|
95
|
+
return ratio >= 3;
|
|
96
|
+
default:
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirmation dialogs, in place of the browser's confirm(): mount one
|
|
3
|
+
* ConfirmModal in the plugin's UI and it shows whatever confirmAction() asks.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { get, writable } from "svelte/store";
|
|
7
|
+
|
|
8
|
+
export type ConfirmOptions = {
|
|
9
|
+
title: string;
|
|
10
|
+
message: string;
|
|
11
|
+
confirmLabel: string;
|
|
12
|
+
cancelLabel?: string;
|
|
13
|
+
destructive?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ConfirmRequest = ConfirmOptions & {
|
|
17
|
+
resolve: (confirmed: boolean) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const confirmRequest = writable<ConfirmRequest | null>(null);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Ask the user to confirm. Resolves true on confirm; false on cancel,
|
|
24
|
+
* Escape, a click outside, or when another confirmation replaces this one.
|
|
25
|
+
*/
|
|
26
|
+
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
|
|
27
|
+
get(confirmRequest)?.resolve(false);
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
confirmRequest.set({ ...options, resolve });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Answer the open confirmation, if any */
|
|
34
|
+
export function answerConfirm(confirmed: boolean): void {
|
|
35
|
+
const request = get(confirmRequest);
|
|
36
|
+
if (!request) return;
|
|
37
|
+
confirmRequest.set(null);
|
|
38
|
+
request.resolve(confirmed);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The prompt before unsaved edits are thrown away: closing a panel or
|
|
43
|
+
* leaving an editor. Resolves true to discard them.
|
|
44
|
+
*/
|
|
45
|
+
export function confirmDiscardChanges(
|
|
46
|
+
message = "Your changes haven't been saved.",
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
return confirmAction({
|
|
49
|
+
title: "Discard changes?",
|
|
50
|
+
message,
|
|
51
|
+
confirmLabel: "Discard changes",
|
|
52
|
+
cancelLabel: "Keep editing",
|
|
53
|
+
destructive: true,
|
|
54
|
+
});
|
|
55
|
+
}
|
package/src/lib/errorHandling.js
CHANGED
|
@@ -145,42 +145,3 @@ export function parseJsonSafe(jsonString) {
|
|
|
145
145
|
return { ok: false, error: message };
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Show an error notification to the user with standardized options.
|
|
151
|
-
* Use this in code.ts files for consistent error notifications.
|
|
152
|
-
* @param {string} message - The error message to display
|
|
153
|
-
* @param {unknown} [error] - Optional error object for logging
|
|
154
|
-
* @param {string} [context] - Optional context for logging
|
|
155
|
-
*/
|
|
156
|
-
export function notifyError(message, error, context) {
|
|
157
|
-
if (error) {
|
|
158
|
-
logError(error, context || message);
|
|
159
|
-
}
|
|
160
|
-
// @ts-ignore - figma global is available in plugin context
|
|
161
|
-
if (typeof figma !== "undefined") {
|
|
162
|
-
figma.notify(message, { error: true });
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Show a success notification to the user with standardized options.
|
|
168
|
-
* @param {string} message - The success message to display
|
|
169
|
-
*/
|
|
170
|
-
export function notifySuccess(message) {
|
|
171
|
-
// @ts-ignore - figma global is available in plugin context
|
|
172
|
-
if (typeof figma !== "undefined") {
|
|
173
|
-
figma.notify(message);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Show a warning notification to the user.
|
|
179
|
-
* @param {string} message - The warning message to display
|
|
180
|
-
*/
|
|
181
|
-
export function notifyWarning(message) {
|
|
182
|
-
// @ts-ignore - figma global is available in plugin context
|
|
183
|
-
if (typeof figma !== "undefined") {
|
|
184
|
-
figma.notify(message, { timeout: 5000 });
|
|
185
|
-
}
|
|
186
|
-
}
|
|
@@ -47,7 +47,7 @@ function applyPadding(
|
|
|
47
47
|
|
|
48
48
|
export const specTokens = {
|
|
49
49
|
accentColors: {
|
|
50
|
-
green: rgb(0.
|
|
50
|
+
green: rgb(0.251, 0.769, 0.349), // #40C459 — AAA
|
|
51
51
|
blue: rgb(0.447, 0.682, 0.988), // #72AEFC — AA
|
|
52
52
|
purple: rgb(0.753, 0.608, 0.965), // #C09BF6 — AA18
|
|
53
53
|
red: rgb(0.98, 0.553, 0.569), // #FA8D91 — DNP
|
|
@@ -195,7 +195,7 @@ export function createTokenChip<K extends NodeKind = "frame">(opts: {
|
|
|
195
195
|
applyAutoLayout(node, {
|
|
196
196
|
name: "label",
|
|
197
197
|
direction: "VERTICAL",
|
|
198
|
-
padding: { right:
|
|
198
|
+
padding: { right: 6, left: 6 },
|
|
199
199
|
fill: opts.background,
|
|
200
200
|
cornerRadius: 2,
|
|
201
201
|
height: 24,
|
package/src/lib/figma-helpers.ts
CHANGED
|
@@ -18,56 +18,43 @@ export function sendToUI<T extends Record<string, unknown>>(
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*/
|
|
25
|
-
export async function getCollections(): Promise<VariableCollection[]> {
|
|
26
|
-
return figma.variables.getLocalVariableCollectionsAsync();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Get all local variables of a specific type
|
|
31
|
-
* @param type - Variable type to filter by
|
|
32
|
-
* @returns Promise resolving to array of variables
|
|
33
|
-
*/
|
|
34
|
-
export async function getVariables(
|
|
35
|
-
type?: VariableResolvedDataType,
|
|
36
|
-
): Promise<Variable[]> {
|
|
37
|
-
return figma.variables.getLocalVariablesAsync(type);
|
|
38
|
-
}
|
|
21
|
+
// Long enough to read: about 60ms a character, and never under `minimum`.
|
|
22
|
+
const readingTime = (message: string, minimum: number) =>
|
|
23
|
+
Math.max(minimum, message.length * 60);
|
|
39
24
|
|
|
40
25
|
/**
|
|
41
|
-
* Show an error notification
|
|
42
|
-
* @param message -
|
|
43
|
-
* @param timeout - How long to show
|
|
26
|
+
* Show an error notification: something failed, or the file needs fixing
|
|
27
|
+
* @param message - What went wrong, then what to do
|
|
28
|
+
* @param timeout - How long to show it (ms); by default long enough to read,
|
|
29
|
+
* 5s at least
|
|
44
30
|
*/
|
|
45
|
-
export function showError(message: string, timeout
|
|
46
|
-
figma.notify(message, {
|
|
31
|
+
export function showError(message: string, timeout?: number): void {
|
|
32
|
+
figma.notify(message, {
|
|
33
|
+
error: true,
|
|
34
|
+
timeout: timeout ?? readingTime(message, 5000),
|
|
35
|
+
});
|
|
47
36
|
}
|
|
48
37
|
|
|
49
38
|
/**
|
|
50
|
-
* Show a success notification to the
|
|
51
|
-
*
|
|
52
|
-
* @param
|
|
39
|
+
* Show a success notification: the change is made. End a change to the file
|
|
40
|
+
* with `UNDO` from `lib/format`.
|
|
41
|
+
* @param message - What changed, with a count
|
|
42
|
+
* @param timeout - How long to show it (ms); by default long enough to read,
|
|
43
|
+
* 3s at least
|
|
53
44
|
*/
|
|
54
|
-
export function showSuccess(message: string, timeout
|
|
55
|
-
figma.notify(message, { timeout });
|
|
45
|
+
export function showSuccess(message: string, timeout?: number): void {
|
|
46
|
+
figma.notify(message, { timeout: timeout ?? readingTime(message, 3000) });
|
|
56
47
|
}
|
|
57
48
|
|
|
58
49
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* @
|
|
50
|
+
* Show a regular notification for a run with nothing to do: an empty
|
|
51
|
+
* selection, nothing to change. Not an error; say what to select or set.
|
|
52
|
+
* @param message - Why nothing happened, then what to do
|
|
53
|
+
* @param timeout - How long to show it (ms); by default long enough to read,
|
|
54
|
+
* 4s at least
|
|
62
55
|
*/
|
|
63
|
-
export function
|
|
64
|
-
|
|
65
|
-
): readonly T[] {
|
|
66
|
-
const selection = figma.currentPage.selection;
|
|
67
|
-
if (nodeType) {
|
|
68
|
-
return selection.filter((node) => node.type === nodeType) as T[];
|
|
69
|
-
}
|
|
70
|
-
return selection as readonly T[];
|
|
56
|
+
export function showNotice(message: string, timeout?: number): void {
|
|
57
|
+
figma.notify(message, { timeout: timeout ?? readingTime(message, 4000) });
|
|
71
58
|
}
|
|
72
59
|
|
|
73
60
|
/**
|
|
@@ -85,35 +72,92 @@ export function focusNodes(nodes: readonly SceneNode[]): void {
|
|
|
85
72
|
* @param family - Font family name
|
|
86
73
|
* @param style - Font style (e.g., "Regular", "Bold")
|
|
87
74
|
*/
|
|
88
|
-
export
|
|
89
|
-
|
|
75
|
+
export function loadFont(family: string, style: string): Promise<void> {
|
|
76
|
+
return loadFontOnce({ family, style });
|
|
90
77
|
}
|
|
91
78
|
|
|
92
79
|
/**
|
|
93
|
-
*
|
|
94
|
-
* @param key - Storage key
|
|
95
|
-
* @param value - Value to store (must be JSON-serializable)
|
|
80
|
+
* Every font a text node uses: its own when uniform, each run's when mixed.
|
|
96
81
|
*/
|
|
97
|
-
export
|
|
98
|
-
|
|
82
|
+
export function fontsOf(node: TextNode): FontName[] {
|
|
83
|
+
if (node.fontName !== figma.mixed) return [node.fontName];
|
|
84
|
+
return node.characters.length > 0
|
|
85
|
+
? node.getRangeAllFontNames(0, node.characters.length)
|
|
86
|
+
: [];
|
|
99
87
|
}
|
|
100
88
|
|
|
89
|
+
const fontLoads = new Map<string, Promise<void>>();
|
|
90
|
+
|
|
101
91
|
/**
|
|
102
|
-
* Load
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* @returns Stored value or default
|
|
92
|
+
* Load a font once per plugin run: later calls for it share the first load,
|
|
93
|
+
* so hundreds of text layers wait once per font rather than a round trip
|
|
94
|
+
* each. A load that fails is forgotten, so the next call tries again.
|
|
106
95
|
*/
|
|
107
|
-
export
|
|
108
|
-
key
|
|
109
|
-
|
|
110
|
-
)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
96
|
+
export function loadFontOnce(font: FontName): Promise<void> {
|
|
97
|
+
const key = `${font.family}\u0000${font.style}`;
|
|
98
|
+
let load = fontLoads.get(key);
|
|
99
|
+
if (!load) {
|
|
100
|
+
load = figma.loadFontAsync(font).catch((error: unknown) => {
|
|
101
|
+
fontLoads.delete(key);
|
|
102
|
+
throw error;
|
|
103
|
+
});
|
|
104
|
+
fontLoads.set(key, load);
|
|
116
105
|
}
|
|
106
|
+
return load;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Load every font a text node uses, each once per run — Figma refuses to
|
|
111
|
+
* write to text whose fonts aren't loaded. Rejects when one won't load, as a
|
|
112
|
+
* missing font doesn't.
|
|
113
|
+
*/
|
|
114
|
+
export async function loadNodeFonts(node: TextNode): Promise<void> {
|
|
115
|
+
await Promise.all(fontsOf(node).map(loadFontOnce));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Set a text node's characters in its own fonts, loading them first. Rejects,
|
|
120
|
+
* leaving the text as it was, when one of its fonts won't load.
|
|
121
|
+
*/
|
|
122
|
+
export async function setText(
|
|
123
|
+
node: TextNode,
|
|
124
|
+
characters: string,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
await loadNodeFonts(node);
|
|
127
|
+
node.characters = characters;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Settings kept in clientStorage under `key`, cleaned by one `sanitize` on
|
|
132
|
+
* the way in and on the way out, so what's saved is always what a load would
|
|
133
|
+
* accept. `sanitize` takes anything — a value an older version stored, a
|
|
134
|
+
* message from the UI, undefined on first run — and returns complete
|
|
135
|
+
* settings. `save` resolves to the settings it stored. Storage errors are
|
|
136
|
+
* logged, not thrown: a load falls back to `sanitize(undefined)`.
|
|
137
|
+
*/
|
|
138
|
+
export function createSettingsStore<T>(
|
|
139
|
+
key: string,
|
|
140
|
+
sanitize: (raw: unknown) => T,
|
|
141
|
+
): { load(): Promise<T>; save(raw: unknown): Promise<T> } {
|
|
142
|
+
return {
|
|
143
|
+
async load() {
|
|
144
|
+
try {
|
|
145
|
+
return sanitize(await figma.clientStorage.getAsync(key));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`Couldn't read "${key}" from clientStorage:`, error);
|
|
148
|
+
return sanitize(undefined);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
async save(raw) {
|
|
152
|
+
const settings = sanitize(raw);
|
|
153
|
+
try {
|
|
154
|
+
await figma.clientStorage.setAsync(key, settings);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`Couldn't save "${key}" to clientStorage:`, error);
|
|
157
|
+
}
|
|
158
|
+
return settings;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
/**
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variable helpers for plugin code (code.ts): alias and color guards, and a
|
|
3
|
+
* variable's value through any aliases.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Whether a variable value is an alias to another variable. */
|
|
7
|
+
export function isVariableAlias(value: unknown): value is VariableAlias {
|
|
8
|
+
return (
|
|
9
|
+
typeof value === "object" &&
|
|
10
|
+
value !== null &&
|
|
11
|
+
(value as VariableAlias).type === "VARIABLE_ALIAS"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether a variable value is a color, RGB or RGBA with 0–1 channels. */
|
|
16
|
+
export function isColorValue(value: unknown): value is RGB | RGBA {
|
|
17
|
+
return (
|
|
18
|
+
typeof value === "object" &&
|
|
19
|
+
value !== null &&
|
|
20
|
+
typeof (value as RGB).r === "number" &&
|
|
21
|
+
typeof (value as RGB).g === "number" &&
|
|
22
|
+
typeof (value as RGB).b === "number"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A color value as RGBA, alpha 1 where it has none, or null for anything else. */
|
|
27
|
+
export function toRgba(value: unknown): RGBA | null {
|
|
28
|
+
return isColorValue(value) ? { a: 1, ...value } : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The file's local variables and collections by id, read once for many lookups. */
|
|
32
|
+
export interface VariableLookup {
|
|
33
|
+
variables: Map<string, Variable>;
|
|
34
|
+
collections: Map<string, VariableCollection>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getVariableLookup(): Promise<VariableLookup> {
|
|
38
|
+
const [variables, collections] = await Promise.all([
|
|
39
|
+
figma.variables.getLocalVariablesAsync(),
|
|
40
|
+
figma.variables.getLocalVariableCollectionsAsync(),
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
variables: new Map(variables.map((v): [string, Variable] => [v.id, v])),
|
|
44
|
+
collections: new Map(
|
|
45
|
+
collections.map((c): [string, VariableCollection] => [c.id, c]),
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A variable's value at a mode, or at its collection's default mode where it
|
|
51
|
+
// has none there (null asks for the default).
|
|
52
|
+
function valueAt(
|
|
53
|
+
variable: Variable,
|
|
54
|
+
modeId: string | null,
|
|
55
|
+
collection: VariableCollection | null | undefined,
|
|
56
|
+
): VariableValue | undefined {
|
|
57
|
+
return (
|
|
58
|
+
(modeId !== null ? variable.valuesByMode[modeId] : undefined) ??
|
|
59
|
+
(collection
|
|
60
|
+
? variable.valuesByMode[collection.defaultModeId]
|
|
61
|
+
: undefined) ??
|
|
62
|
+
Object.values(variable.valuesByMode)[0]
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The mode to read an alias's target at: the same mode where its collection
|
|
67
|
+
// has it (an alias within one collection), else the default.
|
|
68
|
+
const modeIn = (
|
|
69
|
+
modeId: string | null,
|
|
70
|
+
collection: VariableCollection | null | undefined,
|
|
71
|
+
): string | null =>
|
|
72
|
+
modeId !== null && collection?.modes.some((m) => m.modeId === modeId)
|
|
73
|
+
? modeId
|
|
74
|
+
: null;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A variable's value at a mode (null for its collection's default), following
|
|
78
|
+
* aliases to a value that isn't one. Each variable down the chain is read at
|
|
79
|
+
* the same mode where its collection has it, else at its default mode. Given
|
|
80
|
+
* an alias, starts at its target. Local variables only, from `lookup`; null
|
|
81
|
+
* where the chain leaves the file or runs past `maxDepth`.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveVariableValue(
|
|
84
|
+
start: Variable | VariableAlias,
|
|
85
|
+
modeId: string | null,
|
|
86
|
+
lookup: VariableLookup,
|
|
87
|
+
maxDepth = 10,
|
|
88
|
+
): VariableValue | null {
|
|
89
|
+
let variable: Variable | undefined;
|
|
90
|
+
let mode = modeId;
|
|
91
|
+
if (isVariableAlias(start)) {
|
|
92
|
+
variable = lookup.variables.get(start.id);
|
|
93
|
+
if (!variable) return null;
|
|
94
|
+
mode = modeIn(mode, lookup.collections.get(variable.variableCollectionId));
|
|
95
|
+
} else {
|
|
96
|
+
variable = start;
|
|
97
|
+
}
|
|
98
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
99
|
+
const raw = valueAt(
|
|
100
|
+
variable,
|
|
101
|
+
mode,
|
|
102
|
+
lookup.collections.get(variable.variableCollectionId),
|
|
103
|
+
);
|
|
104
|
+
if (!isVariableAlias(raw)) return raw ?? null;
|
|
105
|
+
const target: Variable | undefined = lookup.variables.get(raw.id);
|
|
106
|
+
if (!target) return null;
|
|
107
|
+
mode = modeIn(mode, lookup.collections.get(target.variableCollectionId));
|
|
108
|
+
variable = target;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* As `resolveVariableValue`, but follows aliases into enabled libraries too,
|
|
115
|
+
* fetching any variable or collection `lookup` doesn't have (or every one,
|
|
116
|
+
* without a lookup).
|
|
117
|
+
*/
|
|
118
|
+
export async function resolveVariableValueAsync(
|
|
119
|
+
start: Variable | VariableAlias,
|
|
120
|
+
modeId: string | null,
|
|
121
|
+
lookup?: VariableLookup,
|
|
122
|
+
maxDepth = 10,
|
|
123
|
+
): Promise<VariableValue | null> {
|
|
124
|
+
const variableById = async (id: string) =>
|
|
125
|
+
lookup?.variables.get(id) ??
|
|
126
|
+
(await figma.variables.getVariableByIdAsync(id));
|
|
127
|
+
const collectionOf = async (v: Variable) =>
|
|
128
|
+
lookup?.collections.get(v.variableCollectionId) ??
|
|
129
|
+
(await figma.variables.getVariableCollectionByIdAsync(
|
|
130
|
+
v.variableCollectionId,
|
|
131
|
+
));
|
|
132
|
+
|
|
133
|
+
let variable: Variable | null;
|
|
134
|
+
let mode = modeId;
|
|
135
|
+
if (isVariableAlias(start)) {
|
|
136
|
+
variable = await variableById(start.id);
|
|
137
|
+
if (!variable) return null;
|
|
138
|
+
mode = modeIn(mode, await collectionOf(variable));
|
|
139
|
+
} else {
|
|
140
|
+
variable = start;
|
|
141
|
+
}
|
|
142
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
143
|
+
const raw = valueAt(variable, mode, await collectionOf(variable));
|
|
144
|
+
if (!isVariableAlias(raw)) return raw ?? null;
|
|
145
|
+
const target: Variable | null = await variableById(raw.id);
|
|
146
|
+
if (!target) return null;
|
|
147
|
+
mode = modeIn(mode, await collectionOf(target));
|
|
148
|
+
variable = target;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy helpers with no Figma API, for either thread.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** "1 layer", "3 layers"; `pluralForm` for the irregular ones: "2 entries" */
|
|
6
|
+
export const plural = (
|
|
7
|
+
count: number,
|
|
8
|
+
singular: string,
|
|
9
|
+
pluralForm = `${singular}s`,
|
|
10
|
+
): string => `${count} ${count === 1 ? singular : pluralForm}`;
|
|
11
|
+
|
|
12
|
+
/** "a", "a and b", "a, b and c"; `conjunction` replaces "and" */
|
|
13
|
+
export const joinList = (items: string[], conjunction = "and"): string =>
|
|
14
|
+
items.length <= 1
|
|
15
|
+
? (items[0] ?? "")
|
|
16
|
+
: `${items.slice(0, -1).join(", ")} ${conjunction} ${items[items.length - 1]}`;
|
|
17
|
+
|
|
18
|
+
/** The last sentence of a success notification for a change to the file. */
|
|
19
|
+
export const UNDO = "Press Ctrl/Cmd+Z to undo.";
|
package/src/lib/index.js
CHANGED
|
@@ -5,10 +5,14 @@ export { sendToPlugin, createMessageHandler } from "./messages.js";
|
|
|
5
5
|
export {
|
|
6
6
|
rgbToHex,
|
|
7
7
|
hexToRgb,
|
|
8
|
+
isValidHex,
|
|
8
9
|
getLuminance,
|
|
9
10
|
getContrastRatio,
|
|
10
11
|
meetsContrastLevel,
|
|
11
|
-
} from "./colors.
|
|
12
|
+
} from "./colors.ts";
|
|
13
|
+
|
|
14
|
+
// Copy helpers
|
|
15
|
+
export { plural, joinList, UNDO } from "./format.ts";
|
|
12
16
|
|
|
13
17
|
// Validation utilities
|
|
14
18
|
export {
|
|
@@ -30,9 +34,6 @@ export {
|
|
|
30
34
|
withErrorHandling,
|
|
31
35
|
safeAsync,
|
|
32
36
|
parseJsonSafe,
|
|
33
|
-
notifyError,
|
|
34
|
-
notifySuccess,
|
|
35
|
-
notifyWarning,
|
|
36
37
|
} from "./errorHandling.js";
|
|
37
38
|
|
|
38
39
|
// Resize utilities
|
|
@@ -42,3 +43,11 @@ export {
|
|
|
42
43
|
resizeToFit,
|
|
43
44
|
autoResize,
|
|
44
45
|
} from "./resize.js";
|
|
46
|
+
|
|
47
|
+
// Confirmation dialogs, shown by ConfirmModal
|
|
48
|
+
export {
|
|
49
|
+
confirmAction,
|
|
50
|
+
confirmDiscardChanges,
|
|
51
|
+
answerConfirm,
|
|
52
|
+
confirmRequest,
|
|
53
|
+
} from "./confirm.ts";
|