view-anchor 0.1.2 → 0.2.0

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.
Files changed (44) hide show
  1. package/README.md +118 -34
  2. package/README.zh-CN.md +128 -44
  3. package/dist/index.d.ts +4 -16
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -14
  6. package/dist/measure-loop.d.ts +9 -28
  7. package/dist/measure-loop.d.ts.map +1 -1
  8. package/dist/measure-loop.js +37 -16
  9. package/dist/protocol-publisher.d.ts +41 -0
  10. package/dist/protocol-publisher.d.ts.map +1 -0
  11. package/dist/protocol-publisher.js +191 -0
  12. package/dist/protocol-types.d.ts +36 -0
  13. package/dist/protocol-types.d.ts.map +1 -0
  14. package/dist/protocol-types.js +10 -0
  15. package/dist/protocol.d.ts +35 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +131 -0
  18. package/dist/react.d.ts +18 -30
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +125 -122
  21. package/dist/size-advertiser.d.ts +9 -14
  22. package/dist/size-advertiser.d.ts.map +1 -1
  23. package/dist/size-advertiser.js +20 -28
  24. package/dist/types.d.ts +29 -73
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +1 -15
  27. package/dist/view-anchor.d.ts +36 -77
  28. package/dist/view-anchor.d.ts.map +1 -1
  29. package/dist/view-anchor.js +206 -174
  30. package/docs/bidirectional-design.md +64 -96
  31. package/docs/{anchor-3d.html → index.html} +215 -73
  32. package/docs/mechanism.mdx +55 -49
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +79 -0
  35. package/package.json +30 -4
  36. package/src/index.ts +6 -15
  37. package/src/measure-loop.ts +36 -41
  38. package/src/protocol-publisher.ts +236 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +193 -0
  41. package/src/react.ts +186 -141
  42. package/src/size-advertiser.ts +24 -31
  43. package/src/types.ts +34 -79
  44. package/src/view-anchor.ts +228 -212
@@ -1,21 +1,21 @@
1
1
  ---
2
2
  title: view-anchor
3
- description: 让主进程原生视图(Electron WebContentsView)始终贴住一个 DOM 元素的矩形。
3
+ description: 将主进程原生视图(如 Electron WebContentsView)与 DOM 元素的几何位置保持同步。
4
4
  ---
5
5
 
6
6
  # view-anchor
7
7
 
8
- 让一块主进程原生视图(Electron `WebContentsView`)始终贴住一个 DOM 元素的屏幕矩形。它测量目标元素的 `getBoundingClientRect()`,把矩形交给注入的 `publish` 回调(由你接上 IPC `setBounds`),并在元素位移或缩放时重新发布。
8
+ 让宿主外部的视图(例如 Electron `WebContentsView`)实时对齐某个 DOM 元素的屏幕位置。核心逻辑通过 `getBoundingClientRect()` 测量目标元素,把矩形数据交给注入的 `publish` 回调(通常由调用方转发 IPC `setBounds`),并在元素移动或缩放时同步更新。
9
9
 
10
- 核心不依赖 React、Electron 或任何宿主布局引擎;涉及 React 的代码只在适配层。
10
+ 核心不依赖 React、Electron 或特定的布局引擎;React 相关的逻辑均隔离在 `view-anchor/react` 适配层中。
11
11
 
12
12
  <iframe
13
- src="./anchor-3d.html"
13
+ src="./index.html"
14
14
  title="view-anchor 交互演示"
15
15
  style={{ width: '100%', height: '560px', border: '0', borderRadius: '12px' }}
16
16
  />
17
17
 
18
- ## 架构
18
+ ## 运行机制
19
19
 
20
20
  ```mermaid
21
21
  flowchart LR
@@ -29,91 +29,97 @@ flowchart LR
29
29
  VA -->|"publish(bounds)<br/>IPC → setBounds"| WCV
30
30
  ```
31
31
 
32
- `WebContentsView` 是主进程对象,位置只能由主进程 `setBounds` 设定;而布局是渲染进程用 CSS 算的。两边不直接接触,view-anchor 就是它们之间的桥:占位 div DOM 里占位但不渲染,原生视图浮在其上,由 view-anchor 维持贴合。`publish` 是注入的,所以核心对 Electron 一无所知——测试里它是 spy,生产里是一次 IPC 发送。
32
+ `WebContentsView` 运行在主进程,位置由主进程的 `setBounds` 设置;页面布局则由渲染进程通过 CSS 计算。两者不在同一个进程中,view-anchor 作为桥梁连接它们:占位 div 参与 DOM 布局,外部视图悬浮在上方,由 view-anchor 维持对齐。`publish` 是外部注入的回调,核心模块本身完全不感知 Electron
33
33
 
