@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.
@@ -0,0 +1,151 @@
1
+ # view-anchor 双向化重构设计(提案)
2
+
3
+ > 状态:契约草案,经四轮对抗评审收敛。本页只定契约,不含实现。
4
+
5
+ ## 1. 目标
6
+
7
+ 把 view-anchor 从「单向锚定」重构成「**双向几何桥**」,且保持引擎无关、传输注入:
8
+
9
+ - **正向(已有)** `createViewAnchor(target, { present, publish })` —— 宿主渲染进程量 DOM 占位矩形 → `publish(bounds)` → IPC → 主进程 `WebContentsView.setBounds`。
10
+ - **反向(新增)** `createSizeAdvertiser(target, { axis, publish })` —— 下游 WebContentsView 自己的渲染进程量自身内容尺寸 → `publish(size)` → IPC → 宿主,宿主据此调整占位尺寸,再经正向把视图贴上去。
11
+
12
+ 动机:workbench 的 toolbar 改成「**交给下游控制**」的 WebContentsView,尺寸的事实来源从宿主翻到了下游一侧(典型:宽宿主主导、高下游内容主导),占位映射变成动态的。
13
+
14
+ 非目标:不是同层渲染(合成器层面、另一套机制);不在包里提供 React 反向适配、不在包里提供宿主决策 `decide`(见 §4/§7)。
15
+
16
+ ## 2. 正反向**不共享发射核心**——两个方向最优时机不同
17
+
18
+ > 设计初稿曾想抽一个共享 `createMeasureLoop` 给两个方向。落地时与 refactor-simulator
19
+ > 的生产正向合并,发现这是**错的**:正反向的最优发射时机本就不同,强行共享会让核心
20
+ > 长出 by-direction flag。最终采取**正向同步、反向 RAF** 的刻意不对称。
21
+
22
+ **正向 `createViewAnchor` = 同步发布,不走 RAF。** 原生 overlay 的 `setBounds` 是跨进程、本就晚约 1 合成帧;RAF 再叠一帧 → 拖拽可见拖尾(refactor-simulator 生产验证过的修复)。所以正向在每个 `ResizeObserver` / window `resize` 触发里**同步**测量+发布,抗洪由 `lastPublished` 同值去重承担。撤销安全靠「每次发布开头同步读 `disposed`/`present`」——没有排队帧可跑赢状态变化。正向的 sink 是 IPC→主进程 `setBounds`,**不碰本渲染进程 DOM**,所以同步发布不会形成 RO 重入循环。
23
+
24
+ **反向 `createSizeAdvertiser` = RAF 合并(内部 `createMeasureLoop`,反向专用)。** 反向是一条**跨进程反馈环**:advertise → 宿主 resize 这块视图 → 下游内容 remeasure → 再 advertise。RAF 的「每帧≤1 次发布」对这条环是合理的阻尼。`createMeasureLoop<T>` 只服务反向,通过注入 `produce`/`same`/`sink` 三元组把方向细节关在外层,核心零 flag:
25
+
26
+ ```ts
27
+ function createMeasureLoop<T>(cfg: {
28
+ produce: () => T | null // RAF 体内取值:读最近一帧 borderBoxSize(null=跳过帧)
29
+ same: (a: T, b: T) => boolean // 去重谓词(反向 = extent 相等)
30
+ sink: (value: T) => void // = 注入的 publish
31
+ }): { schedule, emitNow, setActive, cancel, dispose }
32
+ ```
33
+
34
+ **为什么不对称是对的**:正向是单向跟随者,少一帧直接消除可见拖尾;反向是反馈环,多一帧阻尼更稳。把两者塞进一个核心要么逼出 `if(direction)` flag,要么把正向重新拖回 RAF(复活拖尾)。所以**正向内联同步、反向用 RAF 引擎**,各取所需。
35
+
36
+ ## 3. 反向原语契约
37
+
38
+ ```ts
39
+ export type AdvertisedAxis = 'block' | 'inline'
40
+
41
+ // 帧载荷:纯标量,连「哪条轴」都无从携带 → 单轴在类型层不可拼写。
42
+ export interface AdvertisedSize {
43
+ readonly axis: AdvertisedAxis // 镜像工厂常量,逐帧恒等,供宿主白名单校验
44
+ readonly extent: number // 主导轴内容尺寸,CSS px,已 round,钳到 >= 0
45
+ }
46
+
47
+ export interface SizeAdvertiserOptions {
48
+ axis: AdvertisedAxis // 创建期定死,一个 advertiser 一生只报一条轴
49
+ publish: (size: AdvertisedSize) => void // 注入;下游接 IPC/postMessage → 宿主(与正向 publish 同名同形)
50
+ }
51
+
52
+ export interface SizeAdvertiserHandle {
53
+ update(opts: SizeAdvertiserOptions): void // 只能换 publish(换 IPC 通道);axis 不可变
54
+ dispose(): void // 停 observe、取消 RAF,此后永不再 publish
55
+ }
56
+
57
+ export function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOptions): SizeAdvertiserHandle
58
+ ```
59
+
60
+ - 跑在**下游**渲染进程;从 `ResizeObserverEntry.borderBoxSize` 读尺寸(零强制 reflow,**禁**回调内 `getBoundingClientRect`/`scrollHeight`);上报前 `Math.round` + `Math.max(0, ...)`(与正向 `measure` 的 round/钳零对称)。
61
+ - `target` 由调用方选成「主导轴上 shrink-to-fit、不被宿主写入反向决定」的 wrapper(与 `createViewAnchor` 一样,原语不碰调用方 DOM/样式)。
62
+ - **没有 `present`**:反向「停止上报」无指令语义(塌缩还是保持是宿主策略),停就 `dispose`、恢复就重建。不给不可信下游一个隐式控宿主的布尔旁路。
63
+ - 双轴需求 = **两个独立 advertiser 各管一轴**,不是一个 payload 塞两轴(后者把两条单向流伪装成一条双向流,使 DAG 退化成有环图)。
64
+
65
+ ## 4. 单轴所有权(收敛性地基,不可协商)
66
+
67
+ - 一个 advertiser 只测量并只上报**它主导的那条轴**;另一条轴由宿主经 `setBounds` 单向灌入、对下游**只读**。
68
+ - 为什么收敛:宽 = 宿主输入(无回边);高 = block layout 的**输出**而非输入(无回边)→ 整条尺寸传播是单向 DAG,**一步收敛**。违反单轴 = 跨进程双 RAF 乒乓,是抖动/极限环根因。去环靠**拓扑**(单轴 + 类型不可拼写),不靠 epsilon 死区/低通滤波。
69
+
70
+ ## 5. 信任边界(精确划线)
71
+
72
+ > 反向把「尺寸控制权」交给一个**下游控制**(半信任/不可信)的视图。上报值是攻击者输入,不是测量结果。
73
+
74
+ 判据:**凡只需测量上下文的归原语;凡需 viewport/策略/对端身份的归宿主。**
75
+
76
+ | 责任 | 归属 | 说明 |
77
+ |---|---|---|
78
+ | `Math.round` 量化 | 原语 | 测量副产物,出生地最便宜 |
79
+ | 丢 `NaN`/`Infinity` | 原语 | 测量噪声,不该塞进 IPC |
80
+ | 负值 → **钳到 0**(非丢弃) | 原语 | 与正向 `Math.max(0,...)` 对称;钳零=诚实上报「现在测出来是 0」,丢弃会让宿主停在过期值 |
81
+ | 限流 | **两边都不额外加** | 原语的 RAF 合并 + 去重已是终极限流(跟随刷新率、静止零发);宿主用 clamp + `decide` 幂等吸收突发,**不做时间节流**(会吞掉合法离散事件) |
82
+ | `clamp(min, max)` | 宿主 | 唯一持有 viewport/available/策略的一方;对抗恶意巨值的**主防线** |
83
+ | 来源校验(senderFrame/origin/token) | 宿主 | 只能发生在收 IPC 那一刻 |
84
+ | 白名单轴 | 宿主 | 用 payload 的 `axis` 常量比对,`axis !== expected` 则丢弃 |
85
+ | 位置 / 另一轴 / z-order 锁定 | 宿主 | 下游无任何途径改变 → 杜绝全窗覆盖类点击劫持 |
86
+
87
+ **target 选错(反馈环)的防护——克制为主,不过度防御。** 关键事实:违反单轴所有权时,RAF 合并把「死循环」**封顶为每帧一次**——它退化成可见的抖动 / 每帧一次冗余 IPC,而**不是冻死 UI**。既然不是灾难性故障,就不值得为它在每帧路径上堆检测器。最终只保留:
88
+
89
+ - **一条构造期廉价守卫**:`target` 是 `<body>`/`<html>` 时 `console.warn`——这是「stable-but-wrong」(占位永远撑成视图高、不缩到内容)最常见的成因,一次性、零每帧成本。
90
+ - **其余靠文档**:JSDoc 与本节把「target 必须主导轴 shrink-to-fit、不被宿主灌入的尺寸反向决定」讲清。
91
+ - **明确不做**:每帧的 2-cycle / 单调发散检测器(复杂度与「非灾难性、RAF 封顶」的故障不成正比)、运行时熔断(会把可见症状盖成静默失效)、向宿主回传灌入值做对比(会亲手造出 §4 要消灭的反馈边)。发散的真正兜底是宿主侧 `clamp(max)`。
92
+
93
+ ## 6. 两个原语如何「锚」到一起
94
+
95
+ 正向与反向**不直接相互调用**——各自只有一个注入的 `publish` 往 IPC 上发。把两端串到一起的是宿主里的**占位 div** + 宿主的 `decide` 函数。占位 div 是会合点(join point):
96
+
97
+ - **`createSizeAdvertiser` 写**占位 div 的尺寸(经宿主)——内容多高,占位就多高。
98
+ - **`createViewAnchor` 读**占位 div 的矩形——占位在哪、多大,原生视图就贴哪、多大。
99
+
100
+ 即:反向喂占位的「一条轴的大小」,正向把占位的「完整矩形」投到原生视图上。占位 div 是几何的**唯一真相**,两端都围着它转。
101
+
102
+ ### 闭环(以 toolbar 为例)
103
+
104
+ ```
105
+ 宿主渲染进程 宿主主进程 下游渲染进程(toolbar 自己的页面)
106
+ ────────── ──────── ────────────────────────────
107
+ [占位 div] [内容 wrapper(shrink-to-fit)]
108
+ │ ▲ │
109
+ │ │ ② decide 把高写进占位 div │ ① createSizeAdvertiser
110
+ │ │ div.style.height = clamp(size.extent) │ 量 wrapper 的 block-size
111
+ │ └─────────── IPC ◀─────────────────────────────────────┘ publish(size) ──▶
112
+
113
+ │ ③ 占位 div 尺寸变 → createViewAnchor 的 ResizeObserver 触发
114
+ │ 量占位新矩形 → publish(bounds)
115
+
116
+ ──── IPC ──▶ ④ view.setBounds(bounds) ──▶ [WebContentsView(这块 toolbar)]
117
+ │ 视图变 → 下游 viewport 变 → 内容重新布局
118
+ └──▶ 回到 ① advertiser 再量(收敛)
119
+ ```
120
+
121
+ 1. **下游**:`createSizeAdvertiser(wrapper, { axis:'block', publish })` 量内容高 → IPC 发回宿主。
122
+ 2. **宿主**:纯函数 `decide(size, { axis, min, max, available })` 夹一夹,把结果写进**占位 div 的高**(`div.style.height = …`)。
123
+ 3. **宿主**:占位 div 高一变,`createViewAnchor` 挂在它上的 `ResizeObserver` 立刻触发 → 量出占位新矩形(宽 = 宿主布局给的满宽,高 = 刚写进去的内容高)→ `publish(bounds)`。
124
+ 4. **宿主主进程**:`view.setBounds(bounds)` → toolbar 这块 `WebContentsView` 变成新尺寸 → 下游 viewport 变 → 内容重新布局 → 回到 ①。
125
+
126
+ ### 谁负责什么
127
+
128
+ | 角色 | 谁提供 | 在 view-anchor 里? |
129
+ |---|---|---|
130
+ | `createSizeAdvertiser`(量内容 → 发 size) | view-anchor | ✅ |
131
+ | `createViewAnchor`(量占位 → 发 bounds) | view-anchor | ✅ |
132
+ | `decide`(把 size 写进占位 div 的高)+ 两条 IPC 通道 | **宿主(workbench)** | ❌(刻意,属宿主胶水) |
133
+ | 占位 div、shrink-to-fit wrapper | 调用方的 DOM | ❌ |
134
+
135
+ - 收敛逻辑收进宿主一个**纯函数** `decide(...)`(可单测、无跨进程协议)。`decide` 住宿主侧,**不进 view-anchor**——包只出两个单向原语,不出收敛策略,否则就从原语滑向框架(§8)。
136
+ - 连续交互(拖窗改宽)走宿主**单向**路径,不进闭环;反向只服务下游内容的**离散**尺寸变化。
137
+
138
+ ## 7. 与 Electron preferred-size 的关系(宿主侧可选数据源,非替代)
139
+
140
+ 宿主若在 Electron 且能接受零下游代码,可用 `enablePreferredSizeMode` + `preferred-size-changed` 作为反向「**源**」喂给同一个 `decide`,省掉下游注入。但它是 Electron 私有、且有两点待 spike 坐实:① 在 `WebContentsView` 上触发且上报值吸收 zoom;② `setBounds.height` 不回灌 layout(否则正反馈震荡)。
141
+
142
+ view-anchor 的反向原语是**引擎无关/可移植**那条路(非 Electron / iframe 宿主、需选子 target 的场景)。两者并存:preferred-size 是 Electron 捷径,advertiser 是可移植机制,`decide` + §5 安全红线是二者共用的公共底座。这也是反向逻辑该留在 view-anchor、而非写死成 Electron 事件的理由。spike 结果只决定宿主选哪个**源**,不改变 view-anchor 要不要建反向原语。
143
+
144
+ ## 8. 包定位守恒(仍是一个原语,不是框架)
145
+
146
+ 双向化后 view-anchor 仍是「DOM 几何 ↔ 跨进程视图」的**单一职责双向桥**。守住四条即不滑向框架:① 导出面只多 `createSizeAdvertiser` + 其类型;② `createMeasureLoop` 不导出;③ 现在不做 React 反向适配(下游不保证是 React);④ `decide` 留宿主、不进包。
147
+
148
+ ## 9. 已收敛 / 仍待拍板
149
+
150
+ 已由对抗评审定:payload 单标量 `{axis, extent}`(关闭「单标量 vs 可空双轴」);命名 `createSizeAdvertiser`/`AdvertisedSize`/`publish`(关闭命名问题);`present` 砍掉。
151
+ 仍待定:React 反向适配 `useSizeAdvertiser` 是否要做(默认否,等出现真实 React 下游)。
@@ -0,0 +1,96 @@
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
+ ## useViewAnchor(opts)
65
+
66
+ React 适配层,返回一个挂到占位元素上的 ref 回调。
67
+
68
+ ```tsx
69
+ const ref = useViewAnchor({
70
+ present, // boolean
71
+ publish, // (bounds) => void
72
+ deps: [signature], // 可选:会移动矩形、但 DOM 看不见的状态
73
+ })
74
+ return <div ref={ref} />
75
+ ```
76
+
77
+ | 时机 | 行为 |
78
+ |---|---|
79
+ | 挂载 | `createViewAnchor(el, opts)` |
80
+ | `opts` / `deps` 变化 | `update` |
81
+ | 卸下(`ref → null`)或卸载 | 先发一帧零矩形,再 `dispose` |
82
+
83
+ **`deps`** —— `ResizeObserver` 只响应纯几何变化。会移动矩形却不改变被观察元素尺寸的状态(布局拓扑签名、路由切换、兄弟标签页 `display:none`)要放进 `deps`。数组长度需在每次渲染间保持稳定。
84
+
85
+ > 适配层已处理 React 18 StrictMode 的 effect 双触发,以及 hidden→shown 重挂载——保证恰好发布一次真实矩形、不误发零矩形。
86
+
87
+ ## 文件
88
+
89
+ | 文件 | 作用 |
90
+ |---|---|
91
+ | `src/view-anchor.ts` | 命令式核心 `createViewAnchor`。无 React、无 Electron。 |
92
+ | `src/react.ts` | React 适配层 `useViewAnchor`。 |
93
+ | `src/types.ts` | `Bounds`、`ViewAnchorOptions`、`ViewAnchorHandle`。 |
94
+ | `src/index.ts` | 对外公开面。 |
95
+
96
+ 正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize` 监听)——**不含 `requestAnimationFrame`**(正向同步发布;RAF 只在反向 `createSizeAdvertiser` 用)。
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@dimina-kit/view-anchor",
3
+ "version": "0.1.0-dev.20260610082053",
4
+ "description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
5
+ "keywords": [
6
+ "dimina",
7
+ "devtools",
8
+ "electron",
9
+ "webcontentsview",
10
+ "react"
11
+ ],
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "license": "MIT",
18
+ "author": "EchoTechFE",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/EchoTechFE/dimina-kit.git",
22
+ "directory": "packages/view-anchor"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/EchoTechFE/dimina-kit/issues"
26
+ },
27
+ "homepage": "https://github.com/EchoTechFE/dimina-kit/tree/main/packages/view-anchor#readme",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src",
39
+ "docs",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "peerDependencies": {
44
+ "react": ">=18"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "react": {
48
+ "optional": true
49
+ }
50
+ },
51
+ "devDependencies": {
52
+ "@testing-library/react": "^16.3.2",
53
+ "@types/react": "^18.3.12",
54
+ "@vitejs/plugin-react": "^6.0.1",
55
+ "esbuild": "^0.28.0",
56
+ "eslint": "^10.2.1",
57
+ "jsdom": "^29.0.2",
58
+ "react": "^18.3.1",
59
+ "react-dom": "^18.3.1",
60
+ "typescript": "5.9.2",
61
+ "vitest": "^4.1.4",
62
+ "@dimina-kit/eslint-config": "0.1.0",
63
+ "@dimina-kit/typescript-config": "0.1.0"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "scripts": {
69
+ "build": "tsc -p tsconfig.build.json && pnpm run build:docs",
70
+ "build:docs": "node scripts/build-docs.mjs",
71
+ "check-types": "tsc --noEmit",
72
+ "lint": "eslint . --max-warnings 0",
73
+ "test": "vitest run",
74
+ "test:dev": "vitest"
75
+ }
76
+ }
@@ -0,0 +1,255 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
2
+ import { createPlacementAnchor } from './view-anchor.js'
3
+ import type { Placement } from './types.js'
4
+
5
+ // ── press-pause-drag: the windowed RAF geometry sentinel must NOT close
6
+ // while the pointer is still held down ───────────────────────────────
7
+ //
8
+ // TDD — FAILING-FIRST. Locks the press-pause-drag behaviour of the
9
+ // `followGeometry` sentinel (view-anchor-following.md §2.D / §6).
10
+ //
11
+ // The contract (§2.D, line "一次 splitter 拖动 = 开窗→拖动中每帧跟随→
12
+ // 松手后 2~3 帧内判静止→关窗") is explicit that the windowed sentinel only
13
+ // closes on *steady* frames AFTER release (松手 = pointerup). The current
14
+ // implementation instead closes after N consecutive identical frames
15
+ // UNCONDITIONALLY — so a press that pauses before the drag actually starts
16
+ // (very common: user clicks the splitter, hesitates a frame or two, THEN
17
+ // drags) closes the window mid-press, and the entire subsequent drag is
18
+ // dropped (the native view freezes at its pre-drag position).
19
+ //
20
+ // This file pins:
21
+ // 1. press-pause-drag: pointerdown → ≥2 static frames (current close
22
+ // threshold) → rect starts moving → the movement is STILL followed.
23
+ // RED today because the sentinel closes after the static pause and the
24
+ // drag frames find no scheduled rAF.
25
+ // 2. no-regression: pointerup → static frames → the sentinel still
26
+ // eventually closes (we must not "fix" #1 by keeping the window open
27
+ // forever / spinning a rAF while idle).
28
+
29
+ // ── Controllable fake requestAnimationFrame (mirrors view-anchor.test.ts'
30
+ // FakeRaf: a queue we flush one frame at a time). ───────────────────
31
+ class FakeRaf {
32
+ private cbs = new Map<number, FrameRequestCallback>()
33
+ private nextId = 1
34
+ request = vi.fn((cb: FrameRequestCallback): number => {
35
+ const id = this.nextId++
36
+ this.cbs.set(id, cb)
37
+ return id
38
+ })
39
+ cancel = vi.fn((id: number): void => {
40
+ this.cbs.delete(id)
41
+ })
42
+ /** Drain exactly the callbacks pending at call-time; a re-request lands in
43
+ * the next flush, so flushFrame() advances exactly one frame. */
44
+ flushFrame(ts = 0): void {
45
+ const pending = [...this.cbs.entries()]
46
+ this.cbs.clear()
47
+ for (const [, cb] of pending) cb(ts)
48
+ }
49
+ /** Is a frame currently scheduled (sentinel window still open)? */
50
+ get pending(): number {
51
+ return this.cbs.size
52
+ }
53
+ }
54
+
55
+ // ── Minimal ResizeObserver stub (the anchor installs one on a visible
56
+ // target; jsdom has none). We never need to fire it here. ───────────
57
+ class FakeResizeObserver {
58
+ static instances: FakeResizeObserver[] = []
59
+ observed: Element[] = []
60
+ disconnected = false
61
+ constructor(public cb: ResizeObserverCallback) {
62
+ FakeResizeObserver.instances.push(this)
63
+ }
64
+ observe(el: Element): void {
65
+ this.observed.push(el)
66
+ }
67
+ unobserve(): void {
68
+ /* unused */
69
+ }
70
+ disconnect(): void {
71
+ this.disconnected = true
72
+ }
73
+ }
74
+
75
+ let raf: FakeRaf
76
+
77
+ beforeEach(() => {
78
+ FakeResizeObserver.instances = []
79
+ raf = new FakeRaf()
80
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver)
81
+ vi.stubGlobal(
82
+ 'requestAnimationFrame',
83
+ raf.request as unknown as typeof window.requestAnimationFrame,
84
+ )
85
+ vi.stubGlobal(
86
+ 'cancelAnimationFrame',
87
+ raf.cancel as unknown as typeof window.cancelAnimationFrame,
88
+ )
89
+ })
90
+
91
+ afterEach(() => {
92
+ vi.restoreAllMocks()
93
+ vi.unstubAllGlobals()
94
+ })
95
+
96
+ // jsdom's getBoundingClientRect returns zeros; stub it and let `setRect`
97
+ // move the element after creation (to drive the sentinel's poll).
98
+ function buildElement(rect: { x: number; y: number; w: number; h: number }): {
99
+ el: HTMLElement
100
+ setRect: (next: { x: number; y: number; w: number; h: number }) => void
101
+ } {
102
+ const el = document.createElement('div')
103
+ let current = rect
104
+ vi.spyOn(el, 'getBoundingClientRect').mockImplementation(
105
+ () =>
106
+ ({
107
+ x: current.x,
108
+ y: current.y,
109
+ left: current.x,
110
+ top: current.y,
111
+ right: current.x + current.w,
112
+ bottom: current.y + current.h,
113
+ width: current.w,
114
+ height: current.h,
115
+ toJSON: () => ({}),
116
+ }) as DOMRect,
117
+ )
118
+ return {
119
+ el,
120
+ setRect(next) {
121
+ current = next
122
+ },
123
+ }
124
+ }
125
+
126
+ /** A `[role="separator"]` splitter (the drag handle): a capture-phase
127
+ * pointerdown matching it opens the sentinel window (§2.D). */
128
+ function buildSplitter(): HTMLElement {
129
+ const sep = document.createElement('div')
130
+ sep.setAttribute('role', 'separator')
131
+ document.body.appendChild(sep)
132
+ return sep
133
+ }
134
+
135
+ function dispatchPointerdown(target: HTMLElement): void {
136
+ target.dispatchEvent(new Event('pointerdown', { bubbles: true }))
137
+ }
138
+
139
+ /** Pointer release — dispatched bubbling from the splitter so a window
140
+ * capture/bubble listener sees it. Also dispatched on window directly as a
141
+ * belt-and-braces in case the close is gated on a window-level pointerup. */
142
+ function dispatchPointerup(target: HTMLElement): void {
143
+ target.dispatchEvent(new Event('pointerup', { bubbles: true }))
144
+ window.dispatchEvent(new Event('pointerup'))
145
+ }
146
+
147
+ // new options aren't on the public types yet — cast through.
148
+ type FollowOpts = Parameters<typeof createPlacementAnchor>[1] & {
149
+ followGeometry?: boolean
150
+ }
151
+ const mk = (
152
+ el: HTMLElement,
153
+ o: { visible: boolean; publish: (p: Placement) => void; followGeometry?: boolean },
154
+ ): ReturnType<typeof createPlacementAnchor> =>
155
+ createPlacementAnchor(el, o as FollowOpts)
156
+
157
+ describe('createPlacementAnchor — press-pause-drag (followGeometry sentinel must survive a held pause)', () => {
158
+ // 1. THE BUG. pointerdown opens the window; the user then hesitates for a
159
+ // couple of frames (rect identical) BEFORE starting to drag. While the
160
+ // pointer is still DOWN, those static frames must NOT permanently close
161
+ // the sentinel — when the drag finally moves the rect, the move must
162
+ // still be followed.
163
+ it('pointerdown → static pause (≥2 identical frames) → drag moves: the drag is STILL followed', () => {
164
+ const publish = vi.fn<(p: Placement) => void>()
165
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
166
+ mk(el, { visible: true, followGeometry: true, publish })
167
+ const splitter = buildSplitter()
168
+
169
+ // Press the splitter — window opens, a frame is scheduled.
170
+ dispatchPointerdown(splitter)
171
+ expect(raf.request).toHaveBeenCalled()
172
+ publish.mockClear()
173
+
174
+ // Held pause: TWO consecutive identical frames (== the close threshold the
175
+ // B-close test pins). Under the buggy impl these close the window even
176
+ // though the pointer is still down.
177
+ raf.flushFrame() // static frame 1 (rect unchanged since open)
178
+ raf.flushFrame() // static frame 2
179
+
180
+ // Now the drag actually begins: rect moves on subsequent frames. Because
181
+ // the pointer was never released, the sentinel must still be polling.
182
+ setRect({ x: 25, y: 0, w: 100, h: 100 })
183
+ raf.flushFrame()
184
+
185
+ // The bug: the static pause closed the window, so this drag frame either
186
+ // ran nothing (no rAF pending) → publish never called, OR (if the window
187
+ // was already cancelled) `raf.pending` is 0 and the move is lost.
188
+ expect(publish).toHaveBeenCalledTimes(1)
189
+ expect(publish).toHaveBeenLastCalledWith({
190
+ visible: true,
191
+ bounds: { x: 25, y: 0, width: 100, height: 100 },
192
+ })
193
+
194
+ // And it keeps following further drag frames within the same press.
195
+ setRect({ x: 50, y: 0, w: 100, h: 100 })
196
+ raf.flushFrame()
197
+ expect(publish).toHaveBeenCalledTimes(2)
198
+ expect(publish).toHaveBeenLastCalledWith({
199
+ visible: true,
200
+ bounds: { x: 50, y: 0, width: 100, height: 100 },
201
+ })
202
+ })
203
+
204
+ // 1b. A stricter restatement: the window stays OPEN (a frame remains
205
+ // scheduled) across a held static pause. This isolates the mechanism —
206
+ // even before any further move, the sentinel must not have stopped
207
+ // re-arming while the pointer is held.
208
+ it('the sentinel window stays open (a frame stays scheduled) across a held static pause', () => {
209
+ const publish = vi.fn<(p: Placement) => void>()
210
+ const { el } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
211
+ mk(el, { visible: true, followGeometry: true, publish })
212
+ const splitter = buildSplitter()
213
+
214
+ dispatchPointerdown(splitter)
215
+ expect(raf.pending).toBeGreaterThanOrEqual(1)
216
+
217
+ // Several static held frames — pointer still down, must keep re-arming.
218
+ raf.flushFrame()
219
+ raf.flushFrame()
220
+ raf.flushFrame()
221
+
222
+ expect(raf.pending).toBeGreaterThanOrEqual(1)
223
+ })
224
+
225
+ // 2. NO REGRESSION. After the pointer is RELEASED (pointerup), a steady
226
+ // geometry must still close the window — we must not "fix" #1 by leaving
227
+ // the sentinel spinning forever once a press has started.
228
+ it('pointerup then steady frames → the sentinel still closes (does not spin forever)', () => {
229
+ const publish = vi.fn<(p: Placement) => void>()
230
+ const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
231
+ mk(el, { visible: true, followGeometry: true, publish })
232
+ const splitter = buildSplitter()
233
+
234
+ dispatchPointerdown(splitter)
235
+ // A real drag frame to prove it's live.
236
+ setRect({ x: 30, y: 0, w: 100, h: 100 })
237
+ raf.flushFrame()
238
+ expect(raf.pending).toBeGreaterThanOrEqual(1)
239
+
240
+ // Release, then go steady (rect identical from here on).
241
+ dispatchPointerup(splitter)
242
+ // Give the close logic its consecutive-identical frames to converge.
243
+ raf.flushFrame()
244
+ raf.flushFrame()
245
+ raf.flushFrame()
246
+ raf.flushFrame()
247
+
248
+ // Window must have closed: no frame pending and a further flush schedules
249
+ // nothing new (no idle spin).
250
+ const requestsBefore = raf.request.mock.calls.length
251
+ raf.flushFrame()
252
+ expect(raf.pending).toBe(0)
253
+ expect(raf.request.mock.calls.length).toBe(requestsBefore)
254
+ })
255
+ })
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'