comate-ui-inspector 0.1.5-alpha.3 → 0.1.5-alpha.5
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 +17 -23
- package/enhancements/recorder/pane.html +2 -2
- package/enhancements/recorder/pane.js +3 -1
- package/enhancements/recorder/recorder-main.js +17 -2
- package/extension/background.js +7 -1
- package/extension/inspector-main.js +291 -17
- package/extension/native-host/host.mjs +18 -23
- package/hooks/inject-ui-inspector.mjs +44 -8
- package/package.json +1 -1
package/bridge/server.mjs
CHANGED
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
* 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
|
|
11
11
|
*/
|
|
12
12
|
import {createServer} from 'node:http';
|
|
13
|
-
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
|
13
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
|
|
14
14
|
import {dirname, resolve} from 'node:path';
|
|
15
15
|
import {homedir} from 'node:os';
|
|
16
16
|
|
|
17
17
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
18
18
|
const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
19
|
+
// 录制:元信息 + append-only 事件流分离,避免多请求读改写竞态把 active 盖回 true。
|
|
19
20
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
|
+
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
20
22
|
const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
|
|
21
23
|
// 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
|
|
22
24
|
const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
|
|
@@ -43,51 +45,43 @@ function writeStore(list) {
|
|
|
43
45
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
function readRecording() {
|
|
48
|
+
// 录制累积:元信息与事件流分离,避免多请求读改写竞态。
|
|
49
|
+
function readMeta() {
|
|
49
50
|
try {
|
|
50
51
|
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
51
52
|
} catch {
|
|
52
53
|
return null;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
function
|
|
56
|
+
function writeMeta(meta) {
|
|
56
57
|
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
57
|
-
writeFileSync(recordingFile, `${JSON.stringify(
|
|
58
|
+
writeFileSync(recordingFile, `${JSON.stringify(meta ?? null, null, 4)}\n`, 'utf8');
|
|
58
59
|
}
|
|
59
60
|
function recStart(startedAt, pageUrl) {
|
|
60
|
-
const cur =
|
|
61
|
+
const cur = readMeta();
|
|
61
62
|
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
62
|
-
return; // 同一会话重复 start
|
|
63
|
+
return; // 同一会话重复 start,忽略
|
|
63
64
|
}
|
|
64
|
-
|
|
65
|
+
writeMeta({active: true, startedAt, pageUrl});
|
|
66
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
67
|
+
writeFileSync(eventsFile, '', 'utf8');
|
|
65
68
|
}
|
|
66
69
|
function recEvent(kind, item) {
|
|
67
|
-
const cur =
|
|
70
|
+
const cur = readMeta();
|
|
68
71
|
if (!cur || !cur.active) {
|
|
69
72
|
return;
|
|
70
73
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (!Array.isArray(cur[key])) {
|
|
76
|
-
cur[key] = [];
|
|
77
|
-
}
|
|
78
|
-
if (cur[key].length < REC_CAP) {
|
|
79
|
-
cur[key].push(item);
|
|
80
|
-
writeRecording(cur);
|
|
81
|
-
}
|
|
74
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
75
|
+
appendFileSync(eventsFile, `${JSON.stringify({kind, item})}\n`, 'utf8');
|
|
82
76
|
}
|
|
83
77
|
function recStop(endedAt) {
|
|
84
|
-
const cur =
|
|
78
|
+
const cur = readMeta();
|
|
85
79
|
if (!cur) {
|
|
86
80
|
return;
|
|
87
81
|
}
|
|
88
82
|
cur.active = false;
|
|
89
83
|
cur.endedAt = endedAt || Date.now();
|
|
90
|
-
|
|
84
|
+
writeMeta(cur);
|
|
91
85
|
}
|
|
92
86
|
|
|
93
87
|
function readBody(req, cb) {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const {recording} = await chrome.storage.local.get({recording: null});
|
|
20
20
|
if (recording && recording.active) {
|
|
21
21
|
await chrome.storage.local.set({recording: {active: false}});
|
|
22
|
+
refresh();
|
|
22
23
|
} else {
|
|
23
24
|
await chrome.storage.local.set({
|
|
24
25
|
recording: {
|
|
@@ -28,8 +29,9 @@
|
|
|
28
29
|
startedAt: Date.now(),
|
|
29
30
|
},
|
|
30
31
|
});
|
|
32
|
+
// 开始录制后关闭 popup,让用户直接去页面操作;页面顶部会出现「正在录制」提示条。
|
|
33
|
+
window.close();
|
|
31
34
|
}
|
|
32
|
-
refresh();
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
refresh();
|
|
@@ -133,11 +133,26 @@
|
|
|
133
133
|
p.then(
|
|
134
134
|
function (r) {
|
|
135
135
|
var t1 = Date.now();
|
|
136
|
-
emit('network', {
|
|
136
|
+
emit('network', {
|
|
137
|
+
method: method,
|
|
138
|
+
url: url,
|
|
139
|
+
status: r.status,
|
|
140
|
+
startTs: t0,
|
|
141
|
+
endTs: t1,
|
|
142
|
+
ms: t1 - t0,
|
|
143
|
+
});
|
|
137
144
|
},
|
|
138
145
|
function () {
|
|
139
146
|
var t1 = Date.now();
|
|
140
|
-
emit('network', {
|
|
147
|
+
emit('network', {
|
|
148
|
+
method: method,
|
|
149
|
+
url: url,
|
|
150
|
+
status: 0,
|
|
151
|
+
startTs: t0,
|
|
152
|
+
endTs: t1,
|
|
153
|
+
ms: t1 - t0,
|
|
154
|
+
error: true,
|
|
155
|
+
});
|
|
141
156
|
}
|
|
142
157
|
);
|
|
143
158
|
}
|
package/extension/background.js
CHANGED
|
@@ -99,7 +99,13 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
99
99
|
if (!msg || typeof msg.type !== 'string') {
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
|
-
if (
|
|
102
|
+
if (
|
|
103
|
+
msg.type === 'ingest' ||
|
|
104
|
+
msg.type === 'state' ||
|
|
105
|
+
msg.type === 'rec-start' ||
|
|
106
|
+
msg.type === 'rec-event' ||
|
|
107
|
+
msg.type === 'rec-stop'
|
|
108
|
+
) {
|
|
103
109
|
dispatch(msg).then(sendResponse);
|
|
104
110
|
return true; // 异步 sendResponse
|
|
105
111
|
}
|
|
@@ -27,6 +27,200 @@
|
|
|
27
27
|
'opacity:0;transition:opacity 0.15s;pointer-events:none;';
|
|
28
28
|
document.body.appendChild(hint);
|
|
29
29
|
|
|
30
|
+
// 右侧列表弹窗:有选中元素时列出全部已选,支持逐条删除。pointer-events 开(要点删除按钮),
|
|
31
|
+
// 但需在选取逻辑里把它自身排除,避免 Alt 点面板又被当成选元素。
|
|
32
|
+
var panel = document.createElement('div');
|
|
33
|
+
panel.id = 'oreate-ui-inspector-panel';
|
|
34
|
+
panel.style.cssText =
|
|
35
|
+
'position:fixed;top:16px;right:16px;width:260px;max-height:60vh;overflow:auto;z-index:2147483646;' +
|
|
36
|
+
'display:none;background:#111;color:#fff;border-radius:8px;padding:10px 10px 8px;' +
|
|
37
|
+
'font:12px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);';
|
|
38
|
+
var panelHead = document.createElement('div');
|
|
39
|
+
panelHead.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;';
|
|
40
|
+
var panelCount = document.createElement('span');
|
|
41
|
+
panelCount.style.cssText = 'font-weight:600;';
|
|
42
|
+
var panelClear = document.createElement('button');
|
|
43
|
+
panelClear.textContent = '清空';
|
|
44
|
+
panelClear.style.cssText =
|
|
45
|
+
'border:none;background:#2f2f2f;color:#fff;border-radius:4px;padding:2px 8px;cursor:pointer;font:inherit;';
|
|
46
|
+
panelClear.onclick = function () {
|
|
47
|
+
clearSelection();
|
|
48
|
+
postSelection();
|
|
49
|
+
renderPanel();
|
|
50
|
+
showHint('已清空全部选中');
|
|
51
|
+
};
|
|
52
|
+
panelHead.appendChild(panelCount);
|
|
53
|
+
panelHead.appendChild(panelClear);
|
|
54
|
+
var panelList = document.createElement('div');
|
|
55
|
+
panel.appendChild(panelHead);
|
|
56
|
+
panel.appendChild(panelList);
|
|
57
|
+
document.body.appendChild(panel);
|
|
58
|
+
|
|
59
|
+
// 顶部左侧「层级选择」面包屑:悬停元素时列出 标签 + 其 Vue 组件祖先(含 __file),点一级即选中那一层。
|
|
60
|
+
var crumbBar = document.createElement('div');
|
|
61
|
+
crumbBar.id = 'oreate-ui-inspector-crumbs';
|
|
62
|
+
crumbBar.style.cssText =
|
|
63
|
+
'position:fixed;top:16px;left:16px;max-width:60vw;z-index:2147483646;display:none;flex-wrap:wrap;' +
|
|
64
|
+
'align-items:center;gap:4px;background:#111;color:#fff;border-radius:8px;padding:6px 8px;' +
|
|
65
|
+
'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);';
|
|
66
|
+
document.body.appendChild(crumbBar);
|
|
67
|
+
|
|
68
|
+
// 该元素是否属于本插件自身 UI(面板/面包屑/overlay/hint),选取时要跳过。
|
|
69
|
+
function isOwnUi(el) {
|
|
70
|
+
return (
|
|
71
|
+
!!el &&
|
|
72
|
+
(el === overlay ||
|
|
73
|
+
el === hint ||
|
|
74
|
+
el === panel ||
|
|
75
|
+
panel.contains(el) ||
|
|
76
|
+
el === crumbBar ||
|
|
77
|
+
crumbBar.contains(el))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// DOM 树上下级导航状态:navPath 从叶子往上累积,navIdx 当前停在哪一层(0 = 叶子)。
|
|
82
|
+
var navPath = [];
|
|
83
|
+
var navIdx = 0;
|
|
84
|
+
|
|
85
|
+
function renderPanel() {
|
|
86
|
+
if (!selected.length) {
|
|
87
|
+
panel.style.display = 'none';
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
panel.style.display = 'block';
|
|
91
|
+
panelCount.textContent = '已选中 ' + selected.length + ' 个';
|
|
92
|
+
panelList.textContent = '';
|
|
93
|
+
selected.forEach(function (item, i) {
|
|
94
|
+
var info = item.info || {};
|
|
95
|
+
var files = (info.componentChain || []).map(function (c) {
|
|
96
|
+
return c && c.file;
|
|
97
|
+
}).filter(Boolean);
|
|
98
|
+
var row = document.createElement('div');
|
|
99
|
+
row.style.cssText =
|
|
100
|
+
'display:flex;align-items:flex-start;gap:6px;padding:6px 0;border-top:1px solid #2a2a2a;';
|
|
101
|
+
var meta = document.createElement('div');
|
|
102
|
+
meta.style.cssText = 'flex:1;min-width:0;';
|
|
103
|
+
var title = document.createElement('div');
|
|
104
|
+
title.style.cssText = 'font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
|
|
105
|
+
title.textContent = i + 1 + '. <' + (info.tag || '?') + '>' + (info.text ? ' ' + info.text.slice(0, 20) : '');
|
|
106
|
+
var sub = document.createElement('div');
|
|
107
|
+
sub.style.cssText = 'color:#9aa0a6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
|
|
108
|
+
sub.textContent = files.length ? files[0].split('/').pop() : info.selector || '';
|
|
109
|
+
sub.title = files.length ? files.join(' <- ') : info.selector || '';
|
|
110
|
+
meta.appendChild(title);
|
|
111
|
+
meta.appendChild(sub);
|
|
112
|
+
var del = document.createElement('button');
|
|
113
|
+
del.textContent = '删除';
|
|
114
|
+
del.style.cssText =
|
|
115
|
+
'flex:none;border:none;background:none;color:#ff6b6b;cursor:pointer;padding:0 2px;font:inherit;';
|
|
116
|
+
del.onclick = function () {
|
|
117
|
+
removeAt(i);
|
|
118
|
+
};
|
|
119
|
+
row.appendChild(meta);
|
|
120
|
+
row.appendChild(del);
|
|
121
|
+
panelList.appendChild(row);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function removeAt(i) {
|
|
126
|
+
if (i < 0 || i >= selected.length) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
selected[i].box.remove();
|
|
130
|
+
selected.splice(i, 1);
|
|
131
|
+
postSelection();
|
|
132
|
+
renderPanel();
|
|
133
|
+
showHint(selected.length ? '已删除(当前 ' + selected.length + ' 个)' : '已删除全部选中');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 取元素的 Vue 组件祖先链,并带上每层组件的根 DOM 元素(可能没有,fragment 时为 null)。
|
|
137
|
+
function componentChainEls(el) {
|
|
138
|
+
var out = [];
|
|
139
|
+
var inst = el && el.__vueParentComponent;
|
|
140
|
+
while (inst && out.length < 8) {
|
|
141
|
+
var type = inst.type || {};
|
|
142
|
+
var file = type.__file || null;
|
|
143
|
+
var name = type.__name || type.name || null;
|
|
144
|
+
var node = (inst.vnode && inst.vnode.el) || (inst.subTree && inst.subTree.el) || null;
|
|
145
|
+
if (file || name) {
|
|
146
|
+
out.push({name: name, file: file, el: node && node.nodeType === 1 ? node : null});
|
|
147
|
+
}
|
|
148
|
+
inst = inst.parent;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 渲染「层级选择」面包屑:叶子标签 + 组件祖先;带 DOM 元素的可点击,点击即选中那一层。
|
|
154
|
+
function renderBreadcrumb(el) {
|
|
155
|
+
if (!active || !el) {
|
|
156
|
+
crumbBar.style.display = 'none';
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
var crumbs = [{label: '<' + el.nodeName.toLowerCase() + '>', el: el}];
|
|
160
|
+
componentChainEls(el).forEach(function (c) {
|
|
161
|
+
crumbs.push({label: c.name || (c.file ? c.file.split('/').pop() : '组件'), el: c.el, file: c.file});
|
|
162
|
+
});
|
|
163
|
+
crumbBar.textContent = '';
|
|
164
|
+
var tip = document.createElement('span');
|
|
165
|
+
tip.textContent = '层级:';
|
|
166
|
+
tip.style.cssText = 'color:#9aa0a6;';
|
|
167
|
+
crumbBar.appendChild(tip);
|
|
168
|
+
crumbs.forEach(function (c, i) {
|
|
169
|
+
if (i) {
|
|
170
|
+
var sep = document.createElement('span');
|
|
171
|
+
sep.textContent = '›';
|
|
172
|
+
sep.style.cssText = 'color:#555;';
|
|
173
|
+
crumbBar.appendChild(sep);
|
|
174
|
+
}
|
|
175
|
+
var btn = document.createElement(c.el ? 'button' : 'span');
|
|
176
|
+
btn.textContent = c.label;
|
|
177
|
+
btn.title = c.file || '';
|
|
178
|
+
btn.style.cssText = c.el
|
|
179
|
+
? 'border:none;background:#2f2f2f;color:#fff;border-radius:4px;padding:2px 6px;cursor:pointer;font:inherit;'
|
|
180
|
+
: 'color:#9aa0a6;';
|
|
181
|
+
if (c.el) {
|
|
182
|
+
btn.onclick = function () {
|
|
183
|
+
toggleSelect(c.el);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
crumbBar.appendChild(btn);
|
|
187
|
+
});
|
|
188
|
+
crumbBar.style.display = 'flex';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 悬停到页面元素:重置导航路径并高亮,刷新面包屑。
|
|
192
|
+
function setHover(el) {
|
|
193
|
+
navPath = [el];
|
|
194
|
+
navIdx = 0;
|
|
195
|
+
current = el;
|
|
196
|
+
moveOverlay(el);
|
|
197
|
+
renderBreadcrumb(el);
|
|
198
|
+
}
|
|
199
|
+
// ↑ 选父级:按需把父节点压入 navPath,再上移一层。
|
|
200
|
+
function navUp() {
|
|
201
|
+
if (navIdx === navPath.length - 1) {
|
|
202
|
+
var p = navPath[navIdx] && navPath[navIdx].parentElement;
|
|
203
|
+
if (!p || p === document.body || p === document.documentElement || isOwnUi(p)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
navPath.push(p);
|
|
207
|
+
}
|
|
208
|
+
navIdx++;
|
|
209
|
+
current = navPath[navIdx];
|
|
210
|
+
moveOverlay(current);
|
|
211
|
+
renderBreadcrumb(current);
|
|
212
|
+
}
|
|
213
|
+
// ↓ 回子级:沿来时路径下移一层。
|
|
214
|
+
function navDown() {
|
|
215
|
+
if (navIdx === 0) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
navIdx--;
|
|
219
|
+
current = navPath[navIdx];
|
|
220
|
+
moveOverlay(current);
|
|
221
|
+
renderBreadcrumb(current);
|
|
222
|
+
}
|
|
223
|
+
|
|
30
224
|
function showHint(text) {
|
|
31
225
|
window.clearTimeout(showStatus._t);
|
|
32
226
|
hint.style.background = '#111';
|
|
@@ -130,12 +324,34 @@
|
|
|
130
324
|
return el && el.isConnected && el.getClientRects().length > 0;
|
|
131
325
|
}
|
|
132
326
|
|
|
133
|
-
//
|
|
327
|
+
// 解析选中项当前对应的真实元素:优先用已记引用;若框架重渲染把节点换掉了(引用失效),
|
|
328
|
+
// 用当初记录的 CSS 选择器回查找回,避免「还在页面上却被当成消失」而误删。
|
|
329
|
+
function resolveEl(item) {
|
|
330
|
+
if (item.el && item.el.isConnected) {
|
|
331
|
+
return item.el;
|
|
332
|
+
}
|
|
333
|
+
var sel = item.info && item.info.selector;
|
|
334
|
+
if (sel) {
|
|
335
|
+
try {
|
|
336
|
+
var found = document.querySelector(sel);
|
|
337
|
+
if (found) {
|
|
338
|
+
item.el = found;
|
|
339
|
+
return found;
|
|
340
|
+
}
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// 选择器非法,忽略
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 跟随元素位置;剔除真正消失(引用失效且选择器也找不回)的选中项,连同高亮框一起移除。
|
|
134
349
|
function repositionBoxes() {
|
|
135
350
|
var removed = 0;
|
|
136
351
|
for (var i = selected.length - 1; i >= 0; i--) {
|
|
137
|
-
|
|
138
|
-
|
|
352
|
+
var el = resolveEl(selected[i]);
|
|
353
|
+
if (el && isRendered(el)) {
|
|
354
|
+
placeBox(selected[i].box, el);
|
|
139
355
|
} else {
|
|
140
356
|
selected[i].box.remove();
|
|
141
357
|
selected.splice(i, 1);
|
|
@@ -144,6 +360,7 @@
|
|
|
144
360
|
}
|
|
145
361
|
if (removed) {
|
|
146
362
|
postSelection();
|
|
363
|
+
renderPanel();
|
|
147
364
|
showHint(
|
|
148
365
|
selected.length
|
|
149
366
|
? '有选中元素已消失,已移除(当前 ' + selected.length + ' 个)'
|
|
@@ -152,11 +369,45 @@
|
|
|
152
369
|
}
|
|
153
370
|
}
|
|
154
371
|
|
|
372
|
+
// 事件驱动重定位:scroll/resize/ResizeObserver/MutationObserver 触发,用 rAF 合并成每帧一次,
|
|
373
|
+
// 再加一个 500ms 轮询兜底(纯 CSS 动画位移这类不触发上述事件的情况)。替代原来每帧常开的 rAF。
|
|
374
|
+
var repoScheduled = false;
|
|
375
|
+
function scheduleReposition() {
|
|
376
|
+
if (repoScheduled || !selected.length) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
repoScheduled = true;
|
|
380
|
+
window.requestAnimationFrame(function () {
|
|
381
|
+
repoScheduled = false;
|
|
382
|
+
if (selected.length) {
|
|
383
|
+
repositionBoxes();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
var sizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(scheduleReposition) : null;
|
|
388
|
+
function observeSelected() {
|
|
389
|
+
if (!sizeObserver) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
sizeObserver.disconnect();
|
|
393
|
+
for (var i = 0; i < selected.length; i++) {
|
|
394
|
+
if (selected[i].el) {
|
|
395
|
+
try {
|
|
396
|
+
sizeObserver.observe(selected[i].el);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
// 已脱离文档,忽略
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
155
404
|
function clearSelection() {
|
|
156
405
|
for (var i = 0; i < selected.length; i++) {
|
|
157
406
|
selected[i].box.remove();
|
|
158
407
|
}
|
|
159
408
|
selected = [];
|
|
409
|
+
observeSelected();
|
|
410
|
+
renderPanel();
|
|
160
411
|
}
|
|
161
412
|
|
|
162
413
|
function indexOfEl(el) {
|
|
@@ -192,6 +443,8 @@
|
|
|
192
443
|
selected.push({el: el, info: collect(el), box: box});
|
|
193
444
|
showHint('已选中 ' + selected.length + ' 个,可继续选,或回对话写意见');
|
|
194
445
|
}
|
|
446
|
+
observeSelected();
|
|
447
|
+
renderPanel();
|
|
195
448
|
postSelection();
|
|
196
449
|
}
|
|
197
450
|
window.addEventListener(
|
|
@@ -199,14 +452,24 @@
|
|
|
199
452
|
function (e) {
|
|
200
453
|
if (e.key === 'Alt' && !active) {
|
|
201
454
|
active = true;
|
|
202
|
-
showHint('
|
|
455
|
+
showHint('选择模式:Alt 点选可多选;↑/↓ 选上一级/下一级;Enter 选中当前悬停;松开 Alt 退出');
|
|
203
456
|
}
|
|
204
457
|
// 有些元素会吞掉 click(preventDefault/覆盖层),改用 Enter 选中当前 hover 的元素
|
|
205
|
-
if (e.key === 'Enter' && active && current && current
|
|
458
|
+
if (e.key === 'Enter' && active && current && !isOwnUi(current)) {
|
|
206
459
|
e.preventDefault();
|
|
207
460
|
e.stopPropagation();
|
|
208
461
|
toggleSelect(current);
|
|
209
462
|
}
|
|
463
|
+
// ↑/↓ 在 DOM 树上下级之间切换当前高亮元素(只在按住 Alt 的选择模式下拦截)
|
|
464
|
+
if (active && current && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
465
|
+
e.preventDefault();
|
|
466
|
+
e.stopPropagation();
|
|
467
|
+
if (e.key === 'ArrowUp') {
|
|
468
|
+
navUp();
|
|
469
|
+
} else {
|
|
470
|
+
navDown();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
210
473
|
},
|
|
211
474
|
true
|
|
212
475
|
);
|
|
@@ -216,6 +479,7 @@
|
|
|
216
479
|
if (e.key === 'Alt') {
|
|
217
480
|
active = false;
|
|
218
481
|
overlay.style.display = 'none';
|
|
482
|
+
crumbBar.style.display = 'none';
|
|
219
483
|
}
|
|
220
484
|
},
|
|
221
485
|
true
|
|
@@ -227,9 +491,13 @@
|
|
|
227
491
|
return;
|
|
228
492
|
}
|
|
229
493
|
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
494
|
+
// 悬停在插件自身 UI(面板/面包屑)上:不高亮,也不清空当前,便于点面包屑或删除按钮。
|
|
495
|
+
if (isOwnUi(el)) {
|
|
496
|
+
overlay.style.display = 'none';
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (el && el !== current) {
|
|
500
|
+
setHover(el);
|
|
233
501
|
}
|
|
234
502
|
},
|
|
235
503
|
true
|
|
@@ -240,26 +508,32 @@
|
|
|
240
508
|
if (!active || !e.altKey) {
|
|
241
509
|
return;
|
|
242
510
|
}
|
|
511
|
+
var el = document.elementFromPoint(e.clientX, e.clientY);
|
|
512
|
+
// 点到面板(如「删除」按钮):放行默认行为,别 preventDefault/选取。
|
|
513
|
+
if (isOwnUi(el)) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
243
516
|
e.preventDefault();
|
|
244
517
|
e.stopPropagation();
|
|
245
|
-
|
|
246
|
-
if (el && el !== overlay && el !== hint) {
|
|
518
|
+
if (el) {
|
|
247
519
|
toggleSelect(el);
|
|
248
520
|
}
|
|
249
521
|
},
|
|
250
522
|
true
|
|
251
523
|
);
|
|
252
524
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
(
|
|
525
|
+
// 事件驱动的位置跟随(替代每帧常开 rAF):滚动/缩放 + ResizeObserver + DOM 变更,合并到每帧一次;
|
|
526
|
+
// 再加 500ms 轮询兜底,覆盖纯 CSS 位移这类不触发上述事件的情况。
|
|
527
|
+
window.addEventListener('scroll', scheduleReposition, true);
|
|
528
|
+
window.addEventListener('resize', scheduleReposition, true);
|
|
529
|
+
if (typeof MutationObserver !== 'undefined') {
|
|
530
|
+
new MutationObserver(scheduleReposition).observe(document.documentElement, {childList: true, subtree: true});
|
|
531
|
+
}
|
|
532
|
+
window.setInterval(function () {
|
|
258
533
|
if (selected.length) {
|
|
259
534
|
repositionBoxes();
|
|
260
535
|
}
|
|
261
|
-
|
|
262
|
-
})();
|
|
536
|
+
}, 500);
|
|
263
537
|
|
|
264
538
|
// bridge-content 轮询回发的消息:cleared(撤高亮)/ status(toast 提示)
|
|
265
539
|
window.addEventListener('message', function (event) {
|
|
@@ -11,14 +11,17 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
|
|
13
13
|
*/
|
|
14
|
-
import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
|
|
14
|
+
import {readFileSync, writeFileSync, appendFileSync, mkdirSync} from 'node:fs';
|
|
15
15
|
import {dirname, resolve} from 'node:path';
|
|
16
16
|
import {homedir} from 'node:os';
|
|
17
17
|
|
|
18
18
|
// 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
|
|
19
19
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
20
20
|
const statusFile = resolve(homedir(), '.ui-inspector/status.json');
|
|
21
|
+
// 录制拆成两份:元信息(active/startedAt/pageUrl/endedAt)与事件流(append-only,每行一条)。
|
|
22
|
+
// 分开是为了避免「每条事件读改写整份 JSON」的多进程竞态把 active 盖回 true——事件只追加,不碰 active。
|
|
21
23
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
24
|
+
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
22
25
|
|
|
23
26
|
function readStore() {
|
|
24
27
|
try {
|
|
@@ -42,52 +45,44 @@ function writeStore(list) {
|
|
|
42
45
|
writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
function readRecording() {
|
|
48
|
+
// 录制累积:跨刷新/跨页面逐条追加。元信息与事件流分离,避免多进程读改写竞态。
|
|
49
|
+
function readMeta() {
|
|
48
50
|
try {
|
|
49
51
|
return JSON.parse(readFileSync(recordingFile, 'utf8'));
|
|
50
52
|
} catch {
|
|
51
53
|
return null;
|
|
52
54
|
}
|
|
53
55
|
}
|
|
54
|
-
function
|
|
56
|
+
function writeMeta(meta) {
|
|
55
57
|
mkdirSync(dirname(recordingFile), {recursive: true});
|
|
56
|
-
writeFileSync(recordingFile, `${JSON.stringify(
|
|
58
|
+
writeFileSync(recordingFile, `${JSON.stringify(meta ?? null, null, 4)}\n`, 'utf8');
|
|
57
59
|
}
|
|
58
60
|
function recStart(startedAt, pageUrl) {
|
|
59
|
-
const cur =
|
|
60
|
-
// 同一会话(startedAt 相同)重复 start
|
|
61
|
+
const cur = readMeta();
|
|
62
|
+
// 同一会话(startedAt 相同)重复 start(多标签页/弹窗+页面双触发)直接忽略,避免清空已录数据。
|
|
61
63
|
if (cur && cur.active && cur.startedAt === startedAt) {
|
|
62
64
|
return;
|
|
63
65
|
}
|
|
64
|
-
|
|
66
|
+
writeMeta({active: true, startedAt, pageUrl});
|
|
67
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
68
|
+
writeFileSync(eventsFile, '', 'utf8'); // 清空事件流
|
|
65
69
|
}
|
|
66
70
|
function recEvent(kind, item) {
|
|
67
|
-
const cur =
|
|
71
|
+
const cur = readMeta();
|
|
68
72
|
if (!cur || !cur.active) {
|
|
69
73
|
return; // 未在录制,丢弃
|
|
70
74
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (!Array.isArray(cur[key])) {
|
|
76
|
-
cur[key] = [];
|
|
77
|
-
}
|
|
78
|
-
if (cur[key].length < REC_CAP) {
|
|
79
|
-
cur[key].push(item);
|
|
80
|
-
writeRecording(cur);
|
|
81
|
-
}
|
|
75
|
+
mkdirSync(dirname(eventsFile), {recursive: true});
|
|
76
|
+
appendFileSync(eventsFile, `${JSON.stringify({kind, item})}\n`, 'utf8'); // 追加,原子、无竞态
|
|
82
77
|
}
|
|
83
78
|
function recStop(endedAt) {
|
|
84
|
-
const cur =
|
|
79
|
+
const cur = readMeta();
|
|
85
80
|
if (!cur) {
|
|
86
81
|
return;
|
|
87
82
|
}
|
|
88
83
|
cur.active = false;
|
|
89
84
|
cur.endedAt = endedAt || Date.now();
|
|
90
|
-
|
|
85
|
+
writeMeta(cur); // 只有 start/stop 动 active,事件追加不碰它 → stop 后 active 不会被盖回
|
|
91
86
|
}
|
|
92
87
|
|
|
93
88
|
function send(obj) {
|
|
@@ -16,7 +16,9 @@ import {homedir} from 'node:os';
|
|
|
16
16
|
|
|
17
17
|
// 共享落盘:读用户主目录下的 pending / recording(与 host / bridge / mcp 同一路径),一份代码可服务多个仓库。
|
|
18
18
|
const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
|
|
19
|
+
// 录制:元信息(active/startedAt/pageUrl/endedAt)+ append-only 事件流(每行一条 {kind,item})。
|
|
19
20
|
const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
|
|
21
|
+
const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
20
22
|
|
|
21
23
|
function emit(output) {
|
|
22
24
|
process.stdout.write(JSON.stringify(output));
|
|
@@ -31,17 +33,50 @@ function readJson(file, fallback) {
|
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// 读 append-only 事件流,按类型归并
|
|
37
|
+
function readEvents() {
|
|
38
|
+
const clicks = [];
|
|
39
|
+
const con = [];
|
|
40
|
+
const network = [];
|
|
41
|
+
let text = '';
|
|
42
|
+
try {
|
|
43
|
+
text = readFileSync(eventsFile, 'utf8');
|
|
44
|
+
} catch {
|
|
45
|
+
return {clicks, console: con, network};
|
|
46
|
+
}
|
|
47
|
+
for (const line of text.split('\n')) {
|
|
48
|
+
if (!line.trim()) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
let ev;
|
|
52
|
+
try {
|
|
53
|
+
ev = JSON.parse(line);
|
|
54
|
+
} catch {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!ev || !ev.item) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ev.kind === 'click') {
|
|
61
|
+
clicks.push(ev.item);
|
|
62
|
+
} else if (ev.kind === 'console') {
|
|
63
|
+
con.push(ev.item);
|
|
64
|
+
} else if (ev.kind === 'network') {
|
|
65
|
+
network.push(ev.item);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {clicks, console: con, network};
|
|
69
|
+
}
|
|
70
|
+
|
|
34
71
|
const parsed = readJson(storeFile, []);
|
|
35
72
|
const list = Array.isArray(parsed) ? parsed : [];
|
|
36
|
-
const
|
|
73
|
+
const meta = readJson(recordingFile, null);
|
|
37
74
|
// 只注入**已停止**的录制(active === false)。录制进行中(跨刷新/跨页面累积)时不消费、不清空,
|
|
38
75
|
// 以免中途给 Comate 发消息把整段会话吞掉。
|
|
39
|
-
const recDone =
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
(recording.console && recording.console.length) ||
|
|
44
|
-
(recording.network && recording.network.length));
|
|
76
|
+
const recDone = meta && meta.active === false;
|
|
77
|
+
const events = recDone ? readEvents() : {clicks: [], console: [], network: []};
|
|
78
|
+
const recording = recDone ? {...meta, ...events} : null;
|
|
79
|
+
const hasRec = recDone && (events.clicks.length || events.console.length || events.network.length);
|
|
45
80
|
|
|
46
81
|
if (!list.length && !hasRec) {
|
|
47
82
|
emit({decision: 'allow'});
|
|
@@ -113,12 +148,13 @@ sections.push(
|
|
|
113
148
|
'请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
|
|
114
149
|
);
|
|
115
150
|
|
|
116
|
-
// 「拿到即消费」:注入后清空 pending
|
|
151
|
+
// 「拿到即消费」:注入后清空 pending;录制仅在确实注入(已停止)时清空元信息与事件流,进行中的保留。
|
|
117
152
|
try {
|
|
118
153
|
mkdirSync(dirname(storeFile), {recursive: true});
|
|
119
154
|
writeFileSync(storeFile, '[]\n', 'utf8');
|
|
120
155
|
if (hasRec) {
|
|
121
156
|
writeFileSync(recordingFile, 'null\n', 'utf8');
|
|
157
|
+
writeFileSync(eventsFile, '', 'utf8');
|
|
122
158
|
}
|
|
123
159
|
} catch {
|
|
124
160
|
// 清空失败不影响注入
|
package/package.json
CHANGED