view-anchor 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
  <img src="https://raw.githubusercontent.com/lbb00/view-anchor/main/assets/banner.svg" alt="view-anchor — keep anything outside the DOM aligned to a DOM element" width="820">
3
3
  </p>
4
4
 
5
- > A high-performance geometry bridge that keeps anything living outside the DOM aligned to a DOM element: an Electron `WebContentsView`, a native webview in another desktop shell, a cross-origin iframe, or any surface you position from a rectangle. Every move and resize is published synchronously with no duplicate frames.
5
+ > Keep an external surface aligned to a DOM element. `view-anchor` measures the element and synchronously gives your code the current geometry whenever it changes.
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/view-anchor)](https://www.npmjs.com/package/view-anchor)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/view-anchor)](https://www.npmjs.com/package/view-anchor)
@@ -15,25 +15,35 @@
15
15
 
16
16
  ## The problem
17
17
 
18
- Some things you want to place inside your layout are not DOM nodes. An Electron `WebContentsView` is positioned by the main process. A native webview in another desktop shell is positioned by host code. The document inside a cross-origin iframe only knows what you tell it over `postMessage`. Your layout, whether it is flexbox, dockview, or react-resizable-panels, only moves DOM nodes and has no idea that something else is supposed to sit exactly on top of one of them.
18
+ Some surfaces are positioned by application code rather than by CSS. They may be a canvas, a video overlay, an embedded document, or any other thing you can position from a rectangle. Your layout moves DOM elements, but it cannot move that external surface by itself.
19
19
 
20
- `view-anchor` closes that gap. You point it at a placeholder element. It measures the element and, every time the element moves or resizes, hands the new rectangle to your `publish` callback. What happens next is up to you: `ipcRenderer.send` plus `view.setBounds` in Electron, `postMessage` to an iframe, or a direct call into whatever positions the surface.
20
+ Point `view-anchor` at a placeholder element. It measures the element on creation, on `ResizeObserver` notifications, and on window `resize`, then calls your `publish` function. Your function applies, stores, or sends that value using the mechanism your application already has.
21
21
 
22
- The core has no dependency on Electron, a browser shell, React, or any layout library. It only uses `ResizeObserver`, `requestAnimationFrame`, and `getBoundingClientRect`. React support lives in a separate `view-anchor/react` entry.
22
+ The core has no dependency on a host runtime, React, or a layout library. It only uses `ResizeObserver`, `requestAnimationFrame`, and `getBoundingClientRect`. React support lives in a separate `view-anchor/react` entry.
23
+
24
+ ## What `publish` receives
25
+
26
+ `publish` is not a built-in transport. It is a synchronous function that the library calls with one of these plain values:
27
+
28
+ - `createViewAnchor` calls `publish(bounds)`, where `bounds` is `{ x, y, width, height }` in CSS pixels from `getBoundingClientRect()`. When `present` becomes `false`, it calls `publish({ x: 0, y: 0, width: 0, height: 0 })` once and stops observing.
29
+ - `createPlacementAnchor` calls `publish({ visible: true, bounds })` while shown, or `publish({ visible: false })` while hidden. Use it when a visible zero-sized element differs from a hidden one.
30
+ - `createSizeAdvertiser` calls `publish({ axis, extent })`, where `axis` is `block` (height) or `inline` (width), and `extent` is the rounded non-negative content size in CSS pixels.
31
+
32
+ Return `false` only when the value was not accepted; a later measurement may retry it. Return `true` or nothing after accepting or queueing it.
23
33
 
24
34
  ## Built for the hot path
25
35
 
26
36
  Geometry updates fire on every resize and, when following a drag, on every animation frame. The library keeps that work predictable:
27
37
 
28
38
  - **Synchronous delivery.** Measurement and publish happen inside the same `ResizeObserver` callback. No timers, no extra frame of lag.
29
- - **Dedupe before allocate.** A rectangle identical to the last accepted one is rejected by comparing four numbers, before any object is created.
39
+ - **Dedupe outgoing updates.** A rectangle identical to the last accepted one is not passed to `publish` again.
30
40
  - **Frame following only when needed.** `followGeometry` polls `requestAnimationFrame` during a scroll burst, a splitter drag, or an explicit `pulse()`, then closes itself once the rectangle settles. Idle cost is zero, and hidden or invalid targets are capped at 30 frames.
31
41
  - **O(1) generation changes.** In the protocol layer, moving an anchor to a new generation or clearing it does not touch other anchors.
32
42
  - **Latest-wins batching.** Messages queued in the same task are merged in a microtask. The newest placement and size for each anchor are sent separately.
33
43
  - **Release on disposal.** Disposed handles stop observing and release their target and callback references, even when the caller keeps the handle.
34
44
  - **Compact, tree-shakeable core.** Functions are separate exports with `sideEffects: false`; the complete core export is under 3 KB gzipped.
35
45
 
36
- The [performance report](./docs/performance-report.md) contains the reproducible measurements and their environment. They are useful for comparing changes on the same machine, not for predicting DOM layout, Electron IPC, structured clone, or your app's workload.
46
+ The [performance report](./docs/performance-report.md) contains the reproducible measurements and their environment. They are useful for comparing changes on the same machine, not for predicting browser layout, serialization, delivery, or your app's workload.
37
47
 
38
48
  ## Installation
39
49
 
@@ -52,16 +62,31 @@ React is an optional peer dependency. Import the hooks from `view-anchor/react`.
52
62
  ```ts
53
63
  import { createViewAnchor } from 'view-anchor'
54
64
 
65
+ const publish = (bounds) => {
66
+ applyBounds(bounds)
67
+ }
68
+
55
69
  const handle = createViewAnchor(target, {
56
- present: true, // mount the native view
57
- publish: (bounds) => { ... }, // receive live rectangles; wire IPC → setBounds
70
+ present: true,
71
+ publish,
58
72
  })
59
73
 
60
- handle.update({ present, publish }) // apply new options and publish right away
61
- handle.dispose() // stop observing; never publishes again
74
+ handle.update({ present: true, publish }) // apply new options and publish right away
75
+ handle.dispose() // stop observing; never publishes again
62
76
  ```
63
77
 
64
- Set `present: false` to collapse the view. The core publishes a zero rectangle and stops observing. The host can detach the subview while keeping the `WebContents` alive, so re-showing it later is instant.
78
+ Set `present: false` to collapse the surface. The core publishes a zero rectangle and stops observing. Your `publish` function decides whether that removes, hides, or retains the external surface.
79
+
80
+ For ancestor scroll, transforms, or other position-only changes, use `createPlacementAnchor` with `followScroll` or `followGeometry` as needed.
81
+
82
+ `createViewAnchor`, `createPlacementAnchor`, `createSizeAdvertiser`, and `createGeometryBatcher` accept `signal`. Aborting it is equivalent to `dispose()`; an already-aborted signal does not measure, publish, or install listeners.
83
+
84
+ ```ts
85
+ const controller = new AbortController()
86
+ const handle = createViewAnchor(target, { present: true, publish, signal: controller.signal })
87
+
88
+ controller.abort() // same cleanup as handle.dispose()
89
+ ```
65
90
 
66
91
  ### React
67
92
 
@@ -73,8 +98,8 @@ function DebugPanel({ visible }: { visible: boolean }) {
73
98
  present: visible,
74
99
  publish: publishPanelBounds,
75
100
  })