34
34
  ## createViewAnchor(target, opts)
35
35
 
36
- 命令式核心,把一块原生视图绑定到一个元素,返回 `{ update, dispose }`。
36
+ 命令式核心接口,将原生视图绑定到目标元素,返回 `{ update, dispose }`。
37
37
 
38
38
  ```ts
39
39
  const handle = createViewAnchor(target, {
40
- present: true, // 是否挂载原生视图
41
- publish: (bounds) => { ... }, // 接收实时矩形,负责 IPC → setBounds
40
+ present: true, // 是否显示原生视图
41
+ publish: (bounds) => { ... }, // 接收最新矩形,转发给 setBounds
42
42
  })
43
43
  ```
44
44
 
45
- | 状态 / 调用 | 行为 |
45
+ | 状态 / 操作 | 行为 |
46
46
  |---|---|
47
- | `present: true` | 立即发布测量矩形,之后在每次 `ResizeObserver` 触发、窗口 `resize` 时**同步**重新发布。 |
48
- | `present: false` | 停止观察,发布一次 `{0,0,0,0}`。 |
49
- | `update(opts)` | 按新选项同步重新应用(重置去重基线,强制重发一次)。 |
50
- | `dispose()` | 停止观察、移除监听,此后不再发布(也不补发零矩形)。 |
47
+ | `present: true` | 立即发布测量矩形,之后每次 `ResizeObserver` 触发或窗口 `resize` 时同步重发。 |
48
+ | `present: false` | 停止观察,发布一次 `{ x: 0, y: 0, width: 0, height: 0 }`。 |
49
+ | `update(opts)` | 应用新选项,重置去重缓存并立即重新发布一次。 |
50
+ | `dispose()` | 停止所有监听并释放资源,此后不再发布。 |
51
51
 
52
- 测量矩形按 `Math.round` 取整;**width/height 钳到 00=隐藏信号),但 x/y 允许负**——元素滚出上 / 左边缘时原点本就该是负的,钳零会把原生视图钉在边缘而非跟随它移出屏外(各消费者的 IPC schema 自己定 origin 策略)。
52
+ 测量结果使用 `Math.round` 取整。`width` 和 `height` 会限制为 `>= 0`(0 代表收起),但 `x` `y` 允许为负数。当元素滚动出视口上边缘或左边缘时,原点自然会是负值,保留负值可以让视图正常跟随元素滚出屏幕。
53
53
 
54
- **同步发布,不走 RAF**:原生 overlay 是跨进程 `WebContentsView`,`setBounds` 本就比渲染进程的 DOM 绘制晚约 1 个合成帧;再用 RAF 推迟一帧 拖拽时 overlay 可见拖尾(放大时露背景最明显)。在触发回调里直接测量+发布去掉这一自加的帧。RAF 原本承担的「合并同帧多次触发」由**同值去重**接管:若本次测量矩形与上次已发布的逐字段相等就丢弃,所以一次连续拖拽里每个不同矩形至多发一次。`update` 会先把去重基线清空,保证状态变化(zoom 骑在 `publish` 闭包里、不在 `Bounds` 里)即使几何不变也重发一次。
54
+ **为什么同步发布而不走 RAF:** 原生 overlay 处于另一个进程,IPC 传递到 `setBounds` 相比渲染进程本身的绘制已经有一帧左右的合成延迟。如果测量和发布再进一次 RAF,拖拽时就会叠加第二帧延迟,造成明显的跟随拖尾。在 observer 回调中直接同步测量和发布可以省掉这层额外延迟。同帧多次触发的防抖则交由同值去重处理:只有矩形数值与上一次不同时才触发 `publish`。调用 `update` 时会先清除去重基线,确保外部状态变化(如缩放系数变化)时即使几何数据未变也能重新发布。
55
55
 
56
- 撤销安全:没有排队的帧可以「跑赢」状态变化——每次发布开头同步读 `disposed`/`present`,`update`/`dispose` 之后的触发立即 bail。
56
+ 调用 `dispose()` 或更新为 `present: false` 后,内部状态会立即置为停用,后续任何异步触发都会直接返回,避免过期数据覆盖新状态。
57
57
 
58
- ## present / 零矩形 语义
58
+ ## 收起与零矩形
59
59
 
