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.
@@ -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: "PNG", constraint: { type: "SCALE", value: scale } };
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: "flex-start",
139
+ MIN: "start",
93
140
  CENTER: "center",
94
- MAX: "flex-end",
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-content",
101
- FILL: "fill-container",
102
- HUG: "hug-content"
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
- x: round2(box.x),
122
- y: round2(box.y),
123
- width: round2(box.width),
124
- height: round2(box.height)
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 = { start: s.start, end: s.end, text: s.characters };
181
- if (s.fontSize !== base.fontSize) seg.fontSize = round2(s.fontSize);
182
- if (s.fontName.family !== base.fontFamily) seg.fontFamily = s.fontName.family;
183
- if (s.fontName.style !== base.fontWeight) seg.fontWeight = s.fontName.style;
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.lineHeight) seg.lineHeight = lh;
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.decoration) seg.decoration = dec ?? "none";
285
+ if (dec !== base.deco) seg.deco = dec ?? "none";
190
286
  const ls = letterSpacingCss(s.letterSpacing);
191
- if (ls !== base.letterSpacing) seg.letterSpacing = ls ?? "normal";
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
- fontFamily: fontName?.family ?? "",
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
- if (fills.length > 0) base.text.color = fills[0];
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.decoration = dec;
316
+ if (dec) base.text.deco = dec;
218
317
  const ls = letterSpacing ? letterSpacingCss(letterSpacing) : void 0;
219
- if (ls) base.text.letterSpacing = ls;
318
+ if (ls) base.text.ls = ls;
220
319
  if (hyperlink?.type === "URL") base.text.link = hyperlink.value;
221
- if (segments) base.text.segments = segments.map((s) => toSegment(s, 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.backdropBlur = `${round2(e.radius)}px`;
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
- base.layout = {
281
- direction: node.layoutMode === "HORIZONTAL" ? "row" : "column",
282
- gap: round2(node.itemSpacing),
283
- padding: [
284
- round2(node.paddingTop),
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
- align: {
290
- primary: ALIGN_MAP[node.primaryAxisAlignItems] ?? node.primaryAxisAlignItems,
291
- counter: ALIGN_MAP[node.counterAxisAlignItems] ?? node.counterAxisAlignItems
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 buildDevNode(node, assets) {
401
+ async function build(node, assets, palette, depth, maxDepth) {
300
402
  if (!node.visible) return null;
301
- const base = {
302
- id: node.id,
303
- name: node.name,
304
- type: node.type,
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.image = key;
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
- const children = [];
322
- for (const child of node.children) {
323
- const built = await buildDevNode(child, assets);
324
- if (built) children.push(built);
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
- async function executeCommand(command) {
343
- const args = command.args;
344
- switch (command.type) {
345
- case "get_selected":
346
- return { result: collectContext(), assets: [] };
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 node = await findNode(args.guid);
355
- const format = (args.format ?? "PNG").toUpperCase();
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: { image: "asset://0", width: rect.width, height: rect.height, format },
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.url) throw new Error("asset upload failed: \\u54CD\\u5E94\\u7F3A\\u5C11 url \\u5B57\\u6BB5");\n return data.url;\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 url = 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(url)));\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" });
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") {
@@ -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.url) throw new Error("asset upload failed: \u54CD\u5E94\u7F3A\u5C11 url \u5B57\u6BB5");
198
- return data.url;
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 url = await uploadAsset(asset.format, toBytes(asset.bytes));
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(url)));
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.2.0",
4
- "description": "MCP bridge: Pixso plugin -> local MCP server -> Pixso remote MCP",
5
- "type": "module",
6
- "engines": {
7
- "node": ">=18"
8
- },
9
- "exports": {
10
- "./protocol": "./src/protocol.ts"
11
- },
12
- "bin": {
13
- "batter-pixso-mcp": "dist/index.js",
14
- "batter-pixso-mcp-win": "standalone/batter-pixso-mcp.cmd"
15
- },
16
- "files": [
17
- "dist",
18
- "standalone"
19
- ],
20
- "scripts": {
21
- "build": "npm run typecheck && node build.mjs",
22
- "prepack": "node ../../scripts/sync-plugin-dist.mjs && node build-standalone.mjs",
23
- "pack:standalone": "node build-standalone.mjs",
24
- "typecheck": "tsc --noEmit",
25
- "dev": "tsx watch src/index.ts",
26
- "start": "node dist/index.js"
27
- },
28
- "dependencies": {
29
- "@modelcontextprotocol/sdk": "^1.17.0"
30
- },
31
- "devDependencies": {
32
- "@types/node": "^22.10.0",
33
- "esbuild": "^0.24.0",
34
- "tsx": "^4.19.0",
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
+ }