76
- // The native view follows this placeholder div. Hiding the panel
77
- // (visible=false or unmount) collapses it without destroying it.
101
+ // The external surface follows this placeholder. Hiding or unmounting it
102
+ // sends the collapsed value, without deciding how the surface is stored.
78
103
  return <div ref={ref} className="h-full w-full" />
79
104
  }
80
105
  ```
@@ -89,9 +114,12 @@ A zero rectangle cannot tell a hidden view from one that is visible but currentl
89
114
  import { createPlacementAnchor } from 'view-anchor'
90
115
 
91
116
  const handle = createPlacementAnchor(target, {
92
- publish: (placement) => { ... },
93
- followScroll: true, // re-measure when any ancestor scrolls
94
- followGeometry: true, // poll animation frames during scrolls / drags, stop when steady
117
+ visible: true,
118
+ publish(placement) {
119
+ applyPlacement(placement)
120
+ },
121
+ followScroll: true, // re-measure when any ancestor scrolls
122
+ followGeometry: true, // poll animation frames during scrolls / drags, stop when steady
95
123
  guardDisplayNone: true, // zero-area or display:none target → { visible: false }
96
124
  })
97
125
 
@@ -108,19 +136,21 @@ Sometimes the hosted surface's size should come from its own content, for exampl
108
136
  import { createSizeAdvertiser } from 'view-anchor'
109
137
 
110
138
  const handle = createSizeAdvertiser(contentWrapper, {
111
- axis: 'block', // one axis per advertiser: block = height, inline = width
112
- publish: (size) => { ... }, // receives { axis, extent }; wire IPC → host
139
+ axis: 'block', // one axis per advertiser: block = height, inline = width
140
+ publish(size) {
141
+ updatePlaceholderSize(size)
142
+ },
113
143
  })
114
144
 
115
145
  handle.update(publish) // swap the publish channel and report the current size again
116
- handle.dispose() // stop observing; never reports again
146
+ handle.dispose() // stop observing; never reports again
117
147
  ```
118
148
 
119
149
  > **Warning:** the target must shrink to fit its content on the owned axis. If the host sets that size instead, the two sides keep reacting to each other and never settle. See [docs/bidirectional-design.md](./docs/bidirectional-design.md).
120
150
 
121
151
  ### Versioned transport across a boundary
122
152
 
123
- The core hands you plain `Bounds`, `Placement`, and `AdvertisedSize` values. Once those values cross a process or origin boundary, over IPC or `postMessage`, you usually want validation and ordering. The optional `view-anchor/protocol` entry adds versioned message envelopes, bounded decoding of untrusted input, a per-anchor sequence guard that drops stale messages, and a microtask batcher:
153
+ The core hands you plain `Bounds`, `Placement`, and `AdvertisedSize` values. If an application passes them through an asynchronous or untrusted channel, it usually needs validation and ordering. The optional `view-anchor/protocol` entry adds versioned message envelopes, bounded decoding, a per-anchor sequence guard that drops stale messages, and a microtask batcher:
124
154
 
