batter-pixso-mcp 0.2.0 → 0.3.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.
- package/dist/index.js +158 -105
- package/dist/plugin/code.js +237 -80
- package/dist/plugin/ui.html +4 -4
- package/package.json +34 -37
- package/standalone/index.js +33 -98
- package/standalone/plugin/code.js +237 -80
- package/standalone/plugin/ui.html +4 -4
package/dist/plugin/code.js
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
(() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
5
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
6
|
+
|
|
7
|
+
// src/cache.ts
|
|
8
|
+
var TTL_MS = 15 * 60 * 1e3;
|
|
9
|
+
var MAX_ENTRIES = 100;
|
|
10
|
+
var store = /* @__PURE__ */ new Map();
|
|
11
|
+
function readCache(key) {
|
|
12
|
+
const entry = store.get(key);
|
|
13
|
+
if (!entry) return null;
|
|
14
|
+
if (Date.now() - entry.time > TTL_MS) {
|
|
15
|
+
store.delete(key);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
store.delete(key);
|
|
19
|
+
store.set(key, entry);
|
|
20
|
+
console.log(`[batter-bridge] cache hit: ${key}`);
|
|
21
|
+
return entry.value;
|
|
22
|
+
}
|
|
23
|
+
function writeCache(key, value) {
|
|
24
|
+
store.delete(key);
|
|
25
|
+
store.set(key, { time: Date.now(), value });
|
|
26
|
+
if (store.size > MAX_ENTRIES) {
|
|
27
|
+
const oldest = store.keys().next().value;
|
|
28
|
+
if (oldest !== void 0) store.delete(oldest);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
3
32
|
// src/context.ts
|
|
4
33
|
function collectContext() {
|
|
5
34
|
return {
|
|
@@ -17,9 +46,27 @@
|
|
|
17
46
|
|
|
18
47
|
// src/dsl/export.ts
|
|
19
48
|
async function exportNode(node, format, scale = 2) {
|
|
20
|
-
const settings = format === "SVG" ? { format: "SVG" } : { format
|
|
49
|
+
const settings = format === "SVG" ? { format: "SVG" } : { format, constraint: { type: "SCALE", value: scale } };
|
|
21
50
|
return node.exportAsync(settings);
|
|
22
51
|
}
|
|
52
|
+
async function exportWithMarks(node, fallback, assets) {
|
|
53
|
+
const marks = "exportSettings" in node ? node.exportSettings : [];
|
|
54
|
+
if (marks.length === 0) {
|
|
55
|
+
const key = `asset://${assets.length}`;
|
|
56
|
+
assets.push({ key, format: fallback.format, bytes: await exportNode(node, fallback.format, fallback.scale) });
|
|
57
|
+
return [{ img: key, format: fallback.format, scale: fallback.scale }];
|
|
58
|
+
}
|
|
59
|
+
const items = [];
|
|
60
|
+
for (const mark of marks) {
|
|
61
|
+
const key = `asset://${assets.length}`;
|
|
62
|
+
assets.push({ key, format: mark.format, bytes: await node.exportAsync(mark) });
|
|
63
|
+
const item = { img: key, format: mark.format };
|
|
64
|
+
if ("constraint" in mark && mark.constraint?.type === "SCALE") item.scale = mark.constraint.value;
|
|
65
|
+
if ("suffix" in mark && mark.suffix) item.suffix = mark.suffix;
|
|
66
|
+
items.push(item);
|
|
67
|
+
}
|
|
68
|
+
return items;
|
|
69
|
+
}
|
|
23
70
|
|
|
24
71
|
// src/dsl/detect.ts
|
|
25
72
|
var VECTOR_TYPES = /* @__PURE__ */ new Set([
|
|
@@ -89,17 +136,17 @@
|
|
|
89
136
|
|
|
90
137
|
// src/dsl/node.ts
|
|
91
138
|
var ALIGN_MAP = {
|
|
92
|
-
MIN: "
|
|
139
|
+
MIN: "start",
|
|
93
140
|
CENTER: "center",
|
|
94
|
-
MAX: "
|
|
141
|
+
MAX: "end",
|
|
95
142
|
SPACE_BETWEEN: "space-between",
|
|
96
143
|
BASELINE: "baseline"
|
|
97
144
|
};
|
|
98
145
|
var SIZING_MAP = {
|
|
99
146
|
FIXED: "fixed",
|
|
100
|
-
AUTO: "hug
|
|
101
|
-
FILL: "fill
|
|
102
|
-
HUG: "hug
|
|
147
|
+
AUTO: "hug",
|
|
148
|
+
FILL: "fill",
|
|
149
|
+
HUG: "hug"
|
|
103
150
|
};
|
|
104
151
|
var CONSTRAINT_H_MAP = {
|
|
105
152
|
MIN: "left",
|
|
@@ -115,14 +162,63 @@
|
|
|
115
162
|
STRETCH: "stretch",
|
|
116
163
|
SCALE: "scale"
|
|
117
164
|
};
|
|
165
|
+
var AUTO_NAME_RE = /^(Frame|Rectangle|Ellipse|Group|Line|Polygon|Star|Vector|Section)\s\d+$/;
|
|
166
|
+
function isStandaloneColor(css) {
|
|
167
|
+
return !css.includes("gradient(");
|
|
168
|
+
}
|
|
169
|
+
var Palette = class {
|
|
170
|
+
constructor() {
|
|
171
|
+
__publicField(this, "counts", /* @__PURE__ */ new Map());
|
|
172
|
+
__publicField(this, "refs", /* @__PURE__ */ new Map());
|
|
173
|
+
}
|
|
174
|
+
/** 构建期登记一次单色使用,原样返回(替换发生在 apply) */
|
|
175
|
+
use(color) {
|
|
176
|
+
if (color && isStandaloneColor(color)) {
|
|
177
|
+
this.counts.set(color, (this.counts.get(color) ?? 0) + 1);
|
|
178
|
+
}
|
|
179
|
+
return color;
|
|
180
|
+
}
|
|
181
|
+
/** 收尾:替换整棵树的颜色引用,调色板挂根节点 */
|
|
182
|
+
apply(root) {
|
|
183
|
+
const colors = [...this.counts.entries()].filter(([, n]) => n >= 2).map(([c]) => c);
|
|
184
|
+
if (colors.length === 0) return;
|
|
185
|
+
colors.forEach((c, i) => this.refs.set(c, `p${i}`));
|
|
186
|
+
this.rewrite(root);
|
|
187
|
+
root.palette = colors;
|
|
188
|
+
}
|
|
189
|
+
ref(color) {
|
|
190
|
+
return color ? this.refs.get(color) ?? color : color;
|
|
191
|
+
}
|
|
192
|
+
rewrite(node) {
|
|
193
|
+
if (node.fills) node.fills = node.fills.map((c) => this.ref(c));
|
|
194
|
+
if (node.stroke) {
|
|
195
|
+
for (const [color, ref] of this.refs) {
|
|
196
|
+
if (node.stroke.endsWith(color)) {
|
|
197
|
+
node.stroke = node.stroke.slice(0, -color.length) + ref;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (node.text) {
|
|
203
|
+
node.text.color = this.ref(node.text.color);
|
|
204
|
+
for (const seg of node.text.segs ?? []) seg.color = this.ref(seg.color);
|
|
205
|
+
}
|
|
206
|
+
for (const kid of node.kids ?? []) this.rewrite(kid);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
118
209
|
function rectOf(node) {
|
|
119
210
|
const box = "absoluteBoundingBox" in node && node.absoluteBoundingBox ? node.absoluteBoundingBox : { x: 0, y: 0, width: node.width, height: node.height };
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
211
|
+
return [round2(box.x), round2(box.y), round2(box.width), round2(box.height)];
|
|
212
|
+
}
|
|
213
|
+
function levelsBelow(node) {
|
|
214
|
+
if (!("children" in node)) return 0;
|
|
215
|
+
let max = 0;
|
|
216
|
+
for (const child of node.children) {
|
|
217
|
+
if (!child.visible) continue;
|
|
218
|
+
const below = 1 + levelsBelow(child);
|
|
219
|
+
if (below > max) max = below;
|
|
220
|
+
}
|
|
221
|
+
return max;
|
|
126
222
|
}
|
|
127
223
|
function sameStyle(a, b) {
|
|
128
224
|
return a.fontSize === b.fontSize && a.fontName.family === b.fontName.family && a.fontName.style === b.fontName.style && a.textDecoration === b.textDecoration && JSON.stringify(a.fills) === JSON.stringify(b.fills) && JSON.stringify(a.lineHeight) === JSON.stringify(b.lineHeight) && JSON.stringify(a.letterSpacing) === JSON.stringify(b.letterSpacing) && JSON.stringify(a.hyperlink) === JSON.stringify(b.hyperlink);
|
|
@@ -176,24 +272,24 @@
|
|
|
176
272
|
function letterSpacingCss(ls) {
|
|
177
273
|
return ls.unit === "PIXELS" && ls.value !== 0 ? `${round2(ls.value)}px` : void 0;
|
|
178
274
|
}
|
|
179
|
-
function toSegment(s, base) {
|
|
180
|
-
const seg = {
|
|
181
|
-
if (s.fontSize !== base.
|
|
182
|
-
if (s.fontName.family !== base.
|
|
183
|
-
if (s.fontName.style !== base.
|
|
275
|
+
function toSegment(s, base, palette) {
|
|
276
|
+
const seg = { text: s.characters };
|
|
277
|
+
if (round2(s.fontSize) !== base.size) seg.size = round2(s.fontSize);
|
|
278
|
+
if (s.fontName.family !== base.font) seg.font = s.fontName.family;
|
|
279
|
+
if (s.fontName.style !== (base.weight ?? "Regular")) seg.weight = s.fontName.style;
|
|
184
280
|
const lh = lineHeightCss(s.lineHeight);
|
|
185
|
-
if (lh !== base.
|
|
281
|
+
if (lh !== (base.lh ?? "normal")) seg.lh = lh;
|
|
186
282
|
const color = paintsToCss(s.fills)[0];
|
|
187
|
-
if (color && color !== base.color) seg.color = color;
|
|
283
|
+
if (color && color !== base.color) seg.color = palette.use(color);
|
|
188
284
|
const dec = decorationCss(s.textDecoration);
|
|
189
|
-
if (dec !== base.
|
|
285
|
+
if (dec !== base.deco) seg.deco = dec ?? "none";
|
|
190
286
|
const ls = letterSpacingCss(s.letterSpacing);
|
|
191
|
-
if (ls !== base.
|
|
287
|
+
if (ls !== base.ls) seg.ls = ls ?? "normal";
|
|
192
288
|
const link = s.hyperlink?.type === "URL" ? s.hyperlink.value : void 0;
|
|
193
289
|
if (link !== base.link) seg.link = link ?? "";
|
|
194
290
|
return seg;
|
|
195
291
|
}
|
|
196
|
-
function buildText(node, base) {
|
|
292
|
+
function buildText(node, base, palette) {
|
|
197
293
|
const mixed = node.fontSize === figma.mixed || node.fontName === figma.mixed || node.fills === figma.mixed || node.lineHeight === figma.mixed || node.textDecoration === figma.mixed || node.letterSpacing === figma.mixed || node.hyperlink === figma.mixed;
|
|
198
294
|
const segments = mixed && node.characters.length > 0 ? extractSegments(node) : null;
|
|
199
295
|
const dom = segments?.length ? segments.reduce((a, b) => b.characters.length > a.characters.length ? b : a) : null;
|
|
@@ -206,29 +302,32 @@
|
|
|
206
302
|
const hyperlink = node.hyperlink === figma.mixed ? dom?.hyperlink ?? null : node.hyperlink;
|
|
207
303
|
base.text = {
|
|
208
304
|
content: node.characters,
|
|
209
|
-
fontSize,
|
|
210
|
-
|
|
211
|
-
fontWeight: fontName?.style ?? "",
|
|
212
|
-
lineHeight: lineHeight ? lineHeightCss(lineHeight) : void 0,
|
|
213
|
-
align: node.textAlignHorizontal.toLowerCase()
|
|
305
|
+
size: typeof fontSize === "number" ? round2(fontSize) : fontSize,
|
|
306
|
+
font: fontName?.family ?? ""
|
|
214
307
|
};
|
|
215
|
-
|
|
308
|
+
const weight = fontName?.style;
|
|
309
|
+
if (weight && weight !== "Regular") base.text.weight = weight;
|
|
310
|
+
const lh = lineHeight ? lineHeightCss(lineHeight) : void 0;
|
|
311
|
+
if (lh && lh !== "normal") base.text.lh = lh;
|
|
312
|
+
const align = node.textAlignHorizontal.toLowerCase();
|
|
313
|
+
if (align !== "left") base.text.align = align;
|
|
314
|
+
if (fills.length > 0) base.text.color = palette.use(fills[0]);
|
|
216
315
|
const dec = decoration ? decorationCss(decoration) : void 0;
|
|
217
|
-
if (dec) base.text.
|
|
316
|
+
if (dec) base.text.deco = dec;
|
|
218
317
|
const ls = letterSpacing ? letterSpacingCss(letterSpacing) : void 0;
|
|
219
|
-
if (ls) base.text.
|
|
318
|
+
if (ls) base.text.ls = ls;
|
|
220
319
|
if (hyperlink?.type === "URL") base.text.link = hyperlink.value;
|
|
221
|
-
if (segments) base.text.
|
|
320
|
+
if (segments) base.text.segs = segments.map((s) => toSegment(s, base.text, palette));
|
|
222
321
|
return base;
|
|
223
322
|
}
|
|
224
|
-
function applyContainerStyle(node, base) {
|
|
323
|
+
function applyContainerStyle(node, base, palette) {
|
|
225
324
|
const fills = paintsToCss("fills" in node ? node.fills : []);
|
|
226
|
-
if (fills.length > 0) base.fills = fills;
|
|
325
|
+
if (fills.length > 0) base.fills = fills.map((c) => palette.use(c));
|
|
227
326
|
if ("strokes" in node && Array.isArray(node.strokes) && node.strokes.length > 0) {
|
|
228
327
|
const strokes = paintsToCss(node.strokes);
|
|
229
328
|
if (strokes.length > 0) {
|
|
230
329
|
const weight = "strokeWeight" in node ? node.strokeWeight : 1;
|
|
231
|
-
base.stroke = `${typeof weight === "number" ? weight : 1}px solid ${strokes[0]}`;
|
|
330
|
+
base.stroke = `${typeof weight === "number" ? weight : 1}px solid ${palette.use(strokes[0])}`;
|
|
232
331
|
}
|
|
233
332
|
}
|
|
234
333
|
if ("cornerRadius" in node && typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
|
@@ -255,7 +354,7 @@
|
|
|
255
354
|
} else if (e.type === "LAYER_BLUR") {
|
|
256
355
|
base.blur = `${round2(e.radius)}px`;
|
|
257
356
|
} else if (e.type === "BACKGROUND_BLUR") {
|
|
258
|
-
base.
|
|
357
|
+
base.bgBlur = `${round2(e.radius)}px`;
|
|
259
358
|
}
|
|
260
359
|
}
|
|
261
360
|
if (shadows.length > 0) base.shadow = shadows.join(", ");
|
|
@@ -277,33 +376,34 @@
|
|
|
277
376
|
function applyAutoLayout(node, base) {
|
|
278
377
|
if (!("layoutMode" in node)) return;
|
|
279
378
|
if (node.layoutMode !== "HORIZONTAL" && node.layoutMode !== "VERTICAL") return;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
round2(node.paddingRight),
|
|
286
|
-
round2(node.paddingBottom),
|
|
287
|
-
round2(node.paddingLeft)
|
|
379
|
+
const layout = {
|
|
380
|
+
dir: node.layoutMode === "HORIZONTAL" ? "row" : "column",
|
|
381
|
+
align: [
|
|
382
|
+
ALIGN_MAP[node.primaryAxisAlignItems] ?? node.primaryAxisAlignItems,
|
|
383
|
+
ALIGN_MAP[node.counterAxisAlignItems] ?? node.counterAxisAlignItems
|
|
288
384
|
],
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
sizing: {
|
|
294
|
-
primary: SIZING_MAP[node.primaryAxisSizingMode] ?? node.primaryAxisSizingMode,
|
|
295
|
-
counter: SIZING_MAP[node.counterAxisSizingMode] ?? node.counterAxisSizingMode
|
|
296
|
-
}
|
|
385
|
+
sizing: [
|
|
386
|
+
SIZING_MAP[node.primaryAxisSizingMode] ?? node.primaryAxisSizingMode,
|
|
387
|
+
SIZING_MAP[node.counterAxisSizingMode] ?? node.counterAxisSizingMode
|
|
388
|
+
]
|
|
297
389
|
};
|
|
390
|
+
const gap = round2(node.itemSpacing);
|
|
391
|
+
if (gap !== 0) layout.gap = gap;
|
|
392
|
+
const pad = [
|
|
393
|
+
round2(node.paddingTop),
|
|
394
|
+
round2(node.paddingRight),
|
|
395
|
+
round2(node.paddingBottom),
|
|
396
|
+
round2(node.paddingLeft)
|
|
397
|
+
];
|
|
398
|
+
if (pad.some((p) => p !== 0)) layout.pad = pad;
|
|
399
|
+
base.layout = layout;
|
|
298
400
|
}
|
|
299
|
-
async function
|
|
401
|
+
async function build(node, assets, palette, depth, maxDepth) {
|
|
300
402
|
if (!node.visible) return null;
|
|
301
|
-
const base = {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
rect: rectOf(node)
|
|
306
|
-
};
|
|
403
|
+
const base = { id: node.id, type: node.type };
|
|
404
|
+
if (!AUTO_NAME_RE.test(node.name)) base.name = node.name;
|
|
405
|
+
const [x, y, w, h] = rectOf(node);
|
|
406
|
+
base.rect = [x, y, w, h];
|
|
307
407
|
if ("opacity" in node && node.opacity !== 1) base.opacity = round2(node.opacity);
|
|
308
408
|
applyConstraints(node, base);
|
|
309
409
|
const assetFormat = detectAssetFormat(node);
|
|
@@ -311,22 +411,35 @@
|
|
|
311
411
|
const key = `asset://${assets.length}`;
|
|
312
412
|
assets.push({ key, format: assetFormat, bytes: await exportNode(node, assetFormat) });
|
|
313
413
|
base.type = assetFormat === "SVG" ? "ICON" : "IMAGE";
|
|
314
|
-
base.
|
|
414
|
+
base.img = key;
|
|
315
415
|
return base;
|
|
316
416
|
}
|
|
317
|
-
if (node.type === "TEXT") return buildText(node, base);
|
|
318
|
-
applyContainerStyle(node, base);
|
|
417
|
+
if (node.type === "TEXT") return buildText(node, base, palette);
|
|
418
|
+
applyContainerStyle(node, base, palette);
|
|
319
419
|
applyAutoLayout(node, base);
|
|
320
420
|
if ("children" in node && node.children.length > 0) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
421
|
+
if (maxDepth !== void 0 && depth >= maxDepth) {
|
|
422
|
+
const more = levelsBelow(node);
|
|
423
|
+
if (more > 0) base.more = more;
|
|
424
|
+
} else {
|
|
425
|
+
const kids = [];
|
|
426
|
+
for (const child of node.children) {
|
|
427
|
+
const built = await build(child, assets, palette, depth + 1, maxDepth);
|
|
428
|
+
if (built) kids.push(built);
|
|
429
|
+
}
|
|
430
|
+
if (kids.length > 0) base.kids = kids;
|
|
325
431
|
}
|
|
326
|
-
if (children.length > 0) base.children = children;
|
|
327
432
|
}
|
|
328
433
|
return base;
|
|
329
434
|
}
|
|
435
|
+
async function buildDevNode(node, assets, maxDepth) {
|
|
436
|
+
const palette = new Palette();
|
|
437
|
+
const root = await build(node, assets, palette, 0, maxDepth);
|
|
438
|
+
if (!root) return null;
|
|
439
|
+
palette.apply(root);
|
|
440
|
+
root.depth = levelsBelow(node);
|
|
441
|
+
return root;
|
|
442
|
+
}
|
|
330
443
|
|
|
331
444
|
// src/commands.ts
|
|
332
445
|
async function findNode(guid) {
|
|
@@ -339,32 +452,76 @@
|
|
|
339
452
|
if (!selected) throw new Error("\u672A\u4F20\u5165 guid \u4E14\u753B\u5E03\u65E0\u9009\u4E2D\u56FE\u5C42");
|
|
340
453
|
return selected;
|
|
341
454
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
455
|
+
function normMaxDepth(input) {
|
|
456
|
+
return typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.floor(input) : void 0;
|
|
457
|
+
}
|
|
458
|
+
function normImageFormat(input) {
|
|
459
|
+
const f = typeof input === "string" ? input.toUpperCase() : "PNG";
|
|
460
|
+
return f === "SVG" ? "SVG" : f === "JPG" ? "JPG" : "PNG";
|
|
461
|
+
}
|
|
462
|
+
function normParams(type, args) {
|
|
463
|
+
switch (type) {
|
|
464
|
+
case "get_node_dsl":
|
|
465
|
+
return { type, maxDepth: normMaxDepth(args.maxDepth) };
|
|
466
|
+
case "get_screenshot": {
|
|
467
|
+
const f = (args.format ?? "PNG").toUpperCase();
|
|
468
|
+
return { type, format: f === "SVG" ? "SVG" : "PNG", scale: args.scale ?? 2 };
|
|
469
|
+
}
|
|
470
|
+
case "export_image":
|
|
471
|
+
return {
|
|
472
|
+
type,
|
|
473
|
+
format: normImageFormat(args.format),
|
|
474
|
+
scale: typeof args.scale === "number" ? args.scale : 2
|
|
475
|
+
};
|
|
476
|
+
default:
|
|
477
|
+
throw new Error(`\u4E0D\u53EF\u7F13\u5B58\u7684\u547D\u4EE4\u7C7B\u578B: ${type}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async function produce(params, node) {
|
|
481
|
+
switch (params.type) {
|
|
347
482
|
case "get_node_dsl": {
|
|
348
|
-
const node = await findNode(args.guid);
|
|
349
483
|
const assets = [];
|
|
350
|
-
const dsl = await buildDevNode(node, assets);
|
|
484
|
+
const dsl = await buildDevNode(node, assets, params.maxDepth);
|
|
351
485
|
return { result: dsl, assets };
|
|
352
486
|
}
|
|
353
487
|
case "get_screenshot": {
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
const bytes = await exportNode(node, format === "SVG" ? "SVG" : "PNG", args.scale ?? 2);
|
|
357
|
-
const rect = rectOf(node);
|
|
488
|
+
const bytes = await exportNode(node, params.format, params.scale);
|
|
489
|
+
const [, , width, height] = rectOf(node);
|
|
358
490
|
return {
|
|
359
|
-
result: {
|
|
360
|
-
assets: [{ key: "asset://0", format, bytes }]
|
|
491
|
+
result: { img: "asset://0", width, height, format: params.format },
|
|
492
|
+
assets: [{ key: "asset://0", format: params.format, bytes }]
|
|
361
493
|
};
|
|
362
494
|
}
|
|
495
|
+
case "export_image": {
|
|
496
|
+
const [, , width, height] = rectOf(node);
|
|
497
|
+
const assets = [];
|
|
498
|
+
const exports = await exportWithMarks(node, params, assets);
|
|
499
|
+
return { result: { width, height, exports }, assets };
|
|
500
|
+
}
|
|
501
|
+
default:
|
|
502
|
+
throw new Error(`\u672A\u77E5\u547D\u4EE4: ${params.type}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function executeCommand(command) {
|
|
506
|
+
const args = command.args;
|
|
507
|
+
if (command.type === "get_selected") {
|
|
508
|
+
return { result: collectContext(), assets: [] };
|
|
509
|
+
}
|
|
510
|
+
const node = await findNode(args.guid);
|
|
511
|
+
const params = normParams(command.type, args);
|
|
512
|
+
const { type, ...keyParams } = params;
|
|
513
|
+
const key = `${type}:${node.id}:${JSON.stringify(keyParams)}`;
|
|
514
|
+
if (args.cache !== "refresh") {
|
|
515
|
+
const hit = readCache(key);
|
|
516
|
+
if (hit) return hit;
|
|
363
517
|
}
|
|
518
|
+
const fresh = await produce(params, node);
|
|
519
|
+
writeCache(key, fresh);
|
|
520
|
+
return fresh;
|
|
364
521
|
}
|
|
365
522
|
|
|
366
523
|
// src/code.ts
|
|
367
|
-
figma.showUI('<!DOCTYPE html>\n<html>\n<head>\n <meta charset="utf-8" />\n <style>\n /* \u8D34\u8FD1 Figma/Pixso \u539F\u751F\u63D2\u4EF6\uFF1A\u767D\u5E95\u3001Inter 11px\u3001\u7EC6\u8FB9\u6846\u5706\u89D2\u63A7\u4EF6\u3001\u8BED\u4E49\u8272\u72B6\u6001 */\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n body {\n font: 11px/1.5 Inter, "PingFang SC", "Microsoft YaHei", sans-serif;\n background: #ffffff;\n color: #1e1e1e;\n padding: 8px 12px;\n overflow: hidden;\n user-select: none;\n }\n .row {\n display: flex;\n align-items: center;\n gap: 6px;\n white-space: nowrap;\n }\n .row + .row {\n margin-top: 4px;\n }\n .grow {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n /* \u8FDE\u63A5\u72B6\u6001\u884C */\n .dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #b3b3b3;\n flex-shrink: 0;\n animation: pulse 1.6s ease-in-out infinite;\n }\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.35; }\n }\n .dot.ok {\n background: #14ae5c;\n animation: none;\n }\n .dot.err {\n background: #f24822;\n animation: none;\n }\n #conn {\n font-weight: 500;\n color: #1e1e1e;\n }\n /* \u7AEF\u53E3\u8F93\u5165 + \u91CD\u8FDE\uFF1AFigma \u98CE\u683C\u6B21\u63A7\u4EF6 */\n #port {\n width: 42px;\n padding: 1px 4px;\n font: inherit;\n color: #1e1e1e;\n background: #ffffff;\n border: 1px solid #e6e6e6;\n border-radius: 5px;\n text-align: center;\n }\n #port:hover { border-color: #b3b3b3; }\n #port:focus {\n outline: none;\n border-color: #0d99ff;\n box-shadow: 0 0 0 1px #0d99ff;\n }\n #retry {\n padding: 1px 10px;\n font: inherit;\n font-weight: 500;\n color: #1e1e1e;\n background: #ffffff;\n border: 1px solid #e6e6e6;\n border-radius: 5px;\n cursor: pointer;\n }\n #retry:hover {\n background: #f5f5f5;\n border-color: #b3b3b3;\n }\n #retry:active { background: #ebebeb; }\n /* \u6267\u884C\u72B6\u6001\u884C\uFF1A\u6B21\u7EA7\u7070\uFF0C\u5FD9\u788C\u65F6\u84DD\u8272\u547C\u5438 */\n #exec {\n color: #7b7b7b;\n }\n #exec::before {\n content: "\u25B8 ";\n color: #b3b3b3;\n }\n #exec.busy {\n color: #0d99ff;\n animation: pulse 1.2s ease-in-out infinite;\n }\n #exec.busy::before { color: #0d99ff; }\n #exec.ok { color: #14ae5c; }\n #exec.ok::before { color: #14ae5c; }\n #exec.err { color: #f24822; }\n #exec.err::before { color: #f24822; }\n </style>\n</head>\n<body>\n <div class="row">\n <span class="dot" id="dot"></span>\n <span id="conn" class="grow">\u8FDE\u63A5\u4E2D\u2026</span>\n <input id="port" inputmode="numeric" spellcheck="false" title="\u670D\u52A1\u7AEF\u53E3" />\n <button id="retry" type="button">\u91CD\u8FDE</button>\n </div>\n <div class="row"><span id="exec" class="grow">\u7A7A\u95F2</span></div>\n <script>\n"use strict";\n(() => {\n // src/ui.ts\n var DEFAULT_PORT = "3210";\n var dotEl = document.getElementById("dot");\n var connEl = document.getElementById("conn");\n var execEl = document.getElementById("exec");\n var portInput = document.getElementById("port");\n var retryBtn = document.getElementById("retry");\n var serverBase = `http://127.0.0.1:${DEFAULT_PORT}`;\n function setConn(text, cls) {\n connEl.textContent = text;\n dotEl.className = `dot${cls ? ` ${cls}` : ""}`;\n }\n function setExec(text, cls) {\n execEl.textContent = text;\n execEl.className = `grow${cls ? ` ${cls}` : ""}`;\n }\n var sessionId = null;\n var lastContext = null;\n async function ensureSession() {\n if (sessionId) return sessionId;\n const res = await fetch(`${serverBase}/sessions/register`, {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify(lastContext ?? {})\n });\n if (!res.ok) throw new Error(`register failed: HTTP ${res.status}`);\n const data = await res.json();\n sessionId = data.sessionId;\n return sessionId;\n }\n function sleep(ms) {\n const { promise, resolve } = Promise.withResolvers();\n setTimeout(resolve, ms);\n return promise;\n }\n function report(payload) {\n lastContext = payload;\n void (async () => {\n try {\n let sid = await ensureSession();\n let res = await postContext(sid, payload);\n if (res.status === 404) {\n sessionId = null;\n sid = await ensureSession();\n res = await postContext(sid, payload);\n }\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n setConn(`\\u5DF2\\u8FDE\\u63A5 \\xB7 \\u9009\\u533A ${payload.selection?.length ?? 0}`, "ok");\n } catch {\n setConn("\\u540C\\u6B65\\u5931\\u8D25", "err");\n }\n })();\n }\n function postContext(sid, payload) {\n return fetch(`${serverBase}/context?session=${sid}`, {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify(payload)\n });\n }\n function checkHealth() {\n fetch(`${serverBase}/health`).then(() => setConn("\\u5DF2\\u8FDE\\u63A5", "ok")).catch(() => setConn("\\u8FDE\\u63A5\\u5931\\u8D25", "err"));\n }\n var pendingCommands = /* @__PURE__ */ new Map();\n function toBytes(raw) {\n return new Uint8Array(raw);\n }\n async function uploadAsset(format, bytes) {\n const res = await fetch(`${serverBase}/assets?format=${format}`, {\n method: "POST",\n headers: { "Content-Type": "application/octet-stream" },\n body: bytes\n });\n if (!res.ok) throw new Error(`asset upload failed: HTTP ${res.status}`);\n const data = await res.json();\n if (!data.
|
|
524
|
+
figma.showUI('<!DOCTYPE html>\n<html>\n<head>\n <meta charset="utf-8" />\n <style>\n /* \u8D34\u8FD1 Figma/Pixso \u539F\u751F\u63D2\u4EF6\uFF1A\u767D\u5E95\u3001Inter 11px\u3001\u7EC6\u8FB9\u6846\u5706\u89D2\u63A7\u4EF6\u3001\u8BED\u4E49\u8272\u72B6\u6001 */\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n body {\n font: 11px/1.5 Inter, "PingFang SC", "Microsoft YaHei", sans-serif;\n background: #ffffff;\n color: #1e1e1e;\n padding: 8px 12px;\n overflow: hidden;\n user-select: none;\n }\n .row {\n display: flex;\n align-items: center;\n gap: 6px;\n white-space: nowrap;\n }\n .row + .row {\n margin-top: 4px;\n }\n .grow {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n /* \u8FDE\u63A5\u72B6\u6001\u884C */\n .dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: #b3b3b3;\n flex-shrink: 0;\n animation: pulse 1.6s ease-in-out infinite;\n }\n @keyframes pulse {\n 0%, 100% { opacity: 1; }\n 50% { opacity: 0.35; }\n }\n .dot.ok {\n background: #14ae5c;\n animation: none;\n }\n .dot.err {\n background: #f24822;\n animation: none;\n }\n #conn {\n font-weight: 500;\n color: #1e1e1e;\n }\n /* \u7AEF\u53E3\u8F93\u5165 + \u91CD\u8FDE\uFF1AFigma \u98CE\u683C\u6B21\u63A7\u4EF6 */\n #port {\n width: 42px;\n padding: 1px 4px;\n font: inherit;\n color: #1e1e1e;\n background: #ffffff;\n border: 1px solid #e6e6e6;\n border-radius: 5px;\n text-align: center;\n }\n #port:hover { border-color: #b3b3b3; }\n #port:focus {\n outline: none;\n border-color: #0d99ff;\n box-shadow: 0 0 0 1px #0d99ff;\n }\n #retry {\n padding: 1px 10px;\n font: inherit;\n font-weight: 500;\n color: #1e1e1e;\n background: #ffffff;\n border: 1px solid #e6e6e6;\n border-radius: 5px;\n cursor: pointer;\n }\n #retry:hover {\n background: #f5f5f5;\n border-color: #b3b3b3;\n }\n #retry:active { background: #ebebeb; }\n /* \u6267\u884C\u72B6\u6001\u884C\uFF1A\u6B21\u7EA7\u7070\uFF0C\u5FD9\u788C\u65F6\u84DD\u8272\u547C\u5438 */\n #exec {\n color: #7b7b7b;\n }\n #exec::before {\n content: "\u25B8 ";\n color: #b3b3b3;\n }\n #exec.busy {\n color: #0d99ff;\n animation: pulse 1.2s ease-in-out infinite;\n }\n #exec.busy::before { color: #0d99ff; }\n #exec.ok { color: #14ae5c; }\n #exec.ok::before { color: #14ae5c; }\n #exec.err { color: #f24822; }\n #exec.err::before { color: #f24822; }\n </style>\n</head>\n<body>\n <div class="row">\n <span class="dot" id="dot"></span>\n <span id="conn" class="grow">\u8FDE\u63A5\u4E2D\u2026</span>\n <input id="port" inputmode="numeric" spellcheck="false" title="\u670D\u52A1\u7AEF\u53E3" />\n <button id="retry" type="button">\u91CD\u8FDE</button>\n </div>\n <div class="row"><span id="exec" class="grow">\u7A7A\u95F2</span></div>\n <script>\n"use strict";\n(() => {\n // src/ui.ts\n var DEFAULT_PORT = "3210";\n var dotEl = document.getElementById("dot");\n var connEl = document.getElementById("conn");\n var execEl = document.getElementById("exec");\n var portInput = document.getElementById("port");\n var retryBtn = document.getElementById("retry");\n var serverBase = `http://127.0.0.1:${DEFAULT_PORT}`;\n function setConn(text, cls) {\n connEl.textContent = text;\n dotEl.className = `dot${cls ? ` ${cls}` : ""}`;\n }\n function setExec(text, cls) {\n execEl.textContent = text;\n execEl.className = `grow${cls ? ` ${cls}` : ""}`;\n }\n var sessionId = null;\n var lastContext = null;\n async function ensureSession() {\n if (sessionId) return sessionId;\n const res = await fetch(`${serverBase}/sessions/register`, {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify(lastContext ?? {})\n });\n if (!res.ok) throw new Error(`register failed: HTTP ${res.status}`);\n const data = await res.json();\n sessionId = data.sessionId;\n return sessionId;\n }\n function sleep(ms) {\n const { promise, resolve } = Promise.withResolvers();\n setTimeout(resolve, ms);\n return promise;\n }\n function report(payload) {\n lastContext = payload;\n void (async () => {\n try {\n let sid = await ensureSession();\n let res = await postContext(sid, payload);\n if (res.status === 404) {\n sessionId = null;\n sid = await ensureSession();\n res = await postContext(sid, payload);\n }\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n setConn(`\\u5DF2\\u8FDE\\u63A5 \\xB7 \\u9009\\u533A ${payload.selection?.length ?? 0}`, "ok");\n } catch {\n setConn("\\u540C\\u6B65\\u5931\\u8D25", "err");\n }\n })();\n }\n function postContext(sid, payload) {\n return fetch(`${serverBase}/context?session=${sid}`, {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify(payload)\n });\n }\n function checkHealth() {\n fetch(`${serverBase}/health`).then(() => setConn("\\u5DF2\\u8FDE\\u63A5", "ok")).catch(() => setConn("\\u8FDE\\u63A5\\u5931\\u8D25", "err"));\n }\n var pendingCommands = /* @__PURE__ */ new Map();\n function toBytes(raw) {\n return new Uint8Array(raw);\n }\n async function uploadAsset(format, bytes) {\n const res = await fetch(`${serverBase}/assets?format=${format}`, {\n method: "POST",\n headers: { "Content-Type": "application/octet-stream" },\n body: bytes\n });\n if (!res.ok) throw new Error(`asset upload failed: HTTP ${res.status}`);\n const data = await res.json();\n if (!data.name) throw new Error("asset upload failed: \\u54CD\\u5E94\\u7F3A\\u5C11 name \\u5B57\\u6BB5");\n return data.name;\n }\n async function handleCommand(command) {\n setExec(`\\u6267\\u884C\\u4E2D ${command.type}`, "busy");\n const reply = await new Promise((resolve) => {\n pendingCommands.set(command.id, resolve);\n parent.postMessage({ pluginMessage: { type: "command", command } }, "*");\n });\n if (!reply.ok) {\n await postResult(command.id, false, reply.error ?? "unknown error");\n setExec(`\\u5931\\u8D25 ${command.type}`, "err");\n return;\n }\n let result = reply.result;\n const assets = [...reply.assets ?? []].sort((a, b) => b.key.length - a.key.length);\n for (const asset of assets) {\n const name = await uploadAsset(asset.format, toBytes(asset.bytes));\n const text = JSON.stringify(result);\n const quoted = JSON.stringify(asset.key);\n result = JSON.parse(text.split(quoted).join(JSON.stringify(name)));\n }\n await postResult(command.id, true, result);\n setExec(`\\u5B8C\\u6210 ${command.type}`, "ok");\n }\n async function postResult(id, ok, payload) {\n const body = ok ? { ok: true, result: payload } : { ok: false, error: String(payload) };\n await fetch(`${serverBase}/commands/${id}/result`, {\n method: "POST",\n headers: { "Content-Type": "application/json" },\n body: JSON.stringify(body)\n });\n }\n var currentES = null;\n var channelMode = null;\n async function startChannel() {\n for (; ; ) {\n try {\n const sid = await ensureSession();\n const es = new EventSource(`${serverBase}/commands/stream?session=${sid}`);\n currentES = es;\n channelMode = "sse";\n es.onmessage = (e) => {\n const { command } = JSON.parse(e.data);\n void handleCommand(command);\n };\n es.onerror = () => {\n if (es.readyState === EventSource.CLOSED) {\n currentES = null;\n channelMode = "poll";\n void pollLoop();\n }\n };\n return;\n } catch {\n await sleep(2e3);\n }\n }\n }\n async function pollLoop() {\n for (; ; ) {\n try {\n const sid = await ensureSession();\n const res = await fetch(`${serverBase}/commands/next?session=${sid}`);\n if (res.status === 404) {\n sessionId = null;\n await sleep(500);\n continue;\n }\n if (res.status === 204) continue;\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const { command } = await res.json();\n await handleCommand(command);\n } catch {\n await sleep(2e3);\n }\n }\n }\n function reconnect() {\n const port = portInput.value.trim() || DEFAULT_PORT;\n serverBase = `http://127.0.0.1:${port}`;\n sessionId = null;\n setConn("\\u8FDE\\u63A5\\u4E2D\\u2026");\n if (channelMode === "sse") {\n currentES?.close();\n currentES = null;\n channelMode = null;\n void startChannel();\n }\n checkHealth();\n parent.postMessage({ pluginMessage: { type: "pull" } }, "*");\n }\n retryBtn.addEventListener("click", reconnect);\n window.onmessage = (event) => {\n const msg = event.data?.pluginMessage;\n if (msg?.type === "context") {\n report(msg.payload);\n return;\n }\n if (msg?.type === "command-result") {\n const waiter = pendingCommands.get(msg.id);\n pendingCommands.delete(msg.id);\n waiter?.(msg);\n }\n };\n portInput.value = DEFAULT_PORT;\n portInput.addEventListener("change", reconnect);\n parent.postMessage({ pluginMessage: { type: "pull" } }, "*");\n checkHealth();\n void startChannel();\n})();\n<\/script>\n</body>\n</html>\n', { width: 230, height: 62, title: "Batter Bridge" });
|
|
368
525
|
figma.on("selectionchange", push);
|
|
369
526
|
figma.ui.onmessage = (msg) => {
|
|
370
527
|
if (msg?.type === "pull") {
|
package/dist/plugin/ui.html
CHANGED
|
@@ -194,8 +194,8 @@
|
|
|
194
194
|
});
|
|
195
195
|
if (!res.ok) throw new Error(`asset upload failed: HTTP ${res.status}`);
|
|
196
196
|
const data = await res.json();
|
|
197
|
-
if (!data.
|
|
198
|
-
return data.
|
|
197
|
+
if (!data.name) throw new Error("asset upload failed: \u54CD\u5E94\u7F3A\u5C11 name \u5B57\u6BB5");
|
|
198
|
+
return data.name;
|
|
199
199
|
}
|
|
200
200
|
async function handleCommand(command) {
|
|
201
201
|
setExec(`\u6267\u884C\u4E2D ${command.type}`, "busy");
|
|
@@ -211,10 +211,10 @@
|
|
|
211
211
|
let result = reply.result;
|
|
212
212
|
const assets = [...reply.assets ?? []].sort((a, b) => b.key.length - a.key.length);
|
|
213
213
|
for (const asset of assets) {
|
|
214
|
-
const
|
|
214
|
+
const name = await uploadAsset(asset.format, toBytes(asset.bytes));
|
|
215
215
|
const text = JSON.stringify(result);
|
|
216
216
|
const quoted = JSON.stringify(asset.key);
|
|
217
|
-
result = JSON.parse(text.split(quoted).join(JSON.stringify(
|
|
217
|
+
result = JSON.parse(text.split(quoted).join(JSON.stringify(name)));
|
|
218
218
|
}
|
|
219
219
|
await postResult(command.id, true, result);
|
|
220
220
|
setExec(`\u5B8C\u6210 ${command.type}`, "ok");
|
package/package.json
CHANGED
|
@@ -1,37 +1,34 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "batter-pixso-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP bridge: Pixso plugin -> local MCP server -> Pixso remote MCP",
|
|
5
|
-
"type": "module",
|
|
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
|
-
"typescript": "^5.7.0"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "batter-pixso-mcp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "MCP bridge: Pixso plugin -> local MCP server -> Pixso remote MCP",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./protocol": "./src/protocol.ts"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"batter-pixso-mcp": "dist/index.js",
|
|
11
|
+
"batter-pixso-mcp-win": "standalone/batter-pixso-mcp.cmd"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"standalone"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "npm run typecheck && node build.mjs",
|
|
19
|
+
"prepack": "node ../../scripts/sync-plugin-dist.mjs && node build-standalone.mjs",
|
|
20
|
+
"pack:standalone": "node build-standalone.mjs",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"dev": "tsx watch src/index.ts",
|
|
23
|
+
"start": "node dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@modelcontextprotocol/sdk": "^1.17.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^22.10.0",
|
|
30
|
+
"esbuild": "^0.24.0",
|
|
31
|
+
"tsx": "^4.19.0",
|
|
32
|
+
"typescript": "^5.7.0"
|
|
33
|
+
}
|
|
34
|
+
}
|