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 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
+ [![npm version](https://img.shields.io/npm/v/view-anchor)](https://www.npmjs.com/package/view-anchor)
6
+ [![npm downloads](https://img.shields.io/npm/dm/view-anchor)](https://www.npmjs.com/package/view-anchor)
7
+ [![License](https://img.shields.io/npm/l/view-anchor)](./LICENSE)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D24-339933)](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
@@ -0,0 +1,114 @@
1
+ # view-anchor
2
+
3
+ > 让主进程的原生视图(Electron `WebContentsView`)始终对齐某个 DOM 元素的几何位置的引擎无关原语。
4
+
5
+ [![npm version](https://img.shields.io/npm/v/view-anchor)](https://www.npmjs.com/package/view-anchor)
6
+ [![npm downloads](https://img.shields.io/npm/dm/view-anchor)](https://www.npmjs.com/package/view-anchor)
7
+ [![License](https://img.shields.io/npm/l/view-anchor)](./LICENSE)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D24-339933)](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
@@ -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
+ }
@@ -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"}