125
155
  ```ts
126
156
  import {
@@ -129,14 +159,14 @@ import {
129
159
  decodeGeometryWireValue,
130
160
  } from 'view-anchor/protocol'
131
161
 
132
- // sending side (renderer, iframe, ...)
133
- const batcher = createGeometryBatcher((batch) => ipc.send('geometry', batch))
162
+ // `sendGeometryBatch` is supplied by your application.
163
+ const batcher = createGeometryBatcher(sendGeometryBatch)
134
164
  const publish = createPlacementMessagePublisher(
135
165
  { anchorId: 'editor', generation: 3 },
136
166
  batcher.publish,
137
167
  )
138
168
 
139
- // receiving side (main process, host page, ...)
169
+ // receiving side
140
170
  const decoded = decodeGeometryWireValue(received, { maxMessages: 100 })
141
171
  if (decoded.ok) {
142
172
  /* check the sender, then apply only newer messages */
@@ -154,10 +184,10 @@ The full contract is in [docs/protocol.md](./docs/protocol.md).
154
184
 
155
185
  | Export | Kind | Purpose |
156
186
  | --------------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
157
- | `createViewAnchor(target, opts)` | function | Measure a DOM element and publish live bounds. A zero rect means collapsed. |
187
+ | `createViewAnchor(target, opts)` | function | Measure a DOM element and call `publish({ x, y, width, height })`. A zero rect means collapsed. |
158
188
  | `createPlacementAnchor(target, opts)` | function | Same core with explicit `Placement` visibility, opt-in `followScroll` / `followGeometry` / `guardDisplayNone`, and `pulse()`. |
159
189
  | `measurePlacement(target)` | function | Pure measurement: wraps the target rect as `{ visible: true, bounds }`. |
160
- | `createSizeAdvertiser(target, opts)` | function | Reverse direction: report the view's own content size to the host. |
190
+ | `createSizeAdvertiser(target, opts)` | function | Call `publish({ axis, extent })` with one content-size axis. |
161
191
  | `useViewAnchor(opts)` from `view-anchor/react` | hook | Returns a ref callback for a placeholder element. |
162
192
  | `usePlacementAnchor(opts)` from `view-anchor/react` | hook | React adapter for the `Placement` API, including `followScroll` and `followGeometry`. |
163
193
  | `Bounds` | type | `{ x, y, width, height }` in CSS pixels. |
@@ -181,6 +211,8 @@ The full contract is in [docs/protocol.md](./docs/protocol.md).
181
211
 
182
212
  Issues and pull requests are welcome. Before submitting, run `pnpm lint`, `pnpm format:check`, `pnpm check-types`, `pnpm test`, and `pnpm build`. `pnpm benchmark` prints the data used to update the performance report.
183
213
 
214
+ Changes that affect a published version must include a Changeset. Run `pnpm changeset`, select the version bump, and describe the user-visible change. Merging it into `main` opens a version PR; merging that PR publishes the package.
215
+
184
216
  ## License
185
217
 
186
218
  [MIT](./LICENSE) © lbb00
package/README.zh-CN.md CHANGED
@@ -1,8 +1,8 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/lbb00/view-anchor/main/assets/banner.svg" alt="view-anchor — 让 DOM 之外的画面贴合 DOM 元素" width="820">
2
+ <img src="https://raw.githubusercontent.com/lbb00/view-anchor/main/assets/banner.svg" alt="view-anchor — 让 DOM 之外的画面实时对齐 DOM 元素" width="820">
3
3
  </p>
4
4
 
5
- > 高性能几何桥接库,让任何"不在 DOM 里的东西"始终贴合某个 DOM 元素:Electron 的 `WebContentsView`、其他桌面壳里的原生 webview、跨域 iframe,或者任何你能用一个矩形来定位的画面。每次移动和缩放都同步发布、不会重复发帧。
5
+ > DOM 之外的画面实时对齐 DOM 元素:测量占位元素,逐帧同步最新矩形,拖拽无延迟。
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/view-anchor)](https://www.npmjs.com/package/view-anchor)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/view-anchor)](https://www.npmjs.com/package/view-anchor)
@@ -11,29 +11,23 @@
11
11
 
12
12
  [English](./README.md) · [简体中文](./README.zh-CN.md)
13
13
 
14
- > 🎮 **在线 Demo**:[3D 交互演示](https://lbb00.github.io/view-anchor/) 在浏览器里跑的是真实核心代码。拖动分栏、切换面板显示,看原生视图实时跟随。
14
+ > [在线演示](https://lbb00.github.io/view-anchor/):拖动分栏、切换面板或 3D 视角,查看外部画面如何跟随占位元素。
15
15
 
16
- ## 要解决的问题
16
+ ## 它解决什么问题
17
17
 
18
- 有些你想放进布局里的东西并不是 DOM 节点。Electron 的 `WebContentsView` 由主进程定位;其他桌面壳里的原生 webview 由宿主代码定位;跨域 iframe 里的文档只知道你通过 `postMessage` 告诉它的信息。而你的布局,不管是 flexbox、dockview 还是 react-resizable-panels,只会移动 DOM 节点,并不知道有别的东西需要正好盖在其中某个节点上面。
18
+ 有些画面的位置不是由 CSS 直接控制:例如应用自己摆放的叠层、嵌入式文档,或能根据矩形定位的渲染表面。DOM 布局会移动占位元素,却不会自动更新这些画面的位置。
19
19
 
20
- `view-anchor` 负责补上这一段。你把它指向一个占位元素,它测量这个元素,元素每次移动或缩放时,把新的矩形交给你的 `publish` 回调。接下来怎么用由你决定:在 Electron 里接 `ipcRenderer.send` 和 `view.setBounds`,对 iframe 就 `postMessage`,或者直接调用任何负责定位那个画面的代码。
20
+ `view-anchor` 负责测量占位元素。元素尺寸变化或窗口大小变化时,它同步调用你提供的 `publish` 函数。函数怎么使用这个值由应用决定。
21
21
 
22
- 核心不依赖 Electron、任何浏览器壳、React 或布局库,只用到 `ResizeObserver`、`requestAnimationFrame` 和 `getBoundingClientRect`。React 支持放在单独的 `view-anchor/react` 入口里。
22
+ ## `publish` 收到什么
23
23
 
24
- ## 为热路径而写
24
+ `publish` 不是内置传输通道,而是一个同步回调。不同接口会传入不同的普通对象:
25
25
 
26
- 几何更新在每次缩放时都会触发,跟随拖拽时更是每一帧都触发。这个库让这条路径保持可控:
26
+ - `createViewAnchor` 调用 `publish(bounds)`。`bounds` 是相对视口、取整后的 CSS 像素矩形:`{ x, y, width, height }`。`present` 变为 `false` 时,会发布一次全零矩形,然后停止观察。
27
+ - `createPlacementAnchor` 在显示时调用 `publish({ visible: true, bounds })`,隐藏时调用 `publish({ visible: false })`。
28
+ - `createSizeAdvertiser` 调用 `publish({ axis, extent })`。`axis` 为 `block`(高度)或 `inline`(宽度),`extent` 是取整后且不小于零的内容尺寸。
27
29
 
28
- - **同步发布。** 测量和发布在同一个 `ResizeObserver` 回调里完成。没有定时器,不多等一帧。
29
- - **先去重,再分配。** 和上一次已接受的矩形完全相同的结果,只比较四个数字就会被丢弃,不会创建任何对象。
30
- - **只在需要时逐帧跟随。** `followGeometry` 只在滚动、拖动分栏或显式调用 `pulse()` 时才开始按 `requestAnimationFrame` 轮询,矩形稳定后自动停止。空闲时开销为零,隐藏或无效目标最多跟 30 帧。
31
- - **generation 切换是 O(1)。** 协议层里,把某个锚点换到新的 generation 或清除它,不会碰到其他锚点。
32
- - **合并批量,只发最新。** 同一个任务里排队的消息在一个微任务里合并,每个锚点分别发送最新的位置和尺寸。
33
- - **销毁后释放引用。** 句柄 `dispose()` 后会停止观察,并释放目标元素和回调;调用方继续持有句柄也不会保留它们。
34
- - **核心紧凑,可摇树。** 每个函数都是独立导出,并声明了 `sideEffects: false`;完整核心导出 gzip 后小于 3 KB。
35
-
36
- [性能报告](./docs/performance-report.md) 保留可复现的测量数据和环境。它适合比较同一台机器上的改动,不用于预测 DOM layout、Electron IPC、structured clone 或你的应用负载。
30
+ 数据已接收或已经入队时,返回 `true` 或不返回值;当前不能接收时才返回 `false`。下一次测量会重试被拒绝的值。
37
31
 
38
32
  ## 安装
39
33
 
@@ -43,84 +37,103 @@ pnpm add view-anchor
43
37
  npm install view-anchor
44
38
  ```
