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.
- package/dist/index.js +156 -105
- package/dist/plugin/code.js +238 -77
- package/dist/plugin/ui.html +4 -4
- package/package.json +37 -37
- package/standalone/index.js +33 -98
- package/standalone/plugin/code.js +238 -77
- 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,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
|
|
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: "
|
|
121
|
+
MIN: "start",
|
|
93
122
|
CENTER: "center",
|
|
94
|
-
MAX: "
|
|
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
|
|
101
|
-
FILL: "fill
|
|
102
|
-
HUG: "hug
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 = {
|
|
181
|
-
if (s.fontSize !== base.
|
|
182
|
-
if (s.fontName.family !== base.
|
|
183
|
-
if (s.fontName.style !== base.
|
|
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.
|
|
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.
|
|
267
|
+
if (dec !== base.deco) seg.deco = dec ?? "none";
|
|
190
268
|
const ls = letterSpacingCss(s.letterSpacing);
|
|
191
|
-
if (ls !== base.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
298
|
+
if (dec) base.text.deco = dec;
|
|
218
299
|
const ls = letterSpacing ? letterSpacingCss(letterSpacing) : void 0;
|
|
219
|
-
if (ls) base.text.
|
|
300
|
+
if (ls) base.text.ls = ls;
|
|
220
301
|
if (hyperlink?.type === "URL") base.text.link = hyperlink.value;
|
|
221
|
-
if (segments) 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.
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
|
388
|
+
async function build(node, assets, palette, isRoot, depth, maxDepth) {
|
|
300
389
|
if (!node.visible) return null;
|
|
301
|
-
const base = {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
rect
|
|
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.
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
343
|
-
|
|
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
|
|
357
|
-
const
|
|
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: {
|
|
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.
|
|
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") {
|
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,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "batter-pixso-mcp",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
}
|