@cfp-studio/iframe 18.4.0 → 18.4.1

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.
@@ -28,6 +28,9 @@ import {
28
28
  import {
29
29
  clearPageOverlay,
30
30
  isOverlayChrome,
31
+ overlayActionAt,
32
+ runOverlayAction,
33
+ setOverlayActionHandler,
31
34
  setPageInteractionShield,
32
35
  setPageOverlay,
33
36
  type PageOverlayPayload,
@@ -83,6 +86,13 @@ const SCROLL_KEYS = new Set([
83
86
 
84
87
  let initialized = false;
85
88
  let inspectEnabled = false;
89
+ /**
90
+ * 当前渲染面能否「截取单个元素」。
91
+ *
92
+ * 由宿主在 `cfp:set-inspect` 里带下来:browser 模式有 CDP 可裁切,embedded
93
+ * 是 iframe、跨域拿不到像素,所以不该显示那个按钮(点了只会失败)。
94
+ */
95
+ let inspectScreenshotAvailable = false;
86
96
  const nodeElements = new Map<string, Element>();
87
97
 
88
98
  export function setupRuntimeBridge(): void {
@@ -90,10 +100,23 @@ export function setupRuntimeBridge(): void {
90
100
  return;
91
101
  }
92
102
  initialized = true;
103
+ // 选中框上的快捷按钮:把「对哪个 runtime 节点做什么」发给宿主执行。
104
+ setOverlayActionHandler((actionId) => {
105
+ const runtimeNodeId = selectedRuntimeNodeId();
106
+ if (!runtimeNodeId) {
107
+ return;
108
+ }
109
+ publishToHost({ kind: "cfp-selection-action", action: actionId, runtimeNodeId });
110
+ });
93
111
  window.addEventListener("message", (event) => {
94
112
  if (event.data?.kind === "cfp:runtime-request") {
95
113
  publishRuntimeSnapshot();
114
+ } else if (event.data?.kind === "cfp:select-runtime-node") {
115
+ // 宿主(Runtime Tree 点选)要求把某个节点标成「已选中」:
116
+ // 面板里选中与预览里的选中框要双向同步。
117
+ selectRuntimeNodeById(typeof event.data.runtimeNodeId === "string" ? event.data.runtimeNodeId : undefined);
96
118
  } else if (event.data?.kind === "cfp:set-inspect") {
119
+ inspectScreenshotAvailable = event.data.screenshot === true;
97
120
  setInspectEnabled(Boolean(event.data.enabled));
98
121
  } else if (event.data?.kind === "cfp:overlay") {
99
122
  const layer = typeof event.data.layer === "string" ? event.data.layer : "default";
@@ -147,6 +170,8 @@ function setInspectEnabled(enabled: boolean): void {
147
170
  setPageInteractionShield(enabled);
148
171
  if (!enabled) {
149
172
  clearPageOverlay("inspect");
173
+ clearPageOverlay("inspect-selected");
174
+ selectedElement = undefined;
150
175
  return;
151
176
  }
152
177
  // 键盘焦点可能还留在应用里(tab 聚焦的输入框):inspect 时不应该继续往里输入。
@@ -156,17 +181,132 @@ function setInspectEnabled(enabled: boolean): void {
156
181
  }
157
182
  }
158
183
 
184
+ /**
185
+ * inspect 的选中态:点选后保留一个实线框,与 hover 的虚线框区分。
186
+ *
187
+ * 两层 overlay 各自独立(`inspect` / `inspect-selected`),所以 hover 移动时
188
+ * 不会把选中框冲掉。
189
+ */
190
+ let selectedElement: Element | undefined;
191
+
159
192
  function handleInspectClick(event: MouseEvent): void {
160
193
  if (!inspectEnabled) {
161
194
  return;
162
195
  }
196
+ // 先判按钮:本监听挂在 document 的**捕获阶段**,比按钮自己的 click 先跑,
197
+ // 而 `document.elementsFromPoint` 看不到 shadow 内部 —— 不先查这一下,
198
+ // 点按钮会被当成点页面(选中父元素),按钮永远点不响。
199
+ const actionId = overlayActionAt(event.clientX, event.clientY);
200
+ if (actionId) {
201
+ event.preventDefault();
202
+ event.stopPropagation();
203
+ // **直接调处理函数,不要 `button.click()`**:
204
+ // 合成事件的 clientX/Y 是 0,会被本监听器再次命中
205
+ // (`overlayActionAt(0, 0)` 找不到按钮 → 当成点了页面 → 选中左上角元素),
206
+ // 而且 `stopPropagation` 会让按钮自己的监听器永远收不到 click。
207
+ runOverlayAction(actionId);
208
+ return;
209
+ }
163
210
  const element = elementUnderPointer(event.clientX, event.clientY);
164
211
  const source = element ? getElementSource(element) : undefined;
165
212
  event.preventDefault();
166
213
  event.stopPropagation();
214
+ selectedElement = element ?? undefined;
215
+ renderSelectedOverlay();
167
216
  publishToHost({ kind: "cfp-runtime-select", source });
168
217
  }
169
218
 
219
+ /** 当前选中元素对应的 runtimeNodeId(宿主执行动作时要用它定位节点)。 */
220
+ function selectedRuntimeNodeId(): string | undefined {
221
+ if (!selectedElement) {
222
+ return undefined;
223
+ }
224
+ for (const [id, element] of nodeElements) {
225
+ if (element === selectedElement) {
226
+ return id;
227
+ }
228
+ }
229
+ return undefined;
230
+ }
231
+
232
+ /** 选中框:实线 + 更淡的填充,与 hover 的虚线区分开。 */
233
+ function renderSelectedOverlay(): void {
234
+ // **不依赖 inspect 模式**:Runtime Tree 点选时通常没开 inspect,
235
+ // 而「树 → 预览页」的选中框正是那时要显示的。
236
+ // (早期这里是 `!inspectEnabled` 就清空,导致反向同步永远看不到效果。)
237
+ if (!selectedElement || !selectedElement.isConnected) {
238
+ clearPageOverlay("inspect-selected");
239
+ return;
240
+ }
241
+ const rect = selectedElement.getBoundingClientRect();
242
+ const fiber = getElementFiberUpward(selectedElement);
243
+ const componentName = getComponentName(fiber);
244
+ const tag = selectedElement.tagName.toLowerCase();
245
+ const label = componentName ? `${componentName} <${tag}>` : `<${tag}>`;
246
+ setPageOverlay("inspect-selected", {
247
+ visible: true,
248
+ items: [
249
+ {
250
+ x: rect.left,
251
+ y: rect.top,
252
+ width: rect.width,
253
+ height: rect.height,
254
+ color: "#f5a623",
255
+ // 1px 足够:2px 在 iframe 内会显得笨重(实测反馈)。
256
+ borderWidth: 1,
257
+ borderStyle: "solid",
258
+ backgroundAlpha: 0.06,
259
+ label,
260
+ // 两个快捷动作挂在标签右侧:截图当前元素 / 复制 Selection Context Packet。
261
+ // 截图只在能截的渲染面显示:embedded(iframe)跨域拿不到像素,
262
+ // 给了按钮也只会失败,所以直接不渲染。
263
+ actions: [
264
+ ...(inspectScreenshotAvailable
265
+ ? [
266
+ {
267
+ id: "screenshot",
268
+ title: "Screenshot this element",
269
+ // 相机:机身 + 顶部凸起 + 镜头圆(描边版,12px 下轮廓清晰)。
270
+ iconMarkup: '<path d="M1.5 5.5h2.8l1-1.5h5.4l1 1.5h2.8v8h-13z"/><circle cx="8" cy="9" r="2.4"/>',
271
+ },
272
+ ]
273
+ : []),
274
+ {
275
+ id: "copy-context",
276
+ title: "Copy selection context",
277
+ // 剪贴板:板身 + 顶部夹子(夹子用填充,否则 12px 下会糊成一片)。
278
+ iconMarkup:
279
+ '<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"/>',
280
+ },
281
+ ],
282
+ },
283
+ ],
284
+ });
285
+ }
286
+
287
+ /**
288
+ * 宿主侧让某个节点成为「选中态」(Runtime Tree 点选时用)。
289
+ *
290
+ * 通过 `cfp:runtime-detail` 之外的消息传不进来,所以用 `Runtime.evaluate`
291
+ * 直接调这个全局钩子(浏览器模式)/ postMessage(embedded 模式)。
292
+ */
293
+ function selectRuntimeNodeById(runtimeNodeId: string | undefined): void {
294
+ if (!runtimeNodeId) {
295
+ selectedElement = undefined;
296
+ clearPageOverlay("inspect-selected");
297
+ return;
298
+ }
299
+ const element = nodeElements.get(runtimeNodeId);
300
+ if (!element) {
301
+ // 树里的节点可能不在当前 DOM(未渲染的分支)——静默,不清掉已有选中。
302
+ return;
303
+ }
304
+ selectedElement = element;
305
+ // 元素可能不在视口内:先滚动到可见位置,再按最终位置画框。
306
+ element.scrollIntoView?.({ block: "nearest", inline: "nearest" });
307
+ renderSelectedOverlay();
308
+ }
309
+
170
310
  function handleInspectMove(event: MouseEvent): void {
171
311
  if (!inspectEnabled) {
172
312
  return;
@@ -192,6 +332,8 @@ function handleInspectMove(event: MouseEvent): void {
192
332
  width: rect.width,
193
333
  height: rect.height,
194
334
  label,
335
+ // hover 用虚线:与点选后的实线区分,避免「哪个是当前选中」看不出来。
336
+ borderStyle: "dashed",
195
337
  },
196
338
  ],
197
339
  });
@@ -3,9 +3,9 @@ import type { RenderOptions } from "../../src";
3
3
  let state: RenderOptions | null = null;
4
4
 
5
5
  export function getState() {
6
- return state;
6
+ return state;
7
7
  }
8
8
 
9
9
  export function setState(newState: RenderOptions) {
10
- state = newState;
10
+ state = newState;
11
11
  }
@@ -1,74 +1,75 @@
1
1
  /// <reference types="vite/client" />
2
2
  import type { ErrorPayload, UpdatePayload } from "vite/types/hmrPayload";
3
+
3
4
  import { generateMessageFromError } from "./error-message";
4
5
 
5
6
  const IGNORED_UPDATE_PATH = "/__previewjs_internal__/preview.js?";
6
7
 
7
8
  export function setupViteHmrListener() {
8
- const hmr = import.meta.hot;
9
- if (!hmr) {
10
- return;
11
- }
12
- let error: ErrorPayload | null = null;
13
- let isFirstUpdate = true;
14
- hmr.on("vite:beforeFullReload", () => {
15
- window.__PREVIEWJS_IFRAME__.reportEvent({
16
- kind: "vite-before-reload",
17
- });
18
- });
19
- hmr.on("vite:error", (payload: ErrorPayload) => {
20
- error = payload;
21
- if (typeof payload.err?.message !== "string") {
22
- // This error doesn't match the expected payload.
23
- // For example, this can happen with invalid CSS with the
24
- // vite-plugin-vue2 plugin.
25
- // Block this to prevent crashes down the track.
26
- return;
27
- }
28
- window.__PREVIEWJS_IFRAME__.reportEvent({
29
- kind: "error",
30
- source: "vite",
31
- message: generateMessageFromError(payload.err.message, payload.err.stack),
32
- });
33
- });
34
- hmr.on("vite:beforeUpdate", (payload: UpdatePayload) => {
35
- if (payload.updates.every((u) => u.path.startsWith(IGNORED_UPDATE_PATH))) {
36
- // Ignore.
37
- return;
38
- }
39
- // This is a copy of Vite logic that is disabled when the error overlay is off:
40
- // https://github.com/vitejs/vite/blob/f3d15f106f378c3850b62fbebd69fc8f7c7f944b/packages/vite/src/client/client.ts#L61
41
- if (error && isFirstUpdate) {
42
- window.location.reload();
43
- } else {
44
- error = null;
45
- isFirstUpdate = false;
46
- }
47
- if (window.__PREVIEWJS_IFRAME__.lastRenderFailed) {
48
- window.location.reload();
49
- } else {
50
- window.__PREVIEWJS_IFRAME__.reportEvent({
51
- kind: "vite-before-update",
52
- payload,
53
- });
54
- }
55
- });
56
- hmr.on("vite:afterUpdate", (payload: UpdatePayload) => {
57
- if (payload.updates.every((u) => u.path.startsWith(IGNORED_UPDATE_PATH))) {
58
- // Ignore.
59
- return;
60
- }
61
- window.__PREVIEWJS_IFRAME__.reportEvent({
62
- kind: "vite-after-update",
63
- payload,
64
- });
65
- });
66
- hmr.on("vite:invalidate", () => {
67
- window.__PREVIEWJS_IFRAME__.reportEvent({
68
- kind: "vite-invalidate",
69
- });
70
- window.__PREVIEWJS_IFRAME__.refresh({
71
- refetchPreviewableModule: true,
72
- });
73
- });
9
+ const hmr = import.meta.hot;
10
+ if (!hmr) {
11
+ return;
12
+ }
13
+ let error: ErrorPayload | null = null;
14
+ let isFirstUpdate = true;
15
+ hmr.on("vite:beforeFullReload", () => {
16
+ window.__PREVIEWJS_IFRAME__.reportEvent({
17
+ kind: "vite-before-reload",
18
+ });
19
+ });
20
+ hmr.on("vite:error", (payload: ErrorPayload) => {
21
+ error = payload;
22
+ if (typeof payload.err?.message !== "string") {
23
+ // This error doesn't match the expected payload.
24
+ // For example, this can happen with invalid CSS with the
25
+ // vite-plugin-vue2 plugin.
26
+ // Block this to prevent crashes down the track.
27
+ return;
28
+ }
29
+ window.__PREVIEWJS_IFRAME__.reportEvent({
30
+ kind: "error",
31
+ source: "vite",
32
+ message: generateMessageFromError(payload.err.message, payload.err.stack),
33
+ });
34
+ });
35
+ hmr.on("vite:beforeUpdate", (payload: UpdatePayload) => {
36
+ if (payload.updates.every((u) => u.path.startsWith(IGNORED_UPDATE_PATH))) {
37
+ // Ignore.
38
+ return;
39
+ }
40
+ // This is a copy of Vite logic that is disabled when the error overlay is off:
41
+ // https://github.com/vitejs/vite/blob/f3d15f106f378c3850b62fbebd69fc8f7c7f944b/packages/vite/src/client/client.ts#L61
42
+ if (error && isFirstUpdate) {
43
+ window.location.reload();
44
+ } else {
45
+ error = null;
46
+ isFirstUpdate = false;
47
+ }
48
+ if (window.__PREVIEWJS_IFRAME__.lastRenderFailed) {
49
+ window.location.reload();
50
+ } else {
51
+ window.__PREVIEWJS_IFRAME__.reportEvent({
52
+ kind: "vite-before-update",
53
+ payload,
54
+ });
55
+ }
56
+ });
57
+ hmr.on("vite:afterUpdate", (payload: UpdatePayload) => {
58
+ if (payload.updates.every((u) => u.path.startsWith(IGNORED_UPDATE_PATH))) {
59
+ // Ignore.
60
+ return;
61
+ }
62
+ window.__PREVIEWJS_IFRAME__.reportEvent({
63
+ kind: "vite-after-update",
64
+ payload,
65
+ });
66
+ });
67
+ hmr.on("vite:invalidate", () => {
68
+ window.__PREVIEWJS_IFRAME__.reportEvent({
69
+ kind: "vite-invalidate",
70
+ });
71
+ window.__PREVIEWJS_IFRAME__.refresh({
72
+ refetchPreviewableModule: true,
73
+ });
74
+ });
74
75
  }
@@ -1,83 +1,82 @@
1
- <!DOCTYPE html>
1
+ <!doctype html>
2
2
  <html lang="en">
3
- <head>
4
- <link rel="icon" href="/logo.png" />
5
- <script>
6
- // 将全局对象 globalThis 赋值给 window.global,确保在不同环境下全局对象的一致性
7
- window.global = globalThis;
8
- // 将 window.module 设置为 undefined,避免模块冲突
9
- window.module = undefined;
3
+ <head>
4
+ <link rel="icon" href="/logo.png" />
5
+ <script>
6
+ // 将全局对象 globalThis 赋值给 window.global,确保在不同环境下全局对象的一致性
7
+ window.global = globalThis;
8
+ // 将 window.module 设置为 undefined,避免模块冲突
9
+ window.module = undefined;
10
10
 
11
- // 定义一个全局对象 __PREVIEWJS_IFRAME__,用于管理预览框架的状态和事件
12
- window.__PREVIEWJS_IFRAME__ = {
13
- // 初始化 lastRenderFailed 为 false,表示初始渲染未失败
14
- lastRenderFailed: false,
15
- toggleInspect:false,
16
- /**
17
- * 报告预览事件
18
- * @param {Object} event - 事件对象
19
- * @param {string} event.kind - 事件类型,如 "rendered" 或 "error"
20
- * @param {string} [event.source] - 事件源,如 "renderer"
21
- */
22
- reportEvent: (event) => {
23
- // 如果事件类型为 "rendered",则将 lastRenderFailed 设为 false
24
- if (event.kind === "rendered" ) {
25
- window.__PREVIEWJS_IFRAME__.lastRenderFailed = false;
26
- }
27
- // 如果事件类型为 "error" 且事件源为 "renderer",则将 lastRenderFailed 设为 true
28
- else if (event.kind === "error" && event.source === "renderer") {
29
- window.__PREVIEWJS_IFRAME__.lastRenderFailed = true;
30
- }
31
- // 调用控制器的 onPreviewEvent 方法处理事件.
32
- // 跨域 iframe 无法直接读取 parent 的属性,此时走 postMessage.
33
- let delivered = false;
34
- try {
35
- const controller =
36
- // 注意: 与 core/src/html-error.ts 中的代码保持同步.
37
- window.__PREVIEWJS_CONTROLLER__ || window.parent.__PREVIEWJS_CONTROLLER__;
38
- if (controller) {
39
- controller.onPreviewEvent(event);
40
- delivered = true;
41
- }
42
- } catch {
43
- // cross-origin parent; fall through to postMessage
44
- }
45
- if (!delivered && window.parent !== window) {
46
- window.parent.postMessage({ kind: "preview-event", event }, "*");
47
- }
48
- },
49
- /**
50
- * 刷新预览
51
- * 目前为空函数,可根据需求实现具体逻辑
52
- */
53
- refresh: () => {}
54
- };
55
- // 报告启动事件,表明预览框架正在初始化
56
- window.__PREVIEWJS_IFRAME__.reportEvent({
57
- kind: "bootstrapping",
58
- });
59
- </script>
60
- <!-- %OPTIONAL_HEAD_CONTENT% -->
61
- <style>
62
- body {
63
- padding: 1rem;
64
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
65
- "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
66
- "Helvetica Neue", sans-serif;
67
- -webkit-font-smoothing: antialiased;
68
- -moz-osx-font-smoothing: grayscale;
69
- }
11
+ // 定义一个全局对象 __PREVIEWJS_IFRAME__,用于管理预览框架的状态和事件
12
+ window.__PREVIEWJS_IFRAME__ = {
13
+ // 初始化 lastRenderFailed 为 false,表示初始渲染未失败
14
+ lastRenderFailed: false,
15
+ toggleInspect: false,
16
+ /**
17
+ * 报告预览事件
18
+ * @param {Object} event - 事件对象
19
+ * @param {string} event.kind - 事件类型,如 "rendered" 或 "error"
20
+ * @param {string} [event.source] - 事件源,如 "renderer"
21
+ */
22
+ reportEvent: (event) => {
23
+ // 如果事件类型为 "rendered",则将 lastRenderFailed 设为 false
24
+ if (event.kind === "rendered") {
25
+ window.__PREVIEWJS_IFRAME__.lastRenderFailed = false;
26
+ }
27
+ // 如果事件类型为 "error" 且事件源为 "renderer",则将 lastRenderFailed 设为 true
28
+ else if (event.kind === "error" && event.source === "renderer") {
29
+ window.__PREVIEWJS_IFRAME__.lastRenderFailed = true;
30
+ }
31
+ // 调用控制器的 onPreviewEvent 方法处理事件.
32
+ // 跨域 iframe 无法直接读取 parent 的属性,此时走 postMessage.
33
+ let delivered = false;
34
+ try {
35
+ const controller =
36
+ // 注意: 与 core/src/html-error.ts 中的代码保持同步.
37
+ window.__PREVIEWJS_CONTROLLER__ || window.parent.__PREVIEWJS_CONTROLLER__;
38
+ if (controller) {
39
+ controller.onPreviewEvent(event);
40
+ delivered = true;
41
+ }
42
+ } catch {
43
+ // cross-origin parent; fall through to postMessage
44
+ }
45
+ if (!delivered && window.parent !== window) {
46
+ window.parent.postMessage({ kind: "preview-event", event }, "*");
47
+ }
48
+ },
49
+ /**
50
+ * 刷新预览
51
+ * 目前为空函数,可根据需求实现具体逻辑
52
+ */
53
+ refresh: () => {},
54
+ };
55
+ // 报告启动事件,表明预览框架正在初始化
56
+ window.__PREVIEWJS_IFRAME__.reportEvent({
57
+ kind: "bootstrapping",
58
+ });
59
+ </script>
60
+ <!-- %OPTIONAL_HEAD_CONTENT% -->
61
+ <style>
62
+ body {
63
+ padding: 1rem;
64
+ font-family:
65
+ -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
66
+ "Droid Sans", "Helvetica Neue", sans-serif;
67
+ -webkit-font-smoothing: antialiased;
68
+ -moz-osx-font-smoothing: grayscale;
69
+ }
70
70
 
71
- code,
72
- .code {
73
- font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
74
- monospace;
75
- }
76
- </style>
77
- <meta charset="utf-8" />
78
- <meta name="viewport" content="width=device-width, initial-scale=1" />
79
- </head>
80
- <body>
81
- <div id="root"></div>
82
- </body>
71
+ code,
72
+ .code {
73
+ font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
74
+ }
75
+ </style>
76
+ <meta charset="utf-8" />
77
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
78
+ </head>
79
+ <body>
80
+ <div id="root"></div>
81
+ </body>
83
82
  </html>