45
39
 
46
- React 是可选的 peer dependency。Hook 从 `view-anchor/react` 导入。为了兼容 `v0.1.2`,根入口也会重新导出 `useViewAnchor`,所以只要导入了 `view-anchor` 就需要装 React。`view-anchor/protocol` 不需要。
40
+ React 是可选 peer dependency。Hook 从 `view-anchor/react` 导入;根入口为了兼容 `v0.1.2` 也会导出 `useViewAnchor`,因此导入根入口时需要安装 React。`view-anchor/protocol` 不需要 React。
47
41
 
48
- ## 用法
42
+ ## 使用
49
43
 
50
44
  ### 跟随一个 DOM 元素
51
45
 
52
46
  ```ts
53
47
  import { createViewAnchor } from 'view-anchor'
54
48
 
55
- const handle = createViewAnchor(target, {
56
- present: true, // 挂载原生视图
57
- publish: (bounds) => { ... }, // 收到实时矩形;接 IPC → setBounds
49
+ const publish = (bounds) => {
50
+ applyBoundsToExternalSurface(bounds)
51
+ }
52
+
53
+ const handle = createViewAnchor(placeholderEl, {
54
+ present: true,
55
+ publish,
58
56
  })
59
57
 
60
- handle.update({ present, publish }) // 应用新选项并立刻发布一次
61
- handle.dispose() // 停止观察;之后不会再发布
58
+ handle.update({ present: true, publish }) // 更新选项并立即重新发布
59
+ handle.dispose() // 停止观察;之后不会再发布
62
60
  ```
63
61
 
64
- `present` 设为 `false` 可以收起视图。核心会发布一个零矩形并停止观察。宿主可以把子视图摘下来但保留 `WebContents`,这样之后再显示时是即时的。
62
+ 默认的 `createViewAnchor` 监听 `ResizeObserver` 和窗口 `resize`。如果祖先滚动、transform 或拖拽会移动元素而不改变它自身尺寸,请使用下面的 `createPlacementAnchor`,按需开启 `followScroll` 或 `followGeometry`。
63
+
64
+ 把 `present` 设为 `false` 会发布全零矩形并停止观察。你的 `publish` 函数可以把它解释为隐藏、移除,或保留外部画面。
65
+
66
+ `createViewAnchor`、`createPlacementAnchor`、`createSizeAdvertiser` 和 `createGeometryBatcher` 都支持 `signal`。调用 `AbortController.abort()` 的清理效果等同于 `dispose()`;创建前已经 abort 的 signal 不会开始测量或安装监听。
67
+
68
+ ```ts
69
+ const controller = new AbortController()
70
+ const handle = createViewAnchor(placeholderEl, {
71
+ present: true,
72
+ publish,
73
+ signal: controller.signal,
74
+ })
75
+
76
+ controller.abort()
77
+ ```
65
78
 
66
79
  ### React
67
80
 
68
81
  ```tsx
69
82
  import { useViewAnchor } from 'view-anchor/react'
70
83
 
71
- function DebugPanel({ visible }: { visible: boolean }) {
84
+ function ExternalSurfaceContainer({ visible }: { visible: boolean }) {
72
85
  const ref = useViewAnchor({
73
86
  present: visible,
74
- publish: publishPanelBounds,
87
+ publish: applyBoundsToExternalSurface,
75
88
  })
76
- // 原生视图跟随这个占位 div。隐藏面板(visible=false 或卸载)
77
- // 只会收起视图,不会销毁它。
89
+
78
90
  return <div ref={ref} className="h-full w-full" />
79
91
  }
80
92
  ```