60
- - **`present`** —— 「原生视图是否该挂载」的唯一事实来源,与 DOM 生命周期解耦。
61
- - **`{0,0,0,0}`(ZERO)** —— 收起信号。宿主把零面积读作「摘除子视图,但保留其 `WebContents` 存活」,即收起而非销毁,重新挂载瞬时且状态完整。
62
- - **dispose 后保持静默** —— 不补发零矩形。元素真正消失时,应由调用方先发 ZERO dispose(适配层已替你处理)。
60
+ - **`present`**:标识原生视图当前是否需要显示。
61
+ - **`{ x: 0, y: 0, width: 0, height: 0 }`(零矩形)**:收起信号。宿主可以将零面积理解为“从窗口中移出视图,但保留其 `WebContents` 实例”,实现秒开复用而不是反复销毁重建。
62
+ - **dispose 行为**:`dispose()` 只停止监听,不补发零矩形。如果需要在元素移除时通知宿主收起视图,应当在 dispose 之前调用 `update({ present: false, ... })`,React 适配层已自动处理了该生命周期。
63
63
 
64
- ## createPlacementAnchor(target, opts) — 显式可见性变体
64
+ ## 显式可见性(createPlacementAnchor
65
65
 
66
- `createViewAnchor` 用零矩形 `{0,0,0,0}` 兼任「收起」信号,可见性是从几何推断的。`createPlacementAnchor` 把可见性提升为显式判别式,发的是 `Placement` 而非 `Bounds`:
66
+ `createViewAnchor` 使用零矩形表示收起,无法区分“隐藏”与“元素确实存在但尺寸为 0x0”的情况。`createPlacementAnchor` 采用显式的 `Placement` 判别联合类型:
67
67
 
68
68
  ```ts
69
69
  const handle = createPlacementAnchor(target, {
70
- visible: true, // 调用方意图:视图是否该在屏
70
+ visible: true, // 显式控制可见性
71
71
  publish: (placement) => { ... },
72
72
  })
73
73
  ```
74
74
 
75
- | 状态 / 选项 | 行为 |
75
+ | 选项 / 操作 | 行为 |
76
76
  |---|---|
77
- | `visible: true` | `measurePlacement(target)`(`{ visible:true, bounds }`),并在每次 `ResizeObserver` / `resize` 触发**同步**重发。 |
78
- | `visible: false` | `{ visible:false }`(**不是**零矩形),停止观察。 |
79
- | `guardDisplayNone`(默认 false) | 开启后,测出零面积的 target(display:none / 卸载 / 首帧未稳)发 `{ visible:false }` 而非 `{ visible:true, bounds:0×0 }`,并挂 `IntersectionObserver` 捕捉 `ResizeObserver` 不上报的 display:none 切换。 |
80
- | `followScroll`(默认 false) | 捕获阶段监听 `window` 的 `scroll`,祖先滚动容器滚动 target 时重测重发。 |
81
- | `followGeometry`(默认 false) | 按需开窗的 RAF 几何哨兵,捕捉无 DOM 事件上报的祖先 transform / reflow 位移;几何静止几帧后自动关窗,空闲时零成本。可由 `pulse(durationMs?)` 显式开窗。 |
77
+ | `visible: true` | 发布 `{ visible: true, bounds }`,并在尺寸变化时同步重发。 |
78
+ | `visible: false` | 发布 `{ visible: false }`,停止观察。 |
79
+ | `guardDisplayNone`(默认 false) | 开启后,测出零面积的元素发布 `{ visible: false }`,并挂载 `IntersectionObserver` 捕捉 `display: none` 切换。 |
80
+ | `followScroll`(默认 false) | 在捕获阶段监听 `window` 的 `scroll` 事件,祖先容器滚动时重新测量。 |
81
+ | `followGeometry`(默认 false) | 在需要时启动单帧 RAF 轮询,捕获没有 DOM 事件的祖先 transform 或布局位移;几何稳定后自动停止。也可通过 `pulse()` 手动触发。 |
82
82
 
83
- 去重携带判别式(`samePlacement`),所以可见性翻转绝不会被同值合并掉——即使几何看上去一样。真正 0×0 但在屏的视图(`{ visible:true, bounds:{...,width:0} }`)与隐藏视图(`{ visible:false }`)由此可区分,而零矩形约定下二者会塌缩成同一个值。
83
+ 去重逻辑会同时对比 `visible` 状态,因此可见性切换不会被同值去重误吞。
84
84
 
85
85
  ## useViewAnchor(opts)
86
86
 
87
- React 适配层,返回一个挂到占位元素上的 ref 回调。
87
+ React 适配层,从 `view-anchor/react` 导出,返回用于挂载在占位元素上的 ref 回调。
88
88
 
89
89
  ```tsx
90
+ import { useViewAnchor } from 'view-anchor/react'
91
+
90
92
  const ref = useViewAnchor({
91
93
  present, // boolean
92
- publish, // (bounds) => void
93
- deps: [signature], // 可选:会移动矩形、但 DOM 看不见的状态
94
+ publish, // Publisher<Bounds>,同步返回 false 表示未接收
95
+ deps: [signature], // 可选:影响位置但不会触发 ResizeObserver 的外部依赖
94
96
  })
95
97
  return <div ref={ref} />
96
98
  ```
