batter-pixso-mcp 0.2.0 → 0.2.1

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,7 +46,7 @@
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
  }
23
52
 
@@ -89,17 +118,17 @@
89
118
 
90
119
  // src/dsl/node.ts
91
120
  var ALIGN_MAP = {
92
- MIN: "flex-start",
121
+ MIN: "start",
93
122
  CENTER: "center",
94
- MAX: "flex-end",
123
+ MAX: "end",
95
124
  SPACE_BETWEEN: "space-between",
96
125
  BASELINE: "baseline"
97
126
  };
98
127
  var SIZING_MAP = {
99
128
  FIXED: "fixed",
100
- AUTO: "hug-content",
101
- FILL: "fill-container",
102
- HUG: "hug-content"
129
+ AUTO: "hug",
130
+ FILL: "fill",
131
+ HUG: "hug"
103
132
  };
104
133
  var CONSTRAINT_H_MAP = {
105
134
  MIN: "left",
@@ -115,14 +144,63 @@
115
144
  STRETCH: "stretch",
116
145
  SCALE: "scale"
117
146
  };
147
+ var AUTO_NAME_RE = /^(Frame|Rectangle|Ellipse|Group|Line|Polygon|Star|Vector|Section)\s\d+$/;
148
+ function isStandaloneColor(css) {
149
+ return !css.includes("gradient(");
150
+ }
151
+ var Palette = class {
152
+ constructor() {
153
+ __publicField(this, "counts", /* @__PURE__ */ new Map());
154
+ __publicField(this, "refs", /* @__PURE__ */ new Map());
155
+ }
156
+ /** 构建期登记一次单色使用,原样返回(替换发生在 apply) */
157
+ use(color) {
158
+ if (color && isStandaloneColor(color)) {
159
+ this.counts.set(color, (this.counts.get(color) ?? 0) + 1);
160
+ }
161
+ return color;
162
+ }
163
+ /** 收尾:替换整棵树的颜色引用,调色板挂根节点 */
164
+ apply(root) {
165
+ const colors = [...this.counts.entries()].filter(([, n]) => n >= 2).map(([c]) => c);
166
+ if (colors.length === 0) return;
167
+ colors.forEach((c, i) => this.refs.set(c, `p${i}`));
168
+ this.rewrite(root);
169
+ root.palette = colors;
170
+ }
171
+ ref(color) {
172
+ return color ? this.refs.get(color) ?? color : color;
173
+ }
174
+ rewrite(node) {
175
+ if (node.fills) node.fills = node.fills.map((c) => this.ref(c));
176
+ if (node.stroke) {
177
+ for (const [color, ref] of this.refs) {
178
+ if (node.stroke.endsWith(color)) {
179
+ node.stroke = node.stroke.slice(0, -color.length) + ref;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ if (node.text) {
185
+ node.text.color = this.ref(node.text.color);
186
+ for (const seg of node.text.segs ?? []) seg.color = this.ref(seg.color);
187
+ }
188
+ for (const kid of node.kids ?? []) this.rewrite(kid);
189
+ }
190
+ };
118
191
  function rectOf(node) {
119
192
  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
- };
193
+ return [round2(box.x), round2(box.y), round2(box.width), round2(box.height)];
194
+ }
195
+ function levelsBelow(node) {
196
+ if (!("children" in node)) return 0;
197
+ let max = 0;
198
+ for (const child of node.children) {
199
+ if (!child.visible) continue;
200
+ const below = 1 + levelsBelow(child);
201
+ if (below > max) max = below;
202
+ }
203
+ return max;
126
204
  }
127
205
  function sameStyle(a, b) {
128
206
  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 +254,24 @@
176
254
  function letterSpacingCss(ls) {
177
255
  return ls.unit === "PIXELS" && ls.value !== 0 ? `${round2(ls.value)}px` : void 0;
178
256
  }
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;
257
+ function toSegment(s, base, palette) {
258
+ const seg = { text: s.characters };
259
+ if (round2(s.fontSize) !== base.size) seg.size = round2(s.fontSize);
260
+ if (s.fontName.family !== base.font) seg.font = s.fontName.family;
261
+ if (s.fontName.style !== (base.weight ?? "Regular")) seg.weight = s.fontName.style;
184
262
  const lh = lineHeightCss(s.lineHeight);
185
- if (lh !== base.lineHeight) seg.lineHeight = lh;
263
+ if (lh !== (base.lh ?? "normal")) seg.lh = lh;
186
264
  const color = paintsToCss(s.fills)[0];
187
- if (color && color !== base.color) seg.color = color;
265
+ if (color && color !== base.color) seg.color = palette.use(color);
188
266
  const dec = decorationCss(s.textDecoration);
189
- if (dec !== base.decoration) seg.decoration = dec ?? "none";
267
+ if (dec !== base.deco) seg.deco = dec ?? "none";
190
268
  const ls = letterSpacingCss(s.letterSpacing);
191
- if (ls !== base.letterSpacing) seg.letterSpacing = ls ?? "normal";
269
+ if (ls !== base.ls) seg.ls = ls ?? "normal";
192
270
  const link = s.hyperlink?.type === "URL" ? s.hyperlink.value : void 0;
193
271
  if (link !== base.link) seg.link = link ?? "";
194
272
  return seg;
195
273
  }
196
- function buildText(node, base) {
274
+ function buildText(node, base, palette) {
197
275
  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
276
  const segments = mixed && node.characters.length > 0 ? extractSegments(node) : null;
199
277
  const dom = segments?.length ? segments.reduce((a, b) => b.characters.length > a.characters.length ? b : a) : null;
@@ -206,29 +284,32 @@
206
284
  const hyperlink = node.hyperlink === figma.mixed ? dom?.hyperlink ?? null : node.hyperlink;
207
285
  base.text = {
208
286
  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()
287
+ size: typeof fontSize === "number" ? round2(fontSize) : fontSize,
288
+ font: fontName?.family ?? ""
214
289
  };
215
- if (fills.length > 0) base.text.color = fills[0];
290
+ const weight = fontName?.style;
291
+ if (weight && weight !== "Regular") base.text.weight = weight;
292
+ const lh = lineHeight ? lineHeightCss(lineHeight) : void 0;
293
+ if (lh && lh !== "normal") base.text.lh = lh;
294
+ const align = node.textAlignHorizontal.toLowerCase();
295
+ if (align !== "left") base.text.align = align;
296
+ if (fills.length > 0) base.text.color = palette.use(fills[0]);
216
297
  const dec = decoration ? decorationCss(decoration) : void 0;
217
- if (dec) base.text.decoration = dec;
298
+ if (dec) base.text.deco = dec;
218
299
  const ls = letterSpacing ? letterSpacingCss(letterSpacing) : void 0;
219
- if (ls) base.text.letterSpacing = ls;
300
+ if (ls) base.text.ls = ls;
220
301
  if (hyperlink?.type === "URL") base.text.link = hyperlink.value;
221
- if (segments) base.text.segments = segments.map((s) => toSegment(s, base.text));
302
+ if (segments) base.text.segs = segments.map((s) => toSegment(s, base.text, palette));
222
303
  return base;
223
304
  }
224
- function applyContainerStyle(node, base) {
305
+ function applyContainerStyle(node, base, palette) {
225
306
  const fills = paintsToCss("fills" in node ? node.fills : []);
226
- if (fills.length > 0) base.fills = fills;
307
+ if (fills.length > 0) base.fills = fills.map((c) => palette.use(c));
227
308
  if ("strokes" in node && Array.isArray(node.strokes) && node.strokes.length > 0) {
228
309
  const strokes = paintsToCss(node.strokes);
229
310
  if (strokes.length > 0) {
230
311
  const weight = "strokeWeight" in node ? node.strokeWeight : 1;
231
- base.stroke = `${typeof weight === "number" ? weight : 1}px solid ${strokes[0]}`;
312
+ base.stroke = `${typeof weight === "number" ? weight : 1}px solid ${palette.use(strokes[0])}`;
232
313
  }
233
314
  }
234
315
  if ("cornerRadius" in node && typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
@@ -255,7 +336,7 @@
255
336
  } else if (e.type === "LAYER_BLUR") {
256
337
  base.blur = `${round2(e.radius)}px`;
257
338
  } else if (e.type === "BACKGROUND_BLUR") {
258
- base.backdropBlur = `${round2(e.radius)}px`;
339
+ base.bgBlur = `${round2(e.radius)}px`;
259
340
  }
260
341
  }
261
342
  if (shadows.length > 0) base.shadow = shadows.join(", ");
@@ -274,36 +355,46 @@
274
355
  if (h === "left" && v === "top") return;
275
356
  base.constraints = { h, v };
276
357
  }
358
+ function parentIsAbsolute(node) {
359
+ const parent = node.parent;
360
+ if (!parent) return true;
361
+ return !("layoutMode" in parent) || parent.layoutMode === "NONE";
362
+ }
277
363
  function applyAutoLayout(node, base) {
278
364
  if (!("layoutMode" in node)) return;
279
365
  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)
366
+ const layout = {
367
+ dir: node.layoutMode === "HORIZONTAL" ? "row" : "column",
368
+ align: [
369
+ ALIGN_MAP[node.primaryAxisAlignItems] ?? node.primaryAxisAlignItems,
370
+ ALIGN_MAP[node.counterAxisAlignItems] ?? node.counterAxisAlignItems
288
371
  ],
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
- }
372
+ sizing: [
373
+ SIZING_MAP[node.primaryAxisSizingMode] ?? node.primaryAxisSizingMode,
374
+ SIZING_MAP[node.counterAxisSizingMode] ?? node.counterAxisSizingMode
375
+ ]
297
376
  };
377
+ const gap = round2(node.itemSpacing);
378
+ if (gap !== 0) layout.gap = gap;
379
+ const pad = [
380
+ round2(node.paddingTop),
381
+ round2(node.paddingRight),
382
+ round2(node.paddingBottom),
383
+ round2(node.paddingLeft)
384
+ ];
385
+ if (pad.some((p) => p !== 0)) layout.pad = pad;
386
+ base.layout = layout;
298
387
  }
299
- async function buildDevNode(node, assets) {
388
+ async function build(node, assets, palette, isRoot, depth, maxDepth) {
300
389
  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
- };
390
+ const base = { id: node.id, type: node.type };
391
+ if (!AUTO_NAME_RE.test(node.name)) base.name = node.name;
392
+ const [x, y, w, h] = rectOf(node);
393
+ if (isRoot || parentIsAbsolute(node)) {
394
+ base.rect = [x, y, w, h];
395
+ } else {
396
+ base.size = [w, h];
397
+ }
307
398
  if ("opacity" in node && node.opacity !== 1) base.opacity = round2(node.opacity);
308
399
  applyConstraints(node, base);
309
400
  const assetFormat = detectAssetFormat(node);
@@ -311,22 +402,35 @@
311
402
  const key = `asset://${assets.length}`;
312
403
  assets.push({ key, format: assetFormat, bytes: await exportNode(node, assetFormat) });
313
404
  base.type = assetFormat === "SVG" ? "ICON" : "IMAGE";
314
- base.image = key;
405
+ base.img = key;
315
406
  return base;
316
407
  }
317
- if (node.type === "TEXT") return buildText(node, base);
318
- applyContainerStyle(node, base);
408
+ if (node.type === "TEXT") return buildText(node, base, palette);
409
+ applyContainerStyle(node, base, palette);
319
410
  applyAutoLayout(node, base);
320
411
  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);
412
+ if (maxDepth !== void 0 && depth >= maxDepth) {
413
+ const more = levelsBelow(node);
414
+ if (more > 0) base.more = more;
415
+ } else {
416
+ const kids = [];
417
+ for (const child of node.children) {
418
+ const built = await build(child, assets, palette, false, depth + 1, maxDepth);
419
+ if (built) kids.push(built);
420
+ }
421
+ if (kids.length > 0) base.kids = kids;
325
422
  }
326
- if (children.length > 0) base.children = children;
327
423
  }