81
93
 
82
- Hook React 18 和 19 的 StrictMode 双重挂载下不会发出过期的帧。
94
+ 元素卸载时,Hook 会先发布收起值,再释放监听。它兼容 React 18 和 19 的 StrictMode 重挂载。
83
95
 
84
96
  ### 显式可见性
85
97
 
86
- 零矩形分不清"视图被隐藏了"和"视图可见但此刻正好是 0×0"。需要区分这两种情况时,用 `Placement` API。它发布的是 `{ visible: true, bounds }` 或 `{ visible: false }`,并提供可选的滚动跟随和几何跟随:
98
+ 全零矩形无法区分“已隐藏”和“可见但恰好是 0×0”。需要这个区分时使用 `Placement` API
87
99
 
88
100
  ```ts
89
101
  import { createPlacementAnchor } from 'view-anchor'
90
102
 
91
103
  const handle = createPlacementAnchor(target, {
92
- publish: (placement) => { ... },
93
- followScroll: true, // 任意祖先滚动时重新测量
94
- followGeometry: true, // 滚动 / 拖拽期间逐帧轮询,稳定后停止
95
- guardDisplayNone: true, // 零面积或 display:none 的目标 → { visible: false }
104
+ visible: true,
105
+ publish: applyPlacement,
106
+ followScroll: true, // 祖先容器滚动时重新测量
107
+ followGeometry: true, // 拖拽或滚动期间逐帧测量;稳定后停止
108
+ guardDisplayNone: true, // 零面积或 display:none 时发布 { visible: false }
96
109
  })
97
110
 
98
- handle.pulse() // 打开一小段逐帧跟随窗口,比如 CSS 过渡期间
111
+ handle.pulse() // 例如在 CSS 过渡期间主动打开一段逐帧测量
99
112
  ```
100
113
 
101
- React 版本是 `view-anchor/react` 里的 `usePlacementAnchor`。
114
+ React 版本为 `view-anchor/react` 导出的 `usePlacementAnchor`。
102
115
 
103
- ### 由内容决定尺寸
116
+ ### 由内容决定占位尺寸
104
117
 
105
- 有时被嵌入的那个画面的尺寸应该由它自己的内容决定,比如一条由下游代码渲染的工具栏。这种情况在被嵌入的文档里运行 `createSizeAdvertiser`,它把内容尺寸报告回去,宿主里的 DOM 占位元素就能跟着长到一样大:
118
+ 当外部画面的内容尺寸需要反过来调整占位元素时,在内容所在的文档中使用 `createSizeAdvertiser`:
106
119
 
107
120
  ```ts
108
121
  import { createSizeAdvertiser } from 'view-anchor'
109
122
 
110
123
  const handle = createSizeAdvertiser(contentWrapper, {
111
- axis: 'block', // 一个 advertiser 只管一个轴:block = 高度,inline = 宽度
112
- publish: (size) => { ... }, // 收到 { axis, extent };接 IPC → 宿主
124
+ axis: 'block', // 一个 advertiser 只负责一个轴:block = 高度,inline = 宽度
125
+ publish: updatePlaceholderSize,
113
126
  })
114
127
 
115
- handle.update(publish) // 换一个发布通道,并立刻再报告一次当前尺寸
116
- handle.dispose() // 停止观察;之后不会再报告
128
+ handle.update(updatePlaceholderSize) // 有当前尺寸时会立即重新报告
129
+ handle.dispose()
117
130
  ```
118
131
 
119
- > **注意:** 目标元素在它负责的那个轴上必须是随内容收缩的。如果这个尺寸反过来由宿主设置,两边会互相触发、永远停不下来。见 [docs/bidirectional-design.md](./docs/bidirectional-design.md)。
132
+ 目标元素在它负责的轴上必须随内容伸缩。若应用也反过来在同一轴上强制设置尺寸,双方会互相触发布局,难以稳定。详见 [双向几何设计](./docs/bidirectional-design.md)。
120
133
 
121
- ### 跨边界的带版本传输
134
+ ### 需要传递、校验和排序时
122
135
 
123
- 核心交给你的是普通的 `Bounds`、`Placement` 和 `AdvertisedSize` 值。这些值一旦通过 IPC 或 `postMessage` 跨过进程或源的边界,通常就需要校验和排序。可选的 `view-anchor/protocol` 入口提供带版本的消息信封、对不可信输入的有界解码、按锚点丢弃过期消息的序列守卫,以及一个微任务批处理器:
136
+ 核心只交付 `Bounds`、`Placement` 和 `AdvertisedSize`。如果应用需要把这些值交给别的页面、环境或异步通道,可选的 `view-anchor/protocol` 提供消息版本、输入校验、乱序过滤和微任务合并。
124
137
 
125
138
  ```ts
126
139
  import {
@@ -129,57 +142,57 @@ import {
129
142
  decodeGeometryWireValue,
130
143
  } from 'view-anchor/protocol'
131
144
 
132
- // 发送方(渲染进程、iframe 等)
133
- const batcher = createGeometryBatcher((batch) => ipc.send('geometry', batch))
145
+ // sendGeometryBatch 由应用实现:接收一个 batch,并同步返回是否已接收。
146
+ const batcher = createGeometryBatcher(sendGeometryBatch)
134
147
  const publish = createPlacementMessagePublisher(
135
148
  { anchorId: 'editor', generation: 3 },
136
149
  batcher.publish,
137
150
  )
138
151
 
139
- // 接收方(主进程、宿主页面等)
140
152
  const decoded = decodeGeometryWireValue(received, { maxMessages: 100 })
141
153
  if (decoded.ok) {
142
- /* 先校验发送方,再只应用更新的消息 */
154
+ // 先验证来源,再只应用更新的消息。
143
155
  }
144
156
  ```
