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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# view-anchor 双向几何桥
|
|
2
|
+
|
|
3
|
+
view-anchor 是一座**双向几何桥**,引擎无关、传输注入:
|
|
4
|
+
|
|
5
|
+
- **正向** `createViewAnchor(target, { present, publish })` —— 宿主渲染进程量 DOM 占位矩形 → `publish(bounds)` → IPC → 主进程 `WebContentsView.setBounds`。
|
|
6
|
+
- **反向** `createSizeAdvertiser(target, { axis, publish })` —— 下游 WebContentsView 自己的渲染进程量自身内容尺寸 → `publish(size)` → IPC → 宿主,宿主据此调整占位尺寸,再经正向把视图贴上去。
|
|
7
|
+
|
|
8
|
+
适用场景:toolbar 这类「**交给下游控制**」的 WebContentsView,尺寸的事实来源在下游一侧(典型:宽由宿主主导、高由下游内容主导),占位映射是动态的。
|
|
9
|
+
|
|
10
|
+
范围之外:不是同层渲染(合成器层面、另一套机制);包里不提供 React 反向适配、不提供宿主决策 `decide`(见 §3/§6)。
|
|
11
|
+
|
|
12
|
+
## 1. 正反向不共享发射核心——两个方向最优时机不同
|
|
13
|
+
|
|
14
|
+
正反向的最优发射时机本就不同,所以它们**不共享发射核心**,采取**正向同步、反向 RAF** 的刻意不对称。
|
|
15
|
+
|
|
16
|
+
**正向 `createViewAnchor` = 同步发布,不走 RAF。** 原生 overlay 的 `setBounds` 是跨进程、本就晚约 1 合成帧;RAF 再叠一帧 → 拖拽可见拖尾。所以正向在每个 `ResizeObserver` / window `resize` 触发里**同步**测量+发布,抗洪由 `lastPublished` 同值去重承担。撤销安全靠「每次发布开头同步读 `disposed`/`present`」——没有排队帧可跑赢状态变化。正向的 sink 是 IPC→主进程 `setBounds`,**不碰本渲染进程 DOM**,所以同步发布不会形成 RO 重入循环。
|
|
17
|
+
|
|
18
|
+
**反向 `createSizeAdvertiser` = RAF 合并(内部 `createMeasureLoop`,反向专用)。** 反向是一条**跨进程反馈环**:advertise → 宿主 resize 这块视图 → 下游内容 remeasure → 再 advertise。RAF 的「每帧≤1 次发布」对这条环是合理的阻尼。`createMeasureLoop<T>` 只服务反向(正向不用它),通过注入 `produce`/`same`/`sink` 三元组把方向细节关在外层,核心零 flag:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
function createMeasureLoop<T>(cfg: {
|
|
22
|
+
produce: () => T | null // RAF 体内取值:读最近一帧 borderBoxSize(null=跳过帧)
|
|
23
|
+
same: (a: T, b: T) => boolean // 去重谓词(反向 = extent 相等)
|
|
24
|
+
sink: (value: T) => void // = 注入的 publish
|
|
25
|
+
}): { schedule, emitNow, setActive, cancel, dispose }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**为什么不对称是对的**:正向是单向跟随者,少一帧直接消除可见拖尾;反向是反馈环,多一帧阻尼更稳。把两者塞进一个核心要么逼出 `if(direction)` flag,要么把正向也拖进 RAF(带回拖尾)。所以**正向内联同步、反向用 RAF 引擎**,各取所需。
|
|
29
|
+
|
|
30
|
+
## 2. 反向原语契约
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export type AdvertisedAxis = 'block' | 'inline'
|
|
34
|
+
|
|
35
|
+
// 帧载荷:纯标量,连「哪条轴」都无从携带 → 单轴在类型层不可拼写。
|
|
36
|
+
export interface AdvertisedSize {
|
|
37
|
+
readonly axis: AdvertisedAxis // 镜像工厂常量,逐帧恒等,供宿主白名单校验
|
|
38
|
+
readonly extent: number // 主导轴内容尺寸,CSS px,已 round,钳到 >= 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SizeAdvertiserOptions {
|
|
42
|
+
axis: AdvertisedAxis // 创建期定死,一个 advertiser 一生只报一条轴
|
|
43
|
+
publish: (size: AdvertisedSize) => void // 注入;下游接 IPC/postMessage → 宿主(与正向 publish 同名同形)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SizeAdvertiserHandle {
|
|
47
|
+
update(publish: (size: AdvertisedSize) => void): void // 只能换 publish(换 IPC 通道);axis 不可变
|
|
48
|
+
dispose(): void // 停 observe、取消 RAF,此后永不再 publish
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOptions): SizeAdvertiserHandle
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- 跑在**下游**渲染进程;从 `ResizeObserverEntry.borderBoxSize` 读尺寸(零强制 reflow,**禁**回调内 `getBoundingClientRect`/`scrollHeight`);上报前 `Math.round` + `Math.max(0, ...)`(与正向 `measure` 的 round/钳零对称)。
|
|
55
|
+
- `target` 由调用方选成「主导轴上 shrink-to-fit、不被宿主写入反向决定」的 wrapper(与 `createViewAnchor` 一样,原语不碰调用方 DOM/样式)。
|
|
56
|
+
- **没有 `present`**:反向「停止上报」无指令语义(塌缩还是保持是宿主策略),停就 `dispose`、恢复就重建。不给不可信下游一个隐式控宿主的布尔旁路。
|
|
57
|
+
- 双轴需求 = **两个独立 advertiser 各管一轴**,不是一个 payload 塞两轴(后者把两条单向流伪装成一条双向流,使 DAG 退化成有环图)。
|
|
58
|
+
|
|
59
|
+
## 3. 单轴所有权(收敛性地基,不可协商)
|
|
60
|
+
|
|
61
|
+
- 一个 advertiser 只测量并只上报**它主导的那条轴**;另一条轴由宿主经 `setBounds` 单向灌入、对下游**只读**。
|
|
62
|
+
- 为什么收敛:宽 = 宿主输入(无回边);高 = block layout 的**输出**而非输入(无回边)→ 整条尺寸传播是单向 DAG,**一步收敛**。违反单轴 = 跨进程双 RAF 乒乓,是抖动/极限环根因。去环靠**拓扑**(单轴 + 类型不可拼写),不靠 epsilon 死区/低通滤波。
|
|
63
|
+
|
|
64
|
+
## 4. 信任边界(精确划线)
|
|
65
|
+
|
|
66
|
+
> 反向把「尺寸控制权」交给一个**下游控制**(半信任/不可信)的视图。上报值是攻击者输入,不是测量结果。
|
|
67
|
+
|
|
68
|
+
判据:**凡只需测量上下文的归原语;凡需 viewport/策略/对端身份的归宿主。**
|
|
69
|
+
|
|
70
|
+
| 责任 | 归属 | 说明 |
|
|
71
|
+
|---|---|---|
|
|
72
|
+
| `Math.round` 量化 | 原语 | 测量副产物,出生地最便宜 |
|
|
73
|
+
| 丢 `NaN`/`Infinity` | 原语 | 测量噪声,不该塞进 IPC |
|
|
74
|
+
| 负值 → **钳到 0**(非丢弃) | 原语 | 与正向 `Math.max(0,...)` 对称;钳零=诚实上报「现在测出来是 0」,丢弃会让宿主停在过期值 |
|
|
75
|
+
| 限流 | **两边都不额外加** | 原语的 RAF 合并 + 去重已是终极限流(跟随刷新率、静止零发);宿主用 clamp + `decide` 幂等吸收突发,**不做时间节流**(会吞掉合法离散事件) |
|
|
76
|
+
| `clamp(min, max)` | 宿主 | 唯一持有 viewport/available/策略的一方;对抗恶意巨值的**主防线** |
|
|
77
|
+
| 来源校验(senderFrame/origin/token) | 宿主 | 只能发生在收 IPC 那一刻 |
|
|
78
|
+
| 白名单轴 | 宿主 | 用 payload 的 `axis` 常量比对,`axis !== expected` 则丢弃 |
|
|
79
|
+
| 位置 / 另一轴 / z-order 锁定 | 宿主 | 下游无任何途径改变 → 杜绝全窗覆盖类点击劫持 |
|
|
80
|
+
|
|
81
|
+
**target 选错(反馈环)的防护——克制为主,不过度防御。** 关键事实:违反单轴所有权时,RAF 合并把「死循环」**封顶为每帧一次**——它退化成可见的抖动 / 每帧一次冗余 IPC,而**不是冻死 UI**。既然不是灾难性故障,就不在每帧路径上堆检测器。只保留:
|
|
82
|
+
|
|
83
|
+
- **一条构造期廉价守卫**:`target` 是 `<body>`/`<html>` 时 `console.warn`——这是「stable-but-wrong」(占位永远撑成视图高、不缩到内容)最常见的成因,一次性、零每帧成本。
|
|
84
|
+
- **其余靠文档**:JSDoc 与本节把「target 必须主导轴 shrink-to-fit、不被宿主灌入的尺寸反向决定」讲清。
|
|
85
|
+
- **明确不做**:每帧的 2-cycle / 单调发散检测器(复杂度与「非灾难性、RAF 封顶」的故障不成正比)、运行时熔断(会把可见症状盖成静默失效)、向宿主回传灌入值做对比(会亲手造出 §3 要消灭的反馈边)。发散的真正兜底是宿主侧 `clamp(max)`。
|
|
86
|
+
|
|
87
|
+
## 5. 两个原语如何「锚」到一起
|
|
88
|
+
|
|
89
|
+
正向与反向**不直接相互调用**——各自只有一个注入的 `publish` 往 IPC 上发。把两端串到一起的是宿主里的**占位 div** + 宿主的 `decide` 函数。占位 div 是会合点(join point):
|
|
90
|
+
|
|
91
|
+
- **`createSizeAdvertiser` 写**占位 div 的尺寸(经宿主)——内容多高,占位就多高。
|
|
92
|
+
- **`createViewAnchor` 读**占位 div 的矩形——占位在哪、多大,原生视图就贴哪、多大。
|
|
93
|
+
|
|
94
|
+
即:反向喂占位的「一条轴的大小」,正向把占位的「完整矩形」投到原生视图上。占位 div 是几何的**唯一真相**,两端都围着它转。
|
|
95
|
+
|
|
96
|
+
### 闭环(以 toolbar 为例)
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
宿主渲染进程 宿主主进程 下游渲染进程(toolbar 自己的页面)
|
|
100
|
+
────────── ──────── ────────────────────────────
|
|
101
|
+
[占位 div] [内容 wrapper(shrink-to-fit)]
|
|
102
|
+
│ ▲ │
|
|
103
|
+
│ │ ② decide 把高写进占位 div │ ① createSizeAdvertiser
|
|
104
|
+
│ │ div.style.height = clamp(size.extent) │ 量 wrapper 的 block-size
|
|
105
|
+
│ └─────────── IPC ◀─────────────────────────────────────┘ publish(size) ──▶
|
|
106
|
+
│
|
|
107
|
+
│ ③ 占位 div 尺寸变 → createViewAnchor 的 ResizeObserver 触发
|
|
108
|
+
│ 量占位新矩形 → publish(bounds)
|
|
109
|
+
▼
|
|
110
|
+
──── IPC ──▶ ④ view.setBounds(bounds) ──▶ [WebContentsView(这块 toolbar)]
|
|
111
|
+
│ 视图变 → 下游 viewport 变 → 内容重新布局
|
|
112
|
+
└──▶ 回到 ① advertiser 再量(收敛)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
1. **下游**:`createSizeAdvertiser(wrapper, { axis:'block', publish })` 量内容高 → IPC 发回宿主。
|
|
116
|
+
2. **宿主**:纯函数 `decide(size, { axis, min, max, available })` 夹一夹,把结果写进**占位 div 的高**(`div.style.height = …`)。
|
|
117
|
+
3. **宿主**:占位 div 高一变,`createViewAnchor` 挂在它上的 `ResizeObserver` 立刻触发 → 量出占位新矩形(宽 = 宿主布局给的满宽,高 = 刚写进去的内容高)→ `publish(bounds)`。
|
|
118
|
+
4. **宿主主进程**:`view.setBounds(bounds)` → toolbar 这块 `WebContentsView` 变成新尺寸 → 下游 viewport 变 → 内容重新布局 → 回到 ①。
|
|
119
|
+
|
|
120
|
+
### 谁负责什么
|
|
121
|
+
|
|
122
|
+
| 角色 | 谁提供 | 在 view-anchor 里? |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `createSizeAdvertiser`(量内容 → 发 size) | view-anchor | ✅ |
|
|
125
|
+
| `createViewAnchor`(量占位 → 发 bounds) | view-anchor | ✅ |
|
|
126
|
+
| `decide`(把 size 写进占位 div 的高)+ 两条 IPC 通道 | **宿主(workbench)** | ❌(刻意,属宿主胶水) |
|
|
127
|
+
| 占位 div、shrink-to-fit wrapper | 调用方的 DOM | ❌ |
|
|
128
|
+
|
|
129
|
+
- 收敛逻辑收进宿主一个**纯函数** `decide(...)`(可单测、无跨进程协议)。`decide` 住宿主侧,**不进 view-anchor**——包只出两个单向原语,不出收敛策略,否则就从原语滑向框架(§7)。
|
|
130
|
+
- 连续交互(拖窗改宽)走宿主**单向**路径,不进闭环;反向只服务下游内容的**离散**尺寸变化。
|
|
131
|
+
|
|
132
|
+
## 6. 与 Electron preferred-size 的关系(宿主侧可选数据源,非替代)
|
|
133
|
+
|
|
134
|
+
宿主若在 Electron 且能接受零下游代码,可用 `enablePreferredSizeMode` + `preferred-size-changed` 作为反向「**源**」喂给同一个 `decide`,省掉下游注入。但它是 Electron 私有,且依赖两个前提:① 在 `WebContentsView` 上触发且上报值吸收 zoom;② `setBounds.height` 不回灌 layout(否则正反馈震荡)。
|
|
135
|
+
|
|
136
|
+
view-anchor 的反向原语是**引擎无关/可移植**那条路(非 Electron / iframe 宿主、需选子 target 的场景)。两者并存:preferred-size 是 Electron 捷径,advertiser 是可移植机制,`decide` + §4 安全红线是二者共用的公共底座。这也是反向逻辑留在 view-anchor、而非写死成 Electron 事件的理由——宿主选哪个**源**不改变反向原语本身。
|
|
137
|
+
|
|
138
|
+
## 7. 包定位守恒(一个原语,不是框架)
|
|
139
|
+
|
|
140
|
+
view-anchor 是「DOM 几何 ↔ 跨进程视图」的**单一职责双向桥**。守住四条即不滑向框架:① 导出面只含 `createViewAnchor` / `createPlacementAnchor` / `createSizeAdvertiser` / `measurePlacement` / `useViewAnchor` + 其类型;② `createMeasureLoop` 不导出;③ 不提供 React 反向适配(下游不保证是 React);④ `decide` 留宿主、不进包。
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: view-anchor
|
|
3
|
+
description: 让主进程原生视图(Electron WebContentsView)始终贴住一个 DOM 元素的矩形。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# view-anchor
|
|
7
|
+
|
|
8
|
+
让一块主进程原生视图(Electron `WebContentsView`)始终贴住一个 DOM 元素的屏幕矩形。它测量目标元素的 `getBoundingClientRect()`,把矩形交给注入的 `publish` 回调(由你接上 IPC → `setBounds`),并在元素位移或缩放时重新发布。
|
|
9
|
+
|
|
10
|
+
核心不依赖 React、Electron 或任何宿主布局引擎;涉及 React 的代码只在适配层。
|
|
11
|
+
|
|
12
|
+
<iframe
|
|
13
|
+
src="./anchor-3d.html"
|
|
14
|
+
title="view-anchor 交互演示"
|
|
15
|
+
style={{ width: '100%', height: '560px', border: '0', borderRadius: '12px' }}
|
|
16
|
+
/>
|
|
17
|
+
|
|
18
|
+
## 架构
|
|
19
|
+
|
|
20
|
+
```mermaid
|
|
21
|
+
flowchart LR
|
|
22
|
+
subgraph R["渲染进程 · WebContents"]
|
|
23
|
+
DIV["占位 div<br/>(CSS 布局,自身不渲染)"]
|
|
24
|
+
end
|
|
25
|
+
subgraph M["主进程"]
|
|
26
|
+
WCV["WebContentsView<br/>(原生图层,覆盖在网页之上)"]
|
|
27
|
+
end
|
|
28
|
+
DIV -->|"getBoundingClientRect()"| VA["view-anchor"]
|
|
29
|
+
VA -->|"publish(bounds)<br/>IPC → setBounds"| WCV
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`WebContentsView` 是主进程对象,位置只能由主进程 `setBounds` 设定;而布局是渲染进程用 CSS 算的。两边不直接接触,view-anchor 就是它们之间的桥:占位 div 在 DOM 里占位但不渲染,原生视图浮在其上,由 view-anchor 维持贴合。`publish` 是注入的,所以核心对 Electron 一无所知——测试里它是 spy,生产里是一次 IPC 发送。
|
|
33
|
+
|
|
34
|
+
## createViewAnchor(target, opts)
|
|
35
|
+
|
|
36
|
+
命令式核心,把一块原生视图绑定到一个元素,返回 `{ update, dispose }`。
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const handle = createViewAnchor(target, {
|
|
40
|
+
present: true, // 是否挂载原生视图
|
|
41
|
+
publish: (bounds) => { ... }, // 接收实时矩形,负责 IPC → setBounds
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| 状态 / 调用 | 行为 |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `present: true` | 立即发布测量矩形,之后在每次 `ResizeObserver` 触发、窗口 `resize` 时**同步**重新发布。 |
|
|
48
|
+
| `present: false` | 停止观察,发布一次 `{0,0,0,0}`。 |
|
|
49
|
+
| `update(opts)` | 按新选项同步重新应用(重置去重基线,强制重发一次)。 |
|
|
50
|
+
| `dispose()` | 停止观察、移除监听,此后不再发布(也不补发零矩形)。 |
|
|
51
|
+
|
|
52
|
+
测量矩形按 `Math.round` 取整;**width/height 钳到 ≥0(0=隐藏信号),但 x/y 允许负**——元素滚出上 / 左边缘时原点本就该是负的,钳零会把原生视图钉在边缘而非跟随它移出屏外(各消费者的 IPC schema 自己定 origin 策略)。
|
|
53
|
+
|
|
54
|
+
**同步发布,不走 RAF**:原生 overlay 是跨进程 `WebContentsView`,`setBounds` 本就比渲染进程的 DOM 绘制晚约 1 个合成帧;再用 RAF 推迟一帧 → 拖拽时 overlay 可见拖尾(放大时露背景最明显)。在触发回调里直接测量+发布去掉这一自加的帧。RAF 原本承担的「合并同帧多次触发」由**同值去重**接管:若本次测量矩形与上次已发布的逐字段相等就丢弃,所以一次连续拖拽里每个不同矩形至多发一次。`update` 会先把去重基线清空,保证状态变化(zoom 骑在 `publish` 闭包里、不在 `Bounds` 里)即使几何不变也重发一次。
|
|
55
|
+
|
|
56
|
+
撤销安全:没有排队的帧可以「跑赢」状态变化——每次发布开头同步读 `disposed`/`present`,`update`/`dispose` 之后的触发立即 bail。
|
|
57
|
+
|
|
58
|
+
## present / 零矩形 语义
|
|
59
|
+
|
|
60
|
+
- **`present`** —— 「原生视图是否该挂载」的唯一事实来源,与 DOM 生命周期解耦。
|
|
61
|
+
- **`{0,0,0,0}`(ZERO)** —— 收起信号。宿主把零面积读作「摘除子视图,但保留其 `WebContents` 存活」,即收起而非销毁,重新挂载瞬时且状态完整。
|
|
62
|
+
- **dispose 后保持静默** —— 不补发零矩形。元素真正消失时,应由调用方先发 ZERO 再 dispose(适配层已替你处理)。
|
|
63
|
+
|
|
64
|
+
## createPlacementAnchor(target, opts) — 显式可见性变体
|
|
65
|
+
|
|
66
|
+
`createViewAnchor` 用零矩形 `{0,0,0,0}` 兼任「收起」信号,可见性是从几何推断的。`createPlacementAnchor` 把可见性提升为显式判别式,发的是 `Placement` 而非 `Bounds`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const handle = createPlacementAnchor(target, {
|
|
70
|
+
visible: true, // 调用方意图:视图是否该在屏
|
|
71
|
+
publish: (placement) => { ... },
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| 状态 / 选项 | 行为 |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `visible: true` | 发 `measurePlacement(target)`(`{ visible:true, bounds }`),并在每次 `ResizeObserver` / `resize` 触发**同步**重发。 |
|
|
78
|
+
| `visible: false` | 发 `{ visible:false }`(**不是**零矩形),停止观察。 |
|
|
79
|
+
| `guardDisplayNone`(默认 false) | 开启后,测出零面积的 target(display:none / 卸载 / 首帧未稳)发 `{ visible:false }` 而非 `{ visible:true, bounds:0×0 }`,并挂 `IntersectionObserver` 捕捉 `ResizeObserver` 不上报的 display:none 切换。 |
|
|
80
|
+
| `followScroll`(默认 false) | 捕获阶段监听 `window` 的 `scroll`,祖先滚动容器滚动 target 时重测重发。 |
|
|
81
|
+
| `followGeometry`(默认 false) | 按需开窗的 RAF 几何哨兵,捕捉无 DOM 事件上报的祖先 transform / reflow 位移;几何静止几帧后自动关窗,空闲时零成本。可由 `pulse(durationMs?)` 显式开窗。 |
|
|
82
|
+
|
|
83
|
+
去重携带判别式(`samePlacement`),所以可见性翻转绝不会被同值合并掉——即使几何看上去一样。真正 0×0 但在屏的视图(`{ visible:true, bounds:{...,width:0} }`)与隐藏视图(`{ visible:false }`)由此可区分,而零矩形约定下二者会塌缩成同一个值。
|
|
84
|
+
|
|
85
|
+
## useViewAnchor(opts)
|
|
86
|
+
|
|
87
|
+
React 适配层,返回一个挂到占位元素上的 ref 回调。
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
const ref = useViewAnchor({
|
|
91
|
+
present, // boolean
|
|
92
|
+
publish, // (bounds) => void
|
|
93
|
+
deps: [signature], // 可选:会移动矩形、但 DOM 看不见的状态
|
|
94
|
+
})
|
|
95
|
+
return <div ref={ref} />
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
| 时机 | 行为 |
|
|
99
|
+
|---|---|
|
|
100
|
+
| 挂载 | `createViewAnchor(el, opts)` |
|
|
101
|
+
| `opts` / `deps` 变化 | `update` |
|
|
102
|
+
| 卸下(`ref → null`)或卸载 | 先发一帧零矩形,再 `dispose` |
|
|
103
|
+
|
|
104
|
+
**`deps`** —— `ResizeObserver` 只响应纯几何变化。会移动矩形却不改变被观察元素尺寸的状态(布局拓扑签名、路由切换、兄弟标签页 `display:none`)要放进 `deps`。数组长度需在每次渲染间保持稳定。
|
|
105
|
+
|
|
106
|
+
> 适配层已处理 React 18 StrictMode 的 effect 双触发,以及 hidden→shown 重挂载——保证恰好发布一次真实矩形、不误发零矩形。
|
|
107
|
+
|
|
108
|
+
## 文件
|
|
109
|
+
|
|
110
|
+
| 文件 | 作用 |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `src/view-anchor.ts` | 正向命令式核心 `createViewAnchor` / `createPlacementAnchor` / `measurePlacement`。无 React、无 Electron。 |
|
|
113
|
+
| `src/react.ts` | React 适配层 `useViewAnchor`(基于 `createViewAnchor`)。 |
|
|
114
|
+
| `src/size-advertiser.ts` | 反向命令式核心 `createSizeAdvertiser`。 |
|
|
115
|
+
| `src/measure-loop.ts` | 反向专用的 RAF 合并 / 去重 / dispose 引擎 `createMeasureLoop`(不导出)。 |
|
|
116
|
+
| `src/types.ts` | `Bounds`、`Placement`、各核心的选项与句柄类型、反向的 `AdvertisedAxis` / `AdvertisedSize`。 |
|
|
117
|
+
| `src/index.ts` | 对外公开面。 |
|
|
118
|
+
|
|
119
|
+
正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize` 监听)。`requestAnimationFrame` 只在反向 `createSizeAdvertiser`(经 `createMeasureLoop`)和 `createPlacementAnchor` 的 opt-in `followGeometry` 哨兵里用;正向的 `createViewAnchor` 同步发布、不走 RAF。
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "view-anchor",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"electron",
|
|
7
|
+
"webcontentsview",
|
|
8
|
+
"layout",
|
|
9
|
+
"react"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"sideEffects": false,
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=24"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "lbb00",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/lbb00/view-anchor.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/lbb00/view-anchor/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/lbb00/view-anchor#readme",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"docs",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"!**/*.test.*",
|
|
41
|
+
"!**/*.spec.*"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"react": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"react": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@eslint/js": "^10.0.1",
|
|
53
|
+
"@testing-library/react": "^16.3.2",
|
|
54
|
+
"@types/react": "^18.3.12",
|
|
55
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
57
|
+
"esbuild": "^0.28.1",
|
|
58
|
+
"eslint": "^10.2.1",
|
|
59
|
+
"eslint-config-prettier": "^10.1.8",
|
|
60
|
+
"eslint-plugin-only-warn": "^1.2.1",
|
|
61
|
+
"eslint-plugin-react": "^7.37.5",
|
|
62
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
63
|
+
"globals": "^17.5.0",
|
|
64
|
+
"jsdom": "^29.0.2",
|
|
65
|
+
"react": "^18.3.1",
|
|
66
|
+
"react-dom": "^18.3.1",
|
|
67
|
+
"typescript": "5.9.2",
|
|
68
|
+
"typescript-eslint": "^8.58.2",
|
|
69
|
+
"vitest": "^4.1.4"
|
|
70
|
+
},
|
|
71
|
+
"publishConfig": {
|
|
72
|
+
"access": "public"
|
|
73
|
+
},
|
|
74
|
+
"scripts": {
|
|
75
|
+
"build": "tsc -p tsconfig.build.json && pnpm run build:docs",
|
|
76
|
+
"build:docs": "node scripts/build-docs.mjs",
|
|
77
|
+
"check-types": "tsc --noEmit",
|
|
78
|
+
"lint": "eslint . --max-warnings 0",
|
|
79
|
+
"test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
|
|
80
|
+
"test:dev": "vitest",
|
|
81
|
+
"test:coverage": "vitest run --coverage"
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
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 {
|
|
17
|
+
createViewAnchor,
|
|
18
|
+
measurePlacement,
|
|
19
|
+
createPlacementAnchor,
|
|
20
|
+
} from './view-anchor.js'
|
|
21
|
+
export type {
|
|
22
|
+
PlacementAnchorOptions,
|
|
23
|
+
PlacementAnchorHandle,
|
|
24
|
+
} from './view-anchor.js'
|
|
25
|
+
export type {
|
|
26
|
+
Bounds,
|
|
27
|
+
Placement,
|
|
28
|
+
ViewAnchorOptions,
|
|
29
|
+
ViewAnchorHandle,
|
|
30
|
+
} from './types.js'
|
|
31
|
+
export { useViewAnchor } from './react.js'
|
|
32
|
+
export type { UseViewAnchorOptions, ViewAnchorRef } from './react.js'
|
|
33
|
+
export { createSizeAdvertiser } from './size-advertiser.js'
|
|
34
|
+
export type {
|
|
35
|
+
AdvertisedAxis,
|
|
36
|
+
AdvertisedSize,
|
|
37
|
+
SizeAdvertiserOptions,
|
|
38
|
+
SizeAdvertiserHandle,
|
|
39
|
+
} from './types.js'
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
|
|
36
|
+
export function createMeasureLoop<T>(cfg: {
|
|
37
|
+
/** Produce the value to emit in the RAF body. Return `null` to decline the
|
|
38
|
+
* frame entirely (no dedupe, no sink, baseline untouched) — e.g. a
|
|
39
|
+
* non-finite or unavailable measurement. */
|
|
40
|
+
produce: () => T | null
|
|
41
|
+
same: (a: T, b: T) => boolean
|
|
42
|
+
sink: (value: T) => void
|
|
43
|
+
}): MeasureLoop<T> {
|
|
44
|
+
const { produce, same, sink } = cfg
|
|
45
|
+
let rafId: number | null = null
|
|
46
|
+
let active = false
|
|
47
|
+
let disposed = false
|
|
48
|
+
let last: T | null = null
|
|
49
|
+
|
|
50
|
+
const cancel = (): void => {
|
|
51
|
+
if (rafId !== null) {
|
|
52
|
+
cancelAnimationFrame(rafId)
|
|
53
|
+
rafId = null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
schedule(): void {
|
|
59
|
+
if (disposed || !active || rafId !== null) return
|
|
60
|
+
rafId = requestAnimationFrame(() => {
|
|
61
|
+
rafId = null
|
|
62
|
+
if (disposed || !active) return
|
|
63
|
+
const value = produce()
|
|
64
|
+
if (value === null) return // producer declined this frame
|
|
65
|
+
// last-value dedupe: a frame whose produced value equals the last one
|
|
66
|
+
// we emitted costs nothing (no IPC / setBounds).
|
|
67
|
+
if (last !== null && same(value, last)) return
|
|
68
|
+
last = value
|
|
69
|
+
sink(value)
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
emitNow(value: T): void {
|
|
73
|
+
if (disposed) return
|
|
74
|
+
last = value
|
|
75
|
+
sink(value)
|
|
76
|
+
},
|
|
77
|
+
setActive(on: boolean): void {
|
|
78
|
+
active = on
|
|
79
|
+
},
|
|
80
|
+
cancel,
|
|
81
|
+
dispose(): void {
|
|
82
|
+
if (disposed) return
|
|
83
|
+
disposed = true
|
|
84
|
+
cancel()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
2
|
+
import { createViewAnchor } from './view-anchor.js'
|
|
3
|
+
import type { Bounds, ViewAnchorHandle, ViewAnchorOptions } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* React adapter over the imperative `createViewAnchor` core.
|
|
7
|
+
*
|
|
8
|
+
* (React lint forces the `use` prefix on any hook returning a ref
|
|
9
|
+
* callback; the library's identity is still the `ViewAnchor` core — this is
|
|
10
|
+
* just the React binding.)
|
|
11
|
+
*/
|
|
12
|
+
export interface UseViewAnchorOptions extends ViewAnchorOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Non-DOM dependencies that move the target's rect and must force a
|
|
15
|
+
* re-publish (layout signature, project path, a tab toggle's
|
|
16
|
+
* `display:none`, …). A `ResizeObserver` covers pure geometry; `deps`
|
|
17
|
+
* covers state it cannot see. Keep the array length stable across
|
|
18
|
+
* renders (React effect-deps rule).
|
|
19
|
+
*/
|
|
20
|
+
deps?: ReadonlyArray<unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ViewAnchorRef = (el: HTMLElement | null) => void
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Bind a native view's bounds to whichever DOM element the returned ref
|
|
27
|
+
* callback is attached to. On attach → `createViewAnchor(el, opts)`; on
|
|
28
|
+
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
29
|
+
* `update`; on unmount → publish ZERO then `dispose`.
|
|
30
|
+
*
|
|
31
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
32
|
+
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
33
|
+
* `dispose()` only stops observing — it deliberately never publishes again
|
|
34
|
+
* (its Contract 6/7). But the host only collapses the native view when it
|
|
35
|
+
* receives `{0,0,0,0}` (isHidden). In production the debug cell is *unmounted*
|
|
36
|
+
* (not `display:none`) when hidden, so the ref goes to `null` and the native
|
|
37
|
+
* view would otherwise stay frozen at its last bounds, floating on
|
|
38
|
+
* top and occluding content. So the adapter (not core) must emit one ZERO via
|
|
39
|
+
* the already-tested `update({ present:false })` path before disposing.
|
|
40
|
+
*/
|
|
41
|
+
export function useViewAnchor(opts: UseViewAnchorOptions): ViewAnchorRef {
|
|
42
|
+
const handleRef = useRef<ViewAnchorHandle | null>(null)
|
|
43
|
+
const elRef = useRef<HTMLElement | null>(null)
|
|
44
|
+
// Latest opts, read by the stable ref callback when it creates the anchor.
|
|
45
|
+
// Synced render-synchronously (NOT in an effect): the ref callback reads
|
|
46
|
+
// `optsRef.current` during *commit* (when the element attaches), which runs
|
|
47
|
+
// before passive effects. An effect-synced ref would be one render stale at
|
|
48
|
+
// that point, so a hidden→shown remount (`present` flips false→true together
|
|
49
|
+
// with the element re-mounting, exactly what the debug cell does) would
|
|
50
|
+
// create the anchor with the old `present:false` and emit a spurious ZERO
|
|
51
|
+
// before the real rect. A render write keeps it current at commit, and is
|
|
52
|
+
// idempotent under StrictMode's double render.
|
|
53
|
+
const optsRef = useRef(opts)
|
|
54
|
+
// eslint-disable-next-line react-hooks/refs -- see above: must be current at commit, before effects run
|
|
55
|
+
optsRef.current = opts
|
|
56
|
+
|
|
57
|
+
// Baseline for the re-apply effect's change detection. Declared here (before
|
|
58
|
+
// the ref callback) so the callback can re-seed it on (re)create.
|
|
59
|
+
const appliedRef = useRef<ReadonlyArray<unknown>>([
|
|
60
|
+
opts.present,
|
|
61
|
+
opts.publish,
|
|
62
|
+
...(opts.deps ?? []),
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
// Collapse the native view (publish ZERO) and tear the anchor down. Reuse
|
|
66
|
+
// the existing, tested `update({ present:false })` path: it synchronously
|
|
67
|
+
// publishes `{0,0,0,0}` and stops observing (core Contract 5), then
|
|
68
|
+
// `dispose()` makes the anchor inert. Idempotent via the `handleRef.current`
|
|
69
|
+
// null-check so the two callers below can never double-emit ZERO.
|
|
70
|
+
const collapseAndDispose = useRef((): void => {
|
|
71
|
+
const handle = handleRef.current
|
|
72
|
+
if (!handle) return
|
|
73
|
+
handle.update({ present: false, publish: optsRef.current.publish })
|
|
74
|
+
handle.dispose()
|
|
75
|
+
handleRef.current = null
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const ref = useCallback<ViewAnchorRef>((el) => {
|
|
79
|
+
if (el === elRef.current) return
|
|
80
|
+
elRef.current = el
|
|
81
|
+
if (handleRef.current) {
|
|
82
|
+
if (el) {
|
|
83
|
+
// Swapping to *another* live element: dispose the old anchor without a
|
|
84
|
+
// ZERO. The new element publishes its real rect immediately below, so
|
|
85
|
+
// a transient ZERO between the two would only cause a needless
|
|
86
|
+
// detach/re-attach flicker of the native view.
|
|
87
|
+
handleRef.current.dispose()
|
|
88
|
+
handleRef.current = null
|
|
89
|
+
} else {
|
|
90
|
+
// Element detached (ref → null): the anchor point is gone, so collapse
|
|
91
|
+
// the native view (one ZERO) before disposing.
|
|
92
|
+
collapseAndDispose.current()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (el) {
|
|
96
|
+
handleRef.current = createViewAnchor(el, {
|
|
97
|
+
present: optsRef.current.present,
|
|
98
|
+
publish: optsRef.current.publish,
|
|
99
|
+
})
|
|
100
|
+
// The anchor was just created at the current (present, publish, deps), so
|
|
101
|
+
// seed the re-apply baseline to match. Otherwise the post-commit re-apply
|
|
102
|
+
// effect would see this fresh state as a change and publish a second time
|
|
103
|
+
// — on a remount with a changed `present` that is a double-emit.
|
|
104
|
+
appliedRef.current = [
|
|
105
|
+
optsRef.current.present,
|
|
106
|
+
optsRef.current.publish,
|
|
107
|
+
...(optsRef.current.deps ?? []),
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
}, [])
|
|
111
|
+
|
|
112
|
+
// Re-apply on opts/deps change. We must `update` whenever the
|
|
113
|
+
// (present, publish, …deps) tuple actually changes, but NOT on the mount run
|
|
114
|
+
// (the ref callback already created the anchor and published once) and NOT on
|
|
115
|
+
// a StrictMode replay (dev double-fires this effect's setup with the *same*
|
|
116
|
+
// tuple — a blind `update` then re-publishes the mount rect a second time).
|
|
117
|
+
// So instead of guessing "is this the first run?", compare against the
|
|
118
|
+
// last-applied tuple and apply only on a genuine change. The tuple is seeded
|
|
119
|
+
// with the mount opts, so the mount run and its StrictMode replay both see
|
|
120
|
+
// "unchanged" and skip — idempotent by construction. `deps` keeps a stable
|
|
121
|
+
// length across renders (documented above), so positional compare is sound.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const next: ReadonlyArray<unknown> = [
|
|
124
|
+
opts.present,
|
|
125
|
+
opts.publish,
|
|
126
|
+
...(opts.deps ?? []),
|
|
127
|
+
]
|
|
128
|
+
const prev = appliedRef.current
|
|
129
|
+
const changed =
|
|
130
|
+
next.length !== prev.length || next.some((v, i) => !Object.is(v, prev[i]))
|
|
131
|
+
if (!changed) return
|
|
132
|
+
appliedRef.current = next
|
|
133
|
+
handleRef.current?.update({ present: opts.present, publish: opts.publish })
|
|
134
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
|
+
}, [opts.present, opts.publish, ...(opts.deps ?? [])])
|
|
136
|
+
|
|
137
|
+
// Collapse the native view + dispose on teardown.
|
|
138
|
+
//
|
|
139
|
+
// StrictMode-safe lifecycle: this effect's setup/cleanup is double-fired in
|
|
140
|
+
// dev (setup → cleanup → setup). The anchor itself is created/owned by the
|
|
141
|
+
// ref callback, which in React 18 fires exactly once on mount and once with
|
|
142
|
+
// `null` on a real detach — it is NOT replayed by StrictMode. So this effect
|
|
143
|
+
// must not destroy the ref-owned anchor on a *throwaway* unmount, or the
|
|
144
|
+
// re-setup would have nothing to restore.
|
|
145
|
+
//
|
|
146
|
+
// Discriminator: on a real teardown React detaches the element first
|
|
147
|
+
// (`ref(null)` → `elRef.current === null`, and that path already emitted the
|
|
148
|
+
// single ZERO + disposed); on a StrictMode throwaway unmount the element is
|
|
149
|
+
// still attached (`elRef.current !== null`, ref never fired `null`). So we
|
|
150
|
+
// only collapse here when the element is genuinely gone, and otherwise leave
|
|
151
|
+
// the live anchor intact for the immediate re-setup.
|
|
152
|
+
//
|
|
153
|
+
// The setup re-establishes the anchor if a prior cleanup ever tore it down
|
|
154
|
+
// while the element is still attached, keeping setup/cleanup symmetric.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const collapse = collapseAndDispose.current
|
|
157
|
+
if (elRef.current && !handleRef.current) {
|
|
158
|
+
handleRef.current = createViewAnchor(elRef.current, {
|
|
159
|
+
present: optsRef.current.present,
|
|
160
|
+
publish: optsRef.current.publish,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
return () => {
|
|
164
|
+
if (elRef.current === null) collapse()
|
|
165
|
+
}
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
return ref
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type { Bounds }
|