comate-ui-inspector 0.1.6-alpha.2 → 0.1.6-alpha.4

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 CHANGED
@@ -5,8 +5,8 @@
5
5
  * 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
6
6
  * ~/.ui-inspector/*.json。浏览器扩展(transport=http)直连本服务。
7
7
  */
8
- import {createServer} from 'node:http';
9
- import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
8
+ import {createServer, get} from 'node:http';
9
+ import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs';
10
10
  import {dirname, resolve} from 'node:path';
11
11
  import {homedir} from 'node:os';
12
12
 
@@ -17,6 +17,10 @@ const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
17
17
  const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
18
18
  // figma「带入对话」采集的节点结构。
19
19
  const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
20
+ // figma 节点内导出的图片字节落盘到这里(按采集批次分子目录)。
21
+ const figmaAssetsDir = resolve(homedir(), '.ui-inspector/figma-assets');
22
+ // bridge 实际绑定的端口写这里,供 cli(install --remote)读取并打印真正在用的端口。
23
+ const portFile = resolve(homedir(), '.ui-inspector/bridge.json');
20
24
  const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
21
25
  const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
22
26
 
@@ -84,10 +88,89 @@ function recStop(endedAt) {
84
88
  }
85
89
 
86
90
  // figma 节点采集:追加到 figma-pending.json(上限 10 个,防爆)。