145
157
 
146
- 两条规则保证顺序正确:
158
+ 同一个 `{ anchorId, generation }` 应只保留一个 publisher 实例。针对同一地址重新创建 publisher 会让序号回到 1,接收端会把新消息当作过期消息丢弃。需要重新开始时,把 `generation` 加一。
159
+
160
+ 完整约定见 [通信协议](./docs/protocol.md)。
161
+
162
+ ## 性能边界
147
163
 
148
- - **每个 `{ anchorId, generation }` 只保留一个 publisher。** batcher 和 `createGeometrySequenceGuard` 会分别记住每个锚点的位置与尺寸消息的最大序号。针对同一地址重建 publisher 会让序号从 1 重新开始,它发的消息会被当成过期而丢弃。在 React 里用 `useMemo` 或 `useRef` 持有它。确实需要重新开始时,把 `generation` 加一。
149
- - **同步 publisher 返回 `false` 表示"未接受"。** 核心会在下一次触发时重试同一份几何数据。批处理 publisher 入队后就返回 `true`,之后的重试由它自己负责。
164
+ - 相同的已发布矩形不会再次调用 `publish`。
165
+ - `followGeometry` 只在需要时启动动画帧轮询,稳定后停止。
166
+ - `createGeometryBatcher` 会合并同一微任务内的更新;每个锚点的位置和尺寸各保留最新值。
167
+ - `dispose()` 或 abort 会停止监听并释放长期持有的元素和回调引用。
150
168
 
151
- 完整约定见 [docs/protocol.md](./docs/protocol.md)
169
+ 性能数据和测量方式见 [性能报告](./docs/performance-report.md)。这些数据只用于比较同一台机器上的改动,不能代表浏览器布局、传递或应用业务的耗时。
152
170
 
153
171
  ## API
154
172
 
155
- | 导出 | 类型 | 用途 |
156
- | ---------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- |
157
- | `createViewAnchor(target, opts)` | 函数 | 测量 DOM 元素并发布实时 bounds。零矩形表示已收起。 |
158
- | `createPlacementAnchor(target, opts)` | 函数 | 同一个核心,带显式 `Placement` 可见性、可选的 `followScroll` / `followGeometry` / `guardDisplayNone`,以及 `pulse()`。 |
159
- | `measurePlacement(target)` | 函数 | 纯测量:把目标矩形包成 `{ visible: true, bounds }`。 |
160
- | `createSizeAdvertiser(target, opts)` | 函数 | 反向:把视图自身的内容尺寸报告给宿主。 |
161
- | `useViewAnchor(opts)`,来自 `view-anchor/react` | Hook | 返回用于占位元素的 ref 回调。 |
162
- | `usePlacementAnchor(opts)`,来自 `view-anchor/react` | Hook | `Placement` API 的 React 适配,包含 `followScroll` 和 `followGeometry`。 |
163
- | `Bounds` | 类型 | CSS 像素单位的 `{ x, y, width, height }`。 |
164
- | `Placement` | 类型 | `{ visible: true; bounds } \| { visible: false }`。 |
165
- | `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | `createViewAnchor` 的选项和句柄。 |
166
- | `PlacementAnchorOptions` / `PlacementAnchorHandle` | 类型 | `createPlacementAnchor` 的选项和句柄。 |
167
- | `UseViewAnchorOptions` / `ViewAnchorRef`,来自 `view-anchor/react` | 类型 | `useViewAnchor` 的选项和 ref 形状。 |
168
- | `UsePlacementAnchorOptions` / `PlacementAnchorRef`,来自 `view-anchor/react` | 类型 | `usePlacementAnchor` 的选项和 ref 形状。 |
169
- | `AdvertisedAxis` / `AdvertisedSize` | 类型 | 反向方向的轴和负载类型。 |
170
- | `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | 类型 | `createSizeAdvertiser` 的选项和句柄。 |
171
- | `view-anchor/protocol` | 函数 + 类型 | 带版本的消息、严格解码、序列守卫、消息 publisher 和微任务批处理。 |
173
+ | 导出 | 类型 | 用途 |
174
+ | ------------------------------------- | ----------- | -------------------------------------------------------------------------- |
175
+ | `createViewAnchor(target, opts)` | 函数 | 测量 DOM 元素并调用 `publish({ x, y, width, height })`。全零矩形表示收起。 |
176
+ | `createPlacementAnchor(target, opts)` | 函数 | 发布带可见性的 `Placement`,可选滚动和几何跟随。 |
177
+ | `measurePlacement(target)` | 函数 | 读取当前矩形,返回 `{ visible: true, bounds }`。 |
178
+ | `createSizeAdvertiser(target, opts)` | 函数 | 用 `publish({ axis, extent })` 上报一个内容尺寸轴。 |
179
+ | `useViewAnchor(opts)` | Hook | 返回占位元素的 ref 回调。 |
180
+ | `usePlacementAnchor(opts)` | Hook | `Placement` API 的 React 适配。 |
181
+ | `Bounds` | 类型 | `{ x, y, width, height }`,单位为 CSS 像素。 |
182
+ | `Placement` | 类型 | `{ visible: true; bounds } \| { visible: false }`。 |
183
+ | `AdvertisedAxis` / `AdvertisedSize` | 类型 | 内容尺寸上报的轴和数据。 |
184
+ | `view-anchor/protocol` | 函数 + 类型 | 消息封装、校验、排序和批处理。 |
172
185
 
