view-anchor 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/README.zh-CN.md +114 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/measure-loop.d.ts +43 -0
- package/dist/measure-loop.d.ts.map +1 -0
- package/dist/measure-loop.js +49 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +145 -0
- package/dist/size-advertiser.d.ts +21 -0
- package/dist/size-advertiser.d.ts.map +1 -0
- package/dist/size-advertiser.js +83 -0
- package/dist/types.d.ts +111 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/view-anchor.d.ts +111 -0
- package/dist/view-anchor.d.ts.map +1 -0
- package/dist/view-anchor.js +413 -0
- package/docs/anchor-3d.html +615 -0
- package/docs/bidirectional-design.md +140 -0
- package/docs/mechanism.mdx +119 -0
- package/package.json +83 -0
- package/src/index.ts +39 -0
- package/src/measure-loop.ts +87 -0
- package/src/react.ts +171 -0
- package/src/size-advertiser.ts +95 -0
- package/src/types.ts +123 -0
- package/src/view-anchor.ts +500 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 lbb00
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# view-anchor
|
|
2
|
+
|
|
3
|
+
> An engine-agnostic primitive that keeps a main-process native view aligned to a DOM element's geometry.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/view-anchor)
|
|
6
|
+
[](https://www.npmjs.com/package/view-anchor)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
|
|
10
|
+
[English](./README.md) · [简体中文](./README.zh-CN.md)
|
|
11
|
+
|
|
12
|
+
In Electron, a native `WebContentsView` lives in the main process while your layout lives in the renderer. Layout libraries (flexbox, dockview, react-resizable-panels…) only move DOM nodes — they have no idea where the process boundary is. `view-anchor` is the bridge across it: it measures a target element's `getBoundingClientRect()`, hands the rectangle to a `publish` callback (your IPC → `setBounds`), and re-publishes whenever the element moves or resizes.
|
|
13
|
+
|
|
14
|
+
The core has no dependencies on React, Electron, or any host layout engine. React code lives only in the adapter layer.
|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **One-to-one binding** — a single native view follows a single DOM element. `update()` applies new options and re-publishes immediately.
|
|
19
|
+
- **Synchronous, deduplicated publishes** — measures and publishes in the same observer tick, so the native view never trails the DOM by more than the unavoidable cross-process frame. Rects identical to the last publish are dropped.
|
|
20
|
+
- **Collapse without destroying** — `present: false` publishes a zero rect and stops observing; the host can detach the subview while keeping the `WebContents` alive.
|
|
21
|
+
- **Explicit visibility** — the `Placement` API distinguishes a genuinely 0×0-but-visible view from a hidden one, instead of inferring visibility from geometry.
|
|
22
|
+
- **Content-driven sizing (reverse direction)** — `createSizeAdvertiser` reports a view's own content size back so a DOM placeholder can grow to match.
|
|
23
|
+
- **React adapter** — `useViewAnchor` hook that attaches to a placeholder element.
|
|
24
|
+
- **Zero-dependency core** — no React, no Electron, no layout-engine imports in the core.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add view-anchor
|
|
30
|
+
# or
|
|
31
|
+
npm install view-anchor
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
React is an optional peer dependency; you only need it for the `useViewAnchor` adapter.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
### Imperative core
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createViewAnchor } from 'view-anchor'
|
|
42
|
+
|
|
43
|
+
const handle = createViewAnchor(target, {
|
|
44
|
+
present: true, // mount the native view
|
|
45
|
+
publish: (bounds) => { ... }, // receive live rectangles; wire IPC → setBounds
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
handle.update({ present, publish }) // apply new options, re-publishes immediately
|
|
49
|
+
handle.dispose() // stop observing; never publishes again
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### React
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { useViewAnchor } from 'view-anchor'
|
|
56
|
+
|
|
57
|
+
function DebugPanel({ visible }: { visible: boolean }) {
|
|
58
|
+
const ref = useViewAnchor({
|
|
59
|
+
present: visible,
|
|
60
|
+
publish: publishPanelBounds,
|
|
61
|
+
})
|
|
62
|
+
// The native view follows this placeholder div. Hiding the panel
|
|
63
|
+
// (visible=false or unmount) collapses it without destroying it.
|
|
64
|
+
return <div ref={ref} className="h-full w-full" />
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Reverse: content size reporting
|
|
69
|
+
|
|
70
|
+
When a `WebContentsView`'s size is driven by its own content (for example a toolbar owned by downstream code), run inside that view's own renderer process:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createSizeAdvertiser } from 'view-anchor'
|
|
74
|
+
|
|
75
|
+
const handle = createSizeAdvertiser(contentWrapper, {
|
|
76
|
+
axis: 'block', // this advertiser owns one axis only (block=height / inline=width)
|
|
77
|
+
publish: (size) => { ... }, // receives { axis, extent }; wire IPC → host
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
handle.update(publish) // swap the publish channel and immediately report the current size
|
|
81
|
+
handle.dispose() // stop observing; never reports again
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
> **Warning:** the `target` must shrink-to-fit on the owned axis — if its size is set by the hosted view instead, the cross-process loop cannot converge. See [docs/bidirectional-design.md](./docs/bidirectional-design.md).
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
| Export | Kind | Purpose |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `createViewAnchor(target, opts)` | function | Imperative core: measure and publish live bounds. Zero rect means collapsed. |
|
|
91
|
+
| `createPlacementAnchor(target, opts)` | function | Same core with explicit `Placement` visibility, plus opt-in `followScroll` / `followGeometry` / `guardDisplayNone` and `pulse()`. |
|
|
92
|
+
| `measurePlacement(target)` | function | Pure measurement: wraps the target rect as `{ visible: true, bounds }`. |
|
|
93
|
+
| `useViewAnchor(opts)` | hook | React adapter returning a ref callback for a placeholder element. |
|
|
94
|
+
| `createSizeAdvertiser(target, opts)` | function | Reverse core: report the view's own content size to the host. |
|
|
95
|
+
| `Bounds` | type | `{ x, y, width, height }` in CSS pixels. |
|
|
96
|
+
| `Placement` | type | `{ visible: true; bounds } \| { visible: false }` — explicit visibility. |
|
|
97
|
+
| `ViewAnchorOptions` / `ViewAnchorHandle` | type | Options and handle for the forward zero-rect core. |
|
|
98
|
+
| `PlacementAnchorOptions` / `PlacementAnchorHandle` | type | Options and handle for the `Placement` core. |
|
|
99
|
+
| `UseViewAnchorOptions` / `ViewAnchorRef` | type | Options and ref shape for the React adapter. |
|
|
100
|
+
| `AdvertisedAxis` / `AdvertisedSize` | type | Reverse axis and frame payload types. |
|
|
101
|
+
| `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | type | Options and handle for the reverse core. |
|
|
102
|
+
|
|
103
|
+
## Documentation
|
|
104
|
+
|
|
105
|
+
- [docs/mechanism.mdx](./docs/mechanism.mdx) — the forward mechanism in depth: synchronous publishing and stale-frame safety, the `present` / zero-rect / unmount contract, React 18 StrictMode behavior. Includes an interactive 3D demo at [docs/anchor-3d.html](./docs/anchor-3d.html).
|
|
106
|
+
- [docs/bidirectional-design.md](./docs/bidirectional-design.md) — the bidirectional geometry bridge: the intentional sync/RAF asymmetry, single-axis ownership and convergence, trust boundaries.
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
|
|
110
|
+
Issues and pull requests are welcome. Before submitting, run the checks locally: `pnpm lint`, `pnpm check-types`, `pnpm test`, `pnpm build`.
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
[MIT](./LICENSE) © lbb00
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# view-anchor
|
|
2
|
+
|
|
3
|
+
> 让主进程的原生视图(Electron `WebContentsView`)始终对齐某个 DOM 元素的几何位置的引擎无关原语。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/view-anchor)
|
|
6
|
+
[](https://www.npmjs.com/package/view-anchor)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](https://nodejs.org/)
|
|
9
|
+
|
|
10
|
+
[English](./README.md) · [简体中文](./README.zh-CN.md)
|
|
11
|
+
|
|
12
|
+
在 Electron 里,原生 `WebContentsView` 住主进程,你的布局在渲染进程。DOM 布局库(flexbox、dockview、react-resizable-panels……)只移动 DOM 节点,不知道这道进程边界的存在。`view-anchor` 就是跨过这道边界的桥:它测量目标元素的 `getBoundingClientRect()`,把矩形交给一个 `publish` 回调(由你接上 IPC → `setBounds`),并在元素位移或缩放时重新发布。
|
|
13
|
+
|
|
14
|
+
核心不依赖 React、Electron 或任何宿主布局引擎。涉及 React 的代码只在适配层。
|
|
15
|
+
|
|
16
|
+
## 特性
|
|
17
|
+
|
|
18
|
+
- **一对一绑定** —— 一个原生视图跟随一个 DOM 元素。`update()` 应用新选项并立即重新发布。
|
|
19
|
+
- **同步、去重的发布** —— 在同一个观察回调里测量并发布,原生视图不会比 DOM 多拖一帧(除不可避免的跨进程帧外)。与上次逐字段相同的矩形会被跳过。
|
|
20
|
+
- **收起而不销毁** —— `present: false` 发布零矩形并停止观察;宿主可以摘除子视图但保留 `WebContents` 存活。
|
|
21
|
+
- **显式可见性** —— `Placement` API 能区分「真正 0×0 但在屏」和「隐藏」的视图,而不是从尺寸推断可见性。
|
|
22
|
+
- **反向:内容尺寸回流** —— `createSizeAdvertiser` 把视图自身内容的尺寸回报给宿主,让 DOM 占位跟着内容长。
|
|
23
|
+
- **React 适配层** —— `useViewAnchor` hook,挂到一个占位元素上即可。
|
|
24
|
+
- **核心零依赖** —— 核心不 import React、Electron 或任何布局引擎。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm add view-anchor
|
|
30
|
+
# 或
|
|
31
|
+
npm install view-anchor
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
React 是可选 peer 依赖,只有用 `useViewAnchor` 适配层时才需要。
|
|
35
|
+
|
|
36
|
+
## 快速上手
|
|
37
|
+
|
|
38
|
+
### 命令式核心
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createViewAnchor } from 'view-anchor'
|
|
42
|
+
|
|
43
|
+
const handle = createViewAnchor(target, {
|
|
44
|
+
present: true, // 挂载原生视图
|
|
45
|
+
publish: (bounds) => { ... }, // 接收实时矩形;由它负责 IPC → setBounds
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
handle.update({ present, publish }) // 应用新选项(会立即重新发布)
|
|
49
|
+
handle.dispose() // 停止观察;此后不再发布
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### React
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import { useViewAnchor } from 'view-anchor'
|
|
56
|
+
|
|
57
|
+
function DebugPanel({ visible }: { visible: boolean }) {
|
|
58
|
+
const ref = useViewAnchor({
|
|
59
|
+
present: visible,
|
|
60
|
+
publish: publishPanelBounds,
|
|
61
|
+
})
|
|
62
|
+
// 原生视图跟随这个占位 div。隐藏面板
|
|
63
|
+
// (visible=false 或卸载)会让它收起,但不销毁。
|
|
64
|
+
return <div ref={ref} className="h-full w-full" />
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 反向:内容尺寸上报
|
|
69
|
+
|
|
70
|
+
当一块 `WebContentsView` 的尺寸由它**自己的内容**主导时(例如交给下游控制的 toolbar),在**下游视图自己的渲染进程**里跑:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createSizeAdvertiser } from 'view-anchor'
|
|
74
|
+
|
|
75
|
+
const handle = createSizeAdvertiser(contentWrapper, {
|
|
76
|
+
axis: 'block', // 这个 advertiser 只主导一条轴(block=高 / inline=宽)
|
|
77
|
+
publish: (size) => { ... }, // 接收 { axis, extent },由它负责 IPC → 宿主
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
handle.update(publish) // 换 publish(IPC 通道),并立即把当前尺寸发给它
|
|
81
|
+
handle.dispose() // 停止观察;此后不再上报
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
> **注意 footgun**:`target` 必须在主导轴上 shrink-to-fit——如果它的尺寸由宿主灌入的视图尺寸反向决定,跨进程环不会收敛。详见 [docs/bidirectional-design.md](./docs/bidirectional-design.md)。
|
|
85
|
+
|
|
86
|
+
## API
|
|
87
|
+
|
|
88
|
+
| 导出 | 类型 | 作用 |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `createViewAnchor(target, opts)` | 函数 | 正向命令式核心:测量并发布实时边界。零矩形表示收起。 |
|
|
91
|
+
| `createPlacementAnchor(target, opts)` | 函数 | 同款核心的显式 `Placement` 变体,另有 opt-in 的 `followScroll` / `followGeometry` / `guardDisplayNone` 与 `pulse()`。 |
|
|
92
|
+
| `measurePlacement(target)` | 函数 | 纯测量:把目标矩形包成 `{ visible: true, bounds }`。 |
|
|
93
|
+
| `useViewAnchor(opts)` | Hook | React 适配层,返回挂占位元素的 ref 回调。 |
|
|
94
|
+
| `createSizeAdvertiser(target, opts)` | 函数 | 反向核心:把视图自身内容尺寸回报给宿主。 |
|
|
95
|
+
| `Bounds` | 类型 | `{ x, y, width, height }`,单位 CSS 像素。 |
|
|
96
|
+
| `Placement` | 类型 | `{ visible: true; bounds } \| { visible: false }` —— 显式可见性。 |
|
|
97
|
+
| `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | 正向零矩形核心的选项与句柄形状。 |
|
|
98
|
+
| `PlacementAnchorOptions` / `PlacementAnchorHandle` | 类型 | `Placement` 核心的选项与句柄形状。 |
|
|
99
|
+
| `UseViewAnchorOptions` / `ViewAnchorRef` | 类型 | React 适配层的选项与 ref 形状。 |
|
|
100
|
+
| `AdvertisedAxis` / `AdvertisedSize` | 类型 | 反向的轴与帧载荷类型。 |
|
|
101
|
+
| `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | 类型 | 反向核心的选项与句柄形状。 |
|
|
102
|
+
|
|
103
|
+
## 文档
|
|
104
|
+
|
|
105
|
+
- [docs/mechanism.mdx](./docs/mechanism.mdx) —— 正向机制的完整说明:同步发布与陈旧帧安全、`present` / 零矩形 / 卸载契约、React 18 StrictMode 行为。内含可交互 3D 演示 [docs/anchor-3d.html](./docs/anchor-3d.html)。
|
|
106
|
+
- [docs/bidirectional-design.md](./docs/bidirectional-design.md) —— 双向几何桥:同步 / RAF 的刻意不对称、单轴所有权与收敛性、信任边界。
|
|
107
|
+
|
|
108
|
+
## 贡献
|
|
109
|
+
|
|
110
|
+
欢迎提 issue 和 PR。提交前请在本地跑一遍:`pnpm lint`、`pnpm check-types`、`pnpm test`、`pnpm build`。
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
[MIT](./LICENSE) © lbb00
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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`).
|
|
15
|
+
*/
|
|
16
|
+
export { createViewAnchor, measurePlacement, createPlacementAnchor, } from './view-anchor.js';
|
|
17
|
+
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';
|
|
21
|
+
export { createSizeAdvertiser } from './size-advertiser.js';
|
|
22
|
+
export type { AdvertisedAxis, AdvertisedSize, SizeAdvertiserOptions, SizeAdvertiserHandle, } from './types.js';
|
|
23
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,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,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AAC1C,YAAY,EAAE,oBAAoB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAA;AACrE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAA;AAC3D,YAAY,EACV,cAAc,EACd,cAAc,EACd,qBAAqB,EACrB,oBAAoB,GACrB,MAAM,YAAY,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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`).
|
|
15
|
+
*/
|
|
16
|
+
export { createViewAnchor, measurePlacement, createPlacementAnchor, } from './view-anchor.js';
|
|
17
|
+
export { useViewAnchor } from './react.js';
|
|
18
|
+
export { createSizeAdvertiser } from './size-advertiser.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal — the RAF-coalesced measure/dedupe/dispose engine behind the REVERSE
|
|
3
|
+
* primitive `createSizeAdvertiser`.
|
|
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.
|
|
27
|
+
*/
|
|
28
|
+
export interface MeasureLoop<T> {
|
|
29
|
+
schedule(): void;
|
|
30
|
+
emitNow(value: T): void;
|
|
31
|
+
setActive(on: boolean): void;
|
|
32
|
+
cancel(): void;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
export declare function createMeasureLoop<T>(cfg: {
|
|
36
|
+
/** Produce the value to emit in the RAF body. Return `null` to decline the
|
|
37
|
+
* frame entirely (no dedupe, no sink, baseline untouched) — e.g. a
|
|
38
|
+
* non-finite or unavailable measurement. */
|
|
39
|
+
produce: () => T | null;
|
|
40
|
+
same: (a: T, b: T) => boolean;
|
|
41
|
+
sink: (value: T) => void;
|
|
42
|
+
}): MeasureLoop<T>;
|
|
43
|
+
//# sourceMappingURL=measure-loop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measure-loop.d.ts","sourceRoot":"","sources":["../src/measure-loop.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,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;;iDAE6C;IAC7C,OAAO,EAAE,MAAM,CAAC,GAAG,IAAI,CAAA;IACvB,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,KAAK,OAAO,CAAA;IAC7B,IAAI,EAAE,CAAC,KAAK,EAAE,CAAC,KAAK,IAAI,CAAA;CACzB,GAAG,WAAW,CAAC,CAAC,CAAC,CA4CjB"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export function createMeasureLoop(cfg) {
|
|
2
|
+
const { produce, same, sink } = cfg;
|
|
3
|
+
let rafId = null;
|
|
4
|
+
let active = false;
|
|
5
|
+
let disposed = false;
|
|
6
|
+
let last = null;
|
|
7
|
+
const cancel = () => {
|
|
8
|
+
if (rafId !== null) {
|
|
9
|
+
cancelAnimationFrame(rafId);
|
|
10
|
+
rafId = null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
schedule() {
|
|
15
|
+
if (disposed || !active || rafId !== null)
|
|
16
|
+
return;
|
|
17
|
+
rafId = requestAnimationFrame(() => {
|
|
18
|
+
rafId = null;
|
|
19
|
+
if (disposed || !active)
|
|
20
|
+
return;
|
|
21
|
+
const value = produce();
|
|
22
|
+
if (value === null)
|
|
23
|
+
return; // producer declined this frame
|
|
24
|
+
// last-value dedupe: a frame whose produced value equals the last one
|
|
25
|
+
// we emitted costs nothing (no IPC / setBounds).
|
|
26
|
+
if (last !== null && same(value, last))
|
|
27
|
+
return;
|
|
28
|
+
last = value;
|
|
29
|
+
sink(value);
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
emitNow(value) {
|
|
33
|
+
if (disposed)
|
|
34
|
+
return;
|
|
35
|
+
last = value;
|
|
36
|
+
sink(value);
|
|
37
|
+
},
|
|
38
|
+
setActive(on) {
|
|
39
|
+
active = on;
|
|
40
|
+
},
|
|
41
|
+
cancel,
|
|
42
|
+
dispose() {
|
|
43
|
+
if (disposed)
|
|
44
|
+
return;
|
|
45
|
+
disposed = true;
|
|
46
|
+
cancel();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Bounds, ViewAnchorOptions } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* React adapter over the imperative `createViewAnchor` core.
|
|
4
|
+
*
|
|
5
|
+
* (React lint forces the `use` prefix on any hook returning a ref
|
|
6
|
+
* callback; the library's identity is still the `ViewAnchor` core — this is
|
|
7
|
+
* just the React binding.)
|
|
8
|
+
*/
|
|
9
|
+
export interface UseViewAnchorOptions extends ViewAnchorOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Non-DOM dependencies that move the target's rect and must force a
|
|
12
|
+
* re-publish (layout signature, project path, a tab toggle's
|
|
13
|
+
* `display:none`, …). A `ResizeObserver` covers pure geometry; `deps`
|
|
14
|
+
* covers state it cannot see. Keep the array length stable across
|
|
15
|
+
* renders (React effect-deps rule).
|
|
16
|
+
*/
|
|
17
|
+
deps?: ReadonlyArray<unknown>;
|
|
18
|
+
}
|
|
19
|
+
export type ViewAnchorRef = (el: HTMLElement | null) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Bind a native view's bounds to whichever DOM element the returned ref
|
|
22
|
+
* callback is attached to. On attach → `createViewAnchor(el, opts)`; on
|
|
23
|
+
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
24
|
+
* `update`; on unmount → publish ZERO then `dispose`.
|
|
25
|
+
*
|
|
26
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
27
|
+
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
28
|
+
* `dispose()` only stops observing — it deliberately never publishes again
|
|
29
|
+
* (its Contract 6/7). But the host only collapses the native view when it
|
|
30
|
+
* receives `{0,0,0,0}` (isHidden). In production the debug cell is *unmounted*
|
|
31
|
+
* (not `display:none`) when hidden, so the ref goes to `null` and the native
|
|
32
|
+
* view would otherwise stay frozen at its last bounds, floating on
|
|
33
|
+
* top and occluding content. So the adapter (not core) must emit one ZERO via
|
|
34
|
+
* the already-tested `update({ present:false })` path before disposing.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useViewAnchor(opts: UseViewAnchorOptions): ViewAnchorRef;
|
|
37
|
+
export type { Bounds };
|
|
38
|
+
//# sourceMappingURL=react.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAoB,iBAAiB,EAAE,MAAM,YAAY,CAAA;AAE7E;;;;;;GAMG;AACH,MAAM,WAAW,oBAAqB,SAAQ,iBAAiB;IAC7D;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC,OAAO,CAAC,CAAA;CAC9B;AAED,MAAM,MAAM,aAAa,GAAG,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAA;AAE5D;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,oBAAoB,GAAG,aAAa,CAgIvE;AAED,YAAY,EAAE,MAAM,EAAE,CAAA"}
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { createViewAnchor } from './view-anchor.js';
|
|
3
|
+
/**
|
|
4
|
+
* Bind a native view's bounds to whichever DOM element the returned ref
|
|
5
|
+
* callback is attached to. On attach → `createViewAnchor(el, opts)`; on
|
|
6
|
+
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
7
|
+
* `update`; on unmount → publish ZERO then `dispose`.
|
|
8
|
+
*
|
|
9
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
10
|
+
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
11
|
+
* `dispose()` only stops observing — it deliberately never publishes again
|
|
12
|
+
* (its Contract 6/7). But the host only collapses the native view when it
|
|
13
|
+
* receives `{0,0,0,0}` (isHidden). In production the debug cell is *unmounted*
|
|
14
|
+
* (not `display:none`) when hidden, so the ref goes to `null` and the native
|
|
15
|
+
* view would otherwise stay frozen at its last bounds, floating on
|
|
16
|
+
* top and occluding content. So the adapter (not core) must emit one ZERO via
|
|
17
|
+
* the already-tested `update({ present:false })` path before disposing.
|
|
18
|
+
*/
|
|
19
|
+
export function useViewAnchor(opts) {
|
|
20
|
+
const handleRef = useRef(null);
|
|
21
|
+
const elRef = useRef(null);
|
|
22
|
+
// Latest opts, read by the stable ref callback when it creates the anchor.
|
|
23
|
+
// Synced render-synchronously (NOT in an effect): the ref callback reads
|
|
24
|
+
// `optsRef.current` during *commit* (when the element attaches), which runs
|
|
25
|
+
// before passive effects. An effect-synced ref would be one render stale at
|
|
26
|
+
// that point, so a hidden→shown remount (`present` flips false→true together
|
|
27
|
+
// with the element re-mounting, exactly what the debug cell does) would
|
|
28
|
+
// create the anchor with the old `present:false` and emit a spurious ZERO
|
|
29
|
+
// before the real rect. A render write keeps it current at commit, and is
|
|
30
|
+
// idempotent under StrictMode's double render.
|
|
31
|
+
const optsRef = useRef(opts);
|
|
32
|
+
// eslint-disable-next-line react-hooks/refs -- see above: must be current at commit, before effects run
|
|
33
|
+
optsRef.current = opts;
|
|
34
|
+
// Baseline for the re-apply effect's change detection. Declared here (before
|
|
35
|
+
// the ref callback) so the callback can re-seed it on (re)create.
|
|
36
|
+
const appliedRef = useRef([
|
|
37
|
+
opts.present,
|
|
38
|
+
opts.publish,
|
|
39
|
+
...(opts.deps ?? []),
|
|
40
|
+
]);
|
|
41
|
+
// Collapse the native view (publish ZERO) and tear the anchor down. Reuse
|
|
42
|
+
// the existing, tested `update({ present:false })` path: it synchronously
|
|
43
|
+
// publishes `{0,0,0,0}` and stops observing (core Contract 5), then
|
|
44
|
+
// `dispose()` makes the anchor inert. Idempotent via the `handleRef.current`
|
|
45
|
+
// null-check so the two callers below can never double-emit ZERO.
|
|
46
|
+
const collapseAndDispose = useRef(() => {
|
|
47
|
+
const handle = handleRef.current;
|
|
48
|
+
if (!handle)
|
|
49
|
+
return;
|
|
50
|
+
handle.update({ present: false, publish: optsRef.current.publish });
|
|
51
|
+
handle.dispose();
|
|
52
|
+
handleRef.current = null;
|
|
53
|
+
});
|
|
54
|
+
const ref = useCallback((el) => {
|
|
55
|
+
if (el === elRef.current)
|
|
56
|
+
return;
|
|
57
|
+
elRef.current = el;
|
|
58
|
+
if (handleRef.current) {
|
|
59
|
+
if (el) {
|
|
60
|
+
// Swapping to *another* live element: dispose the old anchor without a
|
|
61
|
+
// ZERO. The new element publishes its real rect immediately below, so
|
|
62
|
+
// a transient ZERO between the two would only cause a needless
|
|
63
|
+
// detach/re-attach flicker of the native view.
|
|
64
|
+
handleRef.current.dispose();
|
|
65
|
+
handleRef.current = null;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Element detached (ref → null): the anchor point is gone, so collapse
|
|
69
|
+
// the native view (one ZERO) before disposing.
|
|
70
|
+
collapseAndDispose.current();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (el) {
|
|
74
|
+
handleRef.current = createViewAnchor(el, {
|
|
75
|
+
present: optsRef.current.present,
|
|
76
|
+
publish: optsRef.current.publish,
|
|
77
|
+
});
|
|
78
|
+
// The anchor was just created at the current (present, publish, deps), so
|
|
79
|
+
// seed the re-apply baseline to match. Otherwise the post-commit re-apply
|
|
80
|
+
// effect would see this fresh state as a change and publish a second time
|
|
81
|
+
// — on a remount with a changed `present` that is a double-emit.
|
|
82
|
+
appliedRef.current = [
|
|
83
|
+
optsRef.current.present,
|
|
84
|
+
optsRef.current.publish,
|
|
85
|
+
...(optsRef.current.deps ?? []),
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
// Re-apply on opts/deps change. We must `update` whenever the
|
|
90
|
+
// (present, publish, …deps) tuple actually changes, but NOT on the mount run
|
|
91
|
+
// (the ref callback already created the anchor and published once) and NOT on
|
|
92
|
+
// a StrictMode replay (dev double-fires this effect's setup with the *same*
|
|
93
|
+
// tuple — a blind `update` then re-publishes the mount rect a second time).
|
|
94
|
+
// So instead of guessing "is this the first run?", compare against the
|
|
95
|
+
// last-applied tuple and apply only on a genuine change. The tuple is seeded
|
|
96
|
+
// with the mount opts, so the mount run and its StrictMode replay both see
|
|
97
|
+
// "unchanged" and skip — idempotent by construction. `deps` keeps a stable
|
|
98
|
+
// length across renders (documented above), so positional compare is sound.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const next = [
|
|
101
|
+
opts.present,
|
|
102
|
+
opts.publish,
|
|
103
|
+
...(opts.deps ?? []),
|
|
104
|
+
];
|
|
105
|
+
const prev = appliedRef.current;
|
|
106
|
+
const changed = next.length !== prev.length || next.some((v, i) => !Object.is(v, prev[i]));
|
|
107
|
+
if (!changed)
|
|
108
|
+
return;
|
|
109
|
+
appliedRef.current = next;
|
|
110
|
+
handleRef.current?.update({ present: opts.present, publish: opts.publish });
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, [opts.present, opts.publish, ...(opts.deps ?? [])]);
|
|
113
|
+
// Collapse the native view + dispose on teardown.
|
|
114
|
+
//
|
|
115
|
+
// StrictMode-safe lifecycle: this effect's setup/cleanup is double-fired in
|
|
116
|
+
// dev (setup → cleanup → setup). The anchor itself is created/owned by the
|
|
117
|
+
// ref callback, which in React 18 fires exactly once on mount and once with
|
|
118
|
+
// `null` on a real detach — it is NOT replayed by StrictMode. So this effect
|
|
119
|
+
// must not destroy the ref-owned anchor on a *throwaway* unmount, or the
|
|
120
|
+
// re-setup would have nothing to restore.
|
|
121
|
+
//
|
|
122
|
+
// Discriminator: on a real teardown React detaches the element first
|
|
123
|
+
// (`ref(null)` → `elRef.current === null`, and that path already emitted the
|
|
124
|
+
// single ZERO + disposed); on a StrictMode throwaway unmount the element is
|
|
125
|
+
// still attached (`elRef.current !== null`, ref never fired `null`). So we
|
|
126
|
+
// only collapse here when the element is genuinely gone, and otherwise leave
|
|
127
|
+
// the live anchor intact for the immediate re-setup.
|
|
128
|
+
//
|
|
129
|
+
// The setup re-establishes the anchor if a prior cleanup ever tore it down
|
|
130
|
+
// while the element is still attached, keeping setup/cleanup symmetric.
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const collapse = collapseAndDispose.current;
|
|
133
|
+
if (elRef.current && !handleRef.current) {
|
|
134
|
+
handleRef.current = createViewAnchor(elRef.current, {
|
|
135
|
+
present: optsRef.current.present,
|
|
136
|
+
publish: optsRef.current.publish,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return () => {
|
|
140
|
+
if (elRef.current === null)
|
|
141
|
+
collapse();
|
|
142
|
+
};
|
|
143
|
+
}, []);
|
|
144
|
+
return ref;
|
|
145
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SizeAdvertiserOptions, SizeAdvertiserHandle } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reverse of `createViewAnchor`: runs in a downstream WebContentsView's own
|
|
4
|
+
* renderer, measures the content's own size on ONE owned axis (from the
|
|
5
|
+
* `ResizeObserver` border-box — no `getBoundingClientRect`, no forced reflow),
|
|
6
|
+
* and advertises it via the injected `publish`. Shares the forward primitive's
|
|
7
|
+
* measure/coalesce/dedupe/dispose engine (`createMeasureLoop`).
|
|
8
|
+
*
|
|
9
|
+
* The extent is `Math.round`ed and clamped to `>= 0`; non-finite measurements
|
|
10
|
+
* drop the frame.
|
|
11
|
+
*
|
|
12
|
+
* FOOTGUN — `target` must be shrink-to-fit on the owned axis: its owned-axis
|
|
13
|
+
* size must NOT be driven by the host-applied view size, or the cross-process
|
|
14
|
+
* loop (advertise → host resizes view → remeasure) never converges (it
|
|
15
|
+
* oscillates or stays "stable but wrong"). Measuring `<body>`/`<html>` is the
|
|
16
|
+
* classic mistake — their size *is* the view size. See
|
|
17
|
+
* `docs/bidirectional-design.md`'s single-axis-ownership and trust-boundary
|
|
18
|
+
* sections.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOptions): SizeAdvertiserHandle;
|
|
21
|
+
//# sourceMappingURL=size-advertiser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"size-advertiser.d.ts","sourceRoot":"","sources":["../src/size-advertiser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,qBAAqB,EACrB,oBAAoB,EACrB,MAAM,YAAY,CAAA;AAGnB;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,qBAAqB,GAC1B,oBAAoB,CAkEtB"}
|