@todoforai/figma-api 1.0.5 → 1.0.7
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/AGENTS.md +54 -25
- package/README.md +40 -20
- package/package.json +4 -2
- package/plugin/code.js +276 -23
- package/plugin/manifest.json +1 -1
- package/plugin/ui.html +49 -37
- package/src/bridge.ts +167 -101
- package/src/index.ts +224 -40
package/AGENTS.md
CHANGED
|
@@ -6,9 +6,9 @@ Read this before using `figma-api`. It tells you exactly what to set up.
|
|
|
6
6
|
|
|
7
7
|
- Need to **read** designs, or **write** comments / dev-resources / webhooks?
|
|
8
8
|
→ REST is enough. Just set a token (step 1). Works headless / in CI.
|
|
9
|
-
- Need to **create canvas nodes** (frames, text, shapes)
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
- Need to **create canvas nodes** (frames, text, shapes)? → REST can't. Use the
|
|
10
|
+
**plugin bridge** (step 2). Requires a plugin connected in the Figma **desktop**
|
|
11
|
+
app (browser can't reach the localhost relay — see gotchas).
|
|
12
12
|
|
|
13
13
|
## 1. Auth (always required)
|
|
14
14
|
|
|
@@ -28,54 +28,80 @@ FIGMA_TOKEN=<token> figma-api me
|
|
|
28
28
|
- File commands accept a raw file key **or** a Figma URL; `node-id` is parsed from
|
|
29
29
|
the URL automatically.
|
|
30
30
|
|
|
31
|
-
## 2. Plugin bridge (only for canvas writes
|
|
31
|
+
## 2. Plugin bridge (only for canvas writes)
|
|
32
32
|
|
|
33
33
|
The Figma REST API has **no endpoint** to create canvas nodes. The Figma Plugin
|
|
34
|
-
API does. The bridge
|
|
34
|
+
API does. The bridge is a **WebSocket relay** that drives a companion plugin. It
|
|
35
|
+
speaks the **cursor-talk-to-figma** protocol, so it's interchangeable with that
|
|
36
|
+
ecosystem (their MCP server can drive our plugin and vice versa).
|
|
35
37
|
|
|
36
38
|
```
|
|
37
|
-
figma-api
|
|
39
|
+
figma-api create-frame … ──ws──▶ relay ◀──ws── Figma plugin → runs on canvas
|
|
40
|
+
(join channel, broadcast)
|
|
38
41
|
```
|
|
39
42
|
|
|
40
43
|
Setup (do all three, in order):
|
|
41
44
|
|
|
42
45
|
1. **Start the relay** (keep it running, background it):
|
|
43
46
|
```bash
|
|
44
|
-
figma-api bridge #
|
|
47
|
+
figma-api bridge # WebSocket relay on ws://localhost:3055
|
|
45
48
|
```
|
|
46
|
-
2. **
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
2. **Connect a plugin** — a human step, you cannot automate it. Pick ONE:
|
|
50
|
+
- **Easy (recommended):** install **"Cursor Talk To Figma MCP"** from the
|
|
51
|
+
Figma Community (figma.com/community/plugin/1485687494525374295), run it in
|
|
52
|
+
the **desktop** app, set port `3055`, click **Connect**. It auto-picks a
|
|
53
|
+
random channel — that's fine: the relay bridges channels, so you do **not**
|
|
54
|
+
copy it or pass `--channel`.
|
|
55
|
+
- **Advanced:** in the Figma **desktop** app, Plugins → Development → Import
|
|
56
|
+
plugin from manifest → this package's `plugin/manifest.json`. Run it, set URL
|
|
57
|
+
`ws://localhost:3055`, channel `figma-api`, click **Connect**.
|
|
58
|
+
3. **Drive it** with named commands:
|
|
51
59
|
```bash
|
|
52
60
|
figma-api ping # round-trip check; prints page + selection
|
|
53
|
-
figma-api
|
|
54
|
-
figma-api
|
|
61
|
+
figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card
|
|
62
|
+
figma-api create-text "Hi" --size 24 --parent 10:5
|
|
63
|
+
figma-api set-fill-color 10:5 "#FF0044"
|
|
64
|
+
figma-api get-selection
|
|
55
65
|
```
|
|
56
66
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
Every canvas command takes `--relay <ws-url>` / `--channel <name>` (defaults
|
|
68
|
+
`ws://localhost:3055` / `figma-api`; or `FIGMA_RELAY` / `FIGMA_CHANNEL` env).
|
|
69
|
+
|
|
70
|
+
**Escape hatch:** for anything the named commands don't cover, `run` executes
|
|
71
|
+
arbitrary Plugin API code (`figma` in scope, `await` + `return` supported;
|
|
72
|
+
returned nodes come back as `{id,type,name}`):
|
|
73
|
+
```bash
|
|
74
|
+
figma-api run 'return figma.currentPage.selection.map(n => n.name)'
|
|
75
|
+
figma-api run @script.js
|
|
76
|
+
```
|
|
77
|
+
Prefer the named `create-*`/`set-*`/`get-*` commands; reach for `run` only for
|
|
78
|
+
gaps they don't fill.
|
|
60
79
|
|
|
61
80
|
Cross-machine (CLI and Figma on different hosts): expose the relay publicly with
|
|
62
|
-
`cloudflared tunnel --url http://localhost:
|
|
81
|
+
`cloudflared tunnel --url http://localhost:3055` and paste that `wss://` URL into
|
|
63
82
|
the plugin instead of localhost.
|
|
64
83
|
|
|
65
84
|
## Gotchas an agent will hit
|
|
66
85
|
|
|
67
86
|
- **REST `variables-modify` → 403** on non-Enterprise tokens. Don't retry; switch
|
|
68
|
-
to `figma-api run` with `figma.variables
|
|
87
|
+
to `figma-api run` with `figma.variables.*` (off-Enterprise, via the bridge).
|
|
69
88
|
- **"create a frame/text/shape via REST"** is impossible — there is no endpoint.
|
|
70
|
-
Use the bridge.
|
|
71
|
-
- **
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
Use the bridge (`create-frame`/`create-text`/…).
|
|
90
|
+
- **Use the DESKTOP app for canvas writes.** Both plugins can be *opened* in
|
|
91
|
+
browser Figma, but a browser tab can't connect to `ws://localhost:3055`
|
|
92
|
+
(mixed-content: an https page blocks insecure `ws`), so it never reaches
|
|
93
|
+
"Connected" — this is the usual "plugin won't connect on Linux/browser" cause.
|
|
94
|
+
Desktop has no such block. Dev-manifest import is desktop-only regardless. If
|
|
95
|
+
desktop is truly impossible, expose the relay over TLS (`cloudflared tunnel
|
|
96
|
+
--url http://localhost:3055`) and paste the `wss://` URL into the plugin.
|
|
97
|
+
- **Channel names don't need to match.** The relay bridges across channels, so the
|
|
98
|
+
community plugin's random channel and the CLI's `figma-api` default interoperate
|
|
99
|
+
with no `--channel` flag.
|
|
74
100
|
- **`figma-api run` / the relay have no auth** and execute arbitrary code in the
|
|
75
101
|
user's document. Only run on a trusted machine; don't expose the tunnel URL
|
|
76
102
|
publicly; don't run untrusted code.
|
|
77
103
|
- The bridge handles **one command at a time, one connected plugin**. Don't fan
|
|
78
|
-
out parallel
|
|
104
|
+
out parallel canvas calls.
|
|
79
105
|
- The CLI exits non-zero on plugin error/timeout — check exit codes.
|
|
80
106
|
|
|
81
107
|
## Quick reference
|
|
@@ -83,4 +109,7 @@ the plugin instead of localhost.
|
|
|
83
109
|
`figma-api --help` lists every command; `figma-api <cmd> --help` has params,
|
|
84
110
|
scopes and examples. Read = me/file/nodes/images/comments/components/styles/
|
|
85
111
|
variables-*/dev-resources/webhooks/… Write (REST) = comment-add/reaction-add/
|
|
86
|
-
variables-modify/dev-resource-*/webhook-*. Canvas
|
|
112
|
+
variables-modify/dev-resource-*/webhook-*. Canvas (bridge) = create-frame/
|
|
113
|
+
create-rectangle/create-text/set-fill-color/set-text/move-node/resize-node/
|
|
114
|
+
clone-node/delete-node/get-selection/get-node-info/export-image/focus/select/…
|
|
115
|
+
+ `run` escape hatch.
|
package/README.md
CHANGED
|
@@ -53,40 +53,61 @@ Run `figma-api <command> --help` for parameters, scopes and examples.
|
|
|
53
53
|
|
|
54
54
|
## Plugin bridge — canvas writes
|
|
55
55
|
|
|
56
|
-
The REST API **cannot create canvas nodes** (frames/text/shapes)
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
The REST API **cannot create canvas nodes** (frames/text/shapes). The Figma
|
|
57
|
+
**Plugin API** can. The bridge lets the CLI drive a small companion plugin over a
|
|
58
|
+
WebSocket relay. It speaks the **[cursor-talk-to-figma](https://github.com/grab/cursor-talk-to-figma-mcp)
|
|
59
|
+
protocol**, so the pieces are interchangeable: that ecosystem's MCP server can
|
|
60
|
+
drive our plugin, and our CLI can drive theirs.
|
|
59
61
|
|
|
60
62
|
```
|
|
61
|
-
figma-api
|
|
62
|
-
|
|
63
|
+
figma-api create-frame … ──ws──▶ relay (figma-api bridge) ◀──ws── Figma plugin
|
|
64
|
+
(join channel, broadcast) ──result─▶ (runs on canvas)
|
|
63
65
|
```
|
|
64
66
|
|
|
65
67
|
Setup:
|
|
66
68
|
|
|
67
|
-
1. `figma-api bridge` — start the relay (keep it running).
|
|
68
|
-
2.
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
1. `figma-api bridge` — start the WebSocket relay (keep it running).
|
|
70
|
+
2. Connect a plugin in the Figma **desktop** app — pick one:
|
|
71
|
+
- **Easy (recommended):** install [**Cursor Talk To Figma MCP**](https://www.figma.com/community/plugin/1485687494525374295)
|
|
72
|
+
from the Figma Community, run it, set port `3055`, click **Connect**. It picks
|
|
73
|
+
a random channel — that's fine, the relay bridges channels so you don't copy
|
|
74
|
+
it or pass `--channel`.
|
|
75
|
+
- **Advanced:** Plugins → Development → **Import plugin from manifest** →
|
|
76
|
+
`plugin/manifest.json`. Run it, set URL `ws://localhost:3055`, channel
|
|
77
|
+
`figma-api`, click **Connect**.
|
|
78
|
+
3. Drive it from the CLI with named commands:
|
|
71
79
|
|
|
72
80
|
```bash
|
|
73
81
|
figma-api ping
|
|
74
|
-
figma-api
|
|
75
|
-
figma-api
|
|
82
|
+
figma-api create-frame --width 320 --height 200 --fill "#1F6FEB" --name Card
|
|
83
|
+
figma-api create-text "Hello" --size 24 --weight 700 --color "#111" --parent 10:5
|
|
84
|
+
figma-api set-fill-color 10:5 "#FF0044"
|
|
85
|
+
figma-api get-selection
|
|
76
86
|
```
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
`
|
|
88
|
+
All commands take `--relay <ws-url>` and `--channel <name>` (defaults
|
|
89
|
+
`ws://localhost:3055` / `figma-api`; override via `FIGMA_RELAY` / `FIGMA_CHANNEL`).
|
|
90
|
+
|
|
91
|
+
Escape hatch — for anything the named commands don't cover, `run` executes
|
|
92
|
+
arbitrary Plugin API code (`figma` in scope, `await`/`return` supported):
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
figma-api run 'return figma.currentPage.selection.map(n => n.name)'
|
|
96
|
+
figma-api run @make-card.js
|
|
97
|
+
```
|
|
80
98
|
|
|
81
|
-
Cross-machine: `cloudflared tunnel --url http://localhost:
|
|
82
|
-
|
|
99
|
+
Cross-machine: `cloudflared tunnel --url http://localhost:3055` and paste the
|
|
100
|
+
`wss://` URL into the plugin.
|
|
83
101
|
|
|
84
102
|
> ⚠️ **Security:** the relay has no auth and `run` executes arbitrary code in your
|
|
85
103
|
> Figma document. Only use it on a trusted machine/network; don't expose the tunnel
|
|
86
104
|
> URL publicly or run code you don't trust.
|
|
87
105
|
|
|
88
|
-
> Note:
|
|
89
|
-
>
|
|
106
|
+
> Note: use the **Figma desktop app**. A browser tab can't reach `ws://localhost:3055`
|
|
107
|
+
> (an https page blocks insecure `ws` as mixed content), so the plugin never reaches
|
|
108
|
+
> "Connected" — the usual "won't connect in the browser" cause. Dev-manifest import
|
|
109
|
+
> is desktop-only regardless. If desktop is impossible, expose the relay over TLS
|
|
110
|
+
> (`cloudflared tunnel --url http://localhost:3055`) and paste the `wss://` URL in.
|
|
90
111
|
|
|
91
112
|
## Why both REST and a plugin?
|
|
92
113
|
|
|
@@ -95,7 +116,6 @@ https URL into the plugin.
|
|
|
95
116
|
| Files / nodes / images (read) | ✅ | ✅ |
|
|
96
117
|
| Comments, dev resources, webhooks | ✅ | — |
|
|
97
118
|
| Create frames / text / shapes | ❌ (no endpoint) | ✅ |
|
|
98
|
-
| Edit variables off-Enterprise | ❌ (403) | ✅ |
|
|
99
119
|
| Works headless / CI | ✅ | needs Figma open |
|
|
100
120
|
|
|
101
121
|
## Reference
|
|
@@ -103,8 +123,8 @@ https URL into the plugin.
|
|
|
103
123
|
- [figma/rest-api-spec](https://github.com/figma/rest-api-spec) — authoritative endpoint list.
|
|
104
124
|
- [GLips/Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP) — read design-context MCP.
|
|
105
125
|
- [Official Figma MCP](https://help.figma.com/hc/en-us/articles/32132100833559) — `mcp.figma.com`.
|
|
106
|
-
-
|
|
107
|
-
|
|
126
|
+
- [grab/cursor-talk-to-figma-mcp](https://github.com/grab/cursor-talk-to-figma-mcp) —
|
|
127
|
+
the WebSocket bridge protocol this plugin implements (interchangeable clients).
|
|
108
128
|
|
|
109
129
|
## Dev
|
|
110
130
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@todoforai/figma-api",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Figma CLI — full REST API wrapper (files, comments, variables, webhooks…) plus a plugin bridge for canvas writes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
"rest",
|
|
21
21
|
"design",
|
|
22
22
|
"plugin",
|
|
23
|
-
"mcp"
|
|
23
|
+
"mcp",
|
|
24
|
+
"cursor-talk-to-figma",
|
|
25
|
+
"websocket"
|
|
24
26
|
],
|
|
25
27
|
"repository": {
|
|
26
28
|
"type": "git",
|
package/plugin/code.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
// figma-api bridge plugin
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// figma-api bridge plugin — talks the cursor-talk-to-figma WebSocket protocol so
|
|
2
|
+
// it's a drop-in for that ecosystem (its MCP server can drive this plugin), while
|
|
3
|
+
// staying small. The UI (ui.html) joins a WS channel on the relay, forwards each
|
|
4
|
+
// {command,params} here, this runs it and returns the result.
|
|
5
|
+
//
|
|
6
|
+
// Two ways to drive it:
|
|
7
|
+
// • named commands (create_frame, set_fill_color, …) — cursor-talk-to-figma compatible
|
|
8
|
+
// • "eval" — run arbitrary Plugin API code (our extra; droppable, but handy)
|
|
5
9
|
|
|
6
|
-
//
|
|
7
|
-
// plain objects/arrays
|
|
10
|
+
// ── serialization ────────────────────────────────────────────────────────────
|
|
11
|
+
// Figma nodes → {id,type,name}; recurse plain objects/arrays; guard cycles/bigints.
|
|
8
12
|
function serialize(v, seen) {
|
|
9
13
|
if (v == null || typeof v === "boolean" || typeof v === "number" || typeof v === "string") return v;
|
|
10
14
|
if (typeof v === "bigint") return v.toString();
|
|
@@ -19,29 +23,278 @@ function serialize(v, seen) {
|
|
|
19
23
|
return o;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
// ── small helpers ────────────────────────────────────────────────────────────
|
|
27
|
+
async function byId(id) {
|
|
28
|
+
if (!id) throw new Error("Missing nodeId parameter");
|
|
29
|
+
const n = await figma.getNodeByIdAsync(id);
|
|
30
|
+
if (!n) throw new Error(`Node not found with ID: ${id}`);
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
async function appendTo(node, parentId) {
|
|
34
|
+
if (parentId) {
|
|
35
|
+
const p = await byId(parentId);
|
|
36
|
+
if (!("appendChild" in p)) throw new Error(`Parent node does not support children: ${parentId}`);
|
|
37
|
+
p.appendChild(node);
|
|
38
|
+
} else figma.currentPage.appendChild(node);
|
|
39
|
+
}
|
|
40
|
+
const solid = (c) => ({ type: "SOLID", color: { r: +c.r || 0, g: +c.g || 0, b: +c.b || 0 }, opacity: c.a === undefined ? 1 : +c.a });
|
|
41
|
+
const box = (n) => ({ id: n.id, name: n.name, x: n.x, y: n.y, width: n.width, height: n.height, parentId: n.parent ? n.parent.id : undefined });
|
|
42
|
+
const fontStyle = (w) => ({ 100: "Thin", 200: "Extra Light", 300: "Light", 400: "Regular", 500: "Medium", 600: "Semi Bold", 700: "Bold", 800: "Extra Bold", 900: "Black" }[w] || "Regular");
|
|
43
|
+
|
|
44
|
+
function b64(bytes) {
|
|
45
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
46
|
+
let out = "";
|
|
47
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
48
|
+
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
|
|
49
|
+
const ng = i + 1 < bytes.length, nh = i + 2 < bytes.length;
|
|
50
|
+
out += chars[a >> 2] + chars[((a & 3) << 4) | (ng ? b >> 4 : 0)] +
|
|
51
|
+
(ng ? chars[((b & 15) << 2) | (nh ? c >> 6 : 0)] : "=") + (nh ? chars[c & 63] : "=");
|
|
25
52
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── command handlers (cursor-talk-to-figma compatible) ───────────────────────
|
|
57
|
+
const handlers = {
|
|
58
|
+
async ping() {
|
|
59
|
+
return { pong: true, page: figma.currentPage.name, selection: figma.currentPage.selection.map((n) => n.id) };
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// arbitrary Plugin API code — `figma` in scope, may await and return a value.
|
|
63
|
+
// WARNING: executes whatever the relay delivers. Run only on trusted machines.
|
|
64
|
+
async eval(p) {
|
|
29
65
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
66
|
+
return { value: serialize(await new AsyncFunction("figma", p.code || "")(figma)) };
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async get_document_info() {
|
|
70
|
+
await figma.currentPage.loadAsync();
|
|
71
|
+
const pg = figma.currentPage;
|
|
72
|
+
return { id: pg.id, name: pg.name, type: pg.type, childCount: pg.children.length,
|
|
73
|
+
children: pg.children.map((n) => ({ id: n.id, name: n.name, type: n.type })) };
|
|
74
|
+
},
|
|
75
|
+
async get_selection() {
|
|
76
|
+
return { selectionCount: figma.currentPage.selection.length,
|
|
77
|
+
selection: figma.currentPage.selection.map((n) => ({ id: n.id, name: n.name, type: n.type, visible: n.visible })) };
|
|
78
|
+
},
|
|
79
|
+
async get_node_info(p) {
|
|
80
|
+
const n = await byId(p.nodeId);
|
|
81
|
+
return serialize((await n.exportAsync({ format: "JSON_REST_V1" })).document);
|
|
82
|
+
},
|
|
83
|
+
async get_nodes_info(p) {
|
|
84
|
+
const nodes = await Promise.all((p.nodeIds || []).map((id) => byId(id)));
|
|
85
|
+
return Promise.all(nodes.map(async (n) => ({ nodeId: n.id, document: serialize((await n.exportAsync({ format: "JSON_REST_V1" })).document) })));
|
|
86
|
+
},
|
|
87
|
+
read_my_design() { return handlers.get_selection(); },
|
|
88
|
+
|
|
89
|
+
async create_rectangle(p = {}) {
|
|
90
|
+
const r = figma.createRectangle();
|
|
91
|
+
r.x = p.x ?? 0; r.y = p.y ?? 0; r.resize(p.width ?? 100, p.height ?? 100); r.name = p.name || "Rectangle";
|
|
92
|
+
await appendTo(r, p.parentId);
|
|
93
|
+
return box(r);
|
|
94
|
+
},
|
|
95
|
+
async create_frame(p = {}) {
|
|
96
|
+
const f = figma.createFrame();
|
|
97
|
+
f.x = p.x ?? 0; f.y = p.y ?? 0; f.resize(p.width ?? 100, p.height ?? 100); f.name = p.name || "Frame";
|
|
98
|
+
if (p.layoutMode && p.layoutMode !== "NONE") {
|
|
99
|
+
f.layoutMode = p.layoutMode; f.layoutWrap = p.layoutWrap || "NO_WRAP";
|
|
100
|
+
f.paddingTop = p.paddingTop ?? 10; f.paddingRight = p.paddingRight ?? 10;
|
|
101
|
+
f.paddingBottom = p.paddingBottom ?? 10; f.paddingLeft = p.paddingLeft ?? 10;
|
|
102
|
+
f.primaryAxisAlignItems = p.primaryAxisAlignItems || "MIN";
|
|
103
|
+
f.counterAxisAlignItems = p.counterAxisAlignItems || "MIN";
|
|
104
|
+
f.layoutSizingHorizontal = p.layoutSizingHorizontal || "FIXED";
|
|
105
|
+
f.layoutSizingVertical = p.layoutSizingVertical || "FIXED";
|
|
106
|
+
f.itemSpacing = p.itemSpacing ?? 0;
|
|
107
|
+
}
|
|
108
|
+
if (p.fillColor) f.fills = [solid(p.fillColor)];
|
|
109
|
+
if (p.strokeColor) f.strokes = [solid(p.strokeColor)];
|
|
110
|
+
if (p.strokeWeight !== undefined) f.strokeWeight = p.strokeWeight;
|
|
111
|
+
await appendTo(f, p.parentId);
|
|
112
|
+
return Object.assign(box(f), { fills: f.fills, strokes: f.strokes, layoutMode: f.layoutMode });
|
|
113
|
+
},
|
|
114
|
+
async create_text(p = {}) {
|
|
115
|
+
const t = figma.createText();
|
|
116
|
+
t.x = p.x ?? 0; t.y = p.y ?? 0;
|
|
117
|
+
const text = p.text ?? "Text"; t.name = p.name || text;
|
|
118
|
+
const style = fontStyle(p.fontWeight ?? 400);
|
|
119
|
+
await figma.loadFontAsync({ family: "Inter", style });
|
|
120
|
+
t.fontName = { family: "Inter", style };
|
|
121
|
+
t.fontSize = parseInt(p.fontSize ?? 14);
|
|
122
|
+
t.characters = text;
|
|
123
|
+
t.fills = [solid(p.fontColor || { r: 0, g: 0, b: 0, a: 1 })];
|
|
124
|
+
await appendTo(t, p.parentId);
|
|
125
|
+
return Object.assign(box(t), { characters: t.characters, fontSize: t.fontSize, fontName: t.fontName });
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async set_fill_color(p = {}) {
|
|
129
|
+
const n = await byId(p.nodeId);
|
|
130
|
+
if (!("fills" in n)) throw new Error(`Node does not support fills: ${p.nodeId}`);
|
|
131
|
+
n.fills = [solid(p.color || {})];
|
|
132
|
+
return { id: n.id, name: n.name, fills: n.fills };
|
|
133
|
+
},
|
|
134
|
+
async set_stroke_color(p = {}) {
|
|
135
|
+
const n = await byId(p.nodeId);
|
|
136
|
+
if (!("strokes" in n)) throw new Error(`Node does not support strokes: ${p.nodeId}`);
|
|
137
|
+
n.strokes = [solid(p.color || {})];
|
|
138
|
+
if ("strokeWeight" in n) n.strokeWeight = p.weight ?? 1;
|
|
139
|
+
return { id: n.id, name: n.name, strokes: n.strokes, strokeWeight: "strokeWeight" in n ? n.strokeWeight : undefined };
|
|
140
|
+
},
|
|
141
|
+
async set_corner_radius(p = {}) {
|
|
142
|
+
const n = await byId(p.nodeId);
|
|
143
|
+
if (p.radius === undefined) throw new Error("Missing radius parameter");
|
|
144
|
+
if (!("cornerRadius" in n)) throw new Error(`Node does not support corner radius: ${p.nodeId}`);
|
|
145
|
+
const c = p.corners;
|
|
146
|
+
if (Array.isArray(c) && c.length === 4 && "topLeftRadius" in n) {
|
|
147
|
+
if (c[0]) n.topLeftRadius = p.radius; if (c[1]) n.topRightRadius = p.radius;
|
|
148
|
+
if (c[2]) n.bottomRightRadius = p.radius; if (c[3]) n.bottomLeftRadius = p.radius;
|
|
149
|
+
} else n.cornerRadius = p.radius;
|
|
150
|
+
return { id: n.id, name: n.name, cornerRadius: n.cornerRadius };
|
|
151
|
+
},
|
|
152
|
+
async set_text_content(p = {}) {
|
|
153
|
+
const n = await byId(p.nodeId);
|
|
154
|
+
if (p.text === undefined) throw new Error("Missing text parameter");
|
|
155
|
+
if (n.type !== "TEXT") throw new Error(`Node is not a text node: ${p.nodeId}`);
|
|
156
|
+
await figma.loadFontAsync(n.fontName);
|
|
157
|
+
n.characters = p.text;
|
|
158
|
+
return { id: n.id, name: n.name, characters: n.characters, fontName: n.fontName };
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async move_node(p = {}) {
|
|
162
|
+
const n = await byId(p.nodeId);
|
|
163
|
+
if (p.x === undefined || p.y === undefined) throw new Error("Missing x or y parameters");
|
|
164
|
+
if (!("x" in n)) throw new Error(`Node does not support position: ${p.nodeId}`);
|
|
165
|
+
n.x = p.x; n.y = p.y;
|
|
166
|
+
return { id: n.id, name: n.name, x: n.x, y: n.y };
|
|
167
|
+
},
|
|
168
|
+
async resize_node(p = {}) {
|
|
169
|
+
const n = await byId(p.nodeId);
|
|
170
|
+
if (p.width === undefined || p.height === undefined) throw new Error("Missing width or height parameters");
|
|
171
|
+
if (!("resize" in n)) throw new Error(`Node does not support resizing: ${p.nodeId}`);
|
|
172
|
+
n.resize(p.width, p.height);
|
|
173
|
+
return { id: n.id, name: n.name, width: n.width, height: n.height };
|
|
174
|
+
},
|
|
175
|
+
async clone_node(p = {}) {
|
|
176
|
+
const n = await byId(p.nodeId);
|
|
177
|
+
const c = n.clone();
|
|
178
|
+
if (p.x !== undefined) c.x = p.x; if (p.y !== undefined) c.y = p.y;
|
|
179
|
+
await appendTo(c, p.parentId);
|
|
180
|
+
return box(c);
|
|
181
|
+
},
|
|
182
|
+
async delete_node(p = {}) {
|
|
183
|
+
const n = await byId(p.nodeId);
|
|
184
|
+
const info = { id: n.id, name: n.name, type: n.type };
|
|
185
|
+
n.remove();
|
|
186
|
+
return info;
|
|
187
|
+
},
|
|
188
|
+
async delete_multiple_nodes(p = {}) {
|
|
189
|
+
const deleted = [];
|
|
190
|
+
for (const id of p.nodeIds || []) {
|
|
191
|
+
try { const n = await byId(id); deleted.push({ id: n.id, name: n.name, type: n.type }); n.remove(); }
|
|
192
|
+
catch (e) { deleted.push({ id, error: String(e.message || e) }); }
|
|
193
|
+
}
|
|
194
|
+
return { deleted };
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async set_focus(p = {}) {
|
|
198
|
+
const n = await byId(p.nodeId);
|
|
199
|
+
figma.currentPage.selection = [n];
|
|
200
|
+
figma.viewport.scrollAndZoomIntoView([n]);
|
|
201
|
+
return { id: n.id, name: n.name };
|
|
202
|
+
},
|
|
203
|
+
async set_selections(p = {}) {
|
|
204
|
+
const nodes = await Promise.all((p.nodeIds || []).map((id) => byId(id)));
|
|
205
|
+
figma.currentPage.selection = nodes;
|
|
206
|
+
figma.viewport.scrollAndZoomIntoView(nodes);
|
|
207
|
+
return { selection: nodes.map((n) => ({ id: n.id, name: n.name })) };
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async get_styles() {
|
|
211
|
+
const [colors, texts, effects, grids] = await Promise.all([
|
|
212
|
+
figma.getLocalPaintStylesAsync(), figma.getLocalTextStylesAsync(),
|
|
213
|
+
figma.getLocalEffectStylesAsync(), figma.getLocalGridStylesAsync()]);
|
|
214
|
+
return {
|
|
215
|
+
colors: colors.map((s) => ({ id: s.id, name: s.name, key: s.key, paint: s.paints[0] })),
|
|
216
|
+
texts: texts.map((s) => ({ id: s.id, name: s.name, key: s.key })),
|
|
217
|
+
effects: effects.map((s) => ({ id: s.id, name: s.name, key: s.key })),
|
|
218
|
+
grids: grids.map((s) => ({ id: s.id, name: s.name, key: s.key })),
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
async get_local_components() {
|
|
222
|
+
await figma.loadAllPagesAsync();
|
|
223
|
+
const comps = figma.root.findAllWithCriteria({ types: ["COMPONENT"] });
|
|
224
|
+
return { count: comps.length, components: comps.map((c) => ({ id: c.id, name: c.name, key: c.key })) };
|
|
225
|
+
},
|
|
226
|
+
async create_component_instance(p = {}) {
|
|
227
|
+
if (!p.componentKey) throw new Error("Missing componentKey parameter");
|
|
228
|
+
const comp = await figma.importComponentByKeyAsync(p.componentKey);
|
|
229
|
+
const inst = comp.createInstance();
|
|
230
|
+
inst.x = p.x ?? 0; inst.y = p.y ?? 0;
|
|
231
|
+
await appendTo(inst, p.parentId);
|
|
232
|
+
return box(inst);
|
|
233
|
+
},
|
|
234
|
+
async export_node_as_image(p = {}) {
|
|
235
|
+
const n = await byId(p.nodeId);
|
|
236
|
+
if (!("exportAsync" in n)) throw new Error(`Node does not support exporting: ${p.nodeId}`);
|
|
237
|
+
const scale = p.scale ?? 1;
|
|
238
|
+
const bytes = await n.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: scale } });
|
|
239
|
+
return { nodeId: p.nodeId, format: "PNG", scale, mimeType: "image/png", imageData: b64(bytes) };
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async set_layout_mode(p = {}) {
|
|
243
|
+
const n = await byId(p.nodeId);
|
|
244
|
+
if (!("layoutMode" in n)) throw new Error(`Node does not support auto layout: ${p.nodeId}`);
|
|
245
|
+
n.layoutMode = p.layoutMode || "NONE";
|
|
246
|
+
if (p.layoutWrap !== undefined) n.layoutWrap = p.layoutWrap;
|
|
247
|
+
return { id: n.id, name: n.name, layoutMode: n.layoutMode };
|
|
248
|
+
},
|
|
249
|
+
async set_padding(p = {}) {
|
|
250
|
+
const n = await byId(p.nodeId);
|
|
251
|
+
if (!("paddingTop" in n)) throw new Error(`Node does not support padding: ${p.nodeId}`);
|
|
252
|
+
if (p.paddingTop !== undefined) n.paddingTop = p.paddingTop;
|
|
253
|
+
if (p.paddingRight !== undefined) n.paddingRight = p.paddingRight;
|
|
254
|
+
if (p.paddingBottom !== undefined) n.paddingBottom = p.paddingBottom;
|
|
255
|
+
if (p.paddingLeft !== undefined) n.paddingLeft = p.paddingLeft;
|
|
256
|
+
return { id: n.id, name: n.name };
|
|
257
|
+
},
|
|
258
|
+
async set_item_spacing(p = {}) {
|
|
259
|
+
const n = await byId(p.nodeId);
|
|
260
|
+
if (!("itemSpacing" in n)) throw new Error(`Node does not support item spacing: ${p.nodeId}`);
|
|
261
|
+
n.itemSpacing = p.itemSpacing ?? 0;
|
|
262
|
+
return { id: n.id, name: n.name, itemSpacing: n.itemSpacing };
|
|
263
|
+
},
|
|
264
|
+
async set_axis_align(p = {}) {
|
|
265
|
+
const n = await byId(p.nodeId);
|
|
266
|
+
if (p.primaryAxisAlignItems !== undefined) n.primaryAxisAlignItems = p.primaryAxisAlignItems;
|
|
267
|
+
if (p.counterAxisAlignItems !== undefined) n.counterAxisAlignItems = p.counterAxisAlignItems;
|
|
268
|
+
return { id: n.id, name: n.name };
|
|
269
|
+
},
|
|
270
|
+
async set_layout_sizing(p = {}) {
|
|
271
|
+
const n = await byId(p.nodeId);
|
|
272
|
+
if (p.layoutSizingHorizontal !== undefined) n.layoutSizingHorizontal = p.layoutSizingHorizontal;
|
|
273
|
+
if (p.layoutSizingVertical !== undefined) n.layoutSizingVertical = p.layoutSizingVertical;
|
|
274
|
+
return { id: n.id, name: n.name };
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
async function exec(command, params) {
|
|
279
|
+
const h = handlers[command];
|
|
280
|
+
if (!h) throw new Error(`Unknown command: ${command}`);
|
|
281
|
+
return h(params || {});
|
|
34
282
|
}
|
|
35
283
|
|
|
36
|
-
|
|
284
|
+
// ── plugin ↔ UI wiring ───────────────────────────────────────────────────────
|
|
285
|
+
figma.showUI(__html__, { width: 340, height: 360 });
|
|
37
286
|
|
|
38
287
|
figma.ui.onmessage = async (msg) => {
|
|
39
|
-
if (msg.type === "
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
288
|
+
if (msg.type === "execute-command") {
|
|
289
|
+
try {
|
|
290
|
+
const result = await exec(msg.command, msg.params);
|
|
291
|
+
figma.ui.postMessage({ type: "command-result", id: msg.id, result });
|
|
292
|
+
figma.notify("✅ " + msg.command);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
const error = String((e && e.message) || e);
|
|
295
|
+
figma.ui.postMessage({ type: "command-error", id: msg.id, error });
|
|
296
|
+
figma.notify("⚠️ " + error);
|
|
297
|
+
}
|
|
45
298
|
} else if (msg.type === "close") {
|
|
46
299
|
figma.closePlugin();
|
|
47
300
|
}
|
package/plugin/manifest.json
CHANGED
|
@@ -8,6 +8,6 @@
|
|
|
8
8
|
"documentAccess": "dynamic-page",
|
|
9
9
|
"networkAccess": {
|
|
10
10
|
"allowedDomains": ["*"],
|
|
11
|
-
"reasoning": "
|
|
11
|
+
"reasoning": "Connects to the local figma-api relay over WebSocket to receive drawing commands from the CLI."
|
|
12
12
|
}
|
|
13
13
|
}
|