91
+ function sanitizeName(name) {
92
+ return (
93
+ String(name || 'image')
94
+ .replace(/[^a-zA-Z0-9._-]+/g, '_')
95
+ .replace(/^_+|_+$/g, '')
96
+ .slice(0, 60) || 'image'
97
+ );
98
+ }
99
+
100
+ // 清理旧的图片批次:保留当前仍在 figma-pending.json 里引用的批次目录(尚未被消费的节点),
101
+ // 其余(已被 hook 注入消费、或被挤出 pending 的)全部删除。只在新一次导出时触发——「下次导出 PNG 才删上次的」。
102
+ function pruneStaleAssets() {
103
+ let keep = new Set();
104
+ try {
105
+ const list = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
106
+ for (const entry of Array.isArray(list) ? list : []) {
107
+ const imgs = (entry && entry.node && entry.node.images) || [];
108
+ for (const im of imgs) {
109
+ if (im && im.file) {
110
+ keep.add(dirname(im.file));
111
+ }
112
+ }
113
+ }
114
+ } catch {
115
+ keep = new Set();
116
+ }
117
+ let dirs = [];
118
+ try {
119
+ dirs = readdirSync(figmaAssetsDir);
120
+ } catch {
121
+ return; // 目录不存在,无需清理
122
+ }
123
+ for (const name of dirs) {
124
+ const full = resolve(figmaAssetsDir, name);
125
+ if (!keep.has(full)) {
126
+ try {
127
+ rmSync(full, {recursive: true, force: true});
128
+ } catch {
129
+ // 删除失败忽略
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ // 把 node.images 里的 base64 落成本地 PNG 文件,node 只保留 {file,name,id,width,height},避免 json 膨胀。
136
+ function persistImages(node) {
137
+ if (!node || !Array.isArray(node.images) || !node.images.length) {
138
+ if (node) {
139
+ delete node.images;
140
+ }
141
+ return;
142
+ }
143
+ pruneStaleAssets();
144
+ const batch = resolve(figmaAssetsDir, `${Date.now()}`);
145
+ mkdirSync(batch, {recursive: true});
146
+ const used = {};
147
+ const saved = [];
148
+ node.images.forEach((img, i) => {
149
+ if (!img || !img.base64) {
150
+ return;
151
+ }
152
+ let base = sanitizeName(img.name) + '-' + i;
153
+ if (used[base]) {
154
+ base += '_' + used[base]++;
155
+ } else {
156
+ used[base] = 1;
157
+ }
158
+ const file = resolve(batch, `${base}.png`);
159
+ try {
160
+ writeFileSync(file, Buffer.from(img.base64, 'base64'));
161
+ saved.push({id: img.id, name: img.name, width: img.width, height: img.height, file});
162
+ } catch {
163
+ // 单张落盘失败跳过
164
+ }
165
+ });
166
+ node.images = saved;
167
+ }
168
+
87
169
  function figmaCapture(node) {
88
170
  if (!node) {
89
171
  return;
90
172
  }
173
+ persistImages(node);
91
174
  let list = [];
92
175
  try {
93
176
  const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
@@ -195,6 +278,95 @@ const server = createServer((req, res) => {
195
278
  json(res, 404, {ok: false, error: 'not found'});
196
279
  });
197
280
 
198
- server.listen(PORT, HOST, () => {
199
- process.stdout.write(`ui-inspector bridge listening on http://${HOST}:${PORT}\n`);
281
+ let currentPort = PORT;
282
+ let triedReuse = false;
283
+ let bound = false;
284
+ const MAX_PORT_TRIES = 20;
285
+
286
+ // 绑定成功:把实际端口写进 bridge.json(cli install --remote 据此打印真正在用的端口)。
287
+ server.on('listening', () => {
288
+ bound = true;
289
+ const addr = server.address();
290
+ const actual = (addr && addr.port) || currentPort;
291
+ try {
292
+ mkdirSync(dirname(portFile), {recursive: true});
293
+ writeFileSync(
294
+ portFile,
295
+ `${JSON.stringify({host: HOST, port: actual, pid: process.pid, ts: Date.now()})}\n`,
296
+ 'utf8'
297
+ );
298
+ } catch {
299
+ // 写端口文件失败不影响服务
300
+ }
301
+ process.stdout.write(`ui-inspector bridge listening on http://${HOST}:${actual}\n`);
302
+ });
303
+
304
+ // 顺延到下一个端口;连试 MAX_PORT_TRIES 个都不行才放弃。bound 后幂等,避免重复 listen 崩溃。
305
+ function hopNext() {
306
+ if (bound) {
307
+ return;
308
+ }
309
+ if (currentPort - PORT >= MAX_PORT_TRIES) {
310
+ process.stderr.write(
311
+ `从 ${PORT} 起连续 ${MAX_PORT_TRIES} 个端口都被占用,放弃。手动指定 UI_INSPECTOR_BRIDGE_PORT。\n`
312
+ );
313
+ process.exit(1);
314
+ return;
315
+ }
316
+ currentPort += 1;
317
+ server.listen(currentPort, HOST);
318
+ }
319
+
320
+ // 端口被占用是常见情况。基准端口被占时先探测是不是已有我们的 bridge:是就复用退出(幂等);
321
+ // 否则自动顺延到下一个可用端口。其它启动错误直接友好报错退出,不抛未捕获异常。
322
+ server.on('error', err => {
323
+ if (!err || err.code !== 'EADDRINUSE') {
324
+ process.stderr.write(`ui-inspector bridge 启动失败:${String(err)}\n`);
325
+ process.exit(1);
326
+ return;
327
+ }
328
+ if (currentPort === PORT && !triedReuse) {
329
+ triedReuse = true;
330
+ const probeHost = HOST === '0.0.0.0' ? '127.0.0.1' : HOST;
331
+ // probe 的 end/error/timeout 可能多次触发(如 destroy 又引发 error),用 settled 保证只处理一次。
332
+ let settled = false;
333
+ const settle = fn => {
334
+ if (settled) {
335
+ return;
336
+ }
337
+ settled = true;
338
+ fn();
339
+ };
340
+ const req = get({host: probeHost, port: PORT, path: '/state', timeout: 1500}, res => {
341
+ let body = '';
342
+ res.on('data', c => (body += c));
343
+ res.on('end', () =>
344
+ settle(() => {
345
+ let ok = false;
346
+ try {
347
+ ok = JSON.parse(body).ok === true;
348
+ } catch {
349
+ ok = false;
350
+ }
351
+ if (ok) {
352
+ process.stdout.write(
353
+ `ui-inspector bridge 已在 http://${HOST}:${PORT} 运行,复用现有实例即可。\n`
354
+ );
355
+ process.exit(0);
356
+ } else {
357
+ hopNext(); // 被非 bridge 占用 → 换端口
358
+ }
359
+ })
360
+ );
361
+ });
362
+ req.on('error', () => settle(hopNext));
363
+ req.on('timeout', () => {
364
+ req.destroy();
365
+ settle(hopNext);
366
+ });
367
+ return;
368
+ }
369
+ hopNext();
200
370
  });
371
+
372
+ server.listen(currentPort, HOST);
package/cli.mjs CHANGED
@@ -119,6 +119,29 @@ function installNativeHost() {
119
119
  }
120
120
 
121
121
  // 后台拉起 bridge 并与本进程脱钩,install --remote 用:装完 hook/MCP 顺手把 bridge 跑起来。
122
+ // bridge 可能因端口被占而自动顺延端口,所以不能直接用传入的 port——读它落盘的 bridge.json 拿真正在用的端口。
123
+ function sleepSync(ms) {
124
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
125
+ }
126
+
127
+ // 轮询 ~/.ui-inspector/bridge.json,拿一条「本次启动之后写入」的记录(ts >= since)。读不到返回 null。
128
+ function readActualBridgePort(since, timeoutMs = 4000) {
129
+ const f = resolve(pendingDir, 'bridge.json');
130
+ const deadline = Date.now() + timeoutMs;
131
+ while (Date.now() < deadline) {
132
+ try {
133
+ const j = JSON.parse(readFileSync(f, 'utf8'));
134
+ if (j && j.port && (j.ts || 0) >= since) {
135
+ return j.port;
136
+ }
137
+ } catch {
138
+ // 文件还没写出来
139
+ }
140
+ sleepSync(150);
141
+ }
142
+ return null;
143
+ }
144
+
122
145
  function startBridgeDetached(port, host) {
123
146
  mkdirSync(pendingDir, {recursive: true});
124
147
  const logFile = resolve(pendingDir, 'bridge.log');
@@ -127,14 +150,21 @@ function startBridgeDetached(port, host) {
127
150
  if (host) {
128
151
  env.UI_INSPECTOR_BRIDGE_HOST = String(host);
129
152
  }
153
+ const spawnedAt = Date.now();
130
154
  const child = spawn(process.execPath, [resolve(pkgRoot, 'bridge/server.mjs')], {
131
155
  detached: true,
132
156
  stdio: ['ignore', fd, fd],
133
157
  env,
134
158
  });
135
159
  child.unref();
136
- out(`bridge 已后台启动(pid ${child.pid},监听 ${host || '127.0.0.1'}:${port}),日志:${logFile}`);
160
+ // 端口可能被占而自动顺延;读 bridge.json 拿真正在用的端口。复用现有实例时子进程不写新记录,回落到传入 port。
161
+ const actual = readActualBridgePort(spawnedAt) || port;
162
+ out(`bridge 已后台启动(pid ${child.pid},监听 ${host || '127.0.0.1'}:${actual}),日志:${logFile}`);
163
+ if (String(actual) !== String(port)) {
164
+ out(` 注意:端口 ${port} 被占用,已自动改用 ${actual}——扩展弹窗地址请填这个端口。`);
165
+ }
137
166
  out(` 停止:kill ${child.pid}`);
167
+ return actual;
138
168
  }
139
169
 
140
170
  function install({remote, port, host}) {
@@ -142,9 +172,9 @@ function install({remote, port, host}) {
142
172
  if (remote) {
143
173
  const bindHost = host || '0.0.0.0';
144
174
  out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
145
- startBridgeDetached(port, bindHost);
175
+ const actual = startBridgeDetached(port, bindHost);
146
176
  out(
147
- `\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${port}」(如 devlocal.oreateai.com:${port})即可直连。`
177
+ `\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${actual}」(如 devlocal.oreateai.com:${actual})即可直连。`
148
178
  );
149
179
  return;
150
180
  }
@@ -1,7 +1,9 @@
1
1
  /*
2
2
  * Figma 命令执行器(MAIN world,figma.com):响应「带入对话」——把当前选中节点转成 F2C HTML 结构,
3
- * 回传给 figma-ui 落盘待 hook 注入。页面内已解锁的 window.figma / window.F2CConverter 由
4
- * rewrite.js + browser.global.js 提供。
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 提供。
5
7
  */
6
8
  (function () {
7
9
  if (window.__oreateFigmaExec) {
@@ -9,6 +11,9 @@
9
11
  }
10
12
  window.__oreateFigmaExec = true;
11
13
 
14
+ var MAX_IMAGES = 20; // 单次最多导出的图片数,防爆
15
+ var SCALE = 2; // 导出倍率,兼顾清晰度与体积
16
+
12
17
  function resolveNode() {
13
18
  var fig = window.figma;
14
19
  if (!fig) {
@@ -21,6 +26,109 @@
21
26
  throw new Error('未选中节点:请先在画布选中一个节点');
22
27
  }
23
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;
77
+ }
78
+
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
+ }
87
+ var bin = '';
88
+ for (var i = 0; i < bytes.length; i++) {
89
+ bin += String.fromCharCode(bytes[i]);
90
+ }
91
+ return btoa(bin);
92
+ }
93
+
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
+ // 单张导出失败不影响其余(部分节点可能不可导出)
112
+ }
113
+ }
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;
129
+ }
130
+ }
131
+
24
132
  window.addEventListener('message', function (ev) {
25
133
  if (ev.source !== window || !ev.data || ev.data.__uiInspectorFigmaCapture !== true) {
26
134
  return;
@@ -32,11 +140,15 @@
32
140
  }
33
141
  var node = resolveNode();
34
142
  var html = await window.F2CConverter.convertFigmaNodeToHtml(node, {});
143
+ var images = await exportImages(node);
144
+ var snapshot = await exportSnapshot(node);
35
145
  return {
36
146
  nodeId: node.id,
37
147
  name: node.name,
38
148
  type: node.type,
39
149
  html: typeof html === 'string' ? html : JSON.stringify(html),
150
+ images: images,
151
+ snapshot: snapshot,
40
152
  };
41
153
  })
42
154
  .then(function (node) {
@@ -50,13 +50,36 @@
50
50
  if (!alive()) {
51
51
  return;
52
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
+ }
53
75
  try {
54
- chrome.runtime.sendMessage({type: 'figma-capture', node: payload.node}, resp => {
76
+ chrome.runtime.sendMessage({type: 'figma-capture', node: node}, resp => {
55
77
  if (chrome.runtime.lastError || !resp || !resp.ok) {
56
78
  toast('落盘失败,请重试', false);
57
79
  return;
58
80
  }
59
- toast(`已带入「${payload.node.name || payload.node.nodeId}」,去 Comate 发消息即可生成代码`, true);
81
+ const extra = snap ? ',已可在开发页点「走查」覆盖' : '';
82
+ toast(`已带入「${node.name || node.nodeId}」${extra},去 Comate 发消息即可生成代码`, true);
60
83
  });
61
84
  } catch {
62
85
  toast('扩展上下文失效,请刷新页面', false);
@@ -8,6 +8,20 @@
8
8
  class="hint"
9
9
  style="margin-top: 6px"
10
10
  ></div>
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>
11
25
  <div
12
26
  class="hint"
13
27
  style="margin-top: 6px"
@@ -1,17 +1,70 @@
1
1
  /*
2
- * Figma 增强包弹窗面板:只做状态提示。真正的注入在 figma.com 页面内由 rewrite.js + browser.global.js
3
- * (MAIN world,document_start)完成;导出能力(节点→HTML/图片)由后续的命令通道 + MCP 工具提供。
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 (!el) {
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
- chrome.permissions.contains({origins: ['https://www.figma.com/*']}, granted => {
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
- el.textContent = granted ? '✓ 已授权 figma.com,打开设计稿即生效' : '✗ 尚未授权 figma.com';
15
- el.style.color = granted ? '#1a7f37' : '#cf222e';
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
  })();
@@ -178,6 +178,178 @@
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('pointermove', ovDrag);
200
+ document.removeEventListener('pointerup', ovDragEnd);
201
+ document.removeEventListener('pointercancel', ovDragEnd);
202
+ }
203
+
204
+ function ovApply() {
205
+ if (!ov) {
206
+ return;
207
+ }
208
+ ov.style.left = ovs.x + 'px';
209
+ ov.style.top = ovs.y + 'px';
210
+ ov.style.opacity = String(ovs.opacity);
211
+ ovImg.style.width = (ovs.baseW * ovs.scale || 0) + 'px';
212
+ ovImg.style.pointerEvents = ovs.through ? 'none' : 'auto';
213
+ ov.style.pointerEvents = ovs.through ? 'none' : 'auto';
214
+ }
215
+
216
+ function ovDrag(e) {
217
+ if (!dragFrom) {
218
+ return;
219
+ }
220
+ ovs.x = e.clientX - dragFrom.dx;
221
+ ovs.y = e.clientY - dragFrom.dy;
222
+ ovApply();
223
+ }
224
+ function ovDragEnd() {
225
+ dragFrom = null;
226
+ }
227
+
228
+ function ovBuild(data) {
229
+ ovRemove();
230
+ ovs = {x: 40, y: 40, scale: 1, opacity: 0.5, baseW: data.width || 0, through: false};
231
+ ov = document.createElement('div');
232
+ ov.style.cssText = 'position:fixed;z-index:2147483646;left:40px;top:40px;opacity:0.5;';
233
+ ovImg = document.createElement('img');
234
+ ovImg.src = data.dataUrl;
235
+ ovImg.draggable = false;
236
+ // touch-action:none —— 否则移动端/模拟器会把触摸当成页面滚动吞掉,pointermove 不触发。
237
+ ovImg.style.cssText = 'display:block;cursor:move;touch-action:none;box-shadow:0 0 0 1px rgba(255,0,0,0.35);';
238
+ ovImg.addEventListener('load', function () {
239
+ if (!ovs.baseW) {
240
+ ovs.baseW = ovImg.naturalWidth || 0;
241
+ }
242
+ ovApply();
243
+ });
244
+ // 用 Pointer Events 统一鼠标 + 触摸(含 DevTools 移动端模拟器把鼠标转成 touch 的情况)。
245
+ ovImg.addEventListener('pointerdown', function (e) {
246
+ if (ovs.through) {
247
+ return;
248
+ }
249
+ dragFrom = {dx: e.clientX - ovs.x, dy: e.clientY - ovs.y};
250
+ try {
251
+ ovImg.setPointerCapture(e.pointerId);
252
+ } catch (err) {
253
+ // 某些环境不支持指针捕获,忽略
254
+ }
255
+ e.preventDefault();
256
+ });
257
+ document.addEventListener('pointermove', ovDrag);
258
+ document.addEventListener('pointerup', ovDragEnd);
259
+ document.addEventListener('pointercancel', ovDragEnd);
260
+ ov.appendChild(ovImg);
261
+ document.documentElement.appendChild(ov);
262
+ ovToolbar();
263
+ ovApply();
264
+ }
265
+
266
+ function ovField(label, ctrl) {
267
+ var w = document.createElement('span');
268
+ w.style.cssText = 'display:flex;align-items:center;gap:6px;';
269
+ var l = document.createElement('span');
270
+ l.textContent = label;
271
+ w.appendChild(l);
272
+ w.appendChild(ctrl);
273
+ return w;
274
+ }
275
+
276
+ function ovToolbar() {
277
+ ovBar = document.createElement('div');
278
+ ovBar.style.cssText =
279
+ 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;display:flex;' +
280
+ 'align-items:center;gap:12px;padding:6px 12px;border-radius:999px;background:#111;color:#fff;' +
281
+ 'font:12px/1.4 sans-serif;box-shadow:0 2px 10px rgba(0,0,0,0.3);';
282
+ var o = document.createElement('input');
283
+ o.type = 'range';
284
+ o.min = '0';
285
+ o.max = '100';
286
+ o.value = '50';
287
+ o.style.width = '80px';
288
+ o.oninput = function () {
289
+ ovs.opacity = Number(o.value) / 100;
290
+ ovApply();
291
+ };
292
+ var z = document.createElement('input');
293
+ z.type = 'range';
294
+ z.min = '10';
295
+ z.max = '300';
296
+ z.value = '100';
297
+ z.style.width = '100px';
298
+ var zl = document.createElement('span');
299
+ zl.textContent = '100%';
300
+ z.oninput = function () {
301
+ ovs.scale = Number(z.value) / 100;
302
+ zl.textContent = z.value + '%';
303
+ ovApply();
304
+ };
305
+ var tc = document.createElement('input');
306
+ tc.type = 'checkbox';
307
+ tc.onchange = function () {
308
+ ovs.through = tc.checked;
309
+ ovApply();
310
+ };
311
+ var c = document.createElement('button');
312
+ c.textContent = '关闭';
313
+ c.style.cssText =
314
+ 'border:none;border-radius:999px;padding:3px 10px;background:#ff5a5a;color:#fff;cursor:pointer;font:inherit;';
315
+ c.onclick = ovRemove;
316
+ ovBar.appendChild(ovField('透明', o));
317
+ ovBar.appendChild(ovField('缩放', z));
318
+ ovBar.appendChild(zl);
319
+ ovBar.appendChild(ovField('穿透', tc));
320
+ ovBar.appendChild(c);
321
+ document.documentElement.appendChild(ovBar);
322
+ }
323
+
324
+ // popup 的「走查」按钮 → 这里切换覆盖层:已显示则关闭,否则读 storage 的快照渲染。
325
+ try {
326
+ chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
327
+ if (!msg || msg.type !== 'toggle-figma-overlay') {
328
+ return;
329
+ }
330
+ if (ov) {
331
+ ovRemove();
332
+ sendResponse({ok: true, shown: false});
333
+ return;
334
+ }
335
+ chrome.storage.local.get({figmaOverlay: null}, function (o) {
336
+ if (chrome.runtime.lastError) {
337
+ sendResponse({ok: false, error: '读取快照失败'});
338
+ return;
339
+ }
340
+ if (!o || !o.figmaOverlay || !o.figmaOverlay.dataUrl) {
341
+ sendResponse({ok: false, error: '还没有可走查的节点,先去 Figma 点「带入 Comate 生成代码」'});
342
+ return;
343
+ }
344
+ ovBuild(o.figmaOverlay);
345
+ sendResponse({ok: true, shown: true});
346
+ });
347
+ return true; // 异步 sendResponse
348
+ });
349
+ } catch {
350
+ // 上下文失效
351
+ }
352
+
181
353
  const timer = setInterval(() => {
182
354
  if (!alive()) {
183
355
  clearInterval(timer);
@@ -194,7 +366,6 @@
194
366
  const status = resp.status;
195
367
  const id = status && status.id !== undefined ? status.id : null;
196
368
  if (!statusSeeded) {
197
- // 首轮:记录当前状态 id(可能是上轮遗留),本轮不弹,避免刷新页面重放旧 toast
198
369
  statusSeeded = true;
199
370
  lastStatusId = id;
200
371
  } else if (status && id !== lastStatusId) {
@@ -39,6 +39,17 @@
39
39
  panelHead.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
40
40
  var panelCount = document.createElement('span');
41
41
  panelCount.style.cssText = 'font-weight:600;';
42
+ // 右侧按钮组:收起/展开 + 清空。
43
+ var panelBtns = document.createElement('div');
44
+ panelBtns.style.cssText = 'display:flex;gap:6px;flex:none;';
45
+ var panelToggle = document.createElement('button');
46
+ panelToggle.textContent = '收起';
47
+ panelToggle.style.cssText =
48
+ 'border:none;background:#2f2f2f;color:#fff;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;';
49
+ panelToggle.onclick = function () {
50
+ panelCollapsed = !panelCollapsed;
51
+ renderPanel();
52
+ };
42
53
  var panelClear = document.createElement('button');
43
54
  panelClear.textContent = '清空';
44
55
  panelClear.style.cssText =
@@ -49,8 +60,10 @@
49
60
  renderPanel();
50
61
  showHint('已清空全部选中');
51
62
  };
63
+ panelBtns.appendChild(panelToggle);
64
+ panelBtns.appendChild(panelClear);
52
65
  panelHead.appendChild(panelCount);
53
- panelHead.appendChild(panelClear);
66
+ panelHead.appendChild(panelBtns);
54
67
  var panelList = document.createElement('div');
55
68
  panel.appendChild(panelHead);
56
69
  panel.appendChild(panelList);
@@ -81,6 +94,7 @@
81
94
  // DOM 树上下级导航状态:navPath 从叶子往上累积,navIdx 当前停在哪一层(0 = 叶子)。
82
95
  var navPath = [];
83
96
  var navIdx = 0;
97
+ var panelCollapsed = false; // 侧边栏收起态:仅隐藏列表,保留标题与计数
84
98
 
85
99
  function renderPanel() {
86
100
  if (!selected.length) {
@@ -89,6 +103,8 @@
89
103
  }
90
104
  panel.style.display = 'block';
91
105
  panelCount.textContent = '已选中 ' + selected.length + ' 个';
106
+ panelToggle.textContent = panelCollapsed ? '展开' : '收起';
107
+ panelList.style.display = panelCollapsed ? 'none' : 'block';
92
108
  panelList.textContent = '';
93
109
  renumber();
94
110
  selected.forEach(function (item, i) {
@@ -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
 
@@ -24,6 +24,8 @@ const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
24
24
  const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
25
25
  // figma「带入对话」采集的节点结构,等 hook 注入下一条消息。
26
26
  const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
27
+ // figma 节点内导出的图片字节落盘到这里(按采集批次分子目录)。
28
+ const figmaAssetsDir = resolve(homedir(), '.ui-inspector/figma-assets');
27
29
 
28
30
  function readStore() {
29
31
  try {
@@ -88,10 +90,89 @@ function recStop(endedAt) {
88
90
  }
89
91
 
90
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
+ );
100
+ }
101
+
102
+ // 清理旧的图片批次:保留当前仍在 figma-pending.json 里引用的批次目录(尚未被消费的节点),
103
+ // 其余(已被 hook 注入消费、或被挤出 pending 的)全部删除。只在新一次导出时触发——「下次导出 PNG 才删上次的」。
104
+ function pruneStaleAssets() {
105
+ let keep = new Set();
106
+ try {
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
+ }
116
+ } catch {
117
+ keep = new Set();
118
+ }
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)) {
128
+ try {
129
+ rmSync(full, {recursive: true, force: true});
130
+ } catch {
131
+ // 删除失败忽略
132
+ }
133
+ }
134
+ }
135
+ }
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
+ }
143
+ return;
144
+ }
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
+
91
171
  function figmaCapture(node) {
92
172
  if (!node) {
93
173
  return;
94
174
  }
175
+ persistImages(node);
95
176
  let list = [];
96
177
  try {
97
178
  const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
@@ -225,15 +225,13 @@
225
225
  <input
226
226
  id="bridge-target"
227
227
  type="text"
228
- placeholder="devlocal.oreateai.com:8917"
228
+ placeholder="<remote-ip>:<port>"
229
229
  />
230
230
  </div>
231
231
  <div class="hint">
232
232
  填开发机 bridge 的完整地址(如
233
- <code>devlocal.oreateai.com:8917</code>
234
- )。开发机上
235
- <code>ui-inspector install --remote</code>
236
- 起 bridge(默认绑 0.0.0.0 供直连)。
233
+ <code><\remote-ip>:<\port></code>
234
+ )
237
235
  </div>
238
236
  </div>
239
237
  </section>
@@ -153,6 +153,16 @@ function describeFigma(nodes) {
153
153
  if ((n.html || '').length > 20000) {
154
154
  parts.push('…(结构过长已截断)');
155
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
+ }
156
166
  });
157
167
  return parts.join('\n');
158
168
  }
@@ -186,6 +196,8 @@ try {
186
196
  writeFileSync(eventsFile, '', 'utf8');
187
197
  }
188
198
  if (hasFigma) {
199
+ // figma 节点注入后清空 pending,但导出的 PNG 文件保留——它们由下一次采集导出时清理(见 host/bridge
200
+ // 的 persistImages),这样注入后 Agent 仍可随时 Read 这些图片,直到下次带入新节点才替换。
189
201
  writeFileSync(figmaPendingFile, '[]\n', 'utf8');
190
202
  }
191
203
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.6-alpha.2",
3
+ "version": "0.1.6-alpha.4",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",