comate-ui-inspector 0.1.6-alpha.1 → 0.1.6-alpha.3
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/bridge/server.mjs +101 -68
- package/enhancements/figma/figma-exec.js +122 -44
- package/enhancements/figma/figma-ui.js +97 -0
- package/enhancements/figma/pane.html +26 -3
- package/enhancements/figma/pane.js +59 -6
- package/enhancements/figma/plugin.json +2 -2
- package/extension/background.js +4 -9
- package/extension/bridge-content.js +163 -1
- package/extension/manifest.json +1 -1
- package/extension/native-host/host.mjs +88 -48
- package/hooks/inject-ui-inspector.mjs +42 -2
- package/mcp/server.mjs +0 -89
- package/package.json +1 -1
- package/enhancements/figma/figma-bridge.js +0 -46
package/bridge/server.mjs
CHANGED
|
@@ -3,14 +3,10 @@
|
|
|
3
3
|
* ui-inspector HTTP bridge(零依赖)——远程开发用。
|
|
4
4
|
*
|
|
5
5
|
* 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
|
|
6
|
-
* ~/.ui-inspector/*.json
|
|
7
|
-
* POST /ingest {targets:[...]} → 覆盖写 pending.json,回 {ok,pending}
|
|
8
|
-
* GET /state → 回 {ok,count,status}
|
|
9
|
-
*
|
|
10
|
-
* 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
|
|
6
|
+
* ~/.ui-inspector/*.json。浏览器扩展(transport=http)直连本服务。
|
|
11
7
|
*/
|
|
12
8
|
import {createServer} from 'node:http';
|
|
13
|
-
import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync} from 'node:fs';
|
|
9
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs';
|
|
14
10
|
import {dirname, resolve} from 'node:path';
|
|
15
11
|
import {homedir} from 'node:os';
|
|
16
12
|
|
|
@@ -19,10 +15,11 @@ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
|
19
15
|
// 录制:元信息 + append-only 事件流分离,避免多请求读改写竞态把 active 盖回 true。
|
|
20
16
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
17
|
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
22
|
-
//
|
|
23
|
-
const
|
|
18
|
+
// figma「带入对话」采集的节点结构。
|
|
19
|
+
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
20
|
+
// figma 节点内导出的图片字节落盘到这里(按采集批次分子目录)。
|
|
21
|
+
const figmaAssetsDir = resolve(homedir(), '.ui-inspector/figma-assets');
|
|
24
22
|
const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
|
|
25
|
-
// 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
|
|
26
23
|
const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
|
|
27
24
|
|
|
28
25
|
function readStore() {
|
|
@@ -47,7 +44,9 @@ function writeStore(list) {
|
|
|
47
44
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
48
45
|
}
|
|
49
46
|
|
|
50
|
-
//
|
|
47
|
+
// PLACEHOLDER_HELPERS
|
|
48
|
+
|
|
49
|
+
// 录制累积:元信息与事件流分离。
|
|
51
50
|
function readMeta() {
|
|
52
51
|
try {
|
|
53
52
|
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
@@ -62,7 +61,7 @@ function writeMeta(meta) {
|
|
|
62
61
|
function recStart(startedAt, pageUrl) {
|
|
63
62
|
const cur = readMeta();
|
|
64
63
|
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
65
|
-
return;
|
|
64
|
+
return;
|
|
66
65
|
}
|
|
67
66
|
writeMeta({active: true, startedAt, pageUrl});
|
|
68
67
|
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
@@ -86,62 +85,103 @@ function recStop(endedAt) {
|
|
|
86
85
|
writeMeta(cur);
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
//
|
|
90
|
-
function
|
|
91
|
-
return
|
|
88
|
+
// figma 节点采集:追加到 figma-pending.json(上限 10 个,防爆)。
|
|
89
|
+
function sanitizeName(name) {
|
|
90
|
+
return (
|
|
91
|
+
String(name || 'image')
|
|
92
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
93
|
+
.replace(/^_+|_+$/g, '')
|
|
94
|
+
.slice(0, 60) || 'image'
|
|
95
|
+
);
|
|
92
96
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
|
|
98
|
+
// 清理旧的图片批次:保留当前仍在 figma-pending.json 里引用的批次目录(尚未被消费的节点),
|
|
99
|
+
// 其余(已被 hook 注入消费、或被挤出 pending 的)全部删除。只在新一次导出时触发——「下次导出 PNG 才删上次的」。
|
|
100
|
+
function pruneStaleAssets() {
|
|
101
|
+
let keep = new Set();
|
|
96
102
|
try {
|
|
97
|
-
|
|
103
|
+
const list = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
|
|
104
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
105
|
+
const imgs = (entry && entry.node && entry.node.images) || [];
|
|
106
|
+
for (const im of imgs) {
|
|
107
|
+
if (im && im.file) {
|
|
108
|
+
keep.add(dirname(im.file));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
98
112
|
} catch {
|
|
99
|
-
|
|
113
|
+
keep = new Set();
|
|
100
114
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (c && c.status === 'pending') {
|
|
113
|
-
c.status = 'taken';
|
|
115
|
+
let dirs = [];
|
|
116
|
+
try {
|
|
117
|
+
dirs = readdirSync(figmaAssetsDir);
|
|
118
|
+
} catch {
|
|
119
|
+
return; // 目录不存在,无需清理
|
|
120
|
+
}
|
|
121
|
+
for (const name of dirs) {
|
|
122
|
+
const full = resolve(figmaAssetsDir, name);
|
|
123
|
+
if (!keep.has(full)) {
|
|
114
124
|
try {
|
|
115
|
-
|
|
125
|
+
rmSync(full, {recursive: true, force: true});
|
|
116
126
|
} catch {
|
|
117
|
-
//
|
|
127
|
+
// 删除失败忽略
|
|
118
128
|
}
|
|
119
|
-
out.push({id: c.id, type: c.type, params: c.params || {}});
|
|
120
129
|
}
|
|
121
130
|
}
|
|
122
|
-
return out;
|
|
123
131
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
|
|
133
|
+
// 把 node.images 里的 base64 落成本地 PNG 文件,node 只保留 {file,name,id,width,height},避免 json 膨胀。
|
|
134
|
+
function persistImages(node) {
|
|
135
|
+
if (!node || !Array.isArray(node.images) || !node.images.length) {
|
|
136
|
+
if (node) {
|
|
137
|
+
delete node.images;
|
|
138
|
+
}
|
|
130
139
|
return;
|
|
131
140
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
pruneStaleAssets();
|
|
142
|
+
const batch = resolve(figmaAssetsDir, `${Date.now()}`);
|
|
143
|
+
mkdirSync(batch, {recursive: true});
|
|
144
|
+
const used = {};
|
|
145
|
+
const saved = [];
|
|
146
|
+
node.images.forEach((img, i) => {
|
|
147
|
+
if (!img || !img.base64) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let base = sanitizeName(img.name) + '-' + i;
|
|
151
|
+
if (used[base]) {
|
|
152
|
+
base += '_' + used[base]++;
|
|
153
|
+
} else {
|
|
154
|
+
used[base] = 1;
|
|
155
|
+
}
|
|
156
|
+
const file = resolve(batch, `${base}.png`);
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(file, Buffer.from(img.base64, 'base64'));
|
|
159
|
+
saved.push({id: img.id, name: img.name, width: img.width, height: img.height, file});
|
|
160
|
+
} catch {
|
|
161
|
+
// 单张落盘失败跳过
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
node.images = saved;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function figmaCapture(node) {
|
|
168
|
+
if (!node) {
|
|
169
|
+
return;
|
|
137
170
|
}
|
|
138
|
-
|
|
171
|
+
persistImages(node);
|
|
172
|
+
let list = [];
|
|
139
173
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
|
|
175
|
+
list = Array.isArray(p) ? p : [];
|
|
142
176
|
} catch {
|
|
143
|
-
|
|
177
|
+
list = [];
|
|
178
|
+
}
|
|
179
|
+
list.push({node, ts: Date.now()});
|
|
180
|
+
if (list.length > 10) {
|
|
181
|
+
list = list.slice(-10);
|
|
144
182
|
}
|
|
183
|
+
mkdirSync(dirname(figmaPendingFile), {recursive: true});
|
|
184
|
+
writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
145
185
|
}
|
|
146
186
|
|
|
147
187
|
function readBody(req, cb) {
|
|
@@ -177,18 +217,15 @@ const server = createServer((req, res) => {
|
|
|
177
217
|
}
|
|
178
218
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
179
219
|
if (req.method === 'POST' && url.pathname === '/ingest') {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const {targets} = JSON.parse(body || '{}');
|
|
185
|
-
const now = new Date().toISOString();
|
|
186
|
-
const list = (Array.isArray(targets) ? targets : []).map(target => ({target, receivedAt: now}));
|
|
187
|
-
writeStore(list);
|
|
188
|
-
json(res, 200, {ok: true, pending: list.length});
|
|
189
|
-
} catch (error) {
|
|
190
|
-
json(res, 400, {ok: false, error: String(error)});
|
|
220
|
+
readBody(req, (data, err) => {
|
|
221
|
+
if (err) {
|
|
222
|
+
return json(res, 400, {ok: false, error: String(err)});
|
|
191
223
|
}
|
|
224
|
+
const targets = Array.isArray(data.targets) ? data.targets : [];
|
|
225
|
+
const now = new Date().toISOString();
|
|
226
|
+
const list = targets.map(target => ({target, receivedAt: now}));
|
|
227
|
+
writeStore(list);
|
|
228
|
+
json(res, 200, {ok: true, pending: list.length});
|
|
192
229
|
});
|
|
193
230
|
return;
|
|
194
231
|
}
|
|
@@ -226,16 +263,12 @@ const server = createServer((req, res) => {
|
|
|
226
263
|
});
|
|
227
264
|
return;
|
|
228
265
|
}
|
|
229
|
-
if (req.method === '
|
|
230
|
-
json(res, 200, {ok: true, commands: cmdPoll()});
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
if (req.method === 'POST' && url.pathname === '/cmd/result') {
|
|
266
|
+
if (req.method === 'POST' && url.pathname === '/figma/capture') {
|
|
234
267
|
readBody(req, (data, err) => {
|
|
235
268
|
if (err) {
|
|
236
269
|
return json(res, 400, {ok: false, error: String(err)});
|
|
237
270
|
}
|
|
238
|
-
|
|
271
|
+
figmaCapture(data.node);
|
|
239
272
|
json(res, 200, {ok: true});
|
|
240
273
|
});
|
|
241
274
|
return;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Figma 命令执行器(MAIN world,figma.com
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Figma 命令执行器(MAIN world,figma.com):响应「带入对话」——把当前选中节点转成 F2C HTML 结构,
|
|
3
|
+
* 并把节点内的图片(IMAGE 填充 / 图片节点)用 exportAsync 导出为 PNG,一并回传给 figma-ui 落盘待 hook 注入。
|
|
4
|
+
* F2CConverter 的 convertFigmaNodeToHtml 强制 withoutImages,HTML 里的图片只是占位路径、没有字节,
|
|
5
|
+
* 所以图片要我们自己走 window.figma 的 exportAsync 取。页面内已解锁的 window.figma / window.F2CConverter
|
|
6
|
+
* 由 rewrite.js + browser.global.js 提供。
|
|
6
7
|
*/
|
|
7
8
|
(function () {
|
|
8
9
|
if (window.__oreateFigmaExec) {
|
|
@@ -10,74 +11,151 @@
|
|
|
10
11
|
}
|
|
11
12
|
window.__oreateFigmaExec = true;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
14
|
+
var MAX_IMAGES = 20; // 单次最多导出的图片数,防爆
|
|
15
|
+
var SCALE = 2; // 导出倍率,兼顾清晰度与体积
|
|
16
16
|
|
|
17
|
-
function resolveNode(
|
|
17
|
+
function resolveNode() {
|
|
18
18
|
var fig = window.figma;
|
|
19
19
|
if (!fig) {
|
|
20
|
-
throw new Error('window.figma
|
|
21
|
-
}
|
|
22
|
-
if (params && params.nodeId && typeof fig.getNodeById === 'function') {
|
|
23
|
-
var n = fig.getNodeById(params.nodeId);
|
|
24
|
-
if (n) {
|
|
25
|
-
return n;
|
|
26
|
-
}
|
|
20
|
+
throw new Error('window.figma 不可用(请确认在 figma.com 设计稿页,且扩展已解锁)');
|
|
27
21
|
}
|
|
28
22
|
var sel = fig.currentPage && fig.currentPage.selection;
|
|
29
23
|
if (sel && sel.length) {
|
|
30
24
|
return sel[0];
|
|
31
25
|
}
|
|
32
|
-
throw new Error('
|
|
26
|
+
throw new Error('未选中节点:请先在画布选中一个节点');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 该节点自身是否「携带图片」:IMAGE 类型节点,或 fills 里有可见的 IMAGE 填充。
|
|
30
|
+
function hasImage(node) {
|
|
31
|
+
if (!node) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
if (node.type === 'IMAGE') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
var fills;
|
|
38
|
+
try {
|
|
39
|
+
fills = node.fills;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(fills)) {
|
|
44
|
+
return false; // figma.mixed 或不支持 fills
|
|
45
|
+
}
|
|
46
|
+
return fills.some(function (f) {
|
|
47
|
+
return f && f.type === 'IMAGE' && f.visible !== false;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 深度遍历子树,收集携带图片的节点(含根自身),按先序、去重、限量。
|
|
52
|
+
function collectImageNodes(root) {
|
|
53
|
+
var out = [];
|
|
54
|
+
var seen = {};
|
|
55
|
+
var stack = [root];
|
|
56
|
+
while (stack.length && out.length < MAX_IMAGES) {
|
|
57
|
+
var n = stack.shift();
|
|
58
|
+
if (!n || seen[n.id]) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
seen[n.id] = true;
|
|
62
|
+
if (hasImage(n)) {
|
|
63
|
+
out.push(n);
|
|
64
|
+
}
|
|
65
|
+
var kids;
|
|
66
|
+
try {
|
|
67
|
+
kids = n.children;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
kids = null;
|
|
70
|
+
}
|
|
71
|
+
if (Array.isArray(kids)) {
|
|
72
|
+
// 保持先序:子节点插到队首前面
|
|
73
|
+
stack = kids.concat(stack);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
33
77
|
}
|
|
34
78
|
|
|
35
79
|
function toBase64(bytes) {
|
|
80
|
+
if (window.figma && typeof window.figma.base64Encode === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
return window.figma.base64Encode(bytes);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
/* 落到下面手工编码 */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
36
87
|
var bin = '';
|
|
37
|
-
var
|
|
38
|
-
|
|
39
|
-
bin += String.fromCharCode(arr[i]);
|
|
88
|
+
for (var i = 0; i < bytes.length; i++) {
|
|
89
|
+
bin += String.fromCharCode(bytes[i]);
|
|
40
90
|
}
|
|
41
91
|
return btoa(bin);
|
|
42
92
|
}
|
|
43
93
|
|
|
44
|
-
async function
|
|
45
|
-
var
|
|
46
|
-
var
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
94
|
+
async function exportImages(root) {
|
|
95
|
+
var nodes = collectImageNodes(root);
|
|
96
|
+
var images = [];
|
|
97
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
98
|
+
var n = nodes[i];
|
|
99
|
+
try {
|
|
100
|
+
var bytes = await n.exportAsync({format: 'PNG', constraint: {type: 'SCALE', value: SCALE}});
|
|
101
|
+
images.push({
|
|
102
|
+
id: n.id,
|
|
103
|
+
name: n.name || 'image-' + i,
|
|
104
|
+
type: n.type,
|
|
105
|
+
width: Math.round(n.width || 0),
|
|
106
|
+
height: Math.round(n.height || 0),
|
|
107
|
+
format: 'PNG',
|
|
108
|
+
base64: toBase64(bytes),
|
|
109
|
+
});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// 单张导出失败不影响其余(部分节点可能不可导出)
|
|
50
112
|
}
|
|
51
|
-
var node = resolveNode(params);
|
|
52
|
-
var html = await window.F2CConverter.convertFigmaNodeToHtml(node, params.options || {});
|
|
53
|
-
return {html: typeof html === 'string' ? html : JSON.stringify(html)};
|
|
54
113
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
114
|
+
return images;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 把整个选中节点导出成一张 PNG(用于开发页「走查」覆盖层)。width/height 是节点的 CSS 像素尺寸,
|
|
118
|
+
// 覆盖层按此宽度 1:1 显示即与设计稿同像素。导出倍率 SCALE 保证高清。
|
|
119
|
+
async function exportSnapshot(root) {
|
|
120
|
+
try {
|
|
121
|
+
var bytes = await root.exportAsync({format: 'PNG', constraint: {type: 'SCALE', value: SCALE}});
|
|
122
|
+
return {
|
|
123
|
+
base64: toBase64(bytes),
|
|
124
|
+
width: Math.round(root.width || 0),
|
|
125
|
+
height: Math.round(root.height || 0),
|
|
126
|
+
};
|
|
127
|
+
} catch (e) {
|
|
128
|
+
return null;
|
|
63
129
|
}
|
|
64
|
-
throw new Error('未知命令类型:' + type);
|
|
65
130
|
}
|
|
66
131
|
|
|
67
132
|
window.addEventListener('message', function (ev) {
|
|
68
|
-
if (ev.source !== window || !ev.data ||
|
|
133
|
+
if (ev.source !== window || !ev.data || ev.data.__uiInspectorFigmaCapture !== true) {
|
|
69
134
|
return;
|
|
70
135
|
}
|
|
71
|
-
var cmd = ev.data.__uiInspectorCmd;
|
|
72
136
|
Promise.resolve()
|
|
73
|
-
.then(function () {
|
|
74
|
-
|
|
137
|
+
.then(async function () {
|
|
138
|
+
if (!window.F2CConverter || typeof window.F2CConverter.convertFigmaNodeToHtml !== 'function') {
|
|
139
|
+
throw new Error('F2CConverter 不可用');
|
|
140
|
+
}
|
|
141
|
+
var node = resolveNode();
|
|
142
|
+
var html = await window.F2CConverter.convertFigmaNodeToHtml(node, {});
|
|
143
|
+
var images = await exportImages(node);
|
|
144
|
+
var snapshot = await exportSnapshot(node);
|
|
145
|
+
return {
|
|
146
|
+
nodeId: node.id,
|
|
147
|
+
name: node.name,
|
|
148
|
+
type: node.type,
|
|
149
|
+
html: typeof html === 'string' ? html : JSON.stringify(html),
|
|
150
|
+
images: images,
|
|
151
|
+
snapshot: snapshot,
|
|
152
|
+
};
|
|
75
153
|
})
|
|
76
|
-
.then(function (
|
|
77
|
-
|
|
154
|
+
.then(function (node) {
|
|
155
|
+
window.postMessage({__uiInspectorFigmaCaptured: {node: node}}, '*');
|
|
78
156
|
})
|
|
79
157
|
.catch(function (err) {
|
|
80
|
-
|
|
158
|
+
window.postMessage({__uiInspectorFigmaCaptured: {error: String(err)}}, '*');
|
|
81
159
|
});
|
|
82
160
|
});
|
|
83
161
|
})();
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Figma「带入对话」浮层(ISOLATED world,figma.com):页面右下角一个按钮,点一下把当前选中节点
|
|
3
|
+
* 的 F2C HTML 结构采集下来,经 background 落盘 figma-pending.json;下次在 Comate 发消息时,
|
|
4
|
+
* hook 会把该结构注入对话,Agent 据此生成代码——用户无需手动调工具、无需填路径。
|
|
5
|
+
*/
|
|
6
|
+
(function () {
|
|
7
|
+
if (window.__oreateFigmaUi) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
window.__oreateFigmaUi = true;
|
|
11
|
+
const alive = () => !!(chrome.runtime && chrome.runtime.id);
|
|
12
|
+
|
|
13
|
+
const btn = document.createElement('button');
|
|
14
|
+
btn.textContent = '带入 Comate 生成代码';
|
|
15
|
+
btn.style.cssText =
|
|
16
|
+
'position:fixed;right:16px;bottom:16px;z-index:2147483647;border:none;border-radius:999px;' +
|
|
17
|
+
'padding:8px 14px;background:#0d99ff;color:#fff;font:12px/1.4 sans-serif;cursor:pointer;' +
|
|
18
|
+
'box-shadow:0 2px 12px rgba(0,0,0,0.25);';
|
|
19
|
+
|
|
20
|
+
let tip = null;
|
|
21
|
+
function toast(text, ok) {
|
|
22
|
+
if (!tip) {
|
|
23
|
+
tip = document.createElement('div');
|
|
24
|
+
tip.style.cssText =
|
|
25
|
+
'position:fixed;right:16px;bottom:56px;z-index:2147483647;max-width:280px;padding:6px 10px;' +
|
|
26
|
+
'border-radius:8px;background:#111;color:#fff;font:12px/1.4 sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.25);';
|
|
27
|
+
document.documentElement.appendChild(tip);
|
|
28
|
+
}
|
|
29
|
+
tip.textContent = text;
|
|
30
|
+
tip.style.background = ok === false ? '#dc2626' : ok ? '#16a34a' : '#111';
|
|
31
|
+
tip.style.display = 'block';
|
|
32
|
+
clearTimeout(toast._t);
|
|
33
|
+
toast._t = setTimeout(() => (tip.style.display = 'none'), 3000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
btn.onclick = () => {
|
|
37
|
+
toast('采集选中节点…');
|
|
38
|
+
window.postMessage({__uiInspectorFigmaCapture: true}, '*');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
window.addEventListener('message', event => {
|
|
42
|
+
if (event.source !== window || !event.data || !event.data.__uiInspectorFigmaCaptured) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const payload = event.data.__uiInspectorFigmaCaptured;
|
|
46
|
+
if (payload.error) {
|
|
47
|
+
toast('采集失败:' + payload.error, false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!alive()) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const node = payload.node || {};
|
|
54
|
+
// 整节点快照只给开发页「走查」覆盖层用,存 chrome.storage.local,不随 figma-capture 进 figma-pending.json。
|
|
55
|
+
const snap = node.snapshot;
|
|
56
|
+
delete node.snapshot;
|
|
57
|
+
if (snap && snap.base64) {
|
|
58
|
+
try {
|
|
59
|
+
chrome.storage.local.set(
|
|
60
|
+
{
|
|
61
|
+
figmaOverlay: {
|
|
62
|
+
dataUrl: 'data:image/png;base64,' + snap.base64,
|
|
63
|
+
width: snap.width,
|
|
64
|
+
height: snap.height,
|
|
65
|
+
name: node.name || node.nodeId,
|
|
66
|
+
ts: Date.now(),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
() => void chrome.runtime.lastError
|
|
70
|
+
);
|
|
71
|
+
} catch {
|
|
72
|
+
// 存储失败不影响带入
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
chrome.runtime.sendMessage({type: 'figma-capture', node: node}, resp => {
|
|
77
|
+
if (chrome.runtime.lastError || !resp || !resp.ok) {
|
|
78
|
+
toast('落盘失败,请重试', false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const extra = snap ? ',已可在开发页点「走查」覆盖' : '';
|
|
82
|
+
toast(`已带入「${node.name || node.nodeId}」${extra},去 Comate 发消息即可生成代码`, true);
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
toast('扩展上下文失效,请刷新页面', false);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function mount() {
|
|
90
|
+
if (document.body && !btn.isConnected) {
|
|
91
|
+
document.body.appendChild(btn);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
mount();
|
|
95
|
+
document.addEventListener('DOMContentLoaded', mount);
|
|
96
|
+
setInterval(mount, 2000); // figma 频繁重渲染,兜底保证按钮在
|
|
97
|
+
})();
|
|
@@ -1,9 +1,32 @@
|
|
|
1
|
-
<div class="hint"
|
|
1
|
+
<div class="hint">
|
|
2
|
+
在
|
|
3
|
+
<code>www.figma.com</code>
|
|
4
|
+
打开设计稿后,本模式会在页面注入 F2C 引擎(解锁 Dev Mode / 插件能力)。
|
|
5
|
+
</div>
|
|
2
6
|
<div
|
|
3
7
|
id="figma-status"
|
|
4
8
|
class="hint"
|
|
5
9
|
style="margin-top: 6px"
|
|
6
10
|
></div>
|
|
7
|
-
<div
|
|
8
|
-
|
|
11
|
+
<div style="margin-top: 10px">
|
|
12
|
+
<button
|
|
13
|
+
id="figma-walkthrough"
|
|
14
|
+
class="primary"
|
|
15
|
+
style="width: 100%"
|
|
16
|
+
>
|
|
17
|
+
走查
|
|
18
|
+
</button>
|
|
19
|
+
<div
|
|
20
|
+
id="figma-walkthrough-status"
|
|
21
|
+
class="hint"
|
|
22
|
+
style="margin-top: 4px"
|
|
23
|
+
></div>
|
|
24
|
+
</div>
|
|
25
|
+
<div
|
|
26
|
+
class="hint"
|
|
27
|
+
style="margin-top: 6px"
|
|
28
|
+
>
|
|
29
|
+
需先在上方「设置 → 生效域名」添加
|
|
30
|
+
<code>https://www.figma.com</code>
|
|
31
|
+
并授权,然后重载扩展。
|
|
9
32
|
</div>
|
|
@@ -1,17 +1,70 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Figma
|
|
3
|
-
*
|
|
2
|
+
* Figma 增强包弹窗面板:
|
|
3
|
+
* 1) 显示 figma.com 授权状态;
|
|
4
|
+
* 2)「走查」按钮——把上次带入的整节点快照覆盖到当前开发页面,供像素级比对。
|
|
5
|
+
* 真正的注入在 figma.com 页面内由 rewrite.js + browser.global.js(MAIN world)完成;
|
|
6
|
+
* 覆盖层由开发页的 bridge-content.js 渲染,这里只负责向当前活动 tab 发切换消息。
|
|
4
7
|
*/
|
|
5
8
|
(function () {
|
|
6
9
|
const el = document.getElementById('figma-status');
|
|
7
|
-
if (
|
|
10
|
+
if (el) {
|
|
11
|
+
chrome.permissions.contains({origins: ['https://www.figma.com/*']}, granted => {
|
|
12
|
+
if (chrome.runtime.lastError) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
el.textContent = granted ? '✓ 已授权 figma.com,打开设计稿即生效' : '✗ 尚未授权 figma.com';
|
|
16
|
+
el.style.color = granted ? '#1a7f37' : '#cf222e';
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const btn = document.getElementById('figma-walkthrough');
|
|
21
|
+
const status = document.getElementById('figma-walkthrough-status');
|
|
22
|
+
if (!btn) {
|
|
8
23
|
return;
|
|
9
24
|
}
|
|
10
|
-
|
|
25
|
+
|
|
26
|
+
function setStatus(text, ok) {
|
|
27
|
+
if (!status) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
status.textContent = text;
|
|
31
|
+
status.style.color = ok === false ? '#cf222e' : ok ? '#1a7f37' : '#656d76';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 有没有可用的快照(提示用户先去 figma 带入节点)。
|
|
35
|
+
chrome.storage.local.get({figmaOverlay: null}, o => {
|
|
11
36
|
if (chrome.runtime.lastError) {
|
|
12
37
|
return;
|
|
13
38
|
}
|
|
14
|
-
|
|
15
|
-
|
|
39
|
+
if (!o || !o.figmaOverlay) {
|
|
40
|
+
setStatus('还没有可走查的节点,先去 Figma 点「带入 Comate 生成代码」');
|
|
41
|
+
} else {
|
|
42
|
+
setStatus(`已就绪:${o.figmaOverlay.name || '节点'}(${o.figmaOverlay.width}×${o.figmaOverlay.height})`);
|
|
43
|
+
}
|
|
16
44
|
});
|
|
45
|
+
|
|
46
|
+
btn.onclick = () => {
|
|
47
|
+
chrome.tabs.query({active: true, currentWindow: true}, tabs => {
|
|
48
|
+
const tab = tabs && tabs[0];
|
|
49
|
+
if (!tab || !tab.id) {
|
|
50
|
+
setStatus('找不到当前标签页', false);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
chrome.tabs.sendMessage(tab.id, {type: 'toggle-figma-overlay'}, resp => {
|
|
55
|
+
if (chrome.runtime.lastError) {
|
|
56
|
+
setStatus('当前页面未注入脚本(确认是已授权的开发域名并已刷新)', false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!resp || !resp.ok) {
|
|
60
|
+
setStatus((resp && resp.error) || '切换失败', false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setStatus(resp.shown ? '已覆盖到页面,可拖拽/缩放/调透明度' : '已关闭覆盖层', true);
|
|
64
|
+
});
|
|
65
|
+
} catch (e) {
|
|
66
|
+
setStatus(String(e), false);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
};
|
|
17
70
|
})();
|
package/extension/background.js
CHANGED
|
@@ -83,15 +83,11 @@ async function viaHttp(target, message) {
|
|
|
83
83
|
});
|
|
84
84
|
return await r.json();
|
|
85
85
|
}
|
|
86
|
-
if (message.type === '
|
|
87
|
-
const r = await fetch(`${base}/
|
|
88
|
-
return await r.json();
|
|
89
|
-
}
|
|
90
|
-
if (message.type === 'cmd-result') {
|
|
91
|
-
const r = await fetch(`${base}/cmd/result`, {
|
|
86
|
+
if (message.type === 'figma-capture') {
|
|
87
|
+
const r = await fetch(`${base}/figma/capture`, {
|
|
92
88
|
method: 'POST',
|
|
93
89
|
headers: {'content-type': 'application/json'},
|
|
94
|
-
body: JSON.stringify({
|
|
90
|
+
body: JSON.stringify({node: message.node}),
|
|
95
91
|
});
|
|
96
92
|
return await r.json();
|
|
97
93
|
}
|
|
@@ -117,8 +113,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
117
113
|
msg.type === 'rec-start' ||
|
|
118
114
|
msg.type === 'rec-event' ||
|
|
119
115
|
msg.type === 'rec-stop' ||
|
|
120
|
-
msg.type === '
|
|
121
|
-
msg.type === 'cmd-result'
|
|
116
|
+
msg.type === 'figma-capture'
|
|
122
117
|
) {
|
|
123
118
|
dispatch(msg).then(sendResponse);
|
|
124
119
|
return true; // 异步 sendResponse
|
|
@@ -178,6 +178,169 @@
|
|
|
178
178
|
// 上下文失效
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// ---------- Figma 走查覆盖层(popup 点「走查」→ 这里把带入的整节点快照覆盖到页面)----------
|
|
182
|
+
// 覆盖层容器只放图片并整体调透明度;工具条单独一层(始终不透明、可操作),避免被透明度一起吃掉。
|
|
183
|
+
var ov = null;
|
|
184
|
+
var ovImg = null;
|
|
185
|
+
var ovBar = null;
|
|
186
|
+
var ovs = {x: 40, y: 40, scale: 1, opacity: 0.5, baseW: 0, through: false};
|
|
187
|
+
var dragFrom = null;
|
|
188
|
+
|
|
189
|
+
function ovRemove() {
|
|
190
|
+
if (ov) {
|
|
191
|
+
ov.remove();
|
|
192
|
+
ov = null;
|
|
193
|
+
ovImg = null;
|
|
194
|
+
}
|
|
195
|
+
if (ovBar) {
|
|
196
|
+
ovBar.remove();
|
|
197
|
+
ovBar = null;
|
|
198
|
+
}
|
|
199
|
+
document.removeEventListener('mousemove', ovDrag);
|
|
200
|
+
document.removeEventListener('mouseup', ovDragEnd);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ovApply() {
|
|
204
|
+
if (!ov) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
ov.style.left = ovs.x + 'px';
|
|
208
|
+
ov.style.top = ovs.y + 'px';
|
|
209
|
+
ov.style.opacity = String(ovs.opacity);
|
|
210
|
+
ovImg.style.width = (ovs.baseW * ovs.scale || 0) + 'px';
|
|
211
|
+
ovImg.style.pointerEvents = ovs.through ? 'none' : 'auto';
|
|
212
|
+
ov.style.pointerEvents = ovs.through ? 'none' : 'auto';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function ovDrag(e) {
|
|
216
|
+
if (!dragFrom) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
ovs.x = e.clientX - dragFrom.dx;
|
|
220
|
+
ovs.y = e.clientY - dragFrom.dy;
|
|
221
|
+
ovApply();
|
|
222
|
+
}
|
|
223
|
+
function ovDragEnd() {
|
|
224
|
+
dragFrom = null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ovBuild(data) {
|
|
228
|
+
ovRemove();
|
|
229
|
+
ovs = {x: 40, y: 40, scale: 1, opacity: 0.5, baseW: data.width || 0, through: false};
|
|
230
|
+
ov = document.createElement('div');
|
|
231
|
+
ov.style.cssText = 'position:fixed;z-index:2147483646;left:40px;top:40px;opacity:0.5;';
|
|
232
|
+
ovImg = document.createElement('img');
|
|
233
|
+
ovImg.src = data.dataUrl;
|
|
234
|
+
ovImg.draggable = false;
|
|
235
|
+
ovImg.style.cssText = 'display:block;cursor:move;box-shadow:0 0 0 1px rgba(255,0,0,0.35);';
|
|
236
|
+
ovImg.addEventListener('load', function () {
|
|
237
|
+
if (!ovs.baseW) {
|
|
238
|
+
ovs.baseW = ovImg.naturalWidth || 0;
|
|
239
|
+
}
|
|
240
|
+
ovApply();
|
|
241
|
+
});
|
|
242
|
+
ovImg.addEventListener('mousedown', function (e) {
|
|
243
|
+
if (ovs.through) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
dragFrom = {dx: e.clientX - ovs.x, dy: e.clientY - ovs.y};
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
});
|
|
249
|
+
document.addEventListener('mousemove', ovDrag);
|
|
250
|
+
document.addEventListener('mouseup', ovDragEnd);
|
|
251
|
+
ov.appendChild(ovImg);
|
|
252
|
+
document.documentElement.appendChild(ov);
|
|
253
|
+
ovToolbar();
|
|
254
|
+
ovApply();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ovField(label, ctrl) {
|
|
258
|
+
var w = document.createElement('span');
|
|
259
|
+
w.style.cssText = 'display:flex;align-items:center;gap:6px;';
|
|
260
|
+
var l = document.createElement('span');
|
|
261
|
+
l.textContent = label;
|
|
262
|
+
w.appendChild(l);
|
|
263
|
+
w.appendChild(ctrl);
|
|
264
|
+
return w;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ovToolbar() {
|
|
268
|
+
ovBar = document.createElement('div');
|
|
269
|
+
ovBar.style.cssText =
|
|
270
|
+
'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;display:flex;' +
|
|
271
|
+
'align-items:center;gap:12px;padding:6px 12px;border-radius:999px;background:#111;color:#fff;' +
|
|
272
|
+
'font:12px/1.4 sans-serif;box-shadow:0 2px 10px rgba(0,0,0,0.3);';
|
|
273
|
+
var o = document.createElement('input');
|
|
274
|
+
o.type = 'range';
|
|
275
|
+
o.min = '0';
|
|
276
|
+
o.max = '100';
|
|
277
|
+
o.value = '50';
|
|
278
|
+
o.style.width = '80px';
|
|
279
|
+
o.oninput = function () {
|
|
280
|
+
ovs.opacity = Number(o.value) / 100;
|
|
281
|
+
ovApply();
|
|
282
|
+
};
|
|
283
|
+
var z = document.createElement('input');
|
|
284
|
+
z.type = 'range';
|
|
285
|
+
z.min = '10';
|
|
286
|
+
z.max = '300';
|
|
287
|
+
z.value = '100';
|
|
288
|
+
z.style.width = '100px';
|
|
289
|
+
var zl = document.createElement('span');
|
|
290
|
+
zl.textContent = '100%';
|
|
291
|
+
z.oninput = function () {
|
|
292
|
+
ovs.scale = Number(z.value) / 100;
|
|
293
|
+
zl.textContent = z.value + '%';
|
|
294
|
+
ovApply();
|
|
295
|
+
};
|
|
296
|
+
var tc = document.createElement('input');
|
|
297
|
+
tc.type = 'checkbox';
|
|
298
|
+
tc.onchange = function () {
|
|
299
|
+
ovs.through = tc.checked;
|
|
300
|
+
ovApply();
|
|
301
|
+
};
|
|
302
|
+
var c = document.createElement('button');
|
|
303
|
+
c.textContent = '关闭';
|
|
304
|
+
c.style.cssText =
|
|
305
|
+
'border:none;border-radius:999px;padding:3px 10px;background:#ff5a5a;color:#fff;cursor:pointer;font:inherit;';
|
|
306
|
+
c.onclick = ovRemove;
|
|
307
|
+
ovBar.appendChild(ovField('透明', o));
|
|
308
|
+
ovBar.appendChild(ovField('缩放', z));
|
|
309
|
+
ovBar.appendChild(zl);
|
|
310
|
+
ovBar.appendChild(ovField('穿透', tc));
|
|
311
|
+
ovBar.appendChild(c);
|
|
312
|
+
document.documentElement.appendChild(ovBar);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// popup 的「走查」按钮 → 这里切换覆盖层:已显示则关闭,否则读 storage 的快照渲染。
|
|
316
|
+
try {
|
|
317
|
+
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
|
|
318
|
+
if (!msg || msg.type !== 'toggle-figma-overlay') {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (ov) {
|
|
322
|
+
ovRemove();
|
|
323
|
+
sendResponse({ok: true, shown: false});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
chrome.storage.local.get({figmaOverlay: null}, function (o) {
|
|
327
|
+
if (chrome.runtime.lastError) {
|
|
328
|
+
sendResponse({ok: false, error: '读取快照失败'});
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (!o || !o.figmaOverlay || !o.figmaOverlay.dataUrl) {
|
|
332
|
+
sendResponse({ok: false, error: '还没有可走查的节点,先去 Figma 点「带入 Comate 生成代码」'});
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
ovBuild(o.figmaOverlay);
|
|
336
|
+
sendResponse({ok: true, shown: true});
|
|
337
|
+
});
|
|
338
|
+
return true; // 异步 sendResponse
|
|
339
|
+
});
|
|
340
|
+
} catch {
|
|
341
|
+
// 上下文失效
|
|
342
|
+
}
|
|
343
|
+
|
|
181
344
|
const timer = setInterval(() => {
|
|
182
345
|
if (!alive()) {
|
|
183
346
|
clearInterval(timer);
|
|
@@ -194,7 +357,6 @@
|
|
|
194
357
|
const status = resp.status;
|
|
195
358
|
const id = status && status.id !== undefined ? status.id : null;
|
|
196
359
|
if (!statusSeeded) {
|
|
197
|
-
// 首轮:记录当前状态 id(可能是上轮遗留),本轮不弹,避免刷新页面重放旧 toast
|
|
198
360
|
statusSeeded = true;
|
|
199
361
|
lastStatusId = id;
|
|
200
362
|
} else if (status && id !== lastStatusId) {
|
package/extension/manifest.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"description": "按住 Alt 点元素多选,交给本地 host / bridge,供 Comate 定位源码修改。目标域名与传输通路在扩展弹窗里配置。仅开发用。",
|
|
6
6
|
"minimum_chrome_version": "111",
|
|
7
7
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArHbA4zi22qf48xgfUtkMM6vXEtWiTtmhlppNAMlfG/uFjujAK9EEGssy6674woq5W9ugl+FlVE4rf477m9drGnGjnvaaFc9U8xCVqUoe/+vTmRyDWAtSPyu2aulAXd9P78WCia5ROSpecian7b0pG/iPyqvgZsyJfYk1qC3VyEBOnOqVeWawawKE23OaxUZJaci4wB10xNrWBHBIAztL8xmRDSkkByImwNshtlzbo2pE17LHZGr35FMbQXdRBKWxsdKlT1t0j7ezK73bCFjsYLWoAXVSpK+eAjltGTqKtOTvNxV94TXwr/j0v7ikgz80XD4ffzXV0qDbQ8HCxRSZMwIDAQAB",
|
|
8
|
-
"permissions": ["nativeMessaging", "scripting", "storage"],
|
|
8
|
+
"permissions": ["nativeMessaging", "scripting", "storage", "unlimitedStorage"],
|
|
9
9
|
"host_permissions": ["http://localhost/*", "http://127.0.0.1/*"],
|
|
10
10
|
"optional_host_permissions": ["*://*/*"],
|
|
11
11
|
"background": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
|
|
13
13
|
*/
|
|
14
|
-
import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync} from 'node:fs';
|
|
14
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs';
|
|
15
15
|
import {dirname, resolve} from 'node:path';
|
|
16
16
|
import {homedir} from 'node:os';
|
|
17
17
|
|
|
@@ -22,9 +22,10 @@ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
|
22
22
|
// 分开是为了避免「每条事件读改写整份 JSON」的多进程竞态把 active 盖回 true——事件只追加,不碰 active。
|
|
23
23
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
24
24
|
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// figma「带入对话」采集的节点结构,等 hook 注入下一条消息。
|
|
26
|
+
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
27
|
+
// figma 节点内导出的图片字节落盘到这里(按采集批次分子目录)。
|
|
28
|
+
const figmaAssetsDir = resolve(homedir(), '.ui-inspector/figma-assets');
|
|
28
29
|
|
|
29
30
|
function readStore() {
|
|
30
31
|
try {
|
|
@@ -88,62 +89,103 @@ function recStop(endedAt) {
|
|
|
88
89
|
writeMeta(cur); // 只有 start/stop 动 active,事件追加不碰它 → stop 后 active 不会被盖回
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
//
|
|
92
|
-
function
|
|
93
|
-
return
|
|
92
|
+
// figma 节点采集:追加到 figma-pending.json(上限 10 个,防爆)。
|
|
93
|
+
function sanitizeName(name) {
|
|
94
|
+
return (
|
|
95
|
+
String(name || 'image')
|
|
96
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
97
|
+
.replace(/^_+|_+$/g, '')
|
|
98
|
+
.slice(0, 60) || 'image'
|
|
99
|
+
);
|
|
94
100
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
|
|
102
|
+
// 清理旧的图片批次:保留当前仍在 figma-pending.json 里引用的批次目录(尚未被消费的节点),
|
|
103
|
+
// 其余(已被 hook 注入消费、或被挤出 pending 的)全部删除。只在新一次导出时触发——「下次导出 PNG 才删上次的」。
|
|
104
|
+
function pruneStaleAssets() {
|
|
105
|
+
let keep = new Set();
|
|
98
106
|
try {
|
|
99
|
-
|
|
107
|
+
const list = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
|
|
108
|
+
for (const entry of Array.isArray(list) ? list : []) {
|
|
109
|
+
const imgs = (entry && entry.node && entry.node.images) || [];
|
|
110
|
+
for (const im of imgs) {
|
|
111
|
+
if (im && im.file) {
|
|
112
|
+
keep.add(dirname(im.file));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
100
116
|
} catch {
|
|
101
|
-
|
|
117
|
+
keep = new Set();
|
|
102
118
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
if (c && c.status === 'pending') {
|
|
115
|
-
c.status = 'taken';
|
|
119
|
+
let dirs = [];
|
|
120
|
+
try {
|
|
121
|
+
dirs = readdirSync(figmaAssetsDir);
|
|
122
|
+
} catch {
|
|
123
|
+
return; // 目录不存在,无需清理
|
|
124
|
+
}
|
|
125
|
+
for (const name of dirs) {
|
|
126
|
+
const full = resolve(figmaAssetsDir, name);
|
|
127
|
+
if (!keep.has(full)) {
|
|
116
128
|
try {
|
|
117
|
-
|
|
129
|
+
rmSync(full, {recursive: true, force: true});
|
|
118
130
|
} catch {
|
|
119
|
-
//
|
|
131
|
+
// 删除失败忽略
|
|
120
132
|
}
|
|
121
|
-
out.push({id: c.id, type: c.type, params: c.params || {}});
|
|
122
133
|
}
|
|
123
134
|
}
|
|
124
|
-
return out;
|
|
125
135
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
|
|
137
|
+
// 把 node.images 里的 base64 落成本地 PNG 文件,node 只保留 {file,name,id,width,height},避免 json 膨胀。
|
|
138
|
+
function persistImages(node) {
|
|
139
|
+
if (!node || !Array.isArray(node.images) || !node.images.length) {
|
|
140
|
+
if (node) {
|
|
141
|
+
delete node.images;
|
|
142
|
+
}
|
|
132
143
|
return;
|
|
133
144
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
145
|
+
pruneStaleAssets();
|
|
146
|
+
const batch = resolve(figmaAssetsDir, `${Date.now()}`);
|
|
147
|
+
mkdirSync(batch, {recursive: true});
|
|
148
|
+
const used = {};
|
|
149
|
+
const saved = [];
|
|
150
|
+
node.images.forEach((img, i) => {
|
|
151
|
+
if (!img || !img.base64) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
let base = sanitizeName(img.name) + '-' + i;
|
|
155
|
+
if (used[base]) {
|
|
156
|
+
base += '_' + used[base]++;
|
|
157
|
+
} else {
|
|
158
|
+
used[base] = 1;
|
|
159
|
+
}
|
|
160
|
+
const file = resolve(batch, `${base}.png`);
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(file, Buffer.from(img.base64, 'base64'));
|
|
163
|
+
saved.push({id: img.id, name: img.name, width: img.width, height: img.height, file});
|
|
164
|
+
} catch {
|
|
165
|
+
// 单张落盘失败跳过
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
node.images = saved;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function figmaCapture(node) {
|
|
172
|
+
if (!node) {
|
|
173
|
+
return;
|
|
139
174
|
}
|
|
140
|
-
|
|
175
|
+
persistImages(node);
|
|
176
|
+
let list = [];
|
|
141
177
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
|
|
179
|
+
list = Array.isArray(p) ? p : [];
|
|
144
180
|
} catch {
|
|
145
|
-
|
|
181
|
+
list = [];
|
|
182
|
+
}
|
|
183
|
+
list.push({node, ts: Date.now()});
|
|
184
|
+
if (list.length > 10) {
|
|
185
|
+
list = list.slice(-10);
|
|
146
186
|
}
|
|
187
|
+
mkdirSync(dirname(figmaPendingFile), {recursive: true});
|
|
188
|
+
writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
147
189
|
}
|
|
148
190
|
|
|
149
191
|
function send(obj) {
|
|
@@ -171,10 +213,8 @@ function handle(msg) {
|
|
|
171
213
|
} else if (msg && msg.type === 'rec-stop') {
|
|
172
214
|
recStop(msg.endedAt);
|
|
173
215
|
resp = {ok: true};
|
|
174
|
-
} else if (msg && msg.type === '
|
|
175
|
-
|
|
176
|
-
} else if (msg && msg.type === 'cmd-result') {
|
|
177
|
-
cmdResult(msg.id, msg.result, msg.error);
|
|
216
|
+
} else if (msg && msg.type === 'figma-capture') {
|
|
217
|
+
figmaCapture(msg.node);
|
|
178
218
|
resp = {ok: true};
|
|
179
219
|
} else {
|
|
180
220
|
resp = {ok: false, error: 'unknown message'};
|
|
@@ -19,6 +19,8 @@ const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
|
19
19
|
// 录制:元信息(active/startedAt/pageUrl/endedAt)+ append-only 事件流(每行一条 {kind,item})。
|
|
20
20
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
21
|
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
22
|
+
// figma「带入对话」采集的节点结构(页面按钮触发落盘),注入后让 Agent 据结构生成代码。
|
|
23
|
+
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
22
24
|
|
|
23
25
|
function emit(output) {
|
|
24
26
|
process.stdout.write(JSON.stringify(output));
|
|
@@ -67,8 +69,11 @@ const meta = readJson(recordingFile, null);
|
|
|
67
69
|
const recDone = meta && meta.active === false;
|
|
68
70
|
const recEvents = recDone ? readEvents() : [];
|
|
69
71
|
const hasRec = recDone && recEvents.length > 0;
|
|
72
|
+
const figmaParsed = readJson(figmaPendingFile, []);
|
|
73
|
+
const figmaNodes = Array.isArray(figmaParsed) ? figmaParsed : [];
|
|
74
|
+
const hasFigma = figmaNodes.length > 0;
|
|
70
75
|
|
|
71
|
-
if (!list.length && !hasRec) {
|
|
76
|
+
if (!list.length && !hasRec && !hasFigma) {
|
|
72
77
|
emit({decision: 'allow'});
|
|
73
78
|
}
|
|
74
79
|
|
|
@@ -135,6 +140,33 @@ function describeRecording(meta, evs) {
|
|
|
135
140
|
return parts.join('\n');
|
|
136
141
|
}
|
|
137
142
|
|
|
143
|
+
// figma「带入对话」节点:把每个节点的 F2C HTML 结构铺开,供 Agent 据此生成代码。
|
|
144
|
+
function describeFigma(nodes) {
|
|
145
|
+
const parts = [
|
|
146
|
+
`【ui-inspector Figma】用户在 Figma 选中并带入了 ${nodes.length} 个节点,请据下面的结构(F2C 导出的 HTML)生成对应前端代码:`,
|
|
147
|
+
];
|
|
148
|
+
nodes.forEach((entry, i) => {
|
|
149
|
+
const n = (entry && entry.node) || {};
|
|
150
|
+
const html = String(n.html || '').slice(0, 20000);
|
|
151
|
+
parts.push(`—— 节点 ${i + 1}:${n.name || ''}(id ${n.nodeId || '?'},${n.type || ''})——`);
|
|
152
|
+
parts.push(html || '(无 HTML)');
|
|
153
|
+
if ((n.html || '').length > 20000) {
|
|
154
|
+
parts.push('…(结构过长已截断)');
|
|
155
|
+
}
|
|
156
|
+
const imgs = Array.isArray(n.images) ? n.images.filter(im => im && im.file) : [];
|
|
157
|
+
if (imgs.length) {
|
|
158
|
+
parts.push(
|
|
159
|
+
` 该节点含 ${imgs.length} 张图片,已导出为本地 PNG(HTML 里的图片是占位路径没有字节,请用下面的真实文件;可用 Read 工具查看图片内容,按「名称」与 HTML 占位 src 对应):`
|
|
160
|
+
);
|
|
161
|
+
imgs.forEach(im => {
|
|
162
|
+
const dim = im.width && im.height ? ` ${im.width}x${im.height}` : '';
|
|
163
|
+
parts.push(` - ${im.name || '(未命名)'}(id ${im.id || '?'}${dim}): ${im.file}`);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return parts.join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
138
170
|
const sections = [];
|
|
139
171
|
if (list.length) {
|
|
140
172
|
const total = list.length;
|
|
@@ -148,11 +180,14 @@ if (list.length) {
|
|
|
148
180
|
if (hasRec) {
|
|
149
181
|
sections.push(describeRecording(meta, recEvents));
|
|
150
182
|
}
|
|
183
|
+
if (hasFigma) {
|
|
184
|
+
sections.push(describeFigma(figmaNodes));
|
|
185
|
+
}
|
|
151
186
|
sections.push(
|
|
152
187
|
'请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
|
|
153
188
|
);
|
|
154
189
|
|
|
155
|
-
// 「拿到即消费」:注入后清空 pending
|
|
190
|
+
// 「拿到即消费」:注入后清空 pending / figma;录制仅在确实注入(已停止)时清空,进行中的保留。
|
|
156
191
|
try {
|
|
157
192
|
mkdirSync(dirname(storeFile), {recursive: true});
|
|
158
193
|
writeFileSync(storeFile, '[]\n', 'utf8');
|
|
@@ -160,6 +195,11 @@ try {
|
|
|
160
195
|
writeFileSync(recordingFile, 'null\n', 'utf8');
|
|
161
196
|
writeFileSync(eventsFile, '', 'utf8');
|
|
162
197
|
}
|
|
198
|
+
if (hasFigma) {
|
|
199
|
+
// figma 节点注入后清空 pending,但导出的 PNG 文件保留——它们由下一次采集导出时清理(见 host/bridge
|
|
200
|
+
// 的 persistImages),这样注入后 Agent 仍可随时 Read 这些图片,直到下次带入新节点才替换。
|
|
201
|
+
writeFileSync(figmaPendingFile, '[]\n', 'utf8');
|
|
202
|
+
}
|
|
163
203
|
} catch {
|
|
164
204
|
// 清空失败不影响注入
|
|
165
205
|
}
|
package/mcp/server.mjs
CHANGED
|
@@ -16,8 +16,6 @@ import {createInterface} from 'node:readline';
|
|
|
16
16
|
|
|
17
17
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
18
18
|
const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
19
|
-
// Agent→浏览器命令队列(每条一个文件)。MCP 写 pending,浏览器 figma-bridge 取走执行并写回结果。
|
|
20
|
-
const cmdDir = resolve(homedir(), '.ui-inspector/commands');
|
|
21
19
|
|
|
22
20
|
function readStore() {
|
|
23
21
|
try {
|
|
@@ -43,41 +41,6 @@ function writeStatus(message, level) {
|
|
|
43
41
|
return status;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
|
-
// 下发一条命令到队列,返回 id。
|
|
47
|
-
function enqueueCmd(type, params) {
|
|
48
|
-
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
-
mkdirSync(cmdDir, {recursive: true});
|
|
50
|
-
writeFileSync(resolve(cmdDir, `${id}.json`), JSON.stringify({id, type, params: params || {}, status: 'pending', ts: Date.now()}), 'utf8');
|
|
51
|
-
return id;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 轮询命令结果,直到 done/error 或超时。浏览器需在 figma.com 页打开才会有执行者。
|
|
55
|
-
function waitCmd(id, timeoutMs = 30000) {
|
|
56
|
-
const fp = resolve(cmdDir, `${id}.json`);
|
|
57
|
-
const deadline = Date.now() + timeoutMs;
|
|
58
|
-
return new Promise((res, rej) => {
|
|
59
|
-
const tick = () => {
|
|
60
|
-
let c = null;
|
|
61
|
-
try {
|
|
62
|
-
c = JSON.parse(readFileSync(fp, 'utf8'));
|
|
63
|
-
} catch {
|
|
64
|
-
c = null;
|
|
65
|
-
}
|
|
66
|
-
if (c && c.status === 'done') {
|
|
67
|
-
return res(c.result);
|
|
68
|
-
}
|
|
69
|
-
if (c && c.status === 'error') {
|
|
70
|
-
return rej(new Error(c.error || 'command failed'));
|
|
71
|
-
}
|
|
72
|
-
if (Date.now() > deadline) {
|
|
73
|
-
return rej(new Error('命令超时(确认已在 figma.com 设计稿页打开,且扩展已授权 figma.com 并重载)'));
|
|
74
|
-
}
|
|
75
|
-
setTimeout(tick, 400);
|
|
76
|
-
};
|
|
77
|
-
tick();
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
|
|
81
44
|
const TOOLS = [
|
|
82
45
|
{
|
|
83
46
|
name: 'list_pending_edits',
|
|
@@ -106,32 +69,6 @@ const TOOLS = [
|
|
|
106
69
|
required: ['message'],
|
|
107
70
|
},
|
|
108
71
|
},
|
|
109
|
-
{
|
|
110
|
-
name: 'figma_export_node_html',
|
|
111
|
-
description:
|
|
112
|
-
'把 figma.com 当前设计稿里的某个节点导出为 HTML(F2C),写入本地文件。需浏览器已在 figma.com 设计稿页、扩展安装 figma 增强包并授权 figma.com。',
|
|
113
|
-
inputSchema: {
|
|
114
|
-
type: 'object',
|
|
115
|
-
properties: {
|
|
116
|
-
nodeId: {type: 'string', description: '目标节点 id;省略则用画布当前选中节点'},
|
|
117
|
-
outputPath: {type: 'string', description: '导出 HTML 写入的本地绝对路径'},
|
|
118
|
-
},
|
|
119
|
-
required: ['outputPath'],
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
name: 'figma_export_node_image',
|
|
124
|
-
description: '把 figma.com 当前设计稿里的某个节点导出为图片(PNG/JPG/SVG),写入本地文件。前置条件同上。',
|
|
125
|
-
inputSchema: {
|
|
126
|
-
type: 'object',
|
|
127
|
-
properties: {
|
|
128
|
-
nodeId: {type: 'string', description: '目标节点 id;省略则用画布当前选中节点'},
|
|
129
|
-
outputPath: {type: 'string', description: '导出图片写入的本地绝对路径'},
|
|
130
|
-
format: {type: 'string', enum: ['PNG', 'JPG', 'SVG'], description: '图片格式,默认 PNG'},
|
|
131
|
-
},
|
|
132
|
-
required: ['outputPath'],
|
|
133
|
-
},
|
|
134
|
-
},
|
|
135
72
|
];
|
|
136
73
|
|
|
137
74
|
function callTool(name, args) {
|
|
@@ -148,32 +85,6 @@ function callTool(name, args) {
|
|
|
148
85
|
if (name === 'notify_browser') {
|
|
149
86
|
return writeStatus(args?.message, args?.level);
|
|
150
87
|
}
|
|
151
|
-
if (name === 'figma_export_node_html') {
|
|
152
|
-
const outputPath = args?.outputPath;
|
|
153
|
-
if (!outputPath) {
|
|
154
|
-
throw new Error('outputPath 必填');
|
|
155
|
-
}
|
|
156
|
-
const id = enqueueCmd('figma_export_node_html', {nodeId: args?.nodeId});
|
|
157
|
-
return waitCmd(id).then(result => {
|
|
158
|
-
const html = (result && result.html) || '';
|
|
159
|
-
mkdirSync(dirname(outputPath), {recursive: true});
|
|
160
|
-
writeFileSync(outputPath, html, 'utf8');
|
|
161
|
-
return {ok: true, path: outputPath, bytes: Buffer.byteLength(html)};
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
if (name === 'figma_export_node_image') {
|
|
165
|
-
const outputPath = args?.outputPath;
|
|
166
|
-
if (!outputPath) {
|
|
167
|
-
throw new Error('outputPath 必填');
|
|
168
|
-
}
|
|
169
|
-
const id = enqueueCmd('figma_export_node_image', {nodeId: args?.nodeId, format: args?.format});
|
|
170
|
-
return waitCmd(id).then(result => {
|
|
171
|
-
const base64 = (result && result.base64) || '';
|
|
172
|
-
mkdirSync(dirname(outputPath), {recursive: true});
|
|
173
|
-
writeFileSync(outputPath, Buffer.from(base64, 'base64'));
|
|
174
|
-
return {ok: true, path: outputPath, format: (result && result.format) || 'PNG'};
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
88
|
throw new Error('unknown tool: ' + name);
|
|
178
89
|
}
|
|
179
90
|
|
package/package.json
CHANGED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Figma 命令中继(ISOLATED world,figma.com):轮询 background 要 Agent 下发的命令,转给 MAIN 的
|
|
3
|
-
* figma-exec 执行;把执行结果回传 background(写回命令队列,供 MCP server 取回)。
|
|
4
|
-
* 只在 figma.com 运行,故命令只在真正能执行的页面被取走。
|
|
5
|
-
*/
|
|
6
|
-
(function () {
|
|
7
|
-
const alive = () => !!(chrome.runtime && chrome.runtime.id);
|
|
8
|
-
|
|
9
|
-
// MAIN 执行完的结果 → background → 命令队列
|
|
10
|
-
window.addEventListener('message', event => {
|
|
11
|
-
if (event.source !== window || !event.data || !event.data.__uiInspectorCmdResult) {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
if (!alive()) {
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
const r = event.data.__uiInspectorCmdResult;
|
|
18
|
-
try {
|
|
19
|
-
chrome.runtime.sendMessage(
|
|
20
|
-
{type: 'cmd-result', id: r.id, result: r.result, error: r.error},
|
|
21
|
-
() => void chrome.runtime.lastError
|
|
22
|
-
);
|
|
23
|
-
} catch {
|
|
24
|
-
// 上下文失效
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const timer = setInterval(() => {
|
|
29
|
-
if (!alive()) {
|
|
30
|
-
clearInterval(timer);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
chrome.runtime.sendMessage({type: 'cmd-poll'}, resp => {
|
|
35
|
-
if (chrome.runtime.lastError || !resp || !resp.ok || !Array.isArray(resp.commands)) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
for (const cmd of resp.commands) {
|
|
39
|
-
window.postMessage({__uiInspectorCmd: cmd}, '*');
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
} catch {
|
|
43
|
-
clearInterval(timer);
|
|
44
|
-
}
|
|
45
|
-
}, 1200);
|
|
46
|
-
})();
|