328
424
  return base;
329
425
  }
426
+ async function buildDevNode(node, assets, maxDepth) {
427
+ const palette = new Palette();
428
+ const root = await build(node, assets, palette, true, 0, maxDepth);
429
+ if (!root) return null;
430
+ palette.apply(root);
431
+ root.depth = levelsBelow(node);
432
+ return root;
433
+ }
330
434
 
331
435
  // src/commands.ts
332
436
  async function findNode(guid) {
@@ -339,32 +443,89 @@
339
443
  if (!selected) throw new Error("\u672A\u4F20\u5165 guid \u4E14\u753B\u5E03\u65E0\u9009\u4E2D\u56FE\u5C42");
340
444
  return selected;
341
445
  }
342
- async function executeCommand(command) {
343
- const args = command.args;
446
+ function normMaxDepth(input) {
447
+ return typeof input === "number" && Number.isFinite(input) && input >= 0 ? Math.floor(input) : void 0;
448
+ }
449
+ function normImageFormat(input) {
450
+ const f = typeof input === "string" ? input.toUpperCase() : "PNG";
451
+ return f === "SVG" ? "SVG" : f === "JPG" ? "JPG" : "PNG";
452
+ }
453
+ function normArgs(type, args) {
454
+ switch (type) {
455
+ case "get_node_dsl":
456
+ return { maxDepth: normMaxDepth(args.maxDepth) };
457
+ case "get_screenshot":
458
+ return { format: (args.format ?? "PNG").toUpperCase(), scale: args.scale ?? 2 };
459
+ default:
460
+ return {
461
+ format: normImageFormat(args.format),
462
+ scale: typeof args.scale === "number" ? args.scale : 2
463
+ };
464
+ }
465
+ }
466
+ async function produce(command, node, args) {
344
467
  switch (command.type) {
345
- case "get_selected":
346
- return { result: collectContext(), assets: [] };
347
468
  case "get_node_dsl": {
348
- const node = await findNode(args.guid);
349
469
  const assets = [];
350
- const dsl = await buildDevNode(node, assets);
470
+ const dsl = await buildDevNode(node, assets, normMaxDepth(args.maxDepth));
351
471
  return { result: dsl, assets };
352
472
  }
353
473
  case "get_screenshot": {
354
- const node = await findNode(args.guid);
355
474
  const format = (args.format ?? "PNG").toUpperCase();
356
- const bytes = await exportNode(node, format === "SVG" ? "SVG" : "PNG", args.scale ?? 2);
357
- const rect = rectOf(node);
475
+ const scale = args.scale ?? 2;
476
+ const bytes = await exportNode(node, format === "SVG" ? "SVG" : "PNG", scale);
477
+ const [, , width, height] = rectOf(node);
358
478
  return {
359
- result: { image: "asset://0", width: rect.width, height: rect.height, format },
479
+ result: { img: "asset://0", width, height, format },
360
480
  assets: [{ key: "asset://0", format, bytes }]
361
481
  };
362
482
  }
483
+ default: {
484
+ const [, , width, height] = rectOf(node);
485
+ const assets = [];
486
+ const exports = [];
487
+ const marks = "exportSettings" in node ? node.exportSettings : [];
488
+ if (marks.length > 0) {
489
+ for (const mark of marks) {
490
+ const bytes = await node.exportAsync(mark);
491
+ const key = `asset://${assets.length}`;
492
+ assets.push({ key, format: mark.format, bytes });
493
+ const entry = { img: key, format: mark.format };
494
+ if ("constraint" in mark && mark.constraint?.type === "SCALE") {
495
+ entry.scale = mark.constraint.value;
496
+ }
497
+ if ("suffix" in mark && mark.suffix) entry.suffix = mark.suffix;
498
+ exports.push(entry);
499
+ }
500
+ } else {
501
+ const format = normImageFormat(args.format);
502
+ const scale = typeof args.scale === "number" ? args.scale : 2;
503
+ const bytes = await exportNode(node, format, scale);
504
+ assets.push({ key: "asset://0", format, bytes });
505
+ exports.push({ img: "asset://0", format, scale });
506
+ }
507
+ return { result: { width, height, exports }, assets };
508
+ }
509
+ }
510
+ }
511
+ async function executeCommand(command) {
512
+ const args = command.args;
513
+ if (command.type === "get_selected") {
514
+ return { result: collectContext(), assets: [] };
515
+ }
516
+ const node = await findNode(args.guid);
517
+ const key = `${command.type}:${node.id}:${JSON.stringify(normArgs(command.type, args))}`;
518
+ if (args.cache !== "refresh") {
519
+ const hit = readCache(key);
520
+ if (hit) return hit;
363
521
  }
522
+ const fresh = await produce(command, node, args);
523
+ writeCache(key, fresh);
524
+ return fresh;
364
525
  }
365
526
 
366
527
  // 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" });
528
+ 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
529
  figma.on("selectionchange", push);
369
530
  figma.ui.onmessage = (msg) => {
370
531
  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,37 @@
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.2.1",
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
+ }