@dimina-kit/view-anchor 0.1.0-dev.20260610082053

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 EchoTechFE and dimina-kit contributors
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,87 @@
1
+ # @dimina-kit/view-anchor
2
+
3
+ 让一块主进程原生视图(Electron `WebContentsView`)始终贴住某个 DOM 元素的屏幕矩形,一对一绑定。
4
+
5
+ DOM 布局库(flexbox、dockview、react-resizable-panels……)都不管渲染进程与主进程之间那道边界,它们移动的永远只是 DOM 节点。`view-anchor` 就是跨过这道边界的桥:它测量目标元素的 `getBoundingClientRect()`,把得到的矩形交给一个 `publish` 回调(由你接上 IPC → `setBounds`),并在元素发生位移或缩放时重新发布。
6
+
7
+ 核心不依赖 React、Electron 或任何宿主布局引擎。涉及 React 的代码只在适配层。
8
+
9
+ ## 安装
10
+
11
+ ```sh
12
+ pnpm add @dimina-kit/view-anchor
13
+ ```
14
+
15
+ ## 快速上手
16
+
17
+ 命令式核心:
18
+
19
+ ```ts
20
+ import { createViewAnchor } from '@dimina-kit/view-anchor'
21
+
22
+ const handle = createViewAnchor(target, {
23
+ present: true, // 挂载原生视图
24
+ publish: (bounds) => { ... }, // 接收实时矩形;由它负责 IPC → setBounds
25
+ })
26
+
27
+ handle.update({ present, publish }) // 应用新选项(会立即重新发布)
28
+ handle.dispose() // 停止观察;此后不再发布
29
+ ```
30
+
31
+ React 适配层:
32
+
33
+ ```tsx
34
+ import { useViewAnchor } from '@dimina-kit/view-anchor'
35
+
36
+ function DebugPanel({ visible }: { visible: boolean }) {
37
+ const ref = useViewAnchor({
38
+ present: visible,
39
+ publish: publishSimulatorDevtoolsBounds,
40
+ })
41
+ // 原生视图会跟随这个占位 div;隐藏面板
42
+ // (visible=false 或卸载)会让它收起,但不销毁。
43
+ return <div ref={ref} className="h-full w-full" />
44
+ }
45
+ ```
46
+
47
+ ## 工作原理(一句话版)
48
+
49
+ - `present: true`:立即发布测量矩形,之后在每次 `ResizeObserver` 触发、窗口 `resize` 时**同步**重新发布(不走 RAF——原生 overlay 的 setBounds 本就慢一帧,RAF 再叠一帧会拖尾)。与上次逐字段相同的矩形会被去重跳过。x/y 取整但**允许负**(滚出边缘时跟随),width/height 钳到 ≥0。
50
+ - `present: false`:发布一次 `{0,0,0,0}` 并停止观察(已有的观察器一并拆除)。宿主把「面积为零」读作「摘除子视图,但保留其 `WebContents` 存活」,即收起而非销毁。
51
+ - `dispose()` 之后不再发布任何内容,也不会补发一帧零矩形(那是调用方的责任;React 适配层已替你在元素消失时处理好)。
52
+
53
+ ## 反向:`createSizeAdvertiser`(内容尺寸回流)
54
+
55
+ 正向让原生视图跟随 DOM;反向让 DOM 占位跟随内容。当一块 `WebContentsView` 的尺寸由它**自己的内容**主导时(例如交给下游控制的 toolbar),在**下游视图自己的渲染进程**里跑:
56
+
57
+ ```ts
58
+ import { createSizeAdvertiser } from '@dimina-kit/view-anchor'
59
+
60
+ const handle = createSizeAdvertiser(contentWrapper, {
61
+ axis: 'block', // 这个 advertiser 只主导一条轴(block=高 / inline=宽),不可变
62
+ publish: (size) => { ... }, // 接收 { axis, extent },由它负责 IPC → 宿主
63
+ })
64
+
65
+ handle.update(publish) // 换 publish(IPC 通道),并立即把当前尺寸发给它
66
+ handle.dispose() // 停止观察;此后不再上报
67
+ ```
68
+
69
+ 它从 `ResizeObserver` 的 border-box 取主导轴尺寸(不调 `getBoundingClientRect`、不强制 reflow),`Math.round` + 钳零,复用与正向同一套 RAF 合并 + 去重。宿主收到尺寸后调整占位、再由正向把视图贴上去——两个单向原语经占位 div 串成一座双向桥。**注意 footgun**:`target` 必须在主导轴上 shrink-to-fit(其尺寸不能被宿主灌入的视图尺寸反向决定),否则跨进程环不收敛。详见 [`docs/bidirectional-design.md`](./docs/bidirectional-design.md)。
70
+
71
+ ## 文档
72
+
73
+ - [`docs/mechanism.mdx`](./docs/mechanism.mdx) —— 正向完整机制:RAF 与陈旧帧安全性、`present` / 零矩形 / 卸载契约、React 18 StrictMode 生命周期。内嵌可交互 3D 演示 [`docs/anchor-3d.html`](./docs/anchor-3d.html)。
74
+ - [`docs/bidirectional-design.md`](./docs/bidirectional-design.md) —— 双向化设计:共享 `createMeasureLoop` 核心、单轴所有权与收敛性、信任边界、以及两个原语如何「锚」到一起。
75
+
76
+ ## API
77
+
78
+ | 导出 | 类型 | 作用 |
79
+ |---|---|---|
80
+ | `createViewAnchor(target, opts)` | 函数 | 正向命令式核心,返回 `{ update, dispose }`。不依赖 React、不依赖 Electron。 |
81
+ | `useViewAnchor(opts)` | Hook | 正向 React 适配层,返回一个挂到占位元素上的 ref 回调。 |
82
+ | `createSizeAdvertiser(target, opts)` | 函数 | 反向命令式核心,返回 `{ update, dispose }`。下游量内容尺寸回流给宿主。 |
83
+ | `Bounds` | 类型 | `{ x, y, width, height }`,单位为 CSS 像素。 |
84
+ | `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | 正向核心的选项与句柄形状。 |
85
+ | `UseViewAnchorOptions` / `ViewAnchorRef` | 类型 | 正向适配层的选项与 ref 回调形状。 |
86
+ | `AdvertisedAxis` / `AdvertisedSize` | 类型 | 反向的轴(`'block'\|'inline'`)与帧载荷 `{ axis, extent }`。 |
87
+ | `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | 类型 | 反向核心的选项与句柄形状。 |
@@ -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 (P1 fix): 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
+ * DevTools 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 (P1 fix): 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
+ * DevTools 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,20 @@
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` §4/§5.
18
+ */
19
+ export declare function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOptions): SizeAdvertiserHandle;
20
+ //# 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;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,qBAAqB,GAC1B,oBAAoB,CAkEtB"}
@@ -0,0 +1,82 @@
1
+ import { createMeasureLoop } from './measure-loop.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` §4/§5.
18
+ */
19
+ export function createSizeAdvertiser(target, opts) {
20
+ const axis = opts.axis; // immutable for the advertiser's life
21
+ let publish = opts.publish;
22
+ let observer = null;
23
+ let disposed = false;
24
+ // Latest border-box, stashed by the RO callback and read by `produce` in the
25
+ // RAF body (keep the entry out of the shared, DOM-agnostic loop).
26
+ let latest = null;
27
+ const produce = () => {
28
+ if (!latest)
29
+ return null;
30
+ const raw = axis === 'block' ? latest.blockSize : latest.inlineSize;
31
+ if (!Number.isFinite(raw))
32
+ return null;
33
+ return { axis, extent: Math.max(0, Math.round(raw)) };
34
+ };
35
+ const loop = createMeasureLoop({
36
+ produce,
37
+ same: (a, b) => a.extent === b.extent, // axis is constant
38
+ sink: (size) => publish(size),
39
+ });
40
+ const onResize = (entries) => {
41
+ const entry = entries[entries.length - 1];
42
+ if (entry) {
43
+ latest = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latest;
44
+ }
45
+ loop.schedule();
46
+ };
47
+ // One cheap, once-per-advertiser guard for the textbook feedback-loop footgun.
48
+ const doc = target.ownerDocument;
49
+ if (target === doc.body || target === doc.documentElement) {
50
+ console.warn(`[view-anchor] size-advertiser: <${target === doc.body ? 'body' : 'html'}>'s ` +
51
+ `${axis} size is the host-given view size, not the content size — the ` +
52
+ `advertiser will never shrink to content. Measure a shrink-to-fit wrapper. ` +
53
+ `See bidirectional-design.md §4.`);
54
+ }
55
+ loop.setActive(true);
56
+ observer = new ResizeObserver(onResize);
57
+ observer.observe(target);
58
+ return {
59
+ update(nextPublish) {
60
+ if (disposed)
61
+ return;
62
+ publish = nextPublish;
63
+ // Re-advertise the current size to the new sink immediately (mirrors the
64
+ // forward anchor's re-publish on update) so the new channel is not left
65
+ // sizeless until the next ResizeObserver tick.
66
+ const cur = produce();
67
+ if (cur)
68
+ loop.emitNow(cur);
69
+ },
70
+ dispose() {
71
+ if (disposed)
72
+ return;
73
+ disposed = true;
74
+ loop.cancel();
75
+ if (observer) {
76
+ observer.disconnect();
77
+ observer = null;
78
+ }
79
+ loop.dispose();
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * view-anchor — sync a main-process native view's bounds to a DOM element.
3
+ *
4
+ * Self-contained, engine-agnostic primitive. It knows nothing about
5
+ * Electron (the `publish` callback owns the IPC → `setBounds`), nothing
6
+ * about React (the core is imperative; see `react.ts` for the adapter),
7
+ * and nothing about the host layout engine (the `target` element may come
8
+ * from our own `compile`/`FrameTree`, or — later — a dockview panel's
9
+ * `content.element`; the mechanism is identical).
10
+ *
11
+ * It is the modern, alive replacement for the archived
12
+ * `react-electron-browser-view`: the cross-process bridge that DOM layout
13
+ * libraries (dockview included) deliberately do not provide. dockview's
14
+ * internal `OverlayRenderContainer` does the same getBoundingClientRect →
15
+ * RAF → reposition dance, but its follower is a DOM node; ours is a native
16
+ * `WebContentsView` positioned via the injected `publish`.
17
+ */
18
+ /** A screen-space rectangle, in CSS pixels. Structurally compatible with
19
+ * the host's `ViewBounds` so a publisher typed against either works. */
20
+ export interface Bounds {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+ /**
27
+ * Explicit visibility + geometry for a native view, replacing the legacy
28
+ * magic-`{0,0,0,0}` "hidden" convention (`present:false → ZERO bounds`).
29
+ *
30
+ * Visibility is a DISCRIMINANT, never inferred from geometry. The whole
31
+ * reason this type exists: a genuinely zero-SIZED but on-screen view
32
+ * (`{ visible:true, bounds:{...,width:0,height:0} }`) is now distinct from a
33
+ * detached/hidden one (`{ visible:false }`, which carries no `bounds` at
34
+ * all). Under the old ZERO convention both collapsed to the same value and
35
+ * were indistinguishable.
36
+ */
37
+ export type Placement = {
38
+ visible: true;
39
+ bounds: Bounds;
40
+ } | {
41
+ visible: false;
42
+ };
43
+ export interface ViewAnchorOptions {
44
+ /**
45
+ * Whether the native view should be attached. When `false`, the anchor
46
+ * publishes zero bounds (`{0,0,0,0}`) — the host treats `width === 0 ||
47
+ * height === 0` as "detach the child view but keep its WebContents
48
+ * alive" (detach-but-keep-alive). No DOM measurement is needed in this
49
+ * state.
50
+ */
51
+ present: boolean;
52
+ /** Receives the live rect, or `{0,0,0,0}` when detached. Owns IPC. */
53
+ publish: (bounds: Bounds) => void;
54
+ }
55
+ export interface ViewAnchorHandle {
56
+ /**
57
+ * Apply new options. Re-publishes immediately to reflect the new state
58
+ * (present=true → measure + observe; present=false → zero bounds).
59
+ */
60
+ update(opts: ViewAnchorOptions): void;
61
+ /** Stop observing and remove listeners. After dispose the anchor never
62
+ * publishes again (every emit reads `disposed` synchronously, so there is
63
+ * no queued frame that could fire late). */
64
+ dispose(): void;
65
+ }
66
+ /** Which axis this advertiser owns. `block` = height, `inline` = width
67
+ * (logical-property naming, axis-agnostic to writing mode). */
68
+ export type AdvertisedAxis = 'block' | 'inline';
69
+ /**
70
+ * One frame of advertised size. A pure scalar plus the owning axis — there is
71
+ * deliberately no field for the *other* axis, so "advertise two axes" is not
72
+ * expressible (single-axis ownership is enforced in the type, not at runtime).
73
+ */
74
+ export interface AdvertisedSize {
75
+ /** Mirrors the factory's `axis`; constant across frames. Lets the host
76
+ * whitelist-check the axis it is willing to accept. */
77
+ readonly axis: AdvertisedAxis;
78
+ /** The owned axis's content extent, in CSS px — already rounded and clamped
79
+ * to `>= 0`. */
80
+ readonly extent: number;
81
+ }
82
+ export interface SizeAdvertiserOptions {
83
+ /** The single axis this advertiser owns. Fixed for the advertiser's life. */
84
+ axis: AdvertisedAxis;
85
+ /** Receives each advertised size. Owns the IPC/postMessage → host. Mirrors
86
+ * the forward `publish` (same role: the injected, transport-owning sink). */
87
+ publish: (size: AdvertisedSize) => void;
88
+ }
89
+ export interface SizeAdvertiserHandle {
90
+ /**
91
+ * Swap the `publish` sink (e.g. a new IPC channel) and immediately
92
+ * re-advertise the current size to it (mirrors the forward anchor's
93
+ * re-publish on update), so the new channel is not left sizeless until the
94
+ * next `ResizeObserver` tick.
95
+ *
96
+ * Takes only the new sink — `axis` is immutable by construction, so it is
97
+ * deliberately not expressible here (you cannot attempt to change it). To
98
+ * advertise a different axis, dispose and create a new advertiser.
99
+ */
100
+ update(publish: (size: AdvertisedSize) => void): void;
101
+ /**
102
+ * Stop observing, cancel any pending RAF. After dispose nothing is
103
+ * advertised again. (There is no ZERO/terminal value — collapsing is the
104
+ * host's policy, unlike the forward anchor's `present:false`.)
105
+ *
106
+ * The first advertised value is asynchronous: it awaits the observer's first
107
+ * frame, and a `display:none` target advertises nothing until shown.
108
+ */
109
+ dispose(): void;
110
+ }
111
+ //# sourceMappingURL=types.d.ts.map