97
99
 
98
100
  | 时机 | 行为 |
99
101
  |---|---|
100
- | 挂载 | `createViewAnchor(el, opts)` |
101
- | `opts` / `deps` 变化 | `update` |
102
- | 卸下(`ref null`)或卸载 | 先发一帧零矩形,再 `dispose` |
102
+ | 挂载 | 创建 anchor 实例并开始监听 |
103
+ | `opts` `deps` 变化 | 调用 `update` 更新参数 |
104
+ | 卸载 | microtask 内发布零矩形并释放资源 |
105
+
106
+ **`deps` 参数**:`ResizeObserver` 只在元素自身的 border-box 改变时触发。如果页面上有某些状态会移动元素位置却不改变其尺寸(例如兄弟节点切换、路由跳转、复杂的外部布局更新),可将这些状态放入 `deps`。
107
+
108
+ React 18 在卸载时会传入 `ref(null)`,React 19 支持 ref 清理函数。适配层将卸载通知推迟一个微任务执行:React 19 在 StrictMode 下开发阶段的快速卸载重挂载会被就地取消,真正的卸载则在微任务内正常触发清理。
103
109
 
104
- **`deps`** —— `ResizeObserver` 只响应纯几何变化。会移动矩形却不改变被观察元素尺寸的状态(布局拓扑签名、路由切换、兄弟标签页 `display:none`)要放进 `deps`。数组长度需在每次渲染间保持稳定。
110
+ ## usePlacementAnchor(opts)
105
111
 
106
- > 适配层已处理 React 18 StrictMode effect 双触发,以及 hidden→shown 重挂载——保证恰好发布一次真实矩形、不误发零矩形。
112
+ 同样导出自 `view-anchor/react`,将 `createPlacementAnchor` 的 `visible`、`followScroll`、`followGeometry` 与 `guardDisplayNone` 接入 React ref 生命周期。真正卸载时会发布 `{ visible: false }` 并释放监听。
107
113
 
108
- ## 文件
114
+ ## 模块结构
109
115
 
110
- | 文件 | 作用 |
116
+ | 文件 | 用途 |
111
117
  |---|---|
