batter-pixso-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1844 -0
- package/dist/plugin/code.js +391 -0
- package/dist/plugin/manifest.json +14 -0
- package/dist/plugin/ui.html +310 -0
- package/package.json +34 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
(() => {
|
|
3
|
+
// src/context.ts
|
|
4
|
+
function collectContext() {
|
|
5
|
+
return {
|
|
6
|
+
fileKey: figma.fileKey,
|
|
7
|
+
fileName: figma.root.name,
|
|
8
|
+
pageName: figma.currentPage.name,
|
|
9
|
+
selection: figma.currentPage.selection.map(
|
|
10
|
+
(node) => ({ id: node.id, name: node.name, type: node.type })
|
|
11
|
+
)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function push() {
|
|
15
|
+
figma.ui.postMessage({ type: "context", payload: collectContext() });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/dsl/export.ts
|
|
19
|
+
async function exportNode(node, format, scale = 2) {
|
|
20
|
+
const settings = format === "SVG" ? { format: "SVG" } : { format: "PNG", constraint: { type: "SCALE", value: scale } };
|
|
21
|
+
return node.exportAsync(settings);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/dsl/detect.ts
|
|
25
|
+
var VECTOR_TYPES = /* @__PURE__ */ new Set([
|
|
26
|
+
"VECTOR",
|
|
27
|
+
"BOOLEAN_OPERATION",
|
|
28
|
+
"STAR",
|
|
29
|
+
"LINE",
|
|
30
|
+
"POLYGON"
|
|
31
|
+
]);
|
|
32
|
+
function hasImageFill(node) {
|
|
33
|
+
return "fills" in node && Array.isArray(node.fills) && node.fills.some((p) => p.type === "IMAGE");
|
|
34
|
+
}
|
|
35
|
+
function isAllVectorWithoutText(node) {
|
|
36
|
+
if (node.type === "TEXT") return false;
|
|
37
|
+
if (VECTOR_TYPES.has(node.type)) return true;
|
|
38
|
+
if ("children" in node && node.children.length > 0) {
|
|
39
|
+
return node.children.every(isAllVectorWithoutText);
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
function detectAssetFormat(node) {
|
|
44
|
+
if ("exportSettings" in node && node.exportSettings.length > 0) {
|
|
45
|
+
return node.exportSettings[0].format === "SVG" ? "SVG" : "PNG";
|
|
46
|
+
}
|
|
47
|
+
if (hasImageFill(node)) return "PNG";
|
|
48
|
+
if (VECTOR_TYPES.has(node.type)) return "SVG";
|
|
49
|
+
if ((node.type === "GROUP" || node.type === "INSTANCE" || node.type === "FRAME") && isAllVectorWithoutText(node)) {
|
|
50
|
+
const box = "absoluteBoundingBox" in node ? node.absoluteBoundingBox : null;
|
|
51
|
+
if (box && Math.max(box.width, box.height) <= 64) return "SVG";
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/dsl/util.ts
|
|
57
|
+
function round2(n) {
|
|
58
|
+
return Math.round(n * 100) / 100;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/dsl/paint.ts
|
|
62
|
+
function rgba(color, opacity) {
|
|
63
|
+
const r = Math.round(color.r * 255);
|
|
64
|
+
const g = Math.round(color.g * 255);
|
|
65
|
+
const b = Math.round(color.b * 255);
|
|
66
|
+
const a = opacity ?? 1;
|
|
67
|
+
if (a >= 1) {
|
|
68
|
+
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
|
|
69
|
+
}
|
|
70
|
+
return `rgba(${r}, ${g}, ${b}, ${round2(a)})`;
|
|
71
|
+
}
|
|
72
|
+
function gradientToCss(paint) {
|
|
73
|
+
const stops = paint.gradientStops.map((s) => `${rgba(s.color, s.color.a)} ${round2(s.position * 100)}%`).join(", ");
|
|
74
|
+
const t = paint.gradientTransform;
|
|
75
|
+
const angle = Math.round(Math.atan2(t[0][1], -t[1][1]) * 180 / Math.PI);
|
|
76
|
+
const kind = paint.type === "GRADIENT_RADIAL" ? "radial-gradient" : "linear-gradient";
|
|
77
|
+
return paint.type === "GRADIENT_RADIAL" ? `${kind}(${stops})` : `${kind}(${(angle % 360 + 360) % 360}deg, ${stops})`;
|
|
78
|
+
}
|
|
79
|
+
function paintsToCss(fills) {
|
|
80
|
+
if (!Array.isArray(fills)) return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const paint of fills) {
|
|
83
|
+
if (paint.visible === false) continue;
|
|
84
|
+
if (paint.type === "SOLID") out.push(rgba(paint.color, paint.opacity));
|
|
85
|
+
else if (paint.type.startsWith("GRADIENT")) out.push(gradientToCss(paint));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/dsl/node.ts
|
|
91
|
+
var ALIGN_MAP = {
|
|
92
|
+
MIN: "flex-start",
|
|
93
|
+
CENTER: "center",
|
|
94
|
+
MAX: "flex-end",
|
|
95
|
+
SPACE_BETWEEN: "space-between",
|
|
96
|
+
BASELINE: "baseline"
|
|
97
|
+
};
|
|
98
|
+
var SIZING_MAP = {
|
|
99
|
+
FIXED: "fixed",
|
|
100
|
+
AUTO: "hug-content",
|
|
101
|
+
FILL: "fill-container",
|
|
102
|
+
HUG: "hug-content"
|
|
103
|
+
};
|
|
104
|
+
var CONSTRAINT_H_MAP = {
|
|
105
|
+
MIN: "left",
|
|
106
|
+
CENTER: "center",
|
|
107
|
+
MAX: "right",
|
|
108
|
+
STRETCH: "stretch",
|
|
109
|
+
SCALE: "scale"
|
|
110
|
+
};
|
|
111
|
+
var CONSTRAINT_V_MAP = {
|
|
112
|
+
MIN: "top",
|
|
113
|
+
CENTER: "center",
|
|
114
|
+
MAX: "bottom",
|
|
115
|
+
STRETCH: "stretch",
|
|
116
|
+
SCALE: "scale"
|
|
117
|
+
};
|
|
118
|
+
function rectOf(node) {
|
|
119
|
+
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
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function sameStyle(a, b) {
|
|
128
|
+
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);
|
|
129
|
+
}
|
|
130
|
+
function extractSegmentsFallback(node) {
|
|
131
|
+
const chars = node.characters;
|
|
132
|
+
const runs = [];
|
|
133
|
+
for (let i = 0; i < chars.length; i++) {
|
|
134
|
+
const style = {
|
|
135
|
+
fontSize: node.getRangeFontSize(i, i + 1),
|
|
136
|
+
fontName: node.getRangeFontName(i, i + 1),
|
|
137
|
+
fills: node.getRangeFills(i, i + 1),
|
|
138
|
+
lineHeight: node.getRangeLineHeight(i, i + 1),
|
|
139
|
+
textDecoration: node.getRangeTextDecoration(i, i + 1),
|
|
140
|
+
letterSpacing: node.getRangeLetterSpacing(i, i + 1),
|
|
141
|
+
hyperlink: node.getRangeHyperlink(i, i + 1)
|
|
142
|
+
};
|
|
143
|
+
const prev = runs[runs.length - 1];
|
|
144
|
+
if (prev && sameStyle(prev, style)) {
|
|
145
|
+
prev.end = i + 1;
|
|
146
|
+
prev.characters += chars[i];
|
|
147
|
+
} else {
|
|
148
|
+
runs.push({ start: i, end: i + 1, characters: chars[i], ...style });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return runs;
|
|
152
|
+
}
|
|
153
|
+
function extractSegments(node) {
|
|
154
|
+
if (typeof node.getStyledTextSegments === "function") {
|
|
155
|
+
return node.getStyledTextSegments([
|
|
156
|
+
"fontSize",
|
|
157
|
+
"fontName",
|
|
158
|
+
"fills",
|
|
159
|
+
"lineHeight",
|
|
160
|
+
"textDecoration",
|
|
161
|
+
"letterSpacing",
|
|
162
|
+
"hyperlink"
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
return extractSegmentsFallback(node);
|
|
166
|
+
}
|
|
167
|
+
function lineHeightCss(lh) {
|
|
168
|
+
if (lh.unit === "AUTO") return "normal";
|
|
169
|
+
return lh.unit === "PIXELS" ? `${round2(lh.value)}px` : `${round2(lh.value)}%`;
|
|
170
|
+
}
|
|
171
|
+
function decorationCss(d) {
|
|
172
|
+
if (d === "UNDERLINE") return "underline";
|
|
173
|
+
if (d === "STRIKETHROUGH") return "line-through";
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
function letterSpacingCss(ls) {
|
|
177
|
+
return ls.unit === "PIXELS" && ls.value !== 0 ? `${round2(ls.value)}px` : void 0;
|
|
178
|
+
}
|
|
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;
|
|
184
|
+
const lh = lineHeightCss(s.lineHeight);
|
|
185
|
+
if (lh !== base.lineHeight) seg.lineHeight = lh;
|
|
186
|
+
const color = paintsToCss(s.fills)[0];
|
|
187
|
+
if (color && color !== base.color) seg.color = color;
|
|
188
|
+
const dec = decorationCss(s.textDecoration);
|
|
189
|
+
if (dec !== base.decoration) seg.decoration = dec ?? "none";
|
|
190
|
+
const ls = letterSpacingCss(s.letterSpacing);
|
|
191
|
+
if (ls !== base.letterSpacing) seg.letterSpacing = ls ?? "normal";
|
|
192
|
+
const link = s.hyperlink?.type === "URL" ? s.hyperlink.value : void 0;
|
|
193
|
+
if (link !== base.link) seg.link = link ?? "";
|
|
194
|
+
return seg;
|
|
195
|
+
}
|
|
196
|
+
function buildText(node, base) {
|
|
197
|
+
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
|
+
const segments = mixed && node.characters.length > 0 ? extractSegments(node) : null;
|
|
199
|
+
const dom = segments?.length ? segments.reduce((a, b) => b.characters.length > a.characters.length ? b : a) : null;
|
|
200
|
+
const fontSize = typeof node.fontSize === "number" ? node.fontSize : dom?.fontSize ?? "mixed";
|
|
201
|
+
const fontName = node.fontName === figma.mixed ? dom?.fontName ?? null : node.fontName;
|
|
202
|
+
const fills = paintsToCss(node.fills === figma.mixed ? dom?.fills ?? [] : node.fills);
|
|
203
|
+
const lineHeight = node.lineHeight === figma.mixed ? dom?.lineHeight : node.lineHeight;
|
|
204
|
+
const decoration = node.textDecoration === figma.mixed ? dom?.textDecoration : node.textDecoration;
|
|
205
|
+
const letterSpacing = node.letterSpacing === figma.mixed ? dom?.letterSpacing : node.letterSpacing;
|
|
206
|
+
const hyperlink = node.hyperlink === figma.mixed ? dom?.hyperlink ?? null : node.hyperlink;
|
|
207
|
+
base.text = {
|
|
208
|
+
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()
|
|
214
|
+
};
|
|
215
|
+
if (fills.length > 0) base.text.color = fills[0];
|
|
216
|
+
const dec = decoration ? decorationCss(decoration) : void 0;
|
|
217
|
+
if (dec) base.text.decoration = dec;
|
|
218
|
+
const ls = letterSpacing ? letterSpacingCss(letterSpacing) : void 0;
|
|
219
|
+
if (ls) base.text.letterSpacing = ls;
|
|
220
|
+
if (hyperlink?.type === "URL") base.text.link = hyperlink.value;
|
|
221
|
+
if (segments) base.text.segments = segments.map((s) => toSegment(s, base.text));
|
|
222
|
+
return base;
|
|
223
|
+
}
|
|
224
|
+
function applyContainerStyle(node, base) {
|
|
225
|
+
const fills = paintsToCss("fills" in node ? node.fills : []);
|
|
226
|
+
if (fills.length > 0) base.fills = fills;
|
|
227
|
+
if ("strokes" in node && Array.isArray(node.strokes) && node.strokes.length > 0) {
|
|
228
|
+
const strokes = paintsToCss(node.strokes);
|
|
229
|
+
if (strokes.length > 0) {
|
|
230
|
+
const weight = "strokeWeight" in node ? node.strokeWeight : 1;
|
|
231
|
+
base.stroke = `${typeof weight === "number" ? weight : 1}px solid ${strokes[0]}`;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if ("cornerRadius" in node && typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
|
235
|
+
base.radius = round2(node.cornerRadius);
|
|
236
|
+
} else if ("topLeftRadius" in node) {
|
|
237
|
+
const corners = [
|
|
238
|
+
node.topLeftRadius,
|
|
239
|
+
node.topRightRadius,
|
|
240
|
+
node.bottomRightRadius,
|
|
241
|
+
node.bottomLeftRadius
|
|
242
|
+
];
|
|
243
|
+
if (corners.some((c) => c > 0)) base.radius = corners.map(round2);
|
|
244
|
+
}
|
|
245
|
+
if ("effects" in node && Array.isArray(node.effects)) {
|
|
246
|
+
const shadows = [];
|
|
247
|
+
for (const e of node.effects) {
|
|
248
|
+
if (e.visible === false) continue;
|
|
249
|
+
if (e.type === "DROP_SHADOW" || e.type === "INNER_SHADOW") {
|
|
250
|
+
const prefix = e.type === "INNER_SHADOW" ? "inset " : "";
|
|
251
|
+
const spread = e.spread ? ` ${round2(e.spread)}px` : "";
|
|
252
|
+
shadows.push(
|
|
253
|
+
`${prefix}${round2(e.offset.x)}px ${round2(e.offset.y)}px ${round2(e.radius)}px${spread} ${rgba(e.color, e.color.a)}`
|
|
254
|
+
);
|
|
255
|
+
} else if (e.type === "LAYER_BLUR") {
|
|
256
|
+
base.blur = `${round2(e.radius)}px`;
|
|
257
|
+
} else if (e.type === "BACKGROUND_BLUR") {
|
|
258
|
+
base.backdropBlur = `${round2(e.radius)}px`;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (shadows.length > 0) base.shadow = shadows.join(", ");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function applyConstraints(node, base) {
|
|
265
|
+
if (!("constraints" in node)) return;
|
|
266
|
+
const parent = node.parent;
|
|
267
|
+
if (!parent) return;
|
|
268
|
+
if ("layoutMode" in parent && parent.layoutMode !== "NONE") return;
|
|
269
|
+
if (parent.type !== "FRAME" && parent.type !== "COMPONENT" && parent.type !== "INSTANCE" && parent.type !== "GROUP") {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const h = CONSTRAINT_H_MAP[node.constraints.horizontal] ?? "left";
|
|
273
|
+
const v = CONSTRAINT_V_MAP[node.constraints.vertical] ?? "top";
|
|
274
|
+
if (h === "left" && v === "top") return;
|
|
275
|
+
base.constraints = { h, v };
|
|
276
|
+
}
|
|
277
|
+
function applyAutoLayout(node, base) {
|
|
278
|
+
if (!("layoutMode" in node)) return;
|
|
279
|
+
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)
|
|
288
|
+
],
|
|
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
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
async function buildDevNode(node, assets) {
|
|
300
|
+
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
|
+
};
|
|
307
|
+
if ("opacity" in node && node.opacity !== 1) base.opacity = round2(node.opacity);
|
|
308
|
+
applyConstraints(node, base);
|
|
309
|
+
const assetFormat = detectAssetFormat(node);
|
|
310
|
+
if (assetFormat) {
|
|
311
|
+
const key = `asset://${assets.length}`;
|
|
312
|
+
assets.push({ key, format: assetFormat, bytes: await exportNode(node, assetFormat) });
|
|
313
|
+
base.type = assetFormat === "SVG" ? "ICON" : "IMAGE";
|
|
314
|
+
base.image = key;
|
|
315
|
+
return base;
|
|
316
|
+
}
|
|
317
|
+
if (node.type === "TEXT") return buildText(node, base);
|
|
318
|
+
applyContainerStyle(node, base);
|
|
319
|
+
applyAutoLayout(node, base);
|
|
320
|
+
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);
|
|
325
|
+
}
|
|
326
|
+
if (children.length > 0) base.children = children;
|
|
327
|
+
}
|
|
328
|
+
return base;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/commands.ts
|
|
332
|
+
async function findNode(guid) {
|
|
333
|
+
if (guid) {
|
|
334
|
+
const node = await figma.getNodeByIdAsync(guid);
|
|
335
|
+
if (!node || node.removed) throw new Error(`\u8282\u70B9\u4E0D\u5B58\u5728\u6216\u5DF2\u5220\u9664: ${guid}`);
|
|
336
|
+
return node;
|
|
337
|
+
}
|
|
338
|
+
const selected = figma.currentPage.selection[0];
|
|
339
|
+
if (!selected) throw new Error("\u672A\u4F20\u5165 guid \u4E14\u753B\u5E03\u65E0\u9009\u4E2D\u56FE\u5C42");
|
|
340
|
+
return selected;
|
|
341
|
+
}
|
|
342
|
+
async function executeCommand(command) {
|
|
343
|
+
const args = command.args;
|
|
344
|
+
switch (command.type) {
|
|
345
|
+
case "get_selected":
|
|
346
|
+
return { result: collectContext(), assets: [] };
|
|
347
|
+
case "get_node_dsl": {
|
|
348
|
+
const node = await findNode(args.guid);
|
|
349
|
+
const assets = [];
|
|
350
|
+
const dsl = await buildDevNode(node, assets);
|
|
351
|
+
return { result: dsl, assets };
|
|
352
|
+
}
|
|
353
|
+
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);
|
|
358
|
+
return {
|
|
359
|
+
result: { image: "asset://0", width: rect.width, height: rect.height, format },
|
|
360
|
+
assets: [{ key: "asset://0", format, bytes }]
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 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" });
|
|
368
|
+
figma.on("selectionchange", push);
|
|
369
|
+
figma.ui.onmessage = (msg) => {
|
|
370
|
+
if (msg?.type === "pull") {
|
|
371
|
+
push();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (msg?.type === "command") {
|
|
375
|
+
const command = msg.command;
|
|
376
|
+
executeCommand(command).then(({ result, assets }) => {
|
|
377
|
+
const reply = { type: "command-result", id: command.id, ok: true, result, assets };
|
|
378
|
+
figma.ui.postMessage(reply);
|
|
379
|
+
}).catch((err) => {
|
|
380
|
+
const reply = {
|
|
381
|
+
type: "command-result",
|
|
382
|
+
id: command.id,
|
|
383
|
+
ok: false,
|
|
384
|
+
error: err.message ?? String(err)
|
|
385
|
+
};
|
|
386
|
+
figma.ui.postMessage(reply);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
push();
|
|
391
|
+
})();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Batter Pixso Bridge",
|
|
3
|
+
"id": "batter-pixso-bridge",
|
|
4
|
+
"api": "1.0.0",
|
|
5
|
+
"main": "code.js",
|
|
6
|
+
"ui": "ui.html",
|
|
7
|
+
"editorType": ["figma", "preview", "dev"],
|
|
8
|
+
"networkAccess": {
|
|
9
|
+
"allowedDomains": [
|
|
10
|
+
"http://localhost:3210",
|
|
11
|
+
"http://127.0.0.1:3210"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|