@cfp-studio/iframe 18.4.2 → 18.4.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/dist/index.d.mts +8 -0
- package/package.json +1 -1
- package/preview/__previewjs_internal__/logs.ts +4 -0
- package/preview/__previewjs_internal__/overlay.ts +64 -9
- package/preview/__previewjs_internal__/reflowTracker.spec.ts +254 -0
- package/preview/__previewjs_internal__/reflowTracker.ts +147 -0
- package/preview/__previewjs_internal__/run-renderer.ts +15 -1
- package/preview/__previewjs_internal__/runtime-bridge.ts +292 -62
- package/preview/__previewjs_internal__/selectionModel.spec.ts +79 -0
- package/preview/__previewjs_internal__/selectionModel.ts +84 -0
- package/preview/index.html +43 -0
package/dist/index.d.mts
CHANGED
|
@@ -33,6 +33,14 @@ declare global {
|
|
|
33
33
|
*/
|
|
34
34
|
lastRenderFailed: boolean;
|
|
35
35
|
toggleInspect: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* 编译期错误兜底开关(`index.html` 的内联脚本用)。
|
|
38
|
+
*
|
|
39
|
+
* `true` 时由 `index.html` 上报那些「模块还没加载就崩」的错误
|
|
40
|
+
* (Vite 解析 `.less` 失败、`react-dom` 缺失等);`logs.ts` 接管后置 `false`。
|
|
41
|
+
* 见 `docs/todo/39`。
|
|
42
|
+
*/
|
|
43
|
+
startupErrorGuard: boolean;
|
|
36
44
|
/**
|
|
37
45
|
* 处理 HMR 更新
|
|
38
46
|
* @param payload - HMR 更新的负载
|
package/package.json
CHANGED
|
@@ -45,6 +45,10 @@ export function setUpLogInterception() {
|
|
|
45
45
|
console.info = makeLogger("info", console.info);
|
|
46
46
|
console.warn = makeLogger("warn", console.warn);
|
|
47
47
|
console.error = makeLogger("error", console.error);
|
|
48
|
+
// 接管 `index.html` 的编译期兜底(见 `docs/todo/39`):
|
|
49
|
+
// 本模块靠模块加载才能执行,所以更早的失败只能由 `index.html` 的内联脚本接住。
|
|
50
|
+
// 现在轮到我们了,关掉那个兜底,避免同一个错误上报两次。
|
|
51
|
+
window.__PREVIEWJS_IFRAME__.startupErrorGuard = false;
|
|
48
52
|
window.onerror = (message, source, lineno, colno, error) => {
|
|
49
53
|
window.__PREVIEWJS_IFRAME__.reportEvent({
|
|
50
54
|
kind: "error",
|
|
@@ -18,8 +18,9 @@ export interface PageOverlayItem {
|
|
|
18
18
|
/**
|
|
19
19
|
* 边框线型。
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
* `
|
|
21
|
+
* 现有语义:
|
|
22
|
+
* - `dashed` = inspect 的鼠标悬停(跟着指针走)
|
|
23
|
+
* - `solid` = 已点选的选中态(不论来自预览点选还是树点选 —— 框不编码来源)
|
|
23
24
|
*/
|
|
24
25
|
borderStyle?: "solid" | "dashed";
|
|
25
26
|
/** 背景透明度倍数;选中态更淡,避免遮住内容。 */
|
|
@@ -310,6 +311,10 @@ function ensureHost(): void {
|
|
|
310
311
|
.cfp-overlay-item {
|
|
311
312
|
position: fixed;
|
|
312
313
|
box-sizing: border-box;
|
|
314
|
+
/*
|
|
315
|
+
* 边框本身可命中:这样鼠标移到框线上时能触发 hover,
|
|
316
|
+
* 内部区域仍然穿透 —— 不能整块 auto,否则会挡住页面交互。
|
|
317
|
+
*/
|
|
313
318
|
pointer-events: none;
|
|
314
319
|
border-style: solid;
|
|
315
320
|
background: rgba(55, 148, 255, 0.12);
|
|
@@ -334,9 +339,8 @@ function ensureHost(): void {
|
|
|
334
339
|
font: 11px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
335
340
|
white-space: nowrap;
|
|
336
341
|
}
|
|
337
|
-
.cfp-overlay-label-text
|
|
338
|
-
|
|
339
|
-
/* 两个独立的圆角块;底色由 --cfp-label-bg 决定(跟随 item.color)。 */
|
|
342
|
+
.cfp-overlay-label-text {
|
|
343
|
+
/* 文字块:实心底色由 --cfp-label-bg 决定(跟随 item.color)。 */
|
|
340
344
|
display: inline-flex;
|
|
341
345
|
align-items: center;
|
|
342
346
|
padding: 2px 6px;
|
|
@@ -344,13 +348,62 @@ function ensureHost(): void {
|
|
|
344
348
|
background: var(--cfp-label-bg, #3794ff);
|
|
345
349
|
}
|
|
346
350
|
.cfp-overlay-actions {
|
|
347
|
-
/*
|
|
348
|
-
|
|
351
|
+
/*
|
|
352
|
+
* 按钮块:**透明背景**,与文字块靠 gap 分隔 ——
|
|
353
|
+
* 不再叠一层同色实心块,视觉上少一个方块、也少占一点宽度。
|
|
354
|
+
* 内边距保留 2px,让图标与文字块的间距不至于挤在一起。
|
|
355
|
+
*/
|
|
356
|
+
display: inline-flex;
|
|
357
|
+
align-items: center;
|
|
358
|
+
/* 上下 padding 归零:否则 height:0 的盒子仍会被撑到 4px 高。 */
|
|
359
|
+
padding: 0 3px;
|
|
349
360
|
gap: 3px;
|
|
361
|
+
background: transparent;
|
|
362
|
+
/*
|
|
363
|
+
* **关键:不参与所在 flex 行的尺寸计算。**
|
|
364
|
+
*
|
|
365
|
+
* 这一行是 align-items: center 的 flex,行高由最高子项决定:
|
|
366
|
+
* 文字块 ≈ 19.4px(line-height 15.4 + padding 4),
|
|
367
|
+
* 而按钮是固定 16px + padding 4 = 20px —— 比文字块高 0.6px。
|
|
368
|
+
* 于是按钮「参与布局」与「不参与布局」两种状态下,行高在
|
|
369
|
+
* 19px / 20px 之间取整跳动,表现为鼠标进出标签时整条标签上下呼吸。
|
|
370
|
+
*
|
|
371
|
+
* align-self: center + height: 0 让这个盒子高度为零:
|
|
372
|
+
* 它内部的按钮溢出显示,但**不再撑高这一行**,行高恒等于文字块。
|
|
373
|
+
*/
|
|
374
|
+
height: 0;
|
|
375
|
+
align-self: center;
|
|
376
|
+
/*
|
|
377
|
+
* 默认隐藏,鼠标移到标签上才出现。
|
|
378
|
+
*
|
|
379
|
+
* 为什么用 opacity 而不是 display:none:
|
|
380
|
+
* 按钮靠 overlayActionAt() 做命中测试,而
|
|
381
|
+
* elementsFromPoint() **不会返回 display:none 的元素** ——
|
|
382
|
+
* 用 display 隐藏会让按钮永久点不到。
|
|
383
|
+
*
|
|
384
|
+
* 这里只切 opacity、**不切 visibility**:visibility 是离散属性,
|
|
385
|
+
* 会让浏览器在布局阶段重新判断元素是否参与计算;只改 opacity
|
|
386
|
+
* 走的是合成层,不影响任何几何。
|
|
387
|
+
*/
|
|
388
|
+
opacity: 0;
|
|
389
|
+
transition: opacity 0.12s ease;
|
|
390
|
+
}
|
|
391
|
+
/*
|
|
392
|
+
* 鼠标移到**标签**上时才显示按钮。
|
|
393
|
+
*
|
|
394
|
+
* 没有做「hover 到框」:框是 pointer-events:none(必须如此,否则它会把
|
|
395
|
+
* 页面交互全挡住)。要让框线可命中
|
|
396
|
+
* 就得包一层可交互元素,那又回到「透明蒙版挡页面」的老问题。
|
|
397
|
+
* 标签就在框的左上角,hover 过去只多一个短距离,性价比更高。
|
|
398
|
+
*/
|
|
399
|
+
.cfp-overlay-label:hover .cfp-overlay-actions {
|
|
400
|
+
opacity: 1;
|
|
350
401
|
}
|
|
351
402
|
.cfp-overlay-action {
|
|
352
403
|
/* 父级是 pointer-events:none(不挡页面命中测试),按钮要单独开启。 */
|
|
353
404
|
pointer-events: auto;
|
|
405
|
+
/* 容器高度为 0,按钮靠自身尺寸溢出显示;不收缩。 */
|
|
406
|
+
flex: 0 0 auto;
|
|
354
407
|
display: inline-flex;
|
|
355
408
|
align-items: center;
|
|
356
409
|
justify-content: center;
|
|
@@ -359,7 +412,8 @@ function ensureHost(): void {
|
|
|
359
412
|
padding: 0;
|
|
360
413
|
border: 0;
|
|
361
414
|
border-radius: 2px;
|
|
362
|
-
|
|
415
|
+
/* 透明:底色直接取标签块的颜色,少一层白雾、更干净。 */
|
|
416
|
+
background: transparent;
|
|
363
417
|
color: inherit;
|
|
364
418
|
font-size: 10px;
|
|
365
419
|
line-height: 1;
|
|
@@ -370,7 +424,8 @@ function ensureHost(): void {
|
|
|
370
424
|
pointer-events: none;
|
|
371
425
|
}
|
|
372
426
|
.cfp-overlay-action:hover {
|
|
373
|
-
|
|
427
|
+
/* 悬停时才给一层浅底,明确「点得到」。 */
|
|
428
|
+
background: rgba(255, 255, 255, 0.28);
|
|
374
429
|
}
|
|
375
430
|
`;
|
|
376
431
|
shadowRoot.appendChild(style);
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { type ReflowObserver, type ReflowTarget, trackReflow } from "./reflowTracker";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `trackReflow` 的契约测试(`docs/todo/33` 问题 2)。
|
|
7
|
+
*
|
|
8
|
+
* 这个模块的存在理由:覆盖层用 `position: fixed` + 视口坐标,滚动/缩放后必须重算。
|
|
9
|
+
* 而这类 bug 的典型失手方式是**监听器没解绑**(选中切换频繁 → 累积泄漏)
|
|
10
|
+
* 或**没节流**(滚动时每事件重绘 → 掉帧)。所以测试重点放在这两条上,
|
|
11
|
+
* 而不是「能不能收到事件」。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface FakeTarget extends ReflowTarget {
|
|
15
|
+
emit(type: string): void;
|
|
16
|
+
listenerCount(type: string): number;
|
|
17
|
+
lastOptions(type: string): AddEventListenerOptions | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fakeTarget(): FakeTarget {
|
|
21
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
22
|
+
const options = new Map<string, AddEventListenerOptions | undefined>();
|
|
23
|
+
return {
|
|
24
|
+
addEventListener(type, listener, opts) {
|
|
25
|
+
if (!listeners.has(type)) {
|
|
26
|
+
listeners.set(type, new Set());
|
|
27
|
+
}
|
|
28
|
+
listeners.get(type)!.add(listener);
|
|
29
|
+
options.set(type, opts);
|
|
30
|
+
},
|
|
31
|
+
removeEventListener(type, listener) {
|
|
32
|
+
listeners.get(type)?.delete(listener);
|
|
33
|
+
},
|
|
34
|
+
emit(type) {
|
|
35
|
+
for (const listener of [...(listeners.get(type) ?? [])]) {
|
|
36
|
+
listener();
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
listenerCount(type) {
|
|
40
|
+
return listeners.get(type)?.size ?? 0;
|
|
41
|
+
},
|
|
42
|
+
lastOptions(type) {
|
|
43
|
+
return options.get(type);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 手动步进的调度器:记录排队的回调,由测试决定何时「进入下一帧」。 */
|
|
49
|
+
function manualScheduler() {
|
|
50
|
+
const queue: Array<() => void> = [];
|
|
51
|
+
return {
|
|
52
|
+
schedule: (callback: () => void) => {
|
|
53
|
+
queue.push(callback);
|
|
54
|
+
},
|
|
55
|
+
/** 执行当前排队的全部回调(模拟一帧)。 */
|
|
56
|
+
frame() {
|
|
57
|
+
for (const callback of queue.splice(0)) {
|
|
58
|
+
callback();
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
pending: () => queue.length,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fakeObserver(): ReflowObserver & { observed: Element[]; readonly disconnected: number } {
|
|
66
|
+
const state = { observed: [] as Element[], disconnected: 0 };
|
|
67
|
+
return {
|
|
68
|
+
get observed() {
|
|
69
|
+
return state.observed;
|
|
70
|
+
},
|
|
71
|
+
get disconnected() {
|
|
72
|
+
return state.disconnected;
|
|
73
|
+
},
|
|
74
|
+
observe(target) {
|
|
75
|
+
state.observed.push(target);
|
|
76
|
+
},
|
|
77
|
+
disconnect() {
|
|
78
|
+
state.disconnected++;
|
|
79
|
+
},
|
|
80
|
+
} as unknown as ReflowObserver & { observed: Element[]; readonly disconnected: number };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 占位元素:`trackReflow` 只把它转交给 observer,不读任何属性。 */
|
|
84
|
+
const fakeElement = {} as Element;
|
|
85
|
+
|
|
86
|
+
describe("trackReflow", () => {
|
|
87
|
+
it("scroll 与 resize 都会触发重算", () => {
|
|
88
|
+
const target = fakeTarget();
|
|
89
|
+
const scheduler = manualScheduler();
|
|
90
|
+
let count = 0;
|
|
91
|
+
trackReflow({ onReflow: () => count++, target, schedule: scheduler.schedule });
|
|
92
|
+
|
|
93
|
+
target.emit("scroll");
|
|
94
|
+
scheduler.frame();
|
|
95
|
+
expect(count).toBe(1);
|
|
96
|
+
|
|
97
|
+
target.emit("resize");
|
|
98
|
+
scheduler.frame();
|
|
99
|
+
expect(count).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("同一帧内多个事件只重算一次(节流)", () => {
|
|
103
|
+
const target = fakeTarget();
|
|
104
|
+
const scheduler = manualScheduler();
|
|
105
|
+
let count = 0;
|
|
106
|
+
trackReflow({ onReflow: () => count++, target, schedule: scheduler.schedule });
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < 20; i++) {
|
|
109
|
+
target.emit("scroll");
|
|
110
|
+
}
|
|
111
|
+
expect(scheduler.pending()).toBe(1);
|
|
112
|
+
scheduler.frame();
|
|
113
|
+
expect(count).toBe(1);
|
|
114
|
+
|
|
115
|
+
// 下一帧可以再次触发(节流不是「只跑一次」)。
|
|
116
|
+
target.emit("scroll");
|
|
117
|
+
scheduler.frame();
|
|
118
|
+
expect(count).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("scroll 用捕获阶段监听(内部滚动容器不冒泡到 window)", () => {
|
|
122
|
+
const target = fakeTarget();
|
|
123
|
+
trackReflow({ onReflow: () => {}, target, schedule: manualScheduler().schedule });
|
|
124
|
+
|
|
125
|
+
const options = target.lastOptions("scroll");
|
|
126
|
+
expect(options?.capture).toBe(true);
|
|
127
|
+
expect(options?.passive).toBe(true);
|
|
128
|
+
expect(target.lastOptions("resize")?.passive).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("stop() 解绑全部监听并断开 observer", () => {
|
|
132
|
+
const target = fakeTarget();
|
|
133
|
+
const observer = fakeObserver();
|
|
134
|
+
const tracker = trackReflow({
|
|
135
|
+
onReflow: () => {},
|
|
136
|
+
target,
|
|
137
|
+
schedule: manualScheduler().schedule,
|
|
138
|
+
resizeObserverFactory: () => observer,
|
|
139
|
+
observe: fakeElement,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(target.listenerCount("scroll")).toBe(1);
|
|
143
|
+
expect(target.listenerCount("resize")).toBe(1);
|
|
144
|
+
expect(observer.observed).toEqual([fakeElement]);
|
|
145
|
+
|
|
146
|
+
tracker.stop();
|
|
147
|
+
|
|
148
|
+
expect(target.listenerCount("scroll")).toBe(0);
|
|
149
|
+
expect(target.listenerCount("resize")).toBe(0);
|
|
150
|
+
expect(observer.disconnected).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("stop() 幂等:重复调用只断开一次、且不再重算", () => {
|
|
154
|
+
const target = fakeTarget();
|
|
155
|
+
const scheduler = manualScheduler();
|
|
156
|
+
const observer = fakeObserver();
|
|
157
|
+
let count = 0;
|
|
158
|
+
const tracker = trackReflow({
|
|
159
|
+
onReflow: () => count++,
|
|
160
|
+
target,
|
|
161
|
+
schedule: scheduler.schedule,
|
|
162
|
+
resizeObserverFactory: () => observer,
|
|
163
|
+
observe: fakeElement,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// 先排一个回调,再 stop —— 已排队的回调不应再执行重算。
|
|
167
|
+
target.emit("scroll");
|
|
168
|
+
tracker.stop();
|
|
169
|
+
tracker.stop();
|
|
170
|
+
scheduler.frame();
|
|
171
|
+
|
|
172
|
+
expect(count).toBe(0);
|
|
173
|
+
expect(observer.disconnected).toBe(1);
|
|
174
|
+
|
|
175
|
+
// 解绑后事件不再排队。
|
|
176
|
+
target.emit("scroll");
|
|
177
|
+
expect(scheduler.pending()).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("stop() 之后 flush() 不再重算", () => {
|
|
181
|
+
const target = fakeTarget();
|
|
182
|
+
let count = 0;
|
|
183
|
+
const tracker = trackReflow({ onReflow: () => count++, target, schedule: manualScheduler().schedule });
|
|
184
|
+
|
|
185
|
+
tracker.flush();
|
|
186
|
+
expect(count).toBe(1);
|
|
187
|
+
|
|
188
|
+
tracker.stop();
|
|
189
|
+
tracker.flush();
|
|
190
|
+
expect(count).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("ResizeObserver 回调走同一条节流路径", () => {
|
|
194
|
+
const scheduler = manualScheduler();
|
|
195
|
+
let onResize: (() => void) | undefined;
|
|
196
|
+
let count = 0;
|
|
197
|
+
trackReflow({
|
|
198
|
+
onReflow: () => count++,
|
|
199
|
+
target: fakeTarget(),
|
|
200
|
+
schedule: scheduler.schedule,
|
|
201
|
+
resizeObserverFactory: (callback) => {
|
|
202
|
+
onResize = callback;
|
|
203
|
+
return fakeObserver();
|
|
204
|
+
},
|
|
205
|
+
observe: fakeElement,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
onResize?.();
|
|
209
|
+
onResize?.();
|
|
210
|
+
expect(scheduler.pending()).toBe(1);
|
|
211
|
+
scheduler.frame();
|
|
212
|
+
expect(count).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("没有 ResizeObserver 时仍能工作(只靠 scroll / resize)", () => {
|
|
216
|
+
const target = fakeTarget();
|
|
217
|
+
const scheduler = manualScheduler();
|
|
218
|
+
let count = 0;
|
|
219
|
+
expect(() =>
|
|
220
|
+
trackReflow({
|
|
221
|
+
onReflow: () => count++,
|
|
222
|
+
target,
|
|
223
|
+
schedule: scheduler.schedule,
|
|
224
|
+
resizeObserverFactory: undefined,
|
|
225
|
+
observe: fakeElement,
|
|
226
|
+
}),
|
|
227
|
+
).not.toThrow();
|
|
228
|
+
|
|
229
|
+
target.emit("scroll");
|
|
230
|
+
scheduler.frame();
|
|
231
|
+
expect(count).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("observer 构造抛错时降级,不影响滚动重算", () => {
|
|
235
|
+
const target = fakeTarget();
|
|
236
|
+
const scheduler = manualScheduler();
|
|
237
|
+
let count = 0;
|
|
238
|
+
expect(() =>
|
|
239
|
+
trackReflow({
|
|
240
|
+
onReflow: () => count++,
|
|
241
|
+
target,
|
|
242
|
+
schedule: scheduler.schedule,
|
|
243
|
+
resizeObserverFactory: () => {
|
|
244
|
+
throw new Error("unsupported");
|
|
245
|
+
},
|
|
246
|
+
observe: fakeElement,
|
|
247
|
+
}),
|
|
248
|
+
).not.toThrow();
|
|
249
|
+
|
|
250
|
+
target.emit("scroll");
|
|
251
|
+
scheduler.frame();
|
|
252
|
+
expect(count).toBe(1);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 让某块「位置依赖」的覆盖层跟随布局变化重算(`docs/todo/33` 问题 2)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要这个模块
|
|
5
|
+
*
|
|
6
|
+
* 覆盖层用的是 `position: fixed` + **视口坐标**(`getBoundingClientRect()` 的结果)。
|
|
7
|
+
* 视口坐标只在「计算那一刻」有效 —— 页面一旦滚动或窗口一旦缩放,元素在视口中的位置
|
|
8
|
+
* 就变了,而覆盖层不会自己动。所以必须在选中态存续期间**持续重算**。
|
|
9
|
+
*
|
|
10
|
+
* ## 要监听哪些「变化源」
|
|
11
|
+
*
|
|
12
|
+
* | 变化源 | 监听方式 | 为什么 |
|
|
13
|
+
* | --- | --- | --- |
|
|
14
|
+
* | 窗口/文档滚动 | `scroll` + `capture` | 内部滚动容器(`overflow:auto`)**不会**冒泡到 window,只有捕获阶段能收到 |
|
|
15
|
+
* | 视口缩放 | `resize` | 元素尺寸/位置都可能变 |
|
|
16
|
+
* | 元素自身尺寸 | `ResizeObserver` | 内容变多导致高度变化时,`resize` 未必触发 |
|
|
17
|
+
*
|
|
18
|
+
* 已知局限:`ResizeObserver` 只在**被观察元素尺寸**变化时回调,元素因兄弟节点变化
|
|
19
|
+
* 而位移时不触发。这类位移主要发生在滚动(已覆盖)与页面重排(通常伴随尺寸变化)时,
|
|
20
|
+
* 实际观测到的场景已能覆盖,故**不做逐帧轮询** —— 轮询的是开销与抖动,不值得。
|
|
21
|
+
*
|
|
22
|
+
* ## 节流
|
|
23
|
+
*
|
|
24
|
+
* 滚动事件高频(一次滚动可产生几十个事件)。用 `requestAnimationFrame` 合并:
|
|
25
|
+
* 一帧内的多次请求只重算一次,且重算发生在绘制前,不会出现拖影。
|
|
26
|
+
*
|
|
27
|
+
* ## 清理
|
|
28
|
+
*
|
|
29
|
+
* `stop()` 必须解绑全部监听并断开两个 observer。调用方的契约:
|
|
30
|
+
* - 选中态建立 → `start()`
|
|
31
|
+
* - 选中态结束(取消选中 / 元素失联 / 重入 setup)→ `stop()`
|
|
32
|
+
*
|
|
33
|
+
* 重复 `start()` 会先 `stop()`,因此调用方不需要自己记账。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** 只依赖所需的最小 DOM 能力,方便单测传入假件。 */
|
|
37
|
+
export interface ReflowTarget {
|
|
38
|
+
addEventListener(type: string, listener: () => void, options?: AddEventListenerOptions): void;
|
|
39
|
+
removeEventListener(type: string, listener: () => void, options?: EventListenerOptions): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ReflowObserver {
|
|
43
|
+
observe(target: Element): void;
|
|
44
|
+
disconnect(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TrackOptions {
|
|
48
|
+
/** 执行重算(已节流)。 */
|
|
49
|
+
onReflow: () => void;
|
|
50
|
+
/** 滚动/缩放事件的宿主,默认 `window`。 */
|
|
51
|
+
target?: ReflowTarget;
|
|
52
|
+
/** 一帧调度器,默认 `requestAnimationFrame`;单测可注入同步实现。 */
|
|
53
|
+
schedule?: (callback: () => void) => void;
|
|
54
|
+
/** `ResizeObserver` 构造器,默认全局同名对象;不可用时跳过该观察源。 */
|
|
55
|
+
resizeObserverFactory?: (callback: () => void) => ReflowObserver;
|
|
56
|
+
/** 被观察尺寸/位置的目标(通常是选中元素)。**不传则不建 ResizeObserver**,只靠 scroll / resize。 */
|
|
57
|
+
observe?: Element;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ReflowTracker {
|
|
61
|
+
/** 立刻重算一次(不等待下一帧),用于选中态刚建立时。 */
|
|
62
|
+
flush(): void;
|
|
63
|
+
/** 解绑全部监听并断开 observer;可重复调用。 */
|
|
64
|
+
stop(): void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 让 `onReflow` 在滚动 / 缩放 / 元素尺寸变化时被节流调用。
|
|
69
|
+
*
|
|
70
|
+
* 返回的 tracker 在 `stop()` 前一直有效;`stop()` 幂等。
|
|
71
|
+
*/
|
|
72
|
+
export function trackReflow(options: TrackOptions): ReflowTracker {
|
|
73
|
+
const target = options.target ?? (typeof window === "undefined" ? undefined : (window as unknown as ReflowTarget));
|
|
74
|
+
const schedule =
|
|
75
|
+
options.schedule ??
|
|
76
|
+
((callback: () => void) => {
|
|
77
|
+
if (typeof requestAnimationFrame === "function") {
|
|
78
|
+
requestAnimationFrame(callback);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// 没有 rAF 的环境(极端降级)直接同步执行,宁可掉帧也不要漏重算。
|
|
82
|
+
callback();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
let pending = false;
|
|
86
|
+
let stopped = false;
|
|
87
|
+
|
|
88
|
+
// 节流核心:一帧内只跑一次。`pending` 置位后到回调执行前不再重复排队,
|
|
89
|
+
// 避免快速滚动时把一帧内的 N 个事件排成 N 个回调。
|
|
90
|
+
const requestReflow = (): void => {
|
|
91
|
+
if (stopped || pending) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
pending = true;
|
|
95
|
+
schedule(() => {
|
|
96
|
+
pending = false;
|
|
97
|
+
if (!stopped) {
|
|
98
|
+
options.onReflow();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// 内部滚动容器不冒泡到 window,必须用捕获阶段才能收到。
|
|
104
|
+
// passive: true —— 我们从不 preventDefault,声明 passive 让浏览器不必等待监听器返回。
|
|
105
|
+
// 同一个对象同时用于 add / remove:解绑时 `capture` 必须一致,否则解不掉。
|
|
106
|
+
const scrollListenOptions: AddEventListenerOptions = { capture: true, passive: true };
|
|
107
|
+
const resizeListenOptions: AddEventListenerOptions = { passive: true };
|
|
108
|
+
target?.addEventListener("scroll", requestReflow, scrollListenOptions);
|
|
109
|
+
target?.addEventListener("resize", requestReflow, resizeListenOptions);
|
|
110
|
+
|
|
111
|
+
// 元素自身尺寸变化(内容变多/图片加载完成/字体回流)。
|
|
112
|
+
let resizeObserver: ReflowObserver | undefined;
|
|
113
|
+
const factory =
|
|
114
|
+
options.resizeObserverFactory ??
|
|
115
|
+
(typeof ResizeObserver === "function" ? (callback: () => void) => new ResizeObserver(callback) : undefined);
|
|
116
|
+
if (factory && options.observe) {
|
|
117
|
+
try {
|
|
118
|
+
resizeObserver = factory(requestReflow);
|
|
119
|
+
resizeObserver.observe(options.observe);
|
|
120
|
+
} catch {
|
|
121
|
+
// 观察失败不影响滚动/缩放这两条主路径,静默降级。
|
|
122
|
+
resizeObserver = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
flush(): void {
|
|
128
|
+
if (!stopped) {
|
|
129
|
+
options.onReflow();
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
stop(): void {
|
|
133
|
+
if (stopped) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
stopped = true;
|
|
137
|
+
target?.removeEventListener("scroll", requestReflow, scrollListenOptions);
|
|
138
|
+
target?.removeEventListener("resize", requestReflow, resizeListenOptions);
|
|
139
|
+
try {
|
|
140
|
+
resizeObserver?.disconnect();
|
|
141
|
+
} catch {
|
|
142
|
+
// 断开失败只可能发生在环境已销毁时,忽略。
|
|
143
|
+
}
|
|
144
|
+
resizeObserver = undefined;
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RendererLoader } from "../../src";
|
|
2
|
-
import { publishRuntimeSnapshot } from "./runtime-bridge";
|
|
2
|
+
import { publishRendered, publishRuntimeSnapshot } from "./runtime-bridge";
|
|
3
3
|
import { getState } from "./state";
|
|
4
4
|
|
|
5
5
|
export async function runRenderer({
|
|
@@ -66,9 +66,23 @@ export async function runRenderer({
|
|
|
66
66
|
// Publish before reportEvent: custom preview webviews may not install a
|
|
67
67
|
// __PREVIEWJS_CONTROLLER__, while the runtime bridge is independent.
|
|
68
68
|
setTimeout(publishRuntimeSnapshot, 0);
|
|
69
|
+
// **两条通路都要发,它们服务不同的消费者**(勿合并):
|
|
70
|
+
//
|
|
71
|
+
// ① `reportEvent({kind:"rendered"})` → `PreviewController`
|
|
72
|
+
// (`engine/iframe/src/index.ts`)用它 `clearExpectRenderTimeout()` 并更新
|
|
73
|
+
// `state.rendered`。**不发给它,controller 会在 5 秒后判定「渲染没发生」
|
|
74
|
+
// 并 `resetIframe()`,造成每 5 秒无限重载 —— 表现为持续白闪 + 反复 rebuild。**
|
|
75
|
+
// 这条通路依赖父窗口,只在 embedded 有效,但对 embedded 是**必需**的。
|
|
76
|
+
//
|
|
77
|
+
// ② `publishRendered()` → runtime-bridge(`cfp-rendered`)→ shell → host
|
|
78
|
+
// 宿主侧的 `preview/rendered`。这条跨两种渲染面都通用,
|
|
79
|
+
// browser 模式只有它能到达宿主。
|
|
80
|
+
//
|
|
81
|
+
// 详见 `docs/analysis/preview-event-channels.md`。
|
|
69
82
|
window.__PREVIEWJS_IFRAME__.reportEvent({
|
|
70
83
|
kind: "rendered",
|
|
71
84
|
});
|
|
85
|
+
publishRendered();
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
/**
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
setPageOverlay,
|
|
36
36
|
type PageOverlayPayload,
|
|
37
37
|
} from "./overlay";
|
|
38
|
+
import { type ReflowTracker, trackReflow } from "./reflowTracker";
|
|
39
|
+
import { mergeSelection, pruneDetached, type SelectionSubject, toggleSelection } from "./selectionModel";
|
|
38
40
|
|
|
39
41
|
export type {
|
|
40
42
|
RuntimeBounds,
|
|
@@ -102,6 +104,13 @@ export function setupRuntimeBridge(): void {
|
|
|
102
104
|
initialized = true;
|
|
103
105
|
// 选中框上的快捷按钮:把「对哪个 runtime 节点做什么」发给宿主执行。
|
|
104
106
|
setOverlayActionHandler((actionId) => {
|
|
107
|
+
// 清除选中在**本进程内**就完成,不必绕宿主:
|
|
108
|
+
// 它没有 runtimeNodeId 可发(宿主那条路会先 reveal 节点,对清除是错的),
|
|
109
|
+
// 而且清完框立刻消失才是对的反馈。
|
|
110
|
+
if (actionId === CLEAR_SELECTION_ACTION) {
|
|
111
|
+
clearSelectedElements();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
105
114
|
const runtimeNodeId = selectedRuntimeNodeId();
|
|
106
115
|
if (!runtimeNodeId) {
|
|
107
116
|
return;
|
|
@@ -114,7 +123,10 @@ export function setupRuntimeBridge(): void {
|
|
|
114
123
|
} else if (event.data?.kind === "cfp:select-runtime-node") {
|
|
115
124
|
// 宿主(Runtime Tree 点选)要求把某个节点标成「已选中」:
|
|
116
125
|
// 面板里选中与预览里的选中框要双向同步。
|
|
117
|
-
selectRuntimeNodeById(
|
|
126
|
+
selectRuntimeNodeById(
|
|
127
|
+
typeof event.data.runtimeNodeId === "string" ? event.data.runtimeNodeId : undefined,
|
|
128
|
+
event.data.additive === true,
|
|
129
|
+
);
|
|
118
130
|
} else if (event.data?.kind === "cfp:set-inspect") {
|
|
119
131
|
inspectScreenshotAvailable = event.data.screenshot === true;
|
|
120
132
|
setInspectEnabled(Boolean(event.data.enabled));
|
|
@@ -139,6 +151,16 @@ export function setupRuntimeBridge(): void {
|
|
|
139
151
|
});
|
|
140
152
|
document.addEventListener("click", handleInspectClick, true);
|
|
141
153
|
document.addEventListener("mousemove", handleInspectMove, true);
|
|
154
|
+
// hover 框同理也是视口坐标(见 `reflowTracker.ts` 的说明)。它没有「选中态」
|
|
155
|
+
// 可以依附,鼠标下一次移动会自然重建,所以滚动时**清掉**即可 ——
|
|
156
|
+
// 保留它会停在滚动前的位置,比不显示更误导。
|
|
157
|
+
window.addEventListener(
|
|
158
|
+
"scroll",
|
|
159
|
+
() => {
|
|
160
|
+
clearPageOverlay("inspect");
|
|
161
|
+
},
|
|
162
|
+
{ capture: true, passive: true },
|
|
163
|
+
);
|
|
142
164
|
document.addEventListener("wheel", blockInspectedInteraction, { capture: true, passive: false });
|
|
143
165
|
document.addEventListener("keydown", handleInspectKeyDown, true);
|
|
144
166
|
for (const type of BLOCKED_INPUT_EVENTS) {
|
|
@@ -157,6 +179,18 @@ function blockInspectedInteraction(event: Event): void {
|
|
|
157
179
|
}
|
|
158
180
|
|
|
159
181
|
function handleInspectKeyDown(event: KeyboardEvent): void {
|
|
182
|
+
// Esc 清选中**不受 inspect 开关限制**:树点选的框在 inspect 之外也存在,
|
|
183
|
+
// 用户按 Esc 的意图就是「我不想看这个框了」。放在最前面,且不阻止默认行为 ——
|
|
184
|
+
// 应用自身可能也用 Esc 关弹窗,我们只清自己的框。
|
|
185
|
+
if (event.key === "Escape") {
|
|
186
|
+
if (selectedSubjects.length > 0) {
|
|
187
|
+
clearSelectedElements();
|
|
188
|
+
// 控件上的 Esc 可能与「关闭弹窗」等应用行为重叠;不清掉默认行为,
|
|
189
|
+
// 但要阻止事件继续冒泡到应用的快捷键盘处理器。
|
|
190
|
+
event.stopPropagation();
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
160
194
|
if (!inspectEnabled || !SCROLL_KEYS.has(event.key)) {
|
|
161
195
|
return;
|
|
162
196
|
}
|
|
@@ -170,10 +204,22 @@ function setInspectEnabled(enabled: boolean): void {
|
|
|
170
204
|
setPageInteractionShield(enabled);
|
|
171
205
|
if (!enabled) {
|
|
172
206
|
clearPageOverlay("inspect");
|
|
207
|
+
// 只**隐藏**框,不丢弃选中态:
|
|
208
|
+
// 用户可能刚在树里选了一个节点、还没进 inspect;此时清掉选中,
|
|
209
|
+
// 进 inspect 后就得重新选一次。选中态归 `selectedSubjects` 管,
|
|
210
|
+
// 是否显示归 `inspectEnabled` 管 —— 两者正交。
|
|
211
|
+
//
|
|
212
|
+
// 同时停掉跟随重算:不显示时每帧算 `getBoundingClientRect` 是白跑。
|
|
213
|
+
stopSelectedReflow();
|
|
173
214
|
clearPageOverlay("inspect-selected");
|
|
174
|
-
selectedElement = undefined;
|
|
175
215
|
return;
|
|
176
216
|
}
|
|
217
|
+
// 进入 inspect:把已有的选中重新画出来(可能是在非 inspect 时从树里选的),
|
|
218
|
+
// 并恢复跟随重算。
|
|
219
|
+
if (selectedSubjects.length > 0) {
|
|
220
|
+
startSelectedReflow();
|
|
221
|
+
renderSelectedOverlay();
|
|
222
|
+
}
|
|
177
223
|
// 键盘焦点可能还留在应用里(tab 聚焦的输入框):inspect 时不应该继续往里输入。
|
|
178
224
|
const active = document.activeElement;
|
|
179
225
|
if (active instanceof HTMLElement && active !== document.body && !isOverlayChrome(active)) {
|
|
@@ -182,12 +228,77 @@ function setInspectEnabled(enabled: boolean): void {
|
|
|
182
228
|
}
|
|
183
229
|
|
|
184
230
|
/**
|
|
185
|
-
*
|
|
231
|
+
* 选中态是一个**集合**(支持多选)。
|
|
186
232
|
*
|
|
187
233
|
* 两层 overlay 各自独立(`inspect` / `inspect-selected`),所以 hover 移动时
|
|
188
234
|
* 不会把选中框冲掉。
|
|
235
|
+
*
|
|
236
|
+
* ## 为什么是集合而不是单值
|
|
237
|
+
*
|
|
238
|
+
* 单值时「多选」需要把状态机整个换掉(`selectedElement` → `selectedElements`),
|
|
239
|
+
* 是破坏性重构。改成集合后,单选的语义就是「长度 1 的集合」,
|
|
240
|
+
* 放开多选不需要再动状态容器 —— 渲染层也已经吃 `items[]`(见 `overlay.ts`)。
|
|
241
|
+
*/
|
|
242
|
+
let selectedSubjects: SelectionSubject<Element>[] = [];
|
|
243
|
+
|
|
244
|
+
/** 边框上「清除选中」按钮的 action id(iframe 内就地处理,不发给宿主)。 */
|
|
245
|
+
const CLEAR_SELECTION_ACTION = "clear-selection";
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 选中框的「跟随重算」订阅(`docs/todo/33` 问题 2)。
|
|
249
|
+
*
|
|
250
|
+
* 选框用 `position: fixed` + 视口坐标,所以只要选中态还在,就必须跟随
|
|
251
|
+
* 滚动/缩放/元素尺寸变化重绘 —— 否则框会停在选中那一刻的位置。
|
|
252
|
+
*
|
|
253
|
+
* 多选时只 observe 第一个元素作为代表:`ResizeObserver` 的主要作用是补
|
|
254
|
+
* 「页面重排导致整体位移」这种 `scroll` / `resize` 收不到的情况,
|
|
255
|
+
* 而这类重排通常由容器引起,观察任何一个成员都能感知到。
|
|
256
|
+
* 逐个 observe 会让「N 个元素各触发一次」放大成 O(N²) 次重绘。
|
|
189
257
|
*/
|
|
190
|
-
let
|
|
258
|
+
let selectedReflow: ReflowTracker | undefined;
|
|
259
|
+
|
|
260
|
+
/** 开/关跟随重算;`stopSelectedReflow` 在选中态结束时必须调,否则监听器泄漏。 */
|
|
261
|
+
function startSelectedReflow(): void {
|
|
262
|
+
stopSelectedReflow();
|
|
263
|
+
const first = selectedSubjects[0];
|
|
264
|
+
if (!first) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
selectedReflow = trackReflow({
|
|
268
|
+
onReflow: renderSelectedOverlay,
|
|
269
|
+
observe: first.element,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function stopSelectedReflow(): void {
|
|
274
|
+
selectedReflow?.stop();
|
|
275
|
+
selectedReflow = undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* 选中态的**唯一**写入口。
|
|
280
|
+
*
|
|
281
|
+
* `additive` 为 true 时把元素**追加**到现有选中(已存在则不动),
|
|
282
|
+
* 否则**替换**整个选中集。
|
|
283
|
+
*
|
|
284
|
+
* 同时负责重订「跟随重算」—— 集合变化后观察目标也可能变。
|
|
285
|
+
*/
|
|
286
|
+
function setSelectedElements(subjects: SelectionSubject<Element>[], additive = false): void {
|
|
287
|
+
// 元素可能在选中之后被卸载:顺手剔除,`renderSelectedOverlay` 就不必逐个判 `isConnected`。
|
|
288
|
+
selectedSubjects = pruneDetached(mergeSelection(selectedSubjects, subjects, additive));
|
|
289
|
+
if (selectedSubjects.length > 0) {
|
|
290
|
+
startSelectedReflow();
|
|
291
|
+
} else {
|
|
292
|
+
stopSelectedReflow();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 清空选中(连订阅一起停)。 */
|
|
297
|
+
function clearSelectedElements(): void {
|
|
298
|
+
selectedSubjects = [];
|
|
299
|
+
stopSelectedReflow();
|
|
300
|
+
clearPageOverlay("inspect-selected");
|
|
301
|
+
}
|
|
191
302
|
|
|
192
303
|
function handleInspectClick(event: MouseEvent): void {
|
|
193
304
|
if (!inspectEnabled) {
|
|
@@ -211,77 +322,146 @@ function handleInspectClick(event: MouseEvent): void {
|
|
|
211
322
|
const source = element ? getElementSource(element) : undefined;
|
|
212
323
|
event.preventDefault();
|
|
213
324
|
event.stopPropagation();
|
|
214
|
-
|
|
325
|
+
if (!element) {
|
|
326
|
+
// 点到页面外/什么都没有:清空(与「点空白取消」的通用直觉一致)。
|
|
327
|
+
clearSelectedElements();
|
|
328
|
+
publishToHost({ kind: "cfp-runtime-select", source });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// Ctrl/Cmd 点选 = 追加到现有选中(多选);否则替换。
|
|
332
|
+
// `metaKey` 覆盖 macOS 的 Cmd,`ctrlKey` 覆盖 Windows/Linux 的 Ctrl。
|
|
333
|
+
const additive = event.ctrlKey || event.metaKey;
|
|
334
|
+
if (additive) {
|
|
335
|
+
// Ctrl/Cmd + 点选:已在集合里则**移除**(开关语义),否则加入。
|
|
336
|
+
selectedSubjects = pruneDetached(toggleSelection(selectedSubjects, element));
|
|
337
|
+
if (selectedSubjects.length > 0) {
|
|
338
|
+
startSelectedReflow();
|
|
339
|
+
} else {
|
|
340
|
+
stopSelectedReflow();
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
setSelectedElements([{ element }]);
|
|
344
|
+
}
|
|
215
345
|
renderSelectedOverlay();
|
|
216
346
|
publishToHost({ kind: "cfp-runtime-select", source });
|
|
217
347
|
}
|
|
218
348
|
|
|
219
|
-
/**
|
|
349
|
+
/**
|
|
350
|
+
* 当前选中元素对应的 runtimeNodeId(宿主执行动作时要用它定位节点)。
|
|
351
|
+
*
|
|
352
|
+
* 多选时取**第一个**:快捷动作(截图 / 复制 packet)目前都是单元素语义,
|
|
353
|
+
* 多选下的动作语义(全部截图?并集截图?)尚未定义,先取第一个,
|
|
354
|
+
* 与 `renderSelectedOverlay` 的 label 一致。
|
|
355
|
+
*/
|
|
220
356
|
function selectedRuntimeNodeId(): string | undefined {
|
|
221
|
-
|
|
357
|
+
const first = selectedSubjects[0];
|
|
358
|
+
if (!first) {
|
|
222
359
|
return undefined;
|
|
223
360
|
}
|
|
224
361
|
for (const [id, element] of nodeElements) {
|
|
225
|
-
if (element ===
|
|
362
|
+
if (element === first.element) {
|
|
226
363
|
return id;
|
|
227
364
|
}
|
|
228
365
|
}
|
|
229
366
|
return undefined;
|
|
230
367
|
}
|
|
231
368
|
|
|
232
|
-
/**
|
|
369
|
+
/**
|
|
370
|
+
* 选中框:多层 `items` 一次画完(多选时一层多个框)。
|
|
371
|
+
*
|
|
372
|
+
* 视觉:与 hover **同色(蓝)**,靠**线型**区分 ——
|
|
373
|
+
* - hover:`dashed`(见 `handleInspectMove`)
|
|
374
|
+
* - 选中:`solid`
|
|
375
|
+
*
|
|
376
|
+
* 为什么不再用橙色:橙色与 hover 蓝并列时信息量重复(两者都在表达「这个元素」),
|
|
377
|
+
* 而颜色差异对色弱用户无效;线型是更稳的第二通道。
|
|
378
|
+
* (曾用颜色区分 inspect 点选与树点选,已回退 —— 理由见 `selectionModel.ts` 顶部。)
|
|
379
|
+
*
|
|
380
|
+
* 动作按钮只挂在**第一个**框上:动作目前是单元素语义,挂在每个框上
|
|
381
|
+
* 会让人以为「点哪个按钮就作用于哪个元素」,而实际实现只取第一个。
|
|
382
|
+
*/
|
|
233
383
|
function renderSelectedOverlay(): void {
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
384
|
+
// **只有 inspect 激活时**才在预览里画框(产品决策,2026-11-02):
|
|
385
|
+
//
|
|
386
|
+
// 1. 不激活时,选代码/选树只是「定位到哪段代码」的动作,预览里再画个框会持续干扰,
|
|
387
|
+
// 尤其用户本来就在读页面的时候。
|
|
388
|
+
// 2. 框上的按钮(截图 / 复制 / 清除)都依赖 inspect 上下文
|
|
389
|
+
// (`inspectScreenshotAvailable` 只在 inspect 时下发),不激活时给了也点不动。
|
|
390
|
+
//
|
|
391
|
+
// 选中态本身仍然保留在 `selectedSubjects` 里,进 inspect 就会画出来。
|
|
392
|
+
if (!inspectEnabled) {
|
|
393
|
+
stopSelectedReflow();
|
|
238
394
|
clearPageOverlay("inspect-selected");
|
|
239
395
|
return;
|
|
240
396
|
}
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
397
|
+
const visible = selectedSubjects.filter((subject) => subject.element.isConnected);
|
|
398
|
+
if (visible.length === 0) {
|
|
399
|
+
// 元素全部失联(被卸载/替换):连订阅一起停掉,
|
|
400
|
+
// 否则会在后续每帧滚动里白跑一次 `getBoundingClientRect`。
|
|
401
|
+
if (selectedSubjects.length > 0) {
|
|
402
|
+
selectedSubjects = [];
|
|
403
|
+
}
|
|
404
|
+
stopSelectedReflow();
|
|
405
|
+
clearPageOverlay("inspect-selected");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const items = visible.map((subject, index) => {
|
|
410
|
+
const rect = subject.element.getBoundingClientRect();
|
|
411
|
+
const fiber = getElementFiberUpward(subject.element);
|
|
412
|
+
const componentName = getComponentName(fiber);
|
|
413
|
+
const tag = subject.element.tagName.toLowerCase();
|
|
414
|
+
const label = componentName ? `${componentName} <${tag}>` : `<${tag}>`;
|
|
415
|
+
return {
|
|
416
|
+
x: rect.left,
|
|
417
|
+
y: rect.top,
|
|
418
|
+
width: rect.width,
|
|
419
|
+
height: rect.height,
|
|
420
|
+
// 与 hover 同色(蓝):颜色不再承载含义,**线型**区分状态。
|
|
421
|
+
// 1px 足够:2px 在 iframe 内会显得笨重(实测反馈)。
|
|
422
|
+
color: "#3794ff",
|
|
423
|
+
borderWidth: 1,
|
|
424
|
+
borderStyle: "solid" as const,
|
|
425
|
+
backgroundAlpha: 0.06,
|
|
426
|
+
// 多选时给每个框标序号,便于对照「第几个」。
|
|
427
|
+
label: visible.length > 1 ? `${index + 1}. ${label}` : label,
|
|
428
|
+
// 两个快捷动作挂在标签右侧:截图当前元素 / 复制 Selection Context Packet。
|
|
429
|
+
// 截图只在能截的渲染面显示:embedded(iframe)跨域拿不到像素,
|
|
430
|
+
// 给了按钮也只会失败,所以直接不渲染。
|
|
431
|
+
actions:
|
|
432
|
+
index !== 0
|
|
433
|
+
? []
|
|
434
|
+
: [
|
|
435
|
+
...(inspectScreenshotAvailable
|
|
436
|
+
? [
|
|
437
|
+
{
|
|
438
|
+
id: "screenshot",
|
|
439
|
+
title: "Screenshot this element",
|
|
440
|
+
// 相机:机身 + 顶部凸起 + 镜头圆(描边版,12px 下轮廓清晰)。
|
|
441
|
+
iconMarkup: '<path d="M1.5 5.5h2.8l1-1.5h5.4l1 1.5h2.8v8h-13z"/><circle cx="8" cy="9" r="2.4"/>',
|
|
442
|
+
},
|
|
443
|
+
]
|
|
444
|
+
: []),
|
|
445
|
+
{
|
|
446
|
+
id: "copy-context",
|
|
447
|
+
title: "Copy selection context",
|
|
448
|
+
// 剪贴板:板身 + 顶部夹子(夹子用填充,否则 12px 下会糊成一片)。
|
|
449
|
+
iconMarkup:
|
|
450
|
+
'<rect x="3" y="3" width="10" height="11.5" rx="1"/><rect x="5.8" y="1.5" width="4.4" height="2.6" rx="0.8" fill="currentColor"/>',
|
|
451
|
+
},
|
|
452
|
+
// 「清除」按钮:树面板没有清除动作,不在这里给入口,
|
|
453
|
+
// 用户就只能靠点空白或再点别的元素来清掉。
|
|
454
|
+
{
|
|
455
|
+
id: CLEAR_SELECTION_ACTION,
|
|
456
|
+
title: "Clear selection",
|
|
457
|
+
// 叉号:两条对角线,描边 1.4 在 12px 下仍清晰。
|
|
458
|
+
iconMarkup: '<path d="M4 4l8 8M12 4l-8 8"/>',
|
|
459
|
+
},
|
|
460
|
+
],
|
|
461
|
+
};
|
|
284
462
|
});
|
|
463
|
+
|
|
464
|
+
setPageOverlay("inspect-selected", { visible: true, items });
|
|
285
465
|
}
|
|
286
466
|
|
|
287
467
|
/**
|
|
@@ -290,10 +470,9 @@ function renderSelectedOverlay(): void {
|
|
|
290
470
|
* 通过 `cfp:runtime-detail` 之外的消息传不进来,所以用 `Runtime.evaluate`
|
|
291
471
|
* 直接调这个全局钩子(浏览器模式)/ postMessage(embedded 模式)。
|
|
292
472
|
*/
|
|
293
|
-
function selectRuntimeNodeById(runtimeNodeId: string | undefined): void {
|
|
473
|
+
function selectRuntimeNodeById(runtimeNodeId: string | undefined, additive = false): void {
|
|
294
474
|
if (!runtimeNodeId) {
|
|
295
|
-
|
|
296
|
-
clearPageOverlay("inspect-selected");
|
|
475
|
+
clearSelectedElements();
|
|
297
476
|
return;
|
|
298
477
|
}
|
|
299
478
|
const element = nodeElements.get(runtimeNodeId);
|
|
@@ -301,9 +480,16 @@ function selectRuntimeNodeById(runtimeNodeId: string | undefined): void {
|
|
|
301
480
|
// 树里的节点可能不在当前 DOM(未渲染的分支)——静默,不清掉已有选中。
|
|
302
481
|
return;
|
|
303
482
|
}
|
|
304
|
-
|
|
483
|
+
setSelectedElements([{ element }], additive);
|
|
305
484
|
// 元素可能不在视口内:先滚动到可见位置,再按最终位置画框。
|
|
306
|
-
|
|
485
|
+
//
|
|
486
|
+
// 两个闸门:
|
|
487
|
+
// - 追加模式下不滚动 —— 用户正按着 ctrl 连续点选,页面跳动会让人点偏。
|
|
488
|
+
// - 非 inspect 时不滚动 —— 那条路只同步代码/树,不该把预览页拽走
|
|
489
|
+
// (框本来也不显示,滚过去毫无意义)。
|
|
490
|
+
if (!additive && inspectEnabled) {
|
|
491
|
+
element.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
492
|
+
}
|
|
307
493
|
renderSelectedOverlay();
|
|
308
494
|
}
|
|
309
495
|
|
|
@@ -332,7 +518,7 @@ function handleInspectMove(event: MouseEvent): void {
|
|
|
332
518
|
width: rect.width,
|
|
333
519
|
height: rect.height,
|
|
334
520
|
label,
|
|
335
|
-
// hover
|
|
521
|
+
// hover 用虚线、选中用实线:两者同色(蓝),靠线型区分状态。
|
|
336
522
|
borderStyle: "dashed",
|
|
337
523
|
},
|
|
338
524
|
],
|
|
@@ -357,6 +543,31 @@ function elementUnderPointer(clientX: number, clientY: number): Element | undefi
|
|
|
357
543
|
return fallback && !isOverlayChrome(fallback) ? fallback : undefined;
|
|
358
544
|
}
|
|
359
545
|
|
|
546
|
+
/**
|
|
547
|
+
* 上报「组件渲染完成」(`docs/analysis/preview-event-channels.md` §7)。
|
|
548
|
+
*
|
|
549
|
+
* ## 为什么走 runtime-bridge 而不是 `__PREVIEWJS_IFRAME__.reportEvent`
|
|
550
|
+
*
|
|
551
|
+
* `reportEvent` 那条通路(`__PREVIEWJS_CONTROLLER__` / `parent.postMessage`)
|
|
552
|
+
* **只在 embedded 可用** —— 它依赖父窗口,而 browser 模式的预览页是顶层窗口
|
|
553
|
+
* (`window.parent === window`),两条子路都断。
|
|
554
|
+
*
|
|
555
|
+
* runtime-bridge 是**唯一跨两种模式都存在的桥**:
|
|
556
|
+
* - browser:走 `__cfpBridge`(CDP `Runtime.addBinding` 注入的反向通道)
|
|
557
|
+
* - embedded:走 `parent.postMessage`
|
|
558
|
+
*
|
|
559
|
+
* ## 语义边界
|
|
560
|
+
*
|
|
561
|
+
* 只表示「组件已挂载且没抛错」,**不保证布局稳定**(异步数据 / 图片 / 字体可能未就绪)。
|
|
562
|
+
* 需要布局稳定的场合由消费方自己等,不要在这一层加 settled 猜测。
|
|
563
|
+
*/
|
|
564
|
+
export function publishRendered(): void {
|
|
565
|
+
if (typeof window === "undefined") {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
publishToHost({ kind: "cfp-rendered" });
|
|
569
|
+
}
|
|
570
|
+
|
|
360
571
|
export function publishRuntimeSnapshot(): void {
|
|
361
572
|
if (typeof window === "undefined") {
|
|
362
573
|
return;
|
|
@@ -431,11 +642,30 @@ function collectRuntimeSnapshot(): RuntimeSnapshot {
|
|
|
431
642
|
};
|
|
432
643
|
}
|
|
433
644
|
|
|
645
|
+
/**
|
|
646
|
+
* 渲染错误面板的标记属性(`docs/todo/39`)。
|
|
647
|
+
*
|
|
648
|
+
* 与 `@cfp-studio/analyzer-api` 的 `ERROR_PANEL_ATTRIBUTE` 保持一致。
|
|
649
|
+
* 面板是**预览工具自己画的内容**,不是被预览组件的一部分 ——
|
|
650
|
+
* 不排除的话它会作为普通 DOM 出现在 Runtime Tree 里,还会参与点击选框命中。
|
|
651
|
+
*/
|
|
652
|
+
const ERROR_PANEL_ATTRIBUTE = "data-preview-error-panel";
|
|
653
|
+
|
|
654
|
+
/** 该元素是否是预览工具自己的错误面板。 */
|
|
655
|
+
function isErrorPanel(element: Element): boolean {
|
|
656
|
+
return element.hasAttribute?.(ERROR_PANEL_ATTRIBUTE) ?? false;
|
|
657
|
+
}
|
|
658
|
+
|
|
434
659
|
function walk(element: Element | null, parentId: string | undefined, path: string, nodes: RuntimeNode[]): boolean {
|
|
435
660
|
if (!element || nodes.length >= MAX_NODES) {
|
|
436
661
|
return nodes.length >= MAX_NODES;
|
|
437
662
|
}
|
|
438
663
|
|
|
664
|
+
if (isErrorPanel(element)) {
|
|
665
|
+
// 跳过面板自身与它的子树,但不计为截断。
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
439
669
|
const fiber = getElementFiberUpward(element);
|
|
440
670
|
const source = getSourceLocation(fiber);
|
|
441
671
|
// Path-based ID: stable across snapshots as long as the DOM structure is
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { mergeSelection, pruneDetached, type SelectionSubject, toggleSelection } from "./selectionModel";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 多选选中态的纯逻辑测试。
|
|
7
|
+
*
|
|
8
|
+
* 风险集中在三处,所以用例也围绕它们:
|
|
9
|
+
* 1. `additive` 的追加/替换语义(Ctrl 点选靠它)
|
|
10
|
+
* 2. 开关语义(重复 Ctrl 点同一元素应移除)
|
|
11
|
+
* 3. 失联元素的剔除(否则框会画到看不见的位置,重绘也白跑)
|
|
12
|
+
*
|
|
13
|
+
* 注:曾在此测试「来源 → 视觉编码」(两色方案),已随该方案回退删除。
|
|
14
|
+
* 见 `selectionModel.ts` 顶部关于为何不编码来源的说明。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function element(name: string, connected = true) {
|
|
18
|
+
return { name, isConnected: connected };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function subject(name: string): SelectionSubject<ReturnType<typeof element>> {
|
|
22
|
+
return { element: element(name) };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("mergeSelection", () => {
|
|
26
|
+
it("additive = false 时替换整个集合", () => {
|
|
27
|
+
const next = mergeSelection([subject("a")], [subject("b")], false);
|
|
28
|
+
expect(next.map((s) => s.element.name)).toEqual(["b"]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("additive = true 时追加", () => {
|
|
32
|
+
const next = mergeSelection([subject("a")], [subject("b")], true);
|
|
33
|
+
expect(next.map((s) => s.element.name)).toEqual(["a", "b"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("additive = true 时不重复加入同一元素", () => {
|
|
37
|
+
const a = subject("a");
|
|
38
|
+
expect(mergeSelection([a], [a], true)).toHaveLength(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("不修改入参数组(纯函数)", () => {
|
|
42
|
+
const current = [subject("a")];
|
|
43
|
+
mergeSelection(current, [subject("b")], true);
|
|
44
|
+
expect(current).toHaveLength(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("toggleSelection", () => {
|
|
49
|
+
it("未选中时加入", () => {
|
|
50
|
+
const el = element("a");
|
|
51
|
+
const next = toggleSelection([], el);
|
|
52
|
+
expect(next).toHaveLength(1);
|
|
53
|
+
expect(next[0]!.element).toBe(el);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("已选中时移除(重复 Ctrl 点选 = 取消)", () => {
|
|
57
|
+
const el = element("a");
|
|
58
|
+
expect(toggleSelection([{ element: el }], el)).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("不误伤其它元素", () => {
|
|
62
|
+
const a = element("a");
|
|
63
|
+
const b = element("b");
|
|
64
|
+
const next = toggleSelection([{ element: a }, { element: b }], a);
|
|
65
|
+
expect(next.map((s) => s.element.name)).toEqual(["b"]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("pruneDetached", () => {
|
|
70
|
+
it("剔除脱离文档的元素", () => {
|
|
71
|
+
const alive = { element: element("a", true) };
|
|
72
|
+
const dead = { element: element("b", false) };
|
|
73
|
+
expect(pruneDetached([alive, dead])).toEqual([alive]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("全都在时内容不变", () => {
|
|
77
|
+
expect(pruneDetached([{ element: element("a", true) }])).toHaveLength(1);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 预览选中态的**纯逻辑**(多选集合运算)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么抽出来
|
|
5
|
+
*
|
|
6
|
+
* `runtime-bridge.ts` 顶层就依赖 `window` / `document`,无法在单测环境加载。
|
|
7
|
+
* 而「多选」的核心风险全在集合运算上:`additive` 语义、重复点击的开关行为、
|
|
8
|
+
* 元素卸载后的剔除。这些都是纯函数,抽出来就能测,不必搭 DOM。
|
|
9
|
+
*
|
|
10
|
+
* `runtime-bridge` 只负责「收集元素 → 调这里 → 画图」。
|
|
11
|
+
*
|
|
12
|
+
* ## 为什么没有「来源」概念
|
|
13
|
+
*
|
|
14
|
+
* 曾经用 `origin`(inspect / runtime-tree)区分框的颜色,**已回退**。理由:
|
|
15
|
+
*
|
|
16
|
+
* 1. 颜色编码的是系统内部的来源分类,不是用户需要区分的任务 ——
|
|
17
|
+
* 用户只知道「我点了这个元素」,不知道也不需要知道它从哪条链路进来。
|
|
18
|
+
* 2. 它引入了环形回授问题:inspect 点选 → 宿主 revealRuntimeNode → 树回发
|
|
19
|
+
* `select-runtime-node` → 框被重新绘制成另一种颜色。每个同步点都要判断
|
|
20
|
+
* 「这次该不该覆盖」,复杂度远超收益。
|
|
21
|
+
* 3. 退出 inspect 时需要按来源分流(清橙留蓝),让一个简单动作有了条件分支。
|
|
22
|
+
*
|
|
23
|
+
* 如果将来确实需要表达「这个框是怎么来的」,用**标签前缀**(文字)比颜色便宜得多,
|
|
24
|
+
* 且不受环形回授影响、对色弱用户友好。详见 `docs/todo/33`。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 选中集合的一个成员。 */
|
|
28
|
+
export interface SelectionSubject<E> {
|
|
29
|
+
element: E;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 合并选中集合。
|
|
34
|
+
*
|
|
35
|
+
* @param current 现有集合(不会被修改,返回新数组)
|
|
36
|
+
* @param incoming 新加入的成员
|
|
37
|
+
* @param additive `true` = 追加(已在集合里的元素不重复加);`false` = 替换整个集合
|
|
38
|
+
*/
|
|
39
|
+
export function mergeSelection<E>(
|
|
40
|
+
current: ReadonlyArray<SelectionSubject<E>>,
|
|
41
|
+
incoming: ReadonlyArray<SelectionSubject<E>>,
|
|
42
|
+
additive: boolean,
|
|
43
|
+
): Array<SelectionSubject<E>> {
|
|
44
|
+
// 替换模式:直接以传入的为准。
|
|
45
|
+
if (!additive) {
|
|
46
|
+
return [...incoming];
|
|
47
|
+
}
|
|
48
|
+
const merged = [...current];
|
|
49
|
+
for (const subject of incoming) {
|
|
50
|
+
if (!merged.some((existing) => existing.element === subject.element)) {
|
|
51
|
+
merged.push(subject);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return merged;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `additive` 语义下的**开关**:元素已在集合中则移除,否则加入。
|
|
59
|
+
*
|
|
60
|
+
* 这是 Ctrl/Cmd + 点选的行为:反复点同一个元素应当来回切换,
|
|
61
|
+
* 而不是(像单纯 `additive` 追加那样)无论点多少次都只有「加」。
|
|
62
|
+
*/
|
|
63
|
+
export function toggleSelection<E>(
|
|
64
|
+
current: ReadonlyArray<SelectionSubject<E>>,
|
|
65
|
+
element: E,
|
|
66
|
+
): Array<SelectionSubject<E>> {
|
|
67
|
+
const exists = current.some((subject) => subject.element === element);
|
|
68
|
+
if (exists) {
|
|
69
|
+
return current.filter((subject) => subject.element !== element);
|
|
70
|
+
}
|
|
71
|
+
return [...current, { element }];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 剔除已脱离文档的元素(被卸载/替换)。
|
|
76
|
+
*
|
|
77
|
+
* 不剔的话会有两个问题:框会画到看不见的位置;`ResizeObserver` 一直挂着一个
|
|
78
|
+
* 已不在 DOM 的节点,重算就是白跑。
|
|
79
|
+
*/
|
|
80
|
+
export function pruneDetached<E extends { isConnected: boolean }>(
|
|
81
|
+
subjects: ReadonlyArray<SelectionSubject<E>>,
|
|
82
|
+
): Array<SelectionSubject<E>> {
|
|
83
|
+
return subjects.filter((subject) => subject.element.isConnected);
|
|
84
|
+
}
|
package/preview/index.html
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
// 初始化 lastRenderFailed 为 false,表示初始渲染未失败
|
|
14
14
|
lastRenderFailed: false,
|
|
15
15
|
toggleInspect: false,
|
|
16
|
+
/** 编译期兜底上报开关:`logs.ts` 接管后置 false(见 `docs/todo/39`)。 */
|
|
17
|
+
startupErrorGuard: true,
|
|
16
18
|
/**
|
|
17
19
|
* 报告预览事件
|
|
18
20
|
* @param {Object} event - 事件对象
|
|
@@ -56,6 +58,47 @@
|
|
|
56
58
|
window.__PREVIEWJS_IFRAME__.reportEvent({
|
|
57
59
|
kind: "bootstrapping",
|
|
58
60
|
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 编译期错误的兜底上报(`docs/todo/39`)。
|
|
64
|
+
*
|
|
65
|
+
* **为什么必须放在这里**:Vite 的模块图加载失败(例如 `.less` 缺 `less` 包、
|
|
66
|
+
* `react-dom` 未安装)发生在**任何业务 JS 执行之前** —— `__previewjs_internal__/logs.ts`
|
|
67
|
+
* 里那套 `window.onerror` 自己也靠模块加载才能注册,救不了更早的失败。
|
|
68
|
+
* 结果就是「白屏 + 零报错」,用户无从下手。
|
|
69
|
+
*
|
|
70
|
+
* 这里是内联脚本,一定先于模块执行,所以能接住那些“还没进入应用就崩”的错误。
|
|
71
|
+
* 模块加载成功后,`logs.ts` 会把这两个钩子**覆盖**成更完整的版本(带格式化与
|
|
72
|
+
* `vite:error` 分流),这属于预期叠加:本处只要求“不静默”。
|
|
73
|
+
*/
|
|
74
|
+
const reportStartupError = (raw) => {
|
|
75
|
+
// `logs.ts` 加载成功后会把这个开关关掉(它有更完整的格式化与 vite:error 分流),
|
|
76
|
+
// 避免同一个错误被上报两次。
|
|
77
|
+
if (!window.__PREVIEWJS_IFRAME__.startupErrorGuard) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const text = raw instanceof Error ? raw.stack || raw.message : String(raw);
|
|
81
|
+
window.__PREVIEWJS_IFRAME__.reportEvent({
|
|
82
|
+
kind: "error",
|
|
83
|
+
source: "vite",
|
|
84
|
+
message: text,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
window.addEventListener("error", (event) => {
|
|
89
|
+
// 只接模块脚本 / 资源加载失败,其它错误交给 logs.ts 的版本。
|
|
90
|
+
if (event.target && event.target !== window && event.target.tagName) {
|
|
91
|
+
reportStartupError(`Failed to load ${event.target.tagName.toLowerCase()}: ${event.target.src || event.target.href || ""}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (event.error || event.message) {
|
|
95
|
+
reportStartupError(event.error || event.message);
|
|
96
|
+
}
|
|
97
|
+
}, true);
|
|
98
|
+
|
|
99
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
100
|
+
reportStartupError(event.reason);
|
|
101
|
+
});
|
|
59
102
|
</script>
|
|
60
103
|
<!-- %OPTIONAL_HEAD_CONTENT% -->
|
|
61
104
|
<style>
|