112
- | `src/view-anchor.ts` | 正向命令式核心 `createViewAnchor` / `createPlacementAnchor` / `measurePlacement`。无 React、无 Electron |
113
- | `src/react.ts` | React 适配层 `useViewAnchor`(基于 `createViewAnchor`)。 |
114
- | `src/size-advertiser.ts` | 反向命令式核心 `createSizeAdvertiser`。 |
115
- | `src/measure-loop.ts` | 反向专用的 RAF 合并 / 去重 / dispose 引擎 `createMeasureLoop`(不导出)。 |
116
- | `src/types.ts` | `Bounds`、`Placement`、各核心的选项与句柄类型、反向的 `AdvertisedAxis` / `AdvertisedSize`。 |
117
- | `src/index.ts` | 对外公开面。 |
118
-
119
- 正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize` 监听)。`requestAnimationFrame` 只在反向 `createSizeAdvertiser`(经 `createMeasureLoop`)和 `createPlacementAnchor` 的 opt-in `followGeometry` 哨兵里用;正向的 `createViewAnchor` 同步发布、不走 RAF。
118
+ | `src/view-anchor.ts` | 正向命令式核心:`createViewAnchor`、`createPlacementAnchor`、`measurePlacement`。不含 React Electron 依赖。 |
119
+ | `src/react.ts` | React 适配层:`useViewAnchor` `usePlacementAnchor`。 |
120
+ | `src/size-advertiser.ts` | 反向核心:`createSizeAdvertiser`。 |
121
+ | `src/measure-loop.ts` | 反向专用的 RAF 调度与去重循环(内部实现,不对外导出)。 |
122
+ | `src/types.ts` | 类型定义(`Bounds`、`Placement`、各模块配置与句柄)。 |
123
+ | `src/index.ts` | 核心入口,默认不引入 React。 |
124
+
125
+ 核心运行时仅依赖标准 Web API(`ResizeObserver`、`getBoundingClientRect`、`addEventListener`);`requestAnimationFrame` 仅在反向模块与可选的 `followGeometry` 中按需使用。
@@ -0,0 +1,63 @@
1
+ # view-anchor 性能报告
2
+
3
+ 本报告只保留当前工作树可由 `pnpm benchmark` 直接重建的绝对数据。命令启动 3 个全新的 Node.js 进程;每个进程预热 2 次、保留 7 个样本。CPU 表的“当前中位数”是三个进程中位数的中位数,完整 JSON 含全部 21 个原始样本。
4
+
5
+ 本次环境:Node.js 24.18.0、macOS arm64、Apple M4(10 个逻辑核心)。这些数字只适合同机比较,不是浏览器、Electron IPC 或 DOM layout 的耗时承诺。
6
+
7
+ ## CPU
8
+
9
+ | 场景 | 数据量 | 当前中位数 | 三进程中位数(ms) |
10
+ | --- | ---: | ---: | --- |
11
+ | 所有锚点进入下一 generation | 10,000 | 1.7407ms | 1.5772、1.7407、1.8334 |
12
+ | 逐个 `clear(anchorId)` | 10,000 | 1.0757ms | 1.0757、1.0241、1.0794 |
13
+ | 已有 100,000 个历史锚点后只 flush 1 条 | 1 | 0.0082ms | 0.0102、0.0068、0.0082 |
14
+ | 发布 placement envelope | 1,000,000 次 | 7.2710ms | 7.4852、7.2113、7.2710 |
15
+ | 同一 anchor 连续 publish + flush | 100,000 次 | 8.8899ms | 8.8899、8.8434、9.2207 |
16
+ | 解码合法 batch | 100,000 条 | 3.0990ms | 3.0990、2.9431、3.2170 |
17
+ | 解码末项非法 batch | 100,000 条 | 3.3382ms | 3.3195、3.6282、3.3382 |
18
+ | sequence guard 接收并清理 | 100,000 条 | 12.8928ms | 12.9085、12.8928、12.8151 |
19
+ | `measurePlacement` | 1,000,000 次 | 8.9269ms | 8.9269、8.5498、9.0800 |
20
+
21
+ ## 内存
22
+
23
+ 每个状态在独立 Node.js 进程中执行,并在读数前显式 GC。表中为三个独立进程的中位数增量;heap 是 V8 保留堆,RSS 是进程常驻集合大小,二者不应混用。清理对象后,分配器也不一定立即把内存页归还给操作系统,所以 `afterClear` 的 RSS 不能当作仍有等量 JavaScript 对象存活。
24
+
25
+ 性能 harness 将 `queueMicrotask` 替换为 no-op,然后显式调用 `flush()`;这是刻意测量 batcher 的 pending/retained 状态,**不是**真实宿主 microtask 调度路径。
26
+
27
+ | 状态(100,000 个 anchor) | heap 增量 | RSS 增量 |
28
+ | --- | ---: | ---: |
29
+ | batcher 待 flush | 26,320,176B | 45,858,816B |
30
+ | batcher 成功 flush 后 | 13,303,872B | 43,696,128B |
31
+ | batcher `clear()` 后 | 34,432B | 40,026,112B |
32
+ | sequence guard 保留状态 | 14,088,840B | 27,099,136B |
33
+ | sequence guard `clear()` 后 | 19,568B | 23,461,888B |
34
+
35
+ ## 实际导出代码体积
36
+
37
+ 使用 esbuild bundle、tree-shaking 和 minify,且将 `react` 作为 peer external。测量的是实际执行 JavaScript,不是 npm tarball、source map 或声明文件。
38
+
39
+ | 完整入口 | raw | gzip | brotli |
40
+ | --- | ---: | ---: | ---: |
41
+ | `view-anchor` | 6,627B | 2,588B | 2,297B |
42
+ | `view-anchor/protocol` | 3,581B | 1,412B | 1,276B |
43
+ | `view-anchor/react` | 5,472B | 2,023B | 1,797B |
44
+
45
+ | 单独导出 | raw | gzip | brotli |
46
+ | --- | ---: | ---: | ---: |
47
+ | `createViewAnchor` | 987B | 528B | 472B |
48
+ | `createPlacementAnchor` | 3,246B | 1,246B | 1,120B |
49
+ | `measurePlacement` | 282B | 196B | 167B |
50
+ | `createSizeAdvertiser` | 1,350B | 780B | 680B |
51
+ | `decodeGeometryWireValue` | 1,472B | 664B | 570B |
52
+ | `createGeometrySequenceGuard` | 396B | 258B | 229B |
53
+ | `createGeometryBatcher` | 1,357B | 637B | 579B |
54
+ | `createPlacementMessagePublisher` | 200B | 165B | 135B |
55
+ | `createSizeMessagePublisher` | 185B | 162B | 129B |
56
+ | `useViewAnchor` | 2,116B | 984B | 885B |
57
+ | `usePlacementAnchor` | 4,421B | 1,718B | 1,549B |
58
+
59
+ ## V8 与边界
60
+
61
+ 运行 `pnpm benchmark:v8 > /tmp/view-anchor-v8.log 2>&1` 时,三个新的 benchmark 进程会继承 `--trace-opt`、`--trace-deopt` 与 `--trace-turbo-inlining`。本文没有保留无对应当前 trace 工件的历史优化/反优化结论。
62
+
63
+ 当前测量不覆盖真实浏览器/Electron、DOM layout、structured clone、IPC 或生产工作负载;这些路径需在目标运行时另行测量。
@@ -0,0 +1,79 @@
1
+ # 通信协议
2
+
3
+ `view-anchor` 核心模块只负责测量并同步调用 `publish`。如果需要在进程或 iframe 边界上传输几何数据,可以使用可选的 `view-anchor/protocol` 模块。它提供带版本的消息结构、输入校验、消息防乱序和微任务批处理,不绑定具体的传输通道。
4
+
5
+ ## 消息结构
6
+
7
+ 协议消息包含以下字段:
8
+
9
+ - `v: 1`:协议版本,非支持版本直接拒绝。
10
+ - `kind`:消息类型,`placement` 或 `size`。
11
+ - `anchorId`:锚点的唯一逻辑标识符。
12
+ - `generation`:代次编号。锚点重建或需要完全重置序列时递增,用于丢弃上一代残留的迟到消息。
13
+ - `seq`:同一 publisher 内单调递增的序列号。
14
+ - `placement` 或 `size`:具体的位置或尺寸数据。
15
+
16
+ `generation` 作用于同一个 `anchorId`:一旦接收到新一代的消息,所有该锚点的旧代消息都会被直接丢弃。`generation` 与 `seq` 只负责保证消息顺序,不作为鉴权凭据。接收端依然应当在分发前验证发送方身份(如校验 `senderFrame`、域名或访问令牌)。
17
+
18
+ ## 发送端
19
+
20
+ ```ts
21
+ import {
22
+ createGeometryBatcher,
23
+ createPlacementMessagePublisher,
24
+ } from 'view-anchor/protocol'
25
+
26
+ const batcher = createGeometryBatcher(
27
+ (batch) => ipc.send('geometry', batch),
28
+ { onError: (err) => console.error(err) },
29
+ )
30
+
31
+ const publish = createPlacementMessagePublisher(
32
+ { anchorId: 'editor', generation: 1 },
33
+ batcher.publish,
34
+ )
35
+ ```
36
+
37
+ 使用 publisher 时需注意两点:
38
+
39
+ 1. **每个 `{ anchorId, generation }` 保持单个 publisher 实例**。在 React 中应使用 `useMemo` 或 `useRef` 保存,不要在每次渲染时重新创建。接收端的 `createGeometrySequenceGuard` 会记录见过的最大序列号;如果在同一代次下重建 publisher,序列号会从 1 重新计数,导致新发出的消息被当成过期消息丢弃。确实需要重置时,请将 `generation` 加一。
40
+ 2. **批处理与重试**。`createGeometryBatcher` 会在当前微任务中合并同一事件循环内的多次更新,每个锚点只保留最新的一条。如果下游发送失败或抛出异常,未成功发送的消息会保留在队列中,等待下次调用或显式 `flush()` 时重试。
41
+
42
+ 当锚点销毁时,可以调用 `batcher.clear(anchorId)` 清理对应队列;调用 `dispose()` 会彻底停用批处理器。
43
+
44
+ ## 接收端
45
+
46
+ ```ts
47
+ import {
48
+ createGeometrySequenceGuard,
49
+ decodeGeometryWireValue,
50
+ } from 'view-anchor/protocol'
51
+
52
+ const guard = createGeometrySequenceGuard()
53
+ const result = decodeGeometryWireValue(event.payload, { maxMessages: 100 })
54
+
55
+ if (result.ok) {
56
+ const messages = result.value.kind === 'batch'
57
+ ? result.value.messages
58
+ : [result.value]
59
+
60
+ for (const message of messages) {
61
+ if (
62
+ isAuthorized(event.senderFrame, message.anchorId) &&
63
+ guard.accept(message)
64
+ ) {
65
+ applyGeometry(message)
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ `decodeGeometryWireValue` 接收 `unknown` 类型的原始数据,执行严格的类型和边界检查(包括整数范围、非负尺寸、批次大小限制等),不会向外抛出异常。
72
+
73
+ ## publish 回调的同步约定
74
+
75
+ 所有 `Publisher<T>` 均为同步函数:
76
+ - 返回 `true` 或 `void`:表示数据已成功接收或已加入发送队列。
77
+ - 返回 `false`:表示当前未接收。核心会在下一次测量触发时重新尝试该值。
78
+
79
+ 如果底层传输是异步的(例如异步 IPC 或网络请求),应当先在同步回调中将消息放入本地发送队列并返回 `true`,后续的重试由发送队列自行管理。
package/package.json CHANGED
@@ -1,12 +1,27 @@
1
1
  {
2
2
  "name": "view-anchor",
3
- "version": "0.1.2",
4
- "description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
3
+ "version": "0.2.0",
4
+ "description": "High-performance geometry bridge that keeps anything outside the DOM (an Electron WebContentsView, a native webview, a cross-origin iframe) aligned to a DOM element: synchronous, deduplicated updates, optional versioned message batching, React adapter. ~2.6 KB gzipped, framework-free core.",
5
5
  "keywords": [
6
6
  "electron",
7
7
  "webcontentsview",
8
+ "browserview",
9
+ "webview",
10
+ "native view",
11
+ "iframe",
12
+ "postmessage",
13
+ "ipc",
14
+ "dom",
15
+ "geometry",
16
+ "bounds",
8
17
  "layout",
9
- "react"
18
+ "overlay",
19
+ "resizeobserver",
20
+ "resize observer",
21
+ "react",
22
+ "react hook",
23
+ "typescript",
24
+ "tree-shakeable"
10
25
  ],
11
26
  "type": "module",
12
27
  "sideEffects": false,
@@ -29,6 +44,14 @@
29
44
  ".": {
30
45
  "types": "./dist/index.d.ts",
31
46
  "default": "./dist/index.js"
47
+ },
48
+ "./protocol": {
49
+ "types": "./dist/protocol.d.ts",
50
+ "default": "./dist/protocol.js"
51
+ },
52
+ "./react": {
53
+ "types": "./dist/react.d.ts",
54
+ "default": "./dist/react.js"
32
55
  }
33
56
  },
34
57
  "files": [
@@ -78,6 +101,9 @@
78
101
  "lint": "eslint . --max-warnings 0",
79
102
  "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
80
103
  "test:dev": "vitest",
81
- "test:coverage": "vitest run --coverage"
104
+ "test:coverage": "vitest run --coverage",
105
+ "benchmark": "node --expose-gc scripts/performance-report.mjs",
106
+ "benchmark:v8": "node --trace-opt --trace-deopt --trace-turbo-inlining --expose-gc scripts/performance-report.mjs",
107
+ "check-package": "node scripts/check-package.mjs"
82
108
  }
83
109
  }
package/src/index.ts CHANGED
@@ -1,18 +1,7 @@
1
1
  /**
2
- * view-anchor engine-agnostic primitive that keeps a main-process native
3
- * view (Electron `WebContentsView`) aligned to a DOM element's geometry.
4
- *
5
- * Public surface:
6
- * - `createViewAnchor` — forward: DOM rect → native view bounds.
7
- * - `useViewAnchor` — React adapter returning a ref callback.
8
- * - `createSizeAdvertiser`— reverse: downstream content size → host.
9
- * - `Bounds` / `AdvertisedSize` / option + handle types.
10
- *
11
- * Self-contained on purpose: the only runtime deps are `react` (adapter
12
- * only) and browser APIs (`ResizeObserver` / `requestAnimationFrame` /
13
- * `getBoundingClientRect`). See the design notes and the interactive 3D
14
- * walkthrough in `docs/` (`mechanism.mdx` / `anchor-3d.html`).
2
+ * view-anchor: keeps an external surface aligned with a DOM element's geometry.
15
3
  */
4
+
16
5
  export {
17
6
  createViewAnchor,
18
7
  measurePlacement,
@@ -25,15 +14,17 @@ export type {
25
14
  export type {
26
15
  Bounds,
27
16
  Placement,
17
+ Publisher,
18
+ PublishResult,
28
19
  ViewAnchorOptions,
29
20
  ViewAnchorHandle,
30
21
  } from './types.js'
31
- export { useViewAnchor } from './react.js'
32
- export type { UseViewAnchorOptions, ViewAnchorRef } from './react.js'
33
22
  export { createSizeAdvertiser } from './size-advertiser.js'
23
+ export { useViewAnchor } from './react.js'
34
24
  export type {
35
25
  AdvertisedAxis,
36
26
  AdvertisedSize,
37
27
  SizeAdvertiserOptions,
38
28
  SizeAdvertiserHandle,
39
29
  } from './types.js'
30
+ export type { UseViewAnchorOptions, ViewAnchorRef } from './react.js'
@@ -1,30 +1,11 @@
1
1
  /**
2
- * Internal the RAF-coalesced measure/dedupe/dispose engine behind the REVERSE
3
- * primitive `createSizeAdvertiser`.
2
+ * Internal helper: animation-frame scheduling and deduplication loop for
3
+ * `createSizeAdvertiser`.
4
4
  *
5
- * The forward `createViewAnchor` deliberately does NOT use this: it publishes
6
- * SYNCHRONOUSLY (a native overlay's `setBounds` already lands a cross-process
7
- * frame late, and a RAF stacked a second frame of visible trailing). The
8
- * reverse direction is different — it is a cross-process FEEDBACK loop
9
- * (advertise → host resizes the view → content re-measures → re-advertise), so
10
- * the RAF's one-publish-per-frame coalescing is a useful damper. The two
11
- * directions thus have different optimal emit timing; this engine serves only
12
- * the reverse.
13
- *
14
- * NOT exported from the package: it is pure mechanism with no knowledge of
15
- * direction, the DOM, `ResizeObserver`, or the structure of the value `T` it
16
- * carries. The wrapping primitive injects `produce` / `same` / `sink` and
17
- * drives the lifecycle.
18
- *
19
- * - `schedule()` — coalesce a burst of triggers into ONE RAF; the frame
20
- * body re-`produce()`s, dedupes against the last emit (`same`), and `sink`s.
21
- * Bails if inactive or disposed (stale-RAF safe).
22
- * - `emitNow(v)` — explicit synchronous emit (create / update path). Always
23
- * fires, bypassing the dedupe check, and refreshes the dedupe baseline.
24
- * - `setActive` — gate the observer stream; a queued frame bails on `!active`.
25
- * - `cancel` — drop any in-flight RAF.
26
- * - `dispose` — cancel + go inert; after dispose nothing emits again.
5
+ * Coalesces resize triggers into a single requestAnimationFrame, drops
6
+ * duplicate measurements, and manages disposal.
27
7
  */
8
+
28
9
  export interface MeasureLoop<T> {
29
10
  schedule(): void
30
11
  emitNow(value: T): void
@@ -34,18 +15,34 @@ export interface MeasureLoop<T> {
34
15
  }
35
16
 
36
17
  export function createMeasureLoop<T>(cfg: {
37
- /** Produce the value to emit in the RAF body. Return `null` to decline the
38
- * frame entirely (no dedupe, no sink, baseline untouched) e.g. a
39
- * non-finite or unavailable measurement. */
18
+ /**
19
+ * Produce the value to emit in the animation frame. Return null to skip
20
+ * the frame (e.g. for non-finite measurements).
21
+ */
40
22
  produce: () => T | null
41
23
  same: (a: T, b: T) => boolean
42
- sink: (value: T) => void
24
+ sink: import('./types.js').Publisher<T>
43
25
  }): MeasureLoop<T> {
44
26
  const { produce, same, sink } = cfg
45
27
  let rafId: number | null = null
46
28
  let active = false
47
29
  let disposed = false
48
30
  let last: T | null = null
31
+ let publicationRevision = 0
32
+
33
+ const deliver = (value: T): boolean => {
34
+ const previous = last
35
+ const attempt = ++publicationRevision
36
+ last = value
37
+ try {
38
+ const accepted = sink(value) !== false
39
+ if (!accepted && publicationRevision === attempt) last = previous
40
+ return accepted
41
+ } catch (error) {
42
+ if (publicationRevision === attempt) last = previous
43
+ throw error
44
+ }
45
+ }
49
46
 
50
47
  const cancel = (): void => {
51
48
  if (rafId !== null) {
@@ -54,25 +51,23 @@ export function createMeasureLoop<T>(cfg: {
54
51
  }
55
52
  }
56
53
 
54
+ const frame = (): void => {
55
+ rafId = null
56
+ if (disposed || !active) return
57
+ const value = produce()
58
+ if (value === null) return
59
+ if (last !== null && same(value, last)) return
60
+ deliver(value)
61
+ }
62
+
57
63
  return {
58
64
  schedule(): void {
59
65
  if (disposed || !active || rafId !== null) return
60
- rafId = requestAnimationFrame(() => {
61
- rafId = null
62
- if (disposed || !active) return
63
- const value = produce()
64
- if (value === null) return // producer declined this frame
65
- // last-value dedupe: a frame whose produced value equals the last one
66
- // we emitted costs nothing (no IPC / setBounds).
67
- if (last !== null && same(value, last)) return
68
- last = value
69
- sink(value)
70
- })
66
+ rafId = requestAnimationFrame(frame)
71
67
  },
72
68
  emitNow(value: T): void {
73
69
  if (disposed) return
74
- last = value
75
- sink(value)
70
+ deliver(value)
76
71
  },
77
72
  setActive(on: boolean): void {
78
73
  active = on