173
186
  ## 文档
174
187
 
175
- - [docs/mechanism.md](./docs/mechanism.md):正向方向的原理。同步发布、防止过期帧、`present` / 零矩形 / 卸载的约定、StrictMode 下的行为。内含 [docs/index.html](./docs/index.html) 的 3D 交互演示。
176
- - [docs/bidirectional-design.md](./docs/bidirectional-design.md):两个方向同时运行时的设计。为什么正向是同步的而反向走动画帧、单轴归属,以及信任边界在哪。
177
- - [docs/protocol.md](./docs/protocol.md):消息信封、校验、排序、批处理,以及失败时会发生什么。
178
- - [docs/performance-report.md](./docs/performance-report.md):可复现的 CPU、堆、RSS、极端场景、V8 和导出体积测量。
188
+ - [机制说明](./docs/mechanism.md):正向测量的触发条件、收起和 React 生命周期。
189
+ - [双向几何设计](./docs/bidirectional-design.md):内容尺寸反向调整占位元素时的单轴约束。
190
+ - [通信协议](./docs/protocol.md):消息格式、校验、排序和批处理。
191
+ - [性能报告](./docs/performance-report.md):可复现的性能数据和测量环境。
179
192
 
180
193
  ## 参与贡献
181
194
 
182
- 欢迎提 issue 和 pull request。提交前请运行 `pnpm lint`、`pnpm format:check`、`pnpm check-types`、`pnpm test` 和 `pnpm build`。`pnpm benchmark` 会输出用于更新性能报告的数据。
195
+ 提交前运行 `pnpm lint`、`pnpm format:check`、`pnpm check-types`、`pnpm test` 和 `pnpm build`。`pnpm benchmark` 用于更新性能报告。
183
196
 
184
197
  ## 许可证
185
198
 
@@ -0,0 +1,3 @@
1
+ /** Attach a one-shot abort listener and return a function that detaches it. */
2
+ export declare function watchAbort(signal: AbortSignal | undefined, dispose: () => void): () => void;
3
+ //# sourceMappingURL=abort.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abort.d.ts","sourceRoot":"","sources":["../src/abort.ts"],"names":[],"mappings":"AAEA,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAK3F"}
package/dist/abort.js ADDED
@@ -0,0 +1,9 @@
1
+ const NOOP = () => { };
2
+ /** Attach a one-shot abort listener and return a function that detaches it. */
3
+ export function watchAbort(signal, dispose) {
4
+ if (!signal)
5
+ return NOOP;
6
+ const onAbort = () => dispose();
7
+ signal.addEventListener('abort', onAbort, { once: true });
8
+ return () => signal.removeEventListener('abort', onAbort);
9
+ }
@@ -5,6 +5,8 @@ export type GeometryBatchSend = Publisher<GeometryBatch>;
5
5
  export interface GeometryBatcherOptions {
6
6
  /** Observes every batch-delivery error, including explicit flushes; it must not throw. */
7
7
  onError?: (error: unknown) => void;
8
+ /** Stops this batcher when aborted. An already-aborted signal starts no work. */
9
+ signal?: AbortSignal;
8
10
  }
