figma-local 1.0.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,313 @@
1
+ /**
2
+ * FigJam CDP Client
3
+ *
4
+ * Connects directly to FigJam via Chrome DevTools Protocol,
5
+ * bypassing figma-use which has compatibility issues with FigJam.
6
+ */
7
+
8
+ import WebSocket from 'ws';
9
+ import { getCdpPort } from './figma-patch.js';
10
+
11
+ export class FigJamClient {
12
+ constructor() {
13
+ this.ws = null;
14
+ this.contexts = [];
15
+ this.figmaContextId = null;
16
+ this.msgId = 0;
17
+ this.callbacks = new Map();
18
+ this.pageTitle = null;
19
+ }
20
+
21
+ /**
22
+ * List all available FigJam pages
23
+ */
24
+ static async listPages() {
25
+ const port = getCdpPort();
26
+ const response = await fetch(`http://localhost:${port}/json`);
27
+ const pages = await response.json();
28
+ return pages
29
+ .filter(p => p.title.includes('FigJam'))
30
+ .map(p => ({ title: p.title, id: p.id, url: p.url }));
31
+ }
32
+
33
+ /**
34
+ * Connect to a FigJam page by title (partial match)
35
+ */
36
+ async connect(pageTitle) {
37
+ const port = getCdpPort();
38
+ const response = await fetch(`http://localhost:${port}/json`);
39
+ const pages = await response.json();
40
+ const page = pages.find(p => p.title.includes(pageTitle) && p.title.includes('FigJam'));
41
+
42
+ if (!page) {
43
+ const figjamPages = pages.filter(p => p.title.includes('FigJam'));
44
+ if (figjamPages.length > 0) {
45
+ throw new Error(`Page "${pageTitle}" not found. Available FigJam pages: ${figjamPages.map(p => p.title).join(', ')}`);
46
+ }
47
+ throw new Error('No FigJam pages open. Please open a FigJam file in Figma Desktop.');
48
+ }
49
+
50
+ this.pageTitle = page.title;
51
+
52
+ return new Promise((resolve, reject) => {
53
+ this.ws = new WebSocket(page.webSocketDebuggerUrl);
54
+
55
+ this.ws.on('open', async () => {
56
+ await this.send('Runtime.enable');
57
+
58
+ // Wait for contexts to be discovered
59
+ await new Promise(r => setTimeout(r, 1500));
60
+
61
+ // Find figma context
62
+ for (const ctx of this.contexts) {
63
+ try {
64
+ const result = await this.send('Runtime.evaluate', {
65
+ expression: 'typeof figma !== "undefined"',
66
+ contextId: ctx.id,
67
+ returnByValue: true
68
+ });
69
+
70
+ if (result.result?.result?.value === true) {
71
+ this.figmaContextId = ctx.id;
72
+ break;
73
+ }
74
+ } catch (e) {}
75
+ }
76
+
77
+ if (!this.figmaContextId) {
78
+ reject(new Error('Could not find figma context. Try refreshing the FigJam page.'));
79
+ } else {
80
+ resolve(this);
81
+ }
82
+ });
83
+
84
+ this.ws.on('message', (data) => {
85
+ const msg = JSON.parse(data);
86
+
87
+ if (msg.method === 'Runtime.executionContextCreated') {
88
+ this.contexts.push(msg.params.context);
89
+ }
90
+
91
+ if (msg.id && this.callbacks.has(msg.id)) {
92
+ this.callbacks.get(msg.id)(msg);
93
+ this.callbacks.delete(msg.id);
94
+ }
95
+ });
96
+
97
+ this.ws.on('error', reject);
98
+
99
+ setTimeout(() => reject(new Error('Connection timeout')), 10000);
100
+ });
101
+ }
102
+
103
+ send(method, params = {}) {
104
+ return new Promise((resolve) => {
105
+ const id = ++this.msgId;
106
+ this.callbacks.set(id, resolve);
107
+ this.ws.send(JSON.stringify({ id, method, params }));
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Evaluate JavaScript in the FigJam context
113
+ */
114
+ async eval(expression) {
115
+ if (!this.figmaContextId) {
116
+ throw new Error('Not connected to FigJam');
117
+ }
118
+
119
+ const result = await this.send('Runtime.evaluate', {
120
+ expression,
121
+ contextId: this.figmaContextId,
122
+ returnByValue: true,
123
+ awaitPromise: true
124
+ });
125
+
126
+ if (result.result?.exceptionDetails) {
127
+ const error = result.result.exceptionDetails;
128
+ throw new Error(error.exception?.description || error.text || 'Evaluation error');
129
+ }
130
+
131
+ return result.result?.result?.value;
132
+ }
133
+
134
+ /**
135
+ * Get current page info
136
+ */
137
+ async getPageInfo() {
138
+ return await this.eval(`
139
+ (function() {
140
+ return {
141
+ name: figma.currentPage.name,
142
+ id: figma.currentPage.id,
143
+ childCount: figma.currentPage.children.length,
144
+ editorType: figma.editorType
145
+ };
146
+ })()
147
+ `);
148
+ }
149
+
150
+ /**
151
+ * List all nodes on the current page
152
+ */
153
+ async listNodes(limit = 50) {
154
+ return await this.eval(`
155
+ figma.currentPage.children.slice(0, ${limit}).map(function(n) {
156
+ return {
157
+ id: n.id,
158
+ type: n.type,
159
+ name: n.name || '',
160
+ x: Math.round(n.x),
161
+ y: Math.round(n.y)
162
+ };
163
+ })
164
+ `);
165
+ }
166
+
167
+ /**
168
+ * Create a sticky note
169
+ */
170
+ async createSticky(text, x = 0, y = 0, color) {
171
+ return await this.eval(`
172
+ (async function() {
173
+ var sticky = figma.createSticky();
174
+ sticky.x = ${x};
175
+ sticky.y = ${y};
176
+ ${color ? `sticky.fills = [{type: 'SOLID', color: ${JSON.stringify(hexToRgb(color))}}];` : ''}
177
+ // Load font before setting text
178
+ await figma.loadFontAsync({ family: "Inter", style: "Medium" });
179
+ sticky.text.characters = ${JSON.stringify(text)};
180
+ return { id: sticky.id, x: sticky.x, y: sticky.y };
181
+ })()
182
+ `);
183
+ }
184
+
185
+ /**
186
+ * Create a shape with text
187
+ */
188
+ async createShape(text, x = 0, y = 0, width = 200, height = 100, shapeType = 'ROUNDED_RECTANGLE') {
189
+ return await this.eval(`
190
+ (async function() {
191
+ var shape = figma.createShapeWithText();
192
+ shape.shapeType = ${JSON.stringify(shapeType)};
193
+ shape.x = ${x};
194
+ shape.y = ${y};
195
+ shape.resize(${width}, ${height});
196
+ if (shape.text) {
197
+ await figma.loadFontAsync({ family: "Inter", style: "Medium" });
198
+ shape.text.characters = ${JSON.stringify(text)};
199
+ }
200
+ return { id: shape.id, x: shape.x, y: shape.y };
201
+ })()
202
+ `);
203
+ }
204
+
205
+ /**
206
+ * Create a connector between two nodes
207
+ */
208
+ async createConnector(startNodeId, endNodeId) {
209
+ return await this.eval(`
210
+ (function() {
211
+ var startNode = figma.getNodeById(${JSON.stringify(startNodeId)});
212
+ var endNode = figma.getNodeById(${JSON.stringify(endNodeId)});
213
+ if (!startNode || !endNode) return { error: 'Node not found' };
214
+
215
+ var connector = figma.createConnector();
216
+ connector.connectorStart = { endpointNodeId: startNode.id, magnet: 'AUTO' };
217
+ connector.connectorEnd = { endpointNodeId: endNode.id, magnet: 'AUTO' };
218
+ return { id: connector.id };
219
+ })()
220
+ `);
221
+ }
222
+
223
+ /**
224
+ * Create a text node
225
+ */
226
+ async createText(text, x = 0, y = 0, fontSize = 16) {
227
+ return await this.eval(`
228
+ (async function() {
229
+ var textNode = figma.createText();
230
+ textNode.x = ${x};
231
+ textNode.y = ${y};
232
+ await figma.loadFontAsync({ family: "Inter", style: "Medium" });
233
+ textNode.characters = ${JSON.stringify(text)};
234
+ textNode.fontSize = ${fontSize};
235
+ return { id: textNode.id, x: textNode.x, y: textNode.y };
236
+ })()
237
+ `);
238
+ }
239
+
240
+ /**
241
+ * Delete a node by ID
242
+ */
243
+ async deleteNode(nodeId) {
244
+ return await this.eval(`
245
+ (function() {
246
+ var node = figma.getNodeById(${JSON.stringify(nodeId)});
247
+ if (node) {
248
+ node.remove();
249
+ return { deleted: true };
250
+ }
251
+ return { deleted: false, error: 'Node not found' };
252
+ })()
253
+ `);
254
+ }
255
+
256
+ /**
257
+ * Move a node
258
+ */
259
+ async moveNode(nodeId, x, y) {
260
+ return await this.eval(`
261
+ (function() {
262
+ var node = figma.getNodeById(${JSON.stringify(nodeId)});
263
+ if (node) {
264
+ node.x = ${x};
265
+ node.y = ${y};
266
+ return { id: node.id, x: node.x, y: node.y };
267
+ }
268
+ return { error: 'Node not found' };
269
+ })()
270
+ `);
271
+ }
272
+
273
+ /**
274
+ * Update text content of a node
275
+ */
276
+ async updateText(nodeId, text) {
277
+ return await this.eval(`
278
+ (async function() {
279
+ var node = figma.getNodeById(${JSON.stringify(nodeId)});
280
+ if (!node) return { error: 'Node not found' };
281
+
282
+ await figma.loadFontAsync({ family: "Inter", style: "Medium" });
283
+
284
+ if (node.type === 'STICKY' || node.type === 'SHAPE_WITH_TEXT') {
285
+ node.text.characters = ${JSON.stringify(text)};
286
+ } else if (node.type === 'TEXT') {
287
+ node.characters = ${JSON.stringify(text)};
288
+ } else {
289
+ return { error: 'Node does not support text' };
290
+ }
291
+ return { id: node.id, updated: true };
292
+ })()
293
+ `);
294
+ }
295
+
296
+ close() {
297
+ if (this.ws) {
298
+ this.ws.close();
299
+ this.ws = null;
300
+ }
301
+ }
302
+ }
303
+
304
+ function hexToRgb(hex) {
305
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
306
+ return result ? {
307
+ r: parseInt(result[1], 16) / 255,
308
+ g: parseInt(result[2], 16) / 255,
309
+ b: parseInt(result[3], 16) / 255
310
+ } : { r: 1, g: 0.9, b: 0.5 }; // default yellow
311
+ }
312
+
313
+ export default FigJamClient;