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.
- package/README.md +118 -34
- package/README.zh-CN.md +128 -44
- package/dist/index.d.ts +4 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -14
- package/dist/measure-loop.d.ts +9 -28
- package/dist/measure-loop.d.ts.map +1 -1
- package/dist/measure-loop.js +37 -16
- package/dist/protocol-publisher.d.ts +41 -0
- package/dist/protocol-publisher.d.ts.map +1 -0
- package/dist/protocol-publisher.js +191 -0
- package/dist/protocol-types.d.ts +36 -0
- package/dist/protocol-types.d.ts.map +1 -0
- package/dist/protocol-types.js +10 -0
- package/dist/protocol.d.ts +35 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +131 -0
- package/dist/react.d.ts +18 -30
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +125 -122
- package/dist/size-advertiser.d.ts +9 -14
- package/dist/size-advertiser.d.ts.map +1 -1
- package/dist/size-advertiser.js +20 -28
- package/dist/types.d.ts +29 -73
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -15
- package/dist/view-anchor.d.ts +36 -77
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +206 -174
- package/docs/bidirectional-design.md +64 -96
- package/docs/{anchor-3d.html → index.html} +215 -73
- package/docs/mechanism.mdx +55 -49
- package/docs/performance-report.md +63 -0
- package/docs/protocol.md +79 -0
- package/package.json +30 -4
- package/src/index.ts +6 -15
- package/src/measure-loop.ts +36 -41
- package/src/protocol-publisher.ts +236 -0
- package/src/protocol-types.ts +43 -0
- package/src/protocol.ts +193 -0
- package/src/react.ts +186 -141
- package/src/size-advertiser.ts +24 -31
- package/src/types.ts +34 -79
- package/src/view-anchor.ts +228 -212
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# view-anchor
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 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, and the whole package is about 2.6 KB gzipped.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/view-anchor)
|
|
6
6
|
[](https://www.npmjs.com/package/view-anchor)
|
|
@@ -9,19 +9,45 @@
|
|
|
9
9
|
|
|
10
10
|
[English](./README.md) · [简体中文](./README.zh-CN.md)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
> 🎮 **Live demo**: the [3D interactive demo](https://lbb00.github.io/view-anchor/) runs the real core in your browser. Drag the splitter, toggle the panel, and watch the native view follow.
|
|
13
13
|
|
|
14
|
-
The
|
|
14
|
+
## The problem
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
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.
|
|
17
17
|
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
`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.
|
|
19
|
+
|
|
20
|
+
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.
|
|
21
|
+
|
|
22
|
+
## Built for the hot path
|
|
23
|
+
|
|
24
|
+
Geometry updates fire on every resize and, when following a drag, on every animation frame. The library is written for that path and the numbers are measured, not assumed:
|
|
25
|
+
|
|
26
|
+
- **Synchronous delivery.** Measurement and publish happen inside the same `ResizeObserver` callback. No timers, no extra frame of lag.
|
|
27
|
+
- **Dedupe before allocate.** A rectangle identical to the last accepted one is rejected by comparing four numbers, before any object is created.
|
|
28
|
+
- **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.
|
|
29
|
+
- **O(1) generation changes.** In the protocol layer, moving an anchor to a new generation or clearing it does not touch other anchors.
|
|
30
|
+
- **Latest-wins batching.** Messages queued in the same task are merged in a microtask and only the newest geometry per anchor is sent.
|
|
31
|
+
- **Small, tree-shakeable output.** Every function is a separate export with `sideEffects: false`. If you only need `createViewAnchor`, you pay for 528 bytes gzipped.
|
|
32
|
+
|
|
33
|
+
Numbers from `pnpm benchmark` on Node.js 24, Apple M4, median of three fresh processes:
|
|
34
|
+
|
|
35
|
+
| Operation | Volume | Time |
|
|
36
|
+
| --- | ---: | ---: |
|
|
37
|
+
| `measurePlacement` | 1,000,000 calls | 8.9 ms |
|
|
38
|
+
| Publish a placement message | 1,000,000 calls | 7.3 ms |
|
|
39
|
+
| Decode a valid batch | 100,000 messages | 3.1 ms |
|
|
40
|
+
| Move all anchors to a new generation | 10,000 anchors | 1.7 ms |
|
|
41
|
+
| Flush one message with 100,000 anchors already tracked | 1 message | 0.008 ms |
|
|
42
|
+
|
|
43
|
+
| Entry | Gzipped |
|
|
44
|
+
| --- | ---: |
|
|
45
|
+
| `view-anchor` (everything) | 2.6 KB |
|
|
46
|
+
| `createViewAnchor` alone | 528 B |
|
|
47
|
+
| `view-anchor/protocol` | 1.4 KB |
|
|
48
|
+
| `view-anchor/react` | 2.0 KB |
|
|
49
|
+
|
|
50
|
+
These are same-machine Node.js microbenchmarks. They do not include DOM layout, Electron IPC, or structured clone, so measure those in your own app. Methodology, memory figures, and V8 traces are in [docs/performance-report.md](./docs/performance-report.md).
|
|
25
51
|
|
|
26
52
|
## Installation
|
|
27
53
|
|
|
@@ -31,11 +57,11 @@ pnpm add view-anchor
|
|
|
31
57
|
npm install view-anchor
|
|
32
58
|
```
|
|
33
59
|
|
|
34
|
-
React is an optional peer dependency
|
|
60
|
+
React is an optional peer dependency. Import the hooks from `view-anchor/react`. The root entry also re-exports `useViewAnchor` for compatibility with `v0.1.2`, so any app that imports `view-anchor` needs React installed. `view-anchor/protocol` does not.
|
|
35
61
|
|
|
36
|
-
##
|
|
62
|
+
## Usage
|
|
37
63
|
|
|
38
|
-
###
|
|
64
|
+
### Follow a DOM element
|
|
39
65
|
|
|
40
66
|
```ts
|
|
41
67
|
import { createViewAnchor } from 'view-anchor'
|
|
@@ -45,14 +71,16 @@ const handle = createViewAnchor(target, {
|
|
|
45
71
|
publish: (bounds) => { ... }, // receive live rectangles; wire IPC → setBounds
|
|
46
72
|
})
|
|
47
73
|
|
|
48
|
-
handle.update({ present, publish }) // apply new options
|
|
74
|
+
handle.update({ present, publish }) // apply new options and publish right away
|
|
49
75
|
handle.dispose() // stop observing; never publishes again
|
|
50
76
|
```
|
|
51
77
|
|
|
78
|
+
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.
|
|
79
|
+
|
|
52
80
|
### React
|
|
53
81
|
|
|
54
82
|
```tsx
|
|
55
|
-
import { useViewAnchor } from 'view-anchor'
|
|
83
|
+
import { useViewAnchor } from 'view-anchor/react'
|
|
56
84
|
|
|
57
85
|
function DebugPanel({ visible }: { visible: boolean }) {
|
|
58
86
|
const ref = useViewAnchor({
|
|
@@ -65,49 +93,105 @@ function DebugPanel({ visible }: { visible: boolean }) {
|
|
|
65
93
|
}
|
|
66
94
|
```
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
The hook survives React 18 and 19 StrictMode double-mounting without publishing stale frames.
|
|
69
97
|
|
|
70
|
-
|
|
98
|
+
### Explicit visibility
|
|
99
|
+
|
|
100
|
+
A zero rectangle cannot tell a hidden view from one that is visible but currently 0×0. When that distinction matters, use the `Placement` API. It publishes `{ visible: true, bounds }` or `{ visible: false }` and adds opt-in scroll and geometry following:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createPlacementAnchor } from 'view-anchor'
|
|
104
|
+
|
|
105
|
+
const handle = createPlacementAnchor(target, {
|
|
106
|
+
publish: (placement) => { ... },
|
|
107
|
+
followScroll: true, // re-measure when any ancestor scrolls
|
|
108
|
+
followGeometry: true, // poll animation frames during scrolls / drags, stop when steady
|
|
109
|
+
guardDisplayNone: true, // zero-area or display:none target → { visible: false }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
handle.pulse() // open a short frame-following window, e.g. during a CSS transition
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The React version is `usePlacementAnchor` from `view-anchor/react`.
|
|
116
|
+
|
|
117
|
+
### Let content drive the size
|
|
118
|
+
|
|
119
|
+
Sometimes the hosted surface's size should come from its own content, for example a toolbar rendered by downstream code. Run `createSizeAdvertiser` inside the hosted document. It reports the content size back so a DOM placeholder in the host can grow to match:
|
|
71
120
|
|
|
72
121
|
```ts
|
|
73
122
|
import { createSizeAdvertiser } from 'view-anchor'
|
|
74
123
|
|
|
75
124
|
const handle = createSizeAdvertiser(contentWrapper, {
|
|
76
|
-
axis: 'block', //
|
|
125
|
+
axis: 'block', // one axis per advertiser: block = height, inline = width
|
|
77
126
|
publish: (size) => { ... }, // receives { axis, extent }; wire IPC → host
|
|
78
127
|
})
|
|
79
128
|
|
|
80
|
-
handle.update(publish) // swap the publish channel and
|
|
129
|
+
handle.update(publish) // swap the publish channel and report the current size again
|
|
81
130
|
handle.dispose() // stop observing; never reports again
|
|
82
131
|
```
|
|
83
132
|
|
|
84
|
-
> **Warning:** the
|
|
133
|
+
> **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).
|
|
134
|
+
|
|
135
|
+
### Versioned transport across a boundary
|
|
136
|
+
|
|
137
|
+
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:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import {
|
|
141
|
+
createGeometryBatcher,
|
|
142
|
+
createPlacementMessagePublisher,
|
|
143
|
+
decodeGeometryWireValue,
|
|
144
|
+
} from 'view-anchor/protocol'
|
|
145
|
+
|
|
146
|
+
// sending side (renderer, iframe, ...)
|
|
147
|
+
const batcher = createGeometryBatcher((batch) => ipc.send('geometry', batch))
|
|
148
|
+
const publish = createPlacementMessagePublisher(
|
|
149
|
+
{ anchorId: 'editor', generation: 3 },
|
|
150
|
+
batcher.publish,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
// receiving side (main process, host page, ...)
|
|
154
|
+
const decoded = decodeGeometryWireValue(received, { maxMessages: 100 })
|
|
155
|
+
if (decoded.ok) { /* check the sender, then apply only newer messages */ }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Two rules keep the ordering correct:
|
|
159
|
+
|
|
160
|
+
- **Keep one publisher per `{ anchorId, generation }`.** The batcher and `createGeometrySequenceGuard` remember the highest sequence number seen for each anchor. A publisher rebuilt for the same address restarts at sequence 1 and its messages are dropped as stale. In React, hold it in `useMemo` or `useRef`. Bump `generation` when you really want a fresh start.
|
|
161
|
+
- **A synchronous publisher returns `false` to say "not accepted".** The core then retries the same geometry on the next trigger. A batching publisher returns `true` once queued and owns any later retries.
|
|
162
|
+
|
|
163
|
+
The full contract is in [docs/protocol.md](./docs/protocol.md).
|
|
85
164
|
|
|
86
165
|
## API
|
|
87
166
|
|
|
88
167
|
| Export | Kind | Purpose |
|
|
89
168
|
|---|---|---|
|
|
90
|
-
| `createViewAnchor(target, opts)` | function |
|
|
91
|
-
| `createPlacementAnchor(target, opts)` | function | Same core with explicit `Placement` visibility,
|
|
169
|
+
| `createViewAnchor(target, opts)` | function | Measure a DOM element and publish live bounds. A zero rect means collapsed. |
|
|
170
|
+
| `createPlacementAnchor(target, opts)` | function | Same core with explicit `Placement` visibility, opt-in `followScroll` / `followGeometry` / `guardDisplayNone`, and `pulse()`. |
|
|
92
171
|
| `measurePlacement(target)` | function | Pure measurement: wraps the target rect as `{ visible: true, bounds }`. |
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
172
|
+
| `createSizeAdvertiser(target, opts)` | function | Reverse direction: report the view's own content size to the host. |
|
|
173
|
+
| `useViewAnchor(opts)` from `view-anchor/react` | hook | Returns a ref callback for a placeholder element. |
|
|
174
|
+
| `usePlacementAnchor(opts)` from `view-anchor/react` | hook | React adapter for the `Placement` API, including `followScroll` and `followGeometry`. |
|
|
95
175
|
| `Bounds` | type | `{ x, y, width, height }` in CSS pixels. |
|
|
96
|
-
| `Placement` | type | `{ visible: true; bounds } \| { visible: false }
|
|
97
|
-
| `ViewAnchorOptions` / `ViewAnchorHandle` | type | Options and handle for
|
|
98
|
-
| `PlacementAnchorOptions` / `PlacementAnchorHandle` | type | Options and handle for
|
|
99
|
-
| `UseViewAnchorOptions` / `ViewAnchorRef` | type | Options and ref shape for
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
176
|
+
| `Placement` | type | `{ visible: true; bounds } \| { visible: false }`. |
|
|
177
|
+
| `ViewAnchorOptions` / `ViewAnchorHandle` | type | Options and handle for `createViewAnchor`. |
|
|
178
|
+
| `PlacementAnchorOptions` / `PlacementAnchorHandle` | type | Options and handle for `createPlacementAnchor`. |
|
|
179
|
+
| `UseViewAnchorOptions` / `ViewAnchorRef` from `view-anchor/react` | type | Options and ref shape for `useViewAnchor`. |
|
|
180
|
+
| `UsePlacementAnchorOptions` / `PlacementAnchorRef` from `view-anchor/react` | type | Options and ref shape for `usePlacementAnchor`. |
|
|
181
|
+
| `AdvertisedAxis` / `AdvertisedSize` | type | Axis and payload types for the reverse direction. |
|
|
182
|
+
| `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | type | Options and handle for `createSizeAdvertiser`. |
|
|
183
|
+
| `view-anchor/protocol` | functions + types | Versioned messages, strict decoding, sequence guards, message publishers, and microtask batching. |
|
|
102
184
|
|
|
103
185
|
## Documentation
|
|
104
186
|
|
|
105
|
-
- [docs/mechanism.mdx](./docs/mechanism.mdx)
|
|
106
|
-
- [docs/bidirectional-design.md](./docs/bidirectional-design.md)
|
|
187
|
+
- [docs/mechanism.mdx](./docs/mechanism.mdx): how the forward direction works. Synchronous publishing, stale-frame safety, the `present` / zero-rect / unmount contract, StrictMode behaviour. Includes the interactive 3D demo at [docs/index.html](./docs/index.html).
|
|
188
|
+
- [docs/bidirectional-design.md](./docs/bidirectional-design.md): running both directions at once. Why the forward path is synchronous while the reverse path uses animation frames, single-axis ownership, and where the trust boundary sits.
|
|
189
|
+
- [docs/protocol.md](./docs/protocol.md): message envelopes, validation, ordering, batching, and what happens on failure.
|
|
190
|
+
- [docs/performance-report.md](./docs/performance-report.md): reproducible CPU, heap, RSS, extreme-case, V8, and export-size measurements.
|
|
107
191
|
|
|
108
192
|
## Contributing
|
|
109
193
|
|
|
110
|
-
Issues and pull requests are welcome. Before submitting, run
|
|
194
|
+
Issues and pull requests are welcome. Before submitting, run `pnpm lint`, `pnpm check-types`, `pnpm test`, and `pnpm build`. `pnpm benchmark` regenerates the performance report.
|
|
111
195
|
|
|
112
196
|
## License
|
|
113
197
|
|
package/README.zh-CN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# view-anchor
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 高性能几何桥接库,让任何"不在 DOM 里的东西"始终贴合某个 DOM 元素:Electron 的 `WebContentsView`、其他桌面壳里的原生 webview、跨域 iframe,或者任何你能用一个矩形来定位的画面。每次移动和缩放都同步发布、不会重复发帧,整个包 gzip 后约 2.6 KB。
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/view-anchor)
|
|
6
6
|
[](https://www.npmjs.com/package/view-anchor)
|
|
@@ -9,19 +9,45 @@
|
|
|
9
9
|
|
|
10
10
|
[English](./README.md) · [简体中文](./README.zh-CN.md)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
> 🎮 **在线 Demo**:[3D 交互演示](https://lbb00.github.io/view-anchor/) 在浏览器里跑的是真实核心代码。拖动分栏、切换面板显示,看原生视图实时跟随。
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
## 要解决的问题
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
有些你想放进布局里的东西并不是 DOM 节点。Electron 的 `WebContentsView` 由主进程定位;其他桌面壳里的原生 webview 由宿主代码定位;跨域 iframe 里的文档只知道你通过 `postMessage` 告诉它的信息。而你的布局,不管是 flexbox、dockview 还是 react-resizable-panels,只会移动 DOM 节点,并不知道有别的东西需要正好盖在其中某个节点上面。
|
|
17
17
|
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
`view-anchor` 负责补上这一段。你把它指向一个占位元素,它测量这个元素,元素每次移动或缩放时,把新的矩形交给你的 `publish` 回调。接下来怎么用由你决定:在 Electron 里接 `ipcRenderer.send` 和 `view.setBounds`,对 iframe 就 `postMessage`,或者直接调用任何负责定位那个画面的代码。
|
|
19
|
+
|
|
20
|
+
核心不依赖 Electron、任何浏览器壳、React 或布局库,只用到 `ResizeObserver`、`requestAnimationFrame` 和 `getBoundingClientRect`。React 支持放在单独的 `view-anchor/react` 入口里。
|
|
21
|
+
|
|
22
|
+
## 为热路径而写
|
|
23
|
+
|
|
24
|
+
几何更新在每次缩放时都会触发,跟随拖拽时更是每一帧都触发。这个库就是为这条路径写的,下面的数字是实测,不是推断:
|
|
25
|
+
|
|
26
|
+
- **同步发布。** 测量和发布在同一个 `ResizeObserver` 回调里完成。没有定时器,不多等一帧。
|
|
27
|
+
- **先去重,再分配。** 和上一次已接受的矩形完全相同的结果,只比较四个数字就会被丢弃,不会创建任何对象。
|
|
28
|
+
- **只在需要时逐帧跟随。** `followGeometry` 只在滚动、拖动分栏或显式调用 `pulse()` 时才开始按 `requestAnimationFrame` 轮询,矩形稳定后自动停止。空闲时开销为零,隐藏或无效目标最多跟 30 帧。
|
|
29
|
+
- **generation 切换是 O(1)。** 协议层里,把某个锚点换到新的 generation 或清除它,不会碰到其他锚点。
|
|
30
|
+
- **合并批量,只发最新。** 同一个任务里排队的消息在一个微任务里合并,每个锚点只发送最新的几何数据。
|
|
31
|
+
- **体积小,可摇树。** 每个函数都是独立导出,并声明了 `sideEffects: false`。只用 `createViewAnchor` 的话,你只为 gzip 后 528 字节买单。
|
|
32
|
+
|
|
33
|
+
以下数字来自 `pnpm benchmark`,环境是 Node.js 24、Apple M4,取三个全新进程的中位数:
|
|
34
|
+
|
|
35
|
+
| 操作 | 数据量 | 耗时 |
|
|
36
|
+
| --- | ---: | ---: |
|
|
37
|
+
| `measurePlacement` | 1,000,000 次 | 8.9 ms |
|
|
38
|
+
| 发布一条 placement 消息 | 1,000,000 次 | 7.3 ms |
|
|
39
|
+
| 解码合法 batch | 100,000 条 | 3.1 ms |
|
|
40
|
+
| 所有锚点切换到新 generation | 10,000 个锚点 | 1.7 ms |
|
|
41
|
+
| 已有 100,000 个锚点时只 flush 一条 | 1 条 | 0.008 ms |
|
|
42
|
+
|
|
43
|
+
| 入口 | gzip 后 |
|
|
44
|
+
| --- | ---: |
|
|
45
|
+
| `view-anchor`(全部) | 2.6 KB |
|
|
46
|
+
| 单独 `createViewAnchor` | 528 B |
|
|
47
|
+
| `view-anchor/protocol` | 1.4 KB |
|
|
48
|
+
| `view-anchor/react` | 2.0 KB |
|
|
49
|
+
|
|
50
|
+
这些是同一台机器上的 Node.js 微基准,不包含 DOM layout、Electron IPC 和 structured clone,这几项请在你自己的应用里测。方法、内存数据和 V8 trace 见 [docs/performance-report.md](./docs/performance-report.md)。
|
|
25
51
|
|
|
26
52
|
## 安装
|
|
27
53
|
|
|
@@ -31,84 +57,142 @@ pnpm add view-anchor
|
|
|
31
57
|
npm install view-anchor
|
|
32
58
|
```
|
|
33
59
|
|
|
34
|
-
React
|
|
60
|
+
React 是可选的 peer dependency。Hook 从 `view-anchor/react` 导入。为了兼容 `v0.1.2`,根入口也会重新导出 `useViewAnchor`,所以只要导入了 `view-anchor` 就需要装 React。`view-anchor/protocol` 不需要。
|
|
35
61
|
|
|
36
|
-
##
|
|
62
|
+
## 用法
|
|
37
63
|
|
|
38
|
-
###
|
|
64
|
+
### 跟随一个 DOM 元素
|
|
39
65
|
|
|
40
66
|
```ts
|
|
41
67
|
import { createViewAnchor } from 'view-anchor'
|
|
42
68
|
|
|
43
69
|
const handle = createViewAnchor(target, {
|
|
44
70
|
present: true, // 挂载原生视图
|
|
45
|
-
publish: (bounds) => { ... }, //
|
|
71
|
+
publish: (bounds) => { ... }, // 收到实时矩形;接 IPC → setBounds
|
|
46
72
|
})
|
|
47
73
|
|
|
48
|
-
handle.update({ present, publish }) //
|
|
49
|
-
handle.dispose() //
|
|
74
|
+
handle.update({ present, publish }) // 应用新选项并立刻发布一次
|
|
75
|
+
handle.dispose() // 停止观察;之后不会再发布
|
|
50
76
|
```
|
|
51
77
|
|
|
78
|
+
把 `present` 设为 `false` 可以收起视图。核心会发布一个零矩形并停止观察。宿主可以把子视图摘下来但保留 `WebContents`,这样之后再显示时是即时的。
|
|
79
|
+
|
|
52
80
|
### React
|
|
53
81
|
|
|
54
82
|
```tsx
|
|
55
|
-
import { useViewAnchor } from 'view-anchor'
|
|
83
|
+
import { useViewAnchor } from 'view-anchor/react'
|
|
56
84
|
|
|
57
85
|
function DebugPanel({ visible }: { visible: boolean }) {
|
|
58
86
|
const ref = useViewAnchor({
|
|
59
87
|
present: visible,
|
|
60
88
|
publish: publishPanelBounds,
|
|
61
89
|
})
|
|
62
|
-
// 原生视图跟随这个占位 div
|
|
63
|
-
//
|
|
90
|
+
// 原生视图跟随这个占位 div。隐藏面板(visible=false 或卸载)
|
|
91
|
+
// 只会收起视图,不会销毁它。
|
|
64
92
|
return <div ref={ref} className="h-full w-full" />
|
|
65
93
|
}
|
|
66
94
|
```
|
|
67
95
|
|
|
68
|
-
|
|
96
|
+
Hook 在 React 18 和 19 的 StrictMode 双重挂载下不会发出过期的帧。
|
|
69
97
|
|
|
70
|
-
|
|
98
|
+
### 显式可见性
|
|
99
|
+
|
|
100
|
+
零矩形分不清"视图被隐藏了"和"视图可见但此刻正好是 0×0"。需要区分这两种情况时,用 `Placement` API。它发布的是 `{ visible: true, bounds }` 或 `{ visible: false }`,并提供可选的滚动跟随和几何跟随:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { createPlacementAnchor } from 'view-anchor'
|
|
104
|
+
|
|
105
|
+
const handle = createPlacementAnchor(target, {
|
|
106
|
+
publish: (placement) => { ... },
|
|
107
|
+
followScroll: true, // 任意祖先滚动时重新测量
|
|
108
|
+
followGeometry: true, // 滚动 / 拖拽期间逐帧轮询,稳定后停止
|
|
109
|
+
guardDisplayNone: true, // 零面积或 display:none 的目标 → { visible: false }
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
handle.pulse() // 打开一小段逐帧跟随窗口,比如 CSS 过渡期间
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
React 版本是 `view-anchor/react` 里的 `usePlacementAnchor`。
|
|
116
|
+
|
|
117
|
+
### 由内容决定尺寸
|
|
118
|
+
|
|
119
|
+
有时被嵌入的那个画面的尺寸应该由它自己的内容决定,比如一条由下游代码渲染的工具栏。这种情况在被嵌入的文档里运行 `createSizeAdvertiser`,它把内容尺寸报告回去,宿主里的 DOM 占位元素就能跟着长到一样大:
|
|
71
120
|
|
|
72
121
|
```ts
|
|
73
122
|
import { createSizeAdvertiser } from 'view-anchor'
|
|
74
123
|
|
|
75
124
|
const handle = createSizeAdvertiser(contentWrapper, {
|
|
76
|
-
axis: 'block', //
|
|
77
|
-
publish: (size) => { ... }, //
|
|
125
|
+
axis: 'block', // 一个 advertiser 只管一个轴:block = 高度,inline = 宽度
|
|
126
|
+
publish: (size) => { ... }, // 收到 { axis, extent };接 IPC → 宿主
|
|
78
127
|
})
|
|
79
128
|
|
|
80
|
-
handle.update(publish) //
|
|
81
|
-
handle.dispose() //
|
|
129
|
+
handle.update(publish) // 换一个发布通道,并立刻再报告一次当前尺寸
|
|
130
|
+
handle.dispose() // 停止观察;之后不会再报告
|
|
82
131
|
```
|
|
83
132
|
|
|
84
|
-
>
|
|
133
|
+
> **注意:** 目标元素在它负责的那个轴上必须是随内容收缩的。如果这个尺寸反过来由宿主设置,两边会互相触发、永远停不下来。见 [docs/bidirectional-design.md](./docs/bidirectional-design.md)。
|
|
134
|
+
|
|
135
|
+
### 跨边界的带版本传输
|
|
136
|
+
|
|
137
|
+
核心交给你的是普通的 `Bounds`、`Placement` 和 `AdvertisedSize` 值。这些值一旦通过 IPC 或 `postMessage` 跨过进程或源的边界,通常就需要校验和排序。可选的 `view-anchor/protocol` 入口提供带版本的消息信封、对不可信输入的有界解码、按锚点丢弃过期消息的序列守卫,以及一个微任务批处理器:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import {
|
|
141
|
+
createGeometryBatcher,
|
|
142
|
+
createPlacementMessagePublisher,
|
|
143
|
+
decodeGeometryWireValue,
|
|
144
|
+
} from 'view-anchor/protocol'
|
|
145
|
+
|
|
146
|
+
// 发送方(渲染进程、iframe 等)
|
|
147
|
+
const batcher = createGeometryBatcher((batch) => ipc.send('geometry', batch))
|
|
148
|
+
const publish = createPlacementMessagePublisher(
|
|
149
|
+
{ anchorId: 'editor', generation: 3 },
|
|
150
|
+
batcher.publish,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
// 接收方(主进程、宿主页面等)
|
|
154
|
+
const decoded = decodeGeometryWireValue(received, { maxMessages: 100 })
|
|
155
|
+
if (decoded.ok) { /* 先校验发送方,再只应用更新的消息 */ }
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
两条规则保证顺序正确:
|
|
159
|
+
|
|
160
|
+
- **每个 `{ anchorId, generation }` 只保留一个 publisher。** batcher 和 `createGeometrySequenceGuard` 会记住每个锚点见过的最大序号。针对同一地址重建 publisher 会让序号从 1 重新开始,它发的消息会被当成过期而丢弃。在 React 里用 `useMemo` 或 `useRef` 持有它。确实需要重新开始时,把 `generation` 加一。
|
|
161
|
+
- **同步 publisher 返回 `false` 表示"未接受"。** 核心会在下一次触发时重试同一份几何数据。批处理 publisher 入队后就返回 `true`,之后的重试由它自己负责。
|
|
162
|
+
|
|
163
|
+
完整约定见 [docs/protocol.md](./docs/protocol.md)。
|
|
85
164
|
|
|
86
165
|
## API
|
|
87
166
|
|
|
88
|
-
| 导出 | 类型 |
|
|
167
|
+
| 导出 | 类型 | 用途 |
|
|
89
168
|
|---|---|---|
|
|
90
|
-
| `createViewAnchor(target, opts)` | 函数 |
|
|
91
|
-
| `createPlacementAnchor(target, opts)` | 函数 |
|
|
169
|
+
| `createViewAnchor(target, opts)` | 函数 | 测量 DOM 元素并发布实时 bounds。零矩形表示已收起。 |
|
|
170
|
+
| `createPlacementAnchor(target, opts)` | 函数 | 同一个核心,带显式 `Placement` 可见性、可选的 `followScroll` / `followGeometry` / `guardDisplayNone`,以及 `pulse()`。 |
|
|
92
171
|
| `measurePlacement(target)` | 函数 | 纯测量:把目标矩形包成 `{ visible: true, bounds }`。 |
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
172
|
+
| `createSizeAdvertiser(target, opts)` | 函数 | 反向:把视图自身的内容尺寸报告给宿主。 |
|
|
173
|
+
| `useViewAnchor(opts)`,来自 `view-anchor/react` | Hook | 返回用于占位元素的 ref 回调。 |
|
|
174
|
+
| `usePlacementAnchor(opts)`,来自 `view-anchor/react` | Hook | `Placement` API 的 React 适配,包含 `followScroll` 和 `followGeometry`。 |
|
|
175
|
+
| `Bounds` | 类型 | CSS 像素单位的 `{ x, y, width, height }`。 |
|
|
176
|
+
| `Placement` | 类型 | `{ visible: true; bounds } \| { visible: false }`。 |
|
|
177
|
+
| `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | `createViewAnchor` 的选项和句柄。 |
|
|
178
|
+
| `PlacementAnchorOptions` / `PlacementAnchorHandle` | 类型 | `createPlacementAnchor` 的选项和句柄。 |
|
|
179
|
+
| `UseViewAnchorOptions` / `ViewAnchorRef`,来自 `view-anchor/react` | 类型 | `useViewAnchor` 的选项和 ref 形状。 |
|
|
180
|
+
| `UsePlacementAnchorOptions` / `PlacementAnchorRef`,来自 `view-anchor/react` | 类型 | `usePlacementAnchor` 的选项和 ref 形状。 |
|
|
181
|
+
| `AdvertisedAxis` / `AdvertisedSize` | 类型 | 反向方向的轴和负载类型。 |
|
|
182
|
+
| `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | 类型 | `createSizeAdvertiser` 的选项和句柄。 |
|
|
183
|
+
| `view-anchor/protocol` | 函数 + 类型 | 带版本的消息、严格解码、序列守卫、消息 publisher 和微任务批处理。 |
|
|
102
184
|
|
|
103
185
|
## 文档
|
|
104
186
|
|
|
105
|
-
- [docs/mechanism.mdx](./docs/mechanism.mdx)
|
|
106
|
-
- [docs/bidirectional-design.md](./docs/bidirectional-design.md)
|
|
187
|
+
- [docs/mechanism.mdx](./docs/mechanism.mdx):正向方向的原理。同步发布、防止过期帧、`present` / 零矩形 / 卸载的约定、StrictMode 下的行为。内含 [docs/index.html](./docs/index.html) 的 3D 交互演示。
|
|
188
|
+
- [docs/bidirectional-design.md](./docs/bidirectional-design.md):两个方向同时运行时的设计。为什么正向是同步的而反向走动画帧、单轴归属,以及信任边界在哪。
|
|
189
|
+
- [docs/protocol.md](./docs/protocol.md):消息信封、校验、排序、批处理,以及失败时会发生什么。
|
|
190
|
+
- [docs/performance-report.md](./docs/performance-report.md):可复现的 CPU、堆、RSS、极端场景、V8 和导出体积测量。
|
|
107
191
|
|
|
108
|
-
##
|
|
192
|
+
## 参与贡献
|
|
109
193
|
|
|
110
|
-
欢迎提 issue 和
|
|
194
|
+
欢迎提 issue 和 pull request。提交前请运行 `pnpm lint`、`pnpm check-types`、`pnpm test` 和 `pnpm build`。`pnpm benchmark` 会重新生成性能报告。
|
|
111
195
|
|
|
112
|
-
##
|
|
196
|
+
## 许可证
|
|
113
197
|
|
|
114
198
|
[MIT](./LICENSE) © lbb00
|
package/dist/index.d.ts
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* view-anchor
|
|
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
|
*/
|
|
16
4
|
export { createViewAnchor, measurePlacement, createPlacementAnchor, } from './view-anchor.js';
|
|
17
5
|
export type { PlacementAnchorOptions, PlacementAnchorHandle, } from './view-anchor.js';
|
|
18
|
-
export type { Bounds, Placement, ViewAnchorOptions, ViewAnchorHandle, } from './types.js';
|
|
19
|
-
export { useViewAnchor } from './react.js';
|
|
20
|
-
export type { UseViewAnchorOptions, ViewAnchorRef } from './react.js';
|
|
6
|
+
export type { Bounds, Placement, Publisher, PublishResult, ViewAnchorOptions, ViewAnchorHandle, } from './types.js';
|
|
21
7
|
export { createSizeAdvertiser } from './size-advertiser.js';
|
|
8
|
+
export { useViewAnchor } from './react.js';
|
|
22
9
|
export type { AdvertisedAxis, AdvertisedSize, SizeAdvertiserOptions, SizeAdvertiserHandle, } from './types.js';
|
|
10
|
+
export type { UseViewAnchorOptions, ViewAnchorRef } from './react.js';
|
|
23
11
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,qBAAqB,GACtB,MAAM,kBAAkB,CAAA;AACzB,YAAY,EACV,sBAAsB,EACtB,qBAAqB,GACtB,MAAM,kBAAkB,CAAA;AACzB,YAAY,EACV,MAAM,EACN,SAAS,EACT,SAAS,EACT,aAAa,EACb,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAA;AAC3D,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC1C,YAAY,EACV,cAAc,EACd,cAAc,EACd,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA"}
|
package/dist/index.js
CHANGED
|
@@ -1,18 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* view-anchor
|
|
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
|
*/
|
|
16
4
|
export { createViewAnchor, measurePlacement, createPlacementAnchor, } from './view-anchor.js';
|
|
17
|
-
export { useViewAnchor } from './react.js';
|
|
18
5
|
export { createSizeAdvertiser } from './size-advertiser.js';
|
|
6
|
+
export { useViewAnchor } from './react.js';
|
package/dist/measure-loop.d.ts
CHANGED
|
@@ -1,29 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal
|
|
3
|
-
*
|
|
2
|
+
* Internal helper: animation-frame scheduling and deduplication loop for
|
|
3
|
+
* `createSizeAdvertiser`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
*/
|
|
28
8
|
export interface MeasureLoop<T> {
|
|
29
9
|
schedule(): void;
|
|
@@ -33,11 +13,12 @@ export interface MeasureLoop<T> {
|
|
|
33
13
|
dispose(): void;
|
|
34
14
|
}
|
|
35
15
|
export declare function createMeasureLoop<T>(cfg: {
|
|
36
|
-
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
16
|
+
/**
|
|
17
|
+
* Produce the value to emit in the animation frame. Return null to skip
|
|
18
|
+
* the frame (e.g. for non-finite measurements).
|
|
19
|
+
*/
|
|
39
20
|
produce: () => T | null;
|
|
40
21
|
same: (a: T, b: T) => boolean;
|
|
41
|
-
sink: (
|
|
22
|
+
sink: import('./types.js').Publisher<T>;
|
|
42
23
|
}): MeasureLoop<T>;
|
|
43
24
|
//# sourceMappingURL=measure-loop.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"measure-loop.d.ts","sourceRoot":"","sources":["../src/measure-loop.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"measure-loop.d.ts","sourceRoot":"","sources":["../src/measure-loop.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,QAAQ,IAAI,IAAI,CAAA;IAChB,OAAO,CAAC,KAAK,EAAE,CAAC,GAAG,IAAI,CAAA;IACvB,SAAS,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,CAAA;IAC5B,MAAM,IAAI,IAAI,CAAA;IACd,OAAO,IAAI,IAAI,CAAA;CAChB;AAED,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,GAAG,EAAE;IACxC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACvB,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,OAAO,CAAA;IAC7B,IAAI,EAAE,OAAO,YAAY,EAAE,SAAS,CAAC,CAAC,CAAC,CAAA;CACxC,GAAG,WAAW,CAAC,CAAC,CAAC,CAyDjB"}
|