9
11
  export interface GeometryBatcher {
10
12
  /** Queues one message. Returns false after disposal. */
@@ -34,8 +36,8 @@ export declare function createPlacementMessagePublisher(address: GeometryAddress
34
36
  export declare function createSizeMessagePublisher(address: GeometryAddress, send: GeometrySend): (size: AdvertisedSize) => boolean;
35
37
  /**
36
38
  * Coalesces same-task messages without adding a rendering-frame delay. It owns
37
- * no authorization policy: callers must associate addresses with trusted IPC
38
- * senders before accepting a delivered batch.
39
+ * no authorization policy: callers must associate addresses with trusted
40
+ * sources before accepting a delivered batch.
39
41
  */
40
42
  export declare function createGeometryBatcher(send: GeometryBatchSend, options?: GeometryBatcherOptions): GeometryBatcher;
41
43
  //# sourceMappingURL=protocol-publisher.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"protocol-publisher.d.ts","sourceRoot":"","sources":["../src/protocol-publisher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,eAAe,EAGrB,MAAM,qBAAqB,CAAA;AAE5B,MAAM,MAAM,YAAY,GAAG,SAAS,CAAC,eAAe,CAAC,CAAA;AACrD,MAAM,MAAM,iBAAiB,GAAG,SAAS,CAAC,aAAa,CAAC,CAAA;AAExD,MAAM,WAAW,sBAAsB;IACrC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACnC;AAED,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,OAAO,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAA;IAC1C,4FAA4F;IAC5F,KAAK,IAAI,OAAO,CAAA;IAChB,gEAAgE;IAChE,KAAK,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,uEAAuE;IACvE,OAAO,IAAI,IAAI,CAAA;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,eAAe,EACxB,IAAI,EAAE,YAAY,GACjB,CAAC,SAAS,EAAE,SAAS,KAAK,OAAO,CAcnC;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,eAAe,EACxB,IAAI,EAAE,YAAY,GACjB,CAAC,IAAI,EAAE,cAAc,KAAK,OAAO,CAcnC;AAMD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,iBAAiB,EACvB,OAAO,GAAE,sBAA2B,GACnC,eAAe,CAgKjB"}
1
+ {"version":3,"file":"protocol-publisher.d.ts","sourceRoot":"","sources":["../src/protocol-publisher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAA;AAEtE,OAAO,EAEL,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,eAAe,EAGrB,MAAM,qBAAqB,CAAA;AAE5B,MAAM,MAAM,YAAY,GAAG,SAAS,CAAC,eAAe,CAAC,CAAA;AACrD,MAAM,MAAM,iBAAiB,GAAG,SAAS,CAAC,aAAa,CAAC,CAAA;AAExD,MAAM,WAAW,sBAAsB;IACrC,0FAA0F;IAC1F,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;IAClC,iFAAiF;IACjF,MAAM,CAAC,EAAE,WAAW,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,OAAO,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAA;IAC1C,4FAA4F;IAC5F,KAAK,IAAI,OAAO,CAAA;IAChB,gEAAgE;IAChE,KAAK,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,uEAAuE;IACvE,OAAO,IAAI,IAAI,CAAA;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,eAAe,EACxB,IAAI,EAAE,YAAY,GACjB,CAAC,SAAS,EAAE,SAAS,KAAK,OAAO,CAcnC;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,eAAe,EACxB,IAAI,EAAE,YAAY,GACjB,CAAC,IAAI,EAAE,cAAc,KAAK,OAAO,CAcnC;AAMD;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,iBAAiB,EACvB,OAAO,GAAE,sBAA2B,GACnC,eAAe,CAyKjB"}
@@ -1,3 +1,4 @@
1
+ import { watchAbort } from './abort.js';
1
2
  import { GEOMETRY_PROTOCOL_VERSION, } from './protocol-types.js';
2
3
  /**
3
4
  * Wraps placement updates in a versioned protocol message. Sequence numbers
@@ -46,8 +47,8 @@ export function createSizeMessagePublisher(address, send) {
46
47
  const NOOP_SEND = () => false;
47
48
  /**
48
49
  * Coalesces same-task messages without adding a rendering-frame delay. It owns
49
- * no authorization policy: callers must associate addresses with trusted IPC
50
- * senders before accepting a delivered batch.
50
+ * no authorization policy: callers must associate addresses with trusted
51
+ * sources before accepting a delivered batch.
51
52
  */
52
53
  export function createGeometryBatcher(send, options = {}) {
53
54
  const anchors = new Map();
@@ -55,6 +56,7 @@ export function createGeometryBatcher(send, options = {}) {
55
56
  let disposed = false;
56
57
  let scheduled = false;
57
58
  let flushing = false;
59
+ let removeAbortListener = () => { };
58
60
  // `activeOptions` lets a caller keep reporting to the options object that
59
61
  // was live when its flush() call started, even if a reentrant dispose()
60
62
  // during send() has since cleared the instance-level `options` reference.
@@ -144,6 +146,26 @@ export function createGeometryBatcher(send, options = {}) {
144
146
  flush();
145
147
  });
146
148
  }
149
+ const dispose = () => {
150
+ if (disposed)
151
+ return;
152
+ disposed = true;
153
+ removeAbortListener();
154
+ removeAbortListener = () => { };
155
+ anchors.clear();
156
+ pendingAnchors.clear();
157
+ // Callers may still mutate the original options object after
158
+ // construction (e.g. reassigning onError); flush() captures its own
159
+ // reference before send() runs, so an in-flight error report keeps
160
+ // reading that object even though dispose() drops the instance's
161
+ // long-lived reference here.
162
+ options = {};
163
+ send = NOOP_SEND;
164
+ };
165
+ if (options.signal?.aborted)
166
+ dispose();
167
+ else
168
+ removeAbortListener = watchAbort(options.signal, dispose);
147
169
  return {
148
170
  publish(message) {
149
171
  if (disposed)
@@ -191,17 +213,6 @@ export function createGeometryBatcher(send, options = {}) {
191
213
  pendingAnchors.delete(state);
192
214
  anchors.delete(anchorId);
193
215
  },
194
- dispose() {
195
- disposed = true;
196
- anchors.clear();
197
- pendingAnchors.clear();
198
- // Callers may still mutate the original options object after
199
- // construction (e.g. reassigning onError); flush() captures its own
200
- // reference before send() runs, so an in-flight error report keeps
201
- // reading that object even though dispose() drops the instance's
202
- // long-lived reference here.
203
- options = {};
204
- send = NOOP_SEND;
205
- },
216
+ dispose,
206
217
  };
207
218
  }
@@ -1 +1 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,MAAM,EAAoB,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAE7E,MAAM,WAAW,oBAAqB,SAAQ,iBAAiB;IAC7D;;;OAGG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,2EAA2E;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,CAAA;AAE3E,MAAM,WAAW,yBAA0B,SAAQ,sBAAsB;IACvE;;;OAGG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,8DAA8D;AAC9D,MAAM,MAAM,kBAAkB,GAAG,aAAa,CAAA;AAoI9C,iEAAiE;AACjE,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAM1E;AAsBD,qEAAqE;AACrE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,CAazF;AAED,YAAY,EAAE,MAAM,EAAE,CAAA"}
1
+ {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AACA,OAAO,EAIL,KAAK,sBAAsB,EAC5B,MAAM,kBAAkB,CAAA;AACzB,OAAO,KAAK,EAAE,MAAM,EAAoB,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAE7E,MAAM,WAAW,oBAAqB,SAAQ,iBAAiB;IAC7D;;;OAGG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,2EAA2E;AAC3E,MAAM,MAAM,aAAa,GAAG,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC,MAAM,IAAI,CAAC,CAAA;AAE3E,MAAM,WAAW,yBAA0B,SAAQ,sBAAsB;IACvE;;;OAGG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,8DAA8D;AAC9D,MAAM,MAAM,kBAAkB,GAAG,aAAa,CAAA;AA+H9C,iEAAiE;AACjE,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAM1E;AAsBD,qEAAqE;AACrE,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,CAazF;AAED,YAAY,EAAE,MAAM,EAAE,CAAA"}