figma-plugin-utilities 0.1.0

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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Figma API helpers for plugin code (code.ts)
3
+ */
4
+
5
+ /**
6
+ * Send a typed message to the UI
7
+ * @param type - Message type identifier
8
+ * @param data - Additional data to send
9
+ */
10
+ export function sendToUI<T extends Record<string, unknown>>(
11
+ type: string,
12
+ data?: T
13
+ ): void {
14
+ if (data) {
15
+ figma.ui.postMessage({ type, ...data });
16
+ } else {
17
+ figma.ui.postMessage({ type });
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Get all local variable collections
23
+ * @returns Promise resolving to array of variable collections
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
+ }
39
+
40
+ /**
41
+ * Show an error notification to the user
42
+ * @param message - Error message to display
43
+ * @param timeout - How long to show the notification (ms)
44
+ */
45
+ export function showError(message: string, timeout = 5000): void {
46
+ figma.notify(message, { error: true, timeout });
47
+ }
48
+
49
+ /**
50
+ * Show a success notification to the user
51
+ * @param message - Success message to display
52
+ * @param timeout - How long to show the notification (ms)
53
+ */
54
+ export function showSuccess(message: string, timeout = 3000): void {
55
+ figma.notify(message, { timeout });
56
+ }
57
+
58
+ /**
59
+ * Get the current selection, optionally filtered by type
60
+ * @param nodeType - Optional node type to filter by
61
+ * @returns Array of selected nodes
62
+ */
63
+ export function getSelection<T extends SceneNode>(
64
+ nodeType?: NodeType
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[];
71
+ }
72
+
73
+ /**
74
+ * Focus the viewport on specific nodes
75
+ * @param nodes - Nodes to focus on
76
+ */
77
+ export function focusNodes(nodes: readonly SceneNode[]): void {
78
+ if (nodes.length > 0) {
79
+ figma.viewport.scrollAndZoomIntoView(nodes);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Load a font before using it
85
+ * @param family - Font family name
86
+ * @param style - Font style (e.g., "Regular", "Bold")
87
+ */
88
+ export async function loadFont(
89
+ family: string,
90
+ style: string
91
+ ): Promise<void> {
92
+ await figma.loadFontAsync({ family, style });
93
+ }
94
+
95
+ /**
96
+ * Save data to client storage (persists across sessions)
97
+ * @param key - Storage key
98
+ * @param value - Value to store (must be JSON-serializable)
99
+ */
100
+ export async function saveToStorage<T>(key: string, value: T): Promise<void> {
101
+ await figma.clientStorage.setAsync(key, value);
102
+ }
103
+
104
+ /**
105
+ * Load data from client storage
106
+ * @param key - Storage key
107
+ * @param defaultValue - Default value if key doesn't exist
108
+ * @returns Stored value or default
109
+ */
110
+ export async function loadFromStorage<T>(
111
+ key: string,
112
+ defaultValue?: T
113
+ ): Promise<T | undefined> {
114
+ try {
115
+ const value = await figma.clientStorage.getAsync(key);
116
+ return value !== undefined ? value : defaultValue;
117
+ } catch {
118
+ return defaultValue;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Handle resize message from UI
124
+ * Call this in your message handler when msg.type === "resize"
125
+ * @param msg - Message object with width and height
126
+ */
127
+ export function handleResize(msg: { width: number; height: number }): void {
128
+ figma.ui.resize(msg.width, msg.height);
129
+ }
@@ -0,0 +1,47 @@
1
+ // Message utilities
2
+ export {
3
+ sendToPlugin,
4
+ createMessageHandler,
5
+ } from "./messages.js";
6
+
7
+ // Color utilities
8
+ export {
9
+ rgbToHex,
10
+ hexToRgb,
11
+ getLuminance,
12
+ getContrastRatio,
13
+ meetsContrastLevel,
14
+ } from "./colors.js";
15
+
16
+ // Validation utilities
17
+ export {
18
+ validateUrl,
19
+ validateJsonString,
20
+ sanitizeInput,
21
+ sanitizeName,
22
+ validateEmail,
23
+ validateNumber,
24
+ isEmpty,
25
+ } from "./validation.js";
26
+
27
+ // Error handling utilities
28
+ export {
29
+ formatErrorMessage,
30
+ handleAsyncError,
31
+ createUserErrorMessage,
32
+ logError,
33
+ withErrorHandling,
34
+ safeAsync,
35
+ parseJsonSafe,
36
+ notifyError,
37
+ notifySuccess,
38
+ notifyWarning,
39
+ } from "./errorHandling.js";
40
+
41
+ // Resize utilities
42
+ export {
43
+ setDefaultWidth,
44
+ getContentHeight,
45
+ resizeToFit,
46
+ autoResize,
47
+ } from "./resize.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Message utilities for Figma plugin UI communication
3
+ */
4
+
5
+ /**
6
+ * Send a message to the plugin code
7
+ * @param {string} type - Message type identifier
8
+ * @param {object} data - Additional data to send
9
+ */
10
+ export function sendToPlugin(type, data = {}) {
11
+ parent.postMessage({ pluginMessage: { type, ...data } }, "*");
12
+ }
13
+
14
+ /**
15
+ * Create a message handler with type-based routing
16
+ * @param {Record<string, (msg: any) => void>} handlers - Object mapping message types to handler functions
17
+ * @returns {(event: MessageEvent) => void} Event handler function
18
+ *
19
+ * @example
20
+ * window.onmessage = createMessageHandler({
21
+ * populateOptions: (msg) => {
22
+ * collections = msg.options;
23
+ * },
24
+ * generationComplete: () => {
25
+ * isGenerating = false;
26
+ * }
27
+ * });
28
+ */
29
+ export function createMessageHandler(handlers) {
30
+ return (event) => {
31
+ const msg = event.data && event.data.pluginMessage;
32
+ if (!msg) return;
33
+ const handler = handlers[msg.type];
34
+ if (handler) handler(msg);
35
+ };
36
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Auto-resize utilities for Figma plugin windows
3
+ *
4
+ * Usage in UI:
5
+ * import { resizeToFit, autoResize } from "figma-plugin-utils";
6
+ * resizeToFit(); // One-time resize
7
+ * autoResize(); // Watch for changes and auto-resize
8
+ *
9
+ * Usage in code.ts:
10
+ * import { handleResize } from "figma-plugin-utils/lib/figma-helpers";
11
+ * // In your message handler:
12
+ * if (msg.type === "resize") handleResize(msg);
13
+ */
14
+
15
+ import { sendToPlugin } from "./messages.js";
16
+
17
+ /** Default width for the plugin window */
18
+ let defaultWidth = 300;
19
+
20
+ /**
21
+ * Set the default width used for resize operations
22
+ * @param {number} width - The default width in pixels
23
+ */
24
+ export function setDefaultWidth(width) {
25
+ defaultWidth = width;
26
+ }
27
+
28
+ /**
29
+ * Get the current content height of the plugin UI
30
+ * Uses scrollHeight which works when container doesn't have fixed height
31
+ * @param {HTMLElement} [container] - Container element to measure (should NOT have height: 100%)
32
+ * @returns {number} The content height in pixels
33
+ */
34
+ export function getContentHeight(container) {
35
+ if (!container) {
36
+ return 0;
37
+ }
38
+ return container.scrollHeight;
39
+ }
40
+
41
+ /**
42
+ * Request the plugin to resize to fit content
43
+ * @param {object} [options] - Resize options
44
+ * @param {number} [options.width] - Width in pixels (uses default if not specified)
45
+ * @param {number} [options.height] - Height in pixels (auto-calculated if not specified)
46
+ * @param {number} [options.minHeight=100] - Minimum height in pixels
47
+ * @param {number} [options.maxHeight=800] - Maximum height in pixels
48
+ * @param {number} [options.padding=0] - Extra padding to add to calculated height
49
+ * @param {HTMLElement} [options.container] - Container element to measure
50
+ */
51
+ export function resizeToFit(options = {}) {
52
+ const {
53
+ width = defaultWidth,
54
+ height,
55
+ minHeight = 100,
56
+ maxHeight = 800,
57
+ padding = 0,
58
+ container = document.body,
59
+ } = options;
60
+
61
+ let finalHeight = height;
62
+
63
+ if (finalHeight === undefined) {
64
+ finalHeight = getContentHeight(container) + padding;
65
+ }
66
+
67
+ // Clamp to min/max
68
+ finalHeight = Math.max(minHeight, Math.min(maxHeight, finalHeight));
69
+
70
+ sendToPlugin("resize", { width, height: finalHeight });
71
+ }
72
+
73
+ /**
74
+ * Set up automatic resizing when content changes
75
+ * Uses ResizeObserver to watch for size changes
76
+ *
77
+ * IMPORTANT: The container element must NOT have height: 100% or fixed height.
78
+ * Use bind:this on a wrapper element that flows naturally with content.
79
+ *
80
+ * @param {object} options - Auto-resize options
81
+ * @param {HTMLElement} options.container - Container element to observe (required, must not have fixed height)
82
+ * @param {number} [options.width] - Width in pixels (uses default if not specified)
83
+ * @param {number} [options.minHeight=100] - Minimum height in pixels
84
+ * @param {number} [options.maxHeight=800] - Maximum height in pixels
85
+ * @param {number} [options.padding=0] - Extra padding to add to calculated height
86
+ * @param {number} [options.debounce=50] - Debounce delay in milliseconds
87
+ * @param {number} [options.threshold=20] - Minimum height change to trigger resize (prevents position reset)
88
+ * @returns {function} Cleanup function to stop observing
89
+ */
90
+ export function autoResize(options = {}) {
91
+ const {
92
+ container,
93
+ width = defaultWidth,
94
+ minHeight = 100,
95
+ maxHeight = 800,
96
+ padding = 0,
97
+ debounce = 50,
98
+ threshold = 20,
99
+ } = options;
100
+
101
+ if (!container) {
102
+ console.warn("autoResize: container is required");
103
+ return () => {};
104
+ }
105
+
106
+ let timeoutId = null;
107
+ let lastHeight = 0;
108
+
109
+ const doResize = () => {
110
+ const newHeight = getContentHeight(container) + padding;
111
+ const clampedHeight = Math.max(minHeight, Math.min(maxHeight, newHeight));
112
+
113
+ // Only resize if height changed by more than threshold (prevents position reset on small changes)
114
+ const heightDiff = Math.abs(clampedHeight - lastHeight);
115
+ if (lastHeight === 0 || heightDiff >= threshold) {
116
+ lastHeight = clampedHeight;
117
+ sendToPlugin("resize", { width, height: clampedHeight });
118
+ }
119
+ };
120
+
121
+ const debouncedResize = () => {
122
+ if (timeoutId) clearTimeout(timeoutId);
123
+ timeoutId = setTimeout(doResize, debounce);
124
+ };
125
+
126
+ // Initial resize
127
+ doResize();
128
+
129
+ // Watch for changes
130
+ const observer = new ResizeObserver(debouncedResize);
131
+ observer.observe(container);
132
+
133
+ // Also watch for DOM mutations (new elements added/removed)
134
+ const mutationObserver = new MutationObserver(debouncedResize);
135
+ mutationObserver.observe(container, {
136
+ childList: true,
137
+ subtree: true,
138
+ attributes: true,
139
+ });
140
+
141
+ // Return cleanup function
142
+ return () => {
143
+ if (timeoutId) clearTimeout(timeoutId);
144
+ observer.disconnect();
145
+ mutationObserver.disconnect();
146
+ };
147
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Validation and sanitization utilities
3
+ *
4
+ * Provides input validation and sanitization for user-provided data
5
+ */
6
+
7
+ /**
8
+ * Validate a URL string
9
+ * @param {string} url - The URL to validate
10
+ * @param {Object} [options] - Validation options
11
+ * @param {boolean} [options.required=true] - Whether URL is required
12
+ * @returns {{ valid: boolean, error?: string }}
13
+ */
14
+ export function validateUrl(url, options = { required: true }) {
15
+ if (!url || typeof url !== "string" || !url.trim()) {
16
+ if (options.required) {
17
+ return { valid: false, error: "URL is required" };
18
+ }
19
+ return { valid: true };
20
+ }
21
+
22
+ const trimmed = url.trim();
23
+
24
+ try {
25
+ const urlObj = new URL(trimmed);
26
+
27
+ // Only allow http and https protocols
28
+ if (!["http:", "https:"].includes(urlObj.protocol)) {
29
+ return {
30
+ valid: false,
31
+ error: "URL must use http or https protocol",
32
+ };
33
+ }
34
+
35
+ // Validate hostname
36
+ if (!urlObj.hostname || urlObj.hostname.length === 0) {
37
+ return {
38
+ valid: false,
39
+ error: "Invalid URL: missing hostname",
40
+ };
41
+ }
42
+
43
+ // Check for suspicious patterns
44
+ const suspiciousPatterns = [
45
+ /javascript:/i,
46
+ /data:/i,
47
+ /vbscript:/i,
48
+ /file:/i,
49
+ ];
50
+
51
+ for (const pattern of suspiciousPatterns) {
52
+ if (pattern.test(trimmed)) {
53
+ return {
54
+ valid: false,
55
+ error: "URL contains invalid protocol",
56
+ };
57
+ }
58
+ }
59
+
60
+ return { valid: true };
61
+ } catch (err) {
62
+ return {
63
+ valid: false,
64
+ error: "Invalid URL format",
65
+ };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Validate a JSON string
71
+ * @param {string} jsonString - The JSON string to validate
72
+ * @param {Object} [options] - Validation options
73
+ * @param {number} [options.maxSizeKB] - Maximum size in KB
74
+ * @param {boolean} [options.requireObject] - Whether root must be an object
75
+ * @returns {{ valid: boolean, error?: string, parsed?: unknown }}
76
+ */
77
+ export function validateJsonString(jsonString, options = {}) {
78
+ if (!jsonString || typeof jsonString !== "string") {
79
+ return {
80
+ valid: false,
81
+ error: "JSON string is required",
82
+ };
83
+ }
84
+
85
+ const trimmed = jsonString.trim();
86
+ if (!trimmed) {
87
+ return {
88
+ valid: false,
89
+ error: "JSON string is empty",
90
+ };
91
+ }
92
+
93
+ // Check size if specified
94
+ if (options.maxSizeKB) {
95
+ const sizeKB = Math.round(trimmed.length / 1024);
96
+ if (sizeKB > options.maxSizeKB) {
97
+ return {
98
+ valid: false,
99
+ error: `JSON is too large (${sizeKB} KB). Maximum size: ${options.maxSizeKB} KB`,
100
+ };
101
+ }
102
+ }
103
+
104
+ // Try to parse JSON
105
+ try {
106
+ const parsed = JSON.parse(trimmed);
107
+
108
+ // If requireObject is true, ensure it's an object
109
+ if (
110
+ options.requireObject &&
111
+ (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
112
+ ) {
113
+ return {
114
+ valid: false,
115
+ error: "JSON must be an object",
116
+ };
117
+ }
118
+
119
+ return {
120
+ valid: true,
121
+ parsed,
122
+ };
123
+ } catch (err) {
124
+ const message = err instanceof Error ? err.message : "Invalid JSON format";
125
+ return {
126
+ valid: false,
127
+ error: message,
128
+ };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Sanitize user input string
134
+ * @param {unknown} input - The input to sanitize
135
+ * @param {number} [maxLength] - Maximum length
136
+ * @returns {string}
137
+ */
138
+ export function sanitizeInput(input, maxLength) {
139
+ if (input === null || input === undefined) {
140
+ return "";
141
+ }
142
+
143
+ let str = String(input);
144
+
145
+ // Apply length limit if provided
146
+ if (maxLength && str.length > maxLength) {
147
+ str = str.slice(0, maxLength);
148
+ }
149
+
150
+ // Remove null bytes and control characters (except newlines and tabs)
151
+ str = str.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, "");
152
+
153
+ // Trim whitespace
154
+ str = str.trim();
155
+
156
+ return str;
157
+ }
158
+
159
+ /**
160
+ * Sanitize a name/title for storage
161
+ * @param {unknown} name - The name to sanitize
162
+ * @param {number} [maxLength=200] - Maximum length
163
+ * @returns {string}
164
+ */
165
+ export function sanitizeName(name, maxLength = 200) {
166
+ const sanitized = sanitizeInput(name, maxLength);
167
+
168
+ // Remove problematic characters but keep basic punctuation
169
+ // Allow: letters, numbers, spaces, hyphens, underscores, dots
170
+ return sanitized.replace(/[^a-zA-Z0-9\s\-_.]/g, "") || "Untitled";
171
+ }
172
+
173
+ /**
174
+ * Validate an email address
175
+ * @param {string} email - The email to validate
176
+ * @returns {{ valid: boolean, error?: string }}
177
+ */
178
+ export function validateEmail(email) {
179
+ if (!email || typeof email !== "string" || !email.trim()) {
180
+ return { valid: false, error: "Email is required" };
181
+ }
182
+
183
+ // Basic email regex
184
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
185
+ if (!emailRegex.test(email.trim())) {
186
+ return { valid: false, error: "Invalid email format" };
187
+ }
188
+
189
+ return { valid: true };
190
+ }
191
+
192
+ /**
193
+ * Validate a number within range
194
+ * @param {unknown} value - The value to validate
195
+ * @param {Object} [options] - Validation options
196
+ * @param {number} [options.min] - Minimum value
197
+ * @param {number} [options.max] - Maximum value
198
+ * @param {boolean} [options.integer] - Must be an integer
199
+ * @returns {{ valid: boolean, error?: string, value?: number }}
200
+ */
201
+ export function validateNumber(value, options = {}) {
202
+ const num = Number(value);
203
+
204
+ if (isNaN(num)) {
205
+ return { valid: false, error: "Must be a number" };
206
+ }
207
+
208
+ if (options.integer && !Number.isInteger(num)) {
209
+ return { valid: false, error: "Must be an integer" };
210
+ }
211
+
212
+ if (options.min !== undefined && num < options.min) {
213
+ return { valid: false, error: `Must be at least ${options.min}` };
214
+ }
215
+
216
+ if (options.max !== undefined && num > options.max) {
217
+ return { valid: false, error: `Must be at most ${options.max}` };
218
+ }
219
+
220
+ return { valid: true, value: num };
221
+ }
222
+
223
+ /**
224
+ * Check if a value is empty (null, undefined, empty string, empty array)
225
+ * @param {unknown} value - The value to check
226
+ * @returns {boolean}
227
+ */
228
+ export function isEmpty(value) {
229
+ if (value === null || value === undefined) return true;
230
+ if (typeof value === "string" && value.trim() === "") return true;
231
+ if (Array.isArray(value) && value.length === 0) return true;
232
+ if (typeof value === "object" && Object.keys(value).length === 0) return true;
233
+ return false;
234
+ }