@dimina-kit/view-anchor 0.1.0-dev.20260616102751 → 0.1.0-dev.20260624040247
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/README.md +10 -6
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/view-anchor.d.ts +2 -2
- package/dist/view-anchor.js +18 -18
- package/docs/bidirectional-design.md +17 -28
- package/docs/mechanism.mdx +27 -4
- package/package.json +4 -2
- package/src/follow-geometry-press-drag.fix.test.ts +10 -13
- package/src/placement.test.ts +22 -25
- package/src/react.test.tsx +13 -17
- package/src/react.ts +1 -1
- package/src/view-anchor.test.ts +56 -69
- package/src/view-anchor.ts +20 -20
package/README.md
CHANGED
|
@@ -66,22 +66,26 @@ handle.update(publish) // 换 publish(IPC 通道),并立即把当前尺寸
|
|
|
66
66
|
handle.dispose() // 停止观察;此后不再上报
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
它从 `ResizeObserver` 的 border-box 取主导轴尺寸(不调 `getBoundingClientRect`、不强制 reflow),`Math.round` +
|
|
69
|
+
它从 `ResizeObserver` 的 border-box 取主导轴尺寸(不调 `getBoundingClientRect`、不强制 reflow),`Math.round` + 钳零,经反向专用的 RAF 合并 + 去重发出(正向同步、反向 RAF,刻意不对称)。宿主收到尺寸后调整占位、再由正向把视图贴上去——两个单向原语经占位 div 串成一座双向桥。**注意 footgun**:`target` 必须在主导轴上 shrink-to-fit(其尺寸不能被宿主灌入的视图尺寸反向决定),否则跨进程环不收敛。详见 [`docs/bidirectional-design.md`](./docs/bidirectional-design.md)。
|
|
70
70
|
|
|
71
71
|
## 文档
|
|
72
72
|
|
|
73
|
-
- [`docs/mechanism.mdx`](./docs/mechanism.mdx) ——
|
|
74
|
-
- [`docs/bidirectional-design.md`](./docs/bidirectional-design.md) ——
|
|
73
|
+
- [`docs/mechanism.mdx`](./docs/mechanism.mdx) —— 正向完整机制:同步发布与陈旧帧安全性、`present` / 零矩形 / 卸载契约、React 18 StrictMode 生命周期。内嵌可交互 3D 演示 [`docs/anchor-3d.html`](./docs/anchor-3d.html)。
|
|
74
|
+
- [`docs/bidirectional-design.md`](./docs/bidirectional-design.md) —— 双向几何桥:正向同步 / 反向 RAF 的刻意不对称、单轴所有权与收敛性、信任边界、以及两个原语如何「锚」到一起。
|
|
75
75
|
|
|
76
76
|
## API
|
|
77
77
|
|
|
78
78
|
| 导出 | 类型 | 作用 |
|
|
79
79
|
|---|---|---|
|
|
80
|
-
| `createViewAnchor(target, opts)` | 函数 | 正向命令式核心,返回 `{ update, dispose }
|
|
81
|
-
| `
|
|
80
|
+
| `createViewAnchor(target, opts)` | 函数 | 正向命令式核心,返回 `{ update, dispose }`。`present:false` 用零矩形 `{0,0,0,0}` 表示收起。不依赖 React、不依赖 Electron。 |
|
|
81
|
+
| `createPlacementAnchor(target, opts)` | 函数 | 正向核心的显式 `Placement` 变体:可见性是判别式 `{ visible:true, bounds }` / `{ visible:false }`,绝不从零尺寸推断——真正 0×0 但在屏的视图与隐藏视图就此可区分。另支持 opt-in 的 `guardDisplayNone` / `followScroll` / `followGeometry`(按需开窗的 RAF 几何哨兵)+ `pulse()`。 |
|
|
82
|
+
| `measurePlacement(target)` | 函数 | 纯测量:读 `target` 矩形,包成 `{ visible:true, bounds }`。 |
|
|
83
|
+
| `useViewAnchor(opts)` | Hook | 正向 React 适配层(基于 `createViewAnchor`),返回一个挂到占位元素上的 ref 回调。 |
|
|
82
84
|
| `createSizeAdvertiser(target, opts)` | 函数 | 反向命令式核心,返回 `{ update, dispose }`。下游量内容尺寸回流给宿主。 |
|
|
83
85
|
| `Bounds` | 类型 | `{ x, y, width, height }`,单位为 CSS 像素。 |
|
|
84
|
-
| `
|
|
86
|
+
| `Placement` | 类型 | `{ visible:true; bounds:Bounds } \| { visible:false }`,显式可见性判别式。 |
|
|
87
|
+
| `ViewAnchorOptions` / `ViewAnchorHandle` | 类型 | 正向零矩形核心的选项与句柄形状。 |
|
|
88
|
+
| `PlacementAnchorOptions` / `PlacementAnchorHandle` | 类型 | 正向 `Placement` 核心的选项与句柄形状。 |
|
|
85
89
|
| `UseViewAnchorOptions` / `ViewAnchorRef` | 类型 | 正向适配层的选项与 ref 回调形状。 |
|
|
86
90
|
| `AdvertisedAxis` / `AdvertisedSize` | 类型 | 反向的轴(`'block'\|'inline'`)与帧载荷 `{ axis, extent }`。 |
|
|
87
91
|
| `SizeAdvertiserOptions` / `SizeAdvertiserHandle` | 类型 | 反向核心的选项与句柄形状。 |
|
package/dist/react.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type ViewAnchorRef = (el: HTMLElement | null) => void;
|
|
|
23
23
|
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
24
24
|
* `update`; on unmount → publish ZERO then `dispose`.
|
|
25
25
|
*
|
|
26
|
-
* Why ZERO on disappearance
|
|
26
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
27
27
|
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
28
28
|
* `dispose()` only stops observing — it deliberately never publishes again
|
|
29
29
|
* (its Contract 6/7). But the host only collapses the native view when it
|
package/dist/react.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createViewAnchor } from './view-anchor.js';
|
|
|
6
6
|
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
7
7
|
* `update`; on unmount → publish ZERO then `dispose`.
|
|
8
8
|
*
|
|
9
|
-
* Why ZERO on disappearance
|
|
9
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
10
10
|
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
11
11
|
* `dispose()` only stops observing — it deliberately never publishes again
|
|
12
12
|
* (its Contract 6/7). But the host only collapses the native view when it
|
package/dist/view-anchor.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface PlacementAnchorOptions {
|
|
|
50
50
|
*/
|
|
51
51
|
guardDisplayNone?: boolean;
|
|
52
52
|
/**
|
|
53
|
-
* Opt-in capture-phase ancestor-scroll follow
|
|
53
|
+
* Opt-in capture-phase ancestor-scroll follow. When true, the anchor
|
|
54
54
|
* listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
|
|
55
55
|
* bubble, but reach `window` while capturing), so an ancestor scroll
|
|
56
56
|
* container scrolling the target re-measures and re-publishes. With
|
|
@@ -60,7 +60,7 @@ export interface PlacementAnchorOptions {
|
|
|
60
60
|
*/
|
|
61
61
|
followScroll?: boolean;
|
|
62
62
|
/**
|
|
63
|
-
* Opt-in windowed RAF geometry sentinel
|
|
63
|
+
* Opt-in windowed RAF geometry sentinel. Catches ancestor
|
|
64
64
|
* transform / reflow moves that no DOM event reports. The sentinel is
|
|
65
65
|
* NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
|
|
66
66
|
* splitter pointerdown, or an explicit `pulse()`), polls geometry once per
|
package/dist/view-anchor.js
CHANGED
|
@@ -173,7 +173,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
173
173
|
let io = null;
|
|
174
174
|
let lastPublished = null;
|
|
175
175
|
let disposed = false;
|
|
176
|
-
// ── Windowed RAF geometry sentinel state
|
|
176
|
+
// ── Windowed RAF geometry sentinel state ──────────────────
|
|
177
177
|
// The sentinel is a windowed poll, opened on demand and auto-closing once
|
|
178
178
|
// the geometry goes steady. It publishes IN-FRAME (no nested defer): each
|
|
179
179
|
// frame measures, and either publishes a changed rect synchronously or
|
|
@@ -183,12 +183,12 @@ export function createPlacementAnchor(target, opts) {
|
|
|
183
183
|
let steadyFrames = 0;
|
|
184
184
|
const STEADY_CLOSE_FRAMES = 2;
|
|
185
185
|
// True while a [role="separator"] splitter drag is in progress: set on the
|
|
186
|
-
// capture-phase pointerdown that opened the window, cleared on pointerup
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
186
|
+
// capture-phase pointerdown that opened the window, cleared on pointerup.
|
|
187
|
+
// A held pointer means the drag may still resume after a static pause, so a
|
|
188
|
+
// steady run while held must NOT close the sentinel — it only closes once the
|
|
189
|
+
// pointer is released. Without this gate a press that pauses a couple of
|
|
190
|
+
// frames before the drag actually moves would close mid-press and drop the
|
|
191
|
+
// entire subsequent drag.
|
|
192
192
|
let pointerHeld = false;
|
|
193
193
|
// Absolute time (performance.now()) past which a `pulse(durationMs)` window
|
|
194
194
|
// force-closes even if the geometry is still changing — the upper bound that
|
|
@@ -227,7 +227,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
227
227
|
sentinelDeadline = null;
|
|
228
228
|
return;
|
|
229
229
|
}
|
|
230
|
-
//
|
|
230
|
+
// Upper bound: a pulse window past its deadline closes regardless of
|
|
231
231
|
// motion, so a target that changes every frame can't keep the sentinel alive.
|
|
232
232
|
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
233
233
|
sentinelDeadline = null;
|
|
@@ -236,7 +236,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
236
236
|
const p = computePlacement();
|
|
237
237
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
238
238
|
steadyFrames++;
|
|
239
|
-
// Steady-close only fires once the pointer is RELEASED
|
|
239
|
+
// Steady-close only fires once the pointer is RELEASED: while a
|
|
240
240
|
// splitter drag is held, a static pause is a hesitation, not the end of
|
|
241
241
|
// the drag, so we keep polling (re-arm below) and let `steadyFrames`
|
|
242
242
|
// accrue — it converges to a close within N frames after pointerup.
|
|
@@ -251,7 +251,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
251
251
|
steadyFrames = 0;
|
|
252
252
|
}
|
|
253
253
|
// `publish` may have synchronously disposed (or hidden) the anchor; re-read
|
|
254
|
-
// live state so a re-entrant teardown leaves ZERO scheduled frames
|
|
254
|
+
// live state so a re-entrant teardown leaves ZERO scheduled frames.
|
|
255
255
|
if (!disposed && visible) {
|
|
256
256
|
rafId = requestAnimationFrame(sentinelFrame); // keep polling
|
|
257
257
|
}
|
|
@@ -275,18 +275,18 @@ export function createPlacementAnchor(target, opts) {
|
|
|
275
275
|
sentinelDeadline = null;
|
|
276
276
|
pointerHeld = false;
|
|
277
277
|
};
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// without it, a single synchronous emit() follows the new rect
|
|
278
|
+
// An ancestor scroll moved the target's screen rect. With the sentinel on,
|
|
279
|
+
// open the window so the whole scroll burst is followed frame-by-frame;
|
|
280
|
+
// without it, a single synchronous emit() follows the new rect.
|
|
281
281
|
const onScroll = () => {
|
|
282
282
|
if (followGeometry)
|
|
283
283
|
openSentinel();
|
|
284
284
|
else
|
|
285
285
|
emit();
|
|
286
286
|
};
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
287
|
+
// A capture-phase pointerdown on a [role="separator"] splitter handle marks
|
|
288
|
+
// the start of a drag that moves the target via ancestor reflow (no RO tick)
|
|
289
|
+
// → open the sentinel.
|
|
290
290
|
const onPointerDown = (e) => {
|
|
291
291
|
const t = e.target;
|
|
292
292
|
if (t && t.closest && t.closest('[role="separator"]')) {
|
|
@@ -294,7 +294,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
294
294
|
openSentinel();
|
|
295
295
|
}
|
|
296
296
|
};
|
|
297
|
-
//
|
|
297
|
+
// Pointer released: the drag is over, so a steady run may now close the
|
|
298
298
|
// sentinel. Re-open it (a no-op if already polling) so the steady-close
|
|
299
299
|
// threshold is reached even if the geometry was already static at release.
|
|
300
300
|
const onPointerUp = () => {
|
|
@@ -378,7 +378,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
378
378
|
stopObserving();
|
|
379
379
|
},
|
|
380
380
|
pulse(durationMs) {
|
|
381
|
-
// Imperative window open
|
|
381
|
+
// Imperative window open: start the animation-follow window. It
|
|
382
382
|
// closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
|
|
383
383
|
// at that deadline — whichever comes first. The deadline is the upper bound
|
|
384
384
|
// that guarantees a still-animating target cannot keep the sentinel
|
|
@@ -1,27 +1,21 @@
|
|
|
1
|
-
# view-anchor
|
|
1
|
+
# view-anchor 双向几何桥
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
view-anchor 是一座**双向几何桥**,引擎无关、传输注入:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- **正向** `createViewAnchor(target, { present, publish })` —— 宿主渲染进程量 DOM 占位矩形 → `publish(bounds)` → IPC → 主进程 `WebContentsView.setBounds`。
|
|
6
|
+
- **反向** `createSizeAdvertiser(target, { axis, publish })` —— 下游 WebContentsView 自己的渲染进程量自身内容尺寸 → `publish(size)` → IPC → 宿主,宿主据此调整占位尺寸,再经正向把视图贴上去。
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
适用场景:toolbar 这类「**交给下游控制**」的 WebContentsView,尺寸的事实来源在下游一侧(典型:宽由宿主主导、高由下游内容主导),占位映射是动态的。
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
- **反向(新增)** `createSizeAdvertiser(target, { axis, publish })` —— 下游 WebContentsView 自己的渲染进程量自身内容尺寸 → `publish(size)` → IPC → 宿主,宿主据此调整占位尺寸,再经正向把视图贴上去。
|
|
10
|
+
范围之外:不是同层渲染(合成器层面、另一套机制);包里不提供 React 反向适配、不提供宿主决策 `decide`(见 §4/§7)。
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## 2. 正反向不共享发射核心——两个方向最优时机不同
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
正反向的最优发射时机本就不同,所以它们**不共享发射核心**,采取**正向同步、反向 RAF** 的刻意不对称。
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
**正向 `createViewAnchor` = 同步发布,不走 RAF。** 原生 overlay 的 `setBounds` 是跨进程、本就晚约 1 合成帧;RAF 再叠一帧 → 拖拽可见拖尾。所以正向在每个 `ResizeObserver` / window `resize` 触发里**同步**测量+发布,抗洪由 `lastPublished` 同值去重承担。撤销安全靠「每次发布开头同步读 `disposed`/`present`」——没有排队帧可跑赢状态变化。正向的 sink 是 IPC→主进程 `setBounds`,**不碰本渲染进程 DOM**,所以同步发布不会形成 RO 重入循环。
|
|
17
17
|
|
|
18
|
-
|
|
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:
|
|
18
|
+
**反向 `createSizeAdvertiser` = RAF 合并(内部 `createMeasureLoop`,反向专用)。** 反向是一条**跨进程反馈环**:advertise → 宿主 resize 这块视图 → 下游内容 remeasure → 再 advertise。RAF 的「每帧≤1 次发布」对这条环是合理的阻尼。`createMeasureLoop<T>` 只服务反向(正向不用它),通过注入 `produce`/`same`/`sink` 三元组把方向细节关在外层,核心零 flag:
|
|
25
19
|
|
|
26
20
|
```ts
|
|
27
21
|
function createMeasureLoop<T>(cfg: {
|
|
@@ -31,7 +25,7 @@ function createMeasureLoop<T>(cfg: {
|
|
|
31
25
|
}): { schedule, emitNow, setActive, cancel, dispose }
|
|
32
26
|
```
|
|
33
27
|
|
|
34
|
-
**为什么不对称是对的**:正向是单向跟随者,少一帧直接消除可见拖尾;反向是反馈环,多一帧阻尼更稳。把两者塞进一个核心要么逼出 `if(direction)` flag
|
|
28
|
+
**为什么不对称是对的**:正向是单向跟随者,少一帧直接消除可见拖尾;反向是反馈环,多一帧阻尼更稳。把两者塞进一个核心要么逼出 `if(direction)` flag,要么把正向也拖进 RAF(带回拖尾)。所以**正向内联同步、反向用 RAF 引擎**,各取所需。
|
|
35
29
|
|
|
36
30
|
## 3. 反向原语契约
|
|
37
31
|
|
|
@@ -50,7 +44,7 @@ export interface SizeAdvertiserOptions {
|
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
export interface SizeAdvertiserHandle {
|
|
53
|
-
update(
|
|
47
|
+
update(publish: (size: AdvertisedSize) => void): void // 只能换 publish(换 IPC 通道);axis 不可变
|
|
54
48
|
dispose(): void // 停 observe、取消 RAF,此后永不再 publish
|
|
55
49
|
}
|
|
56
50
|
|
|
@@ -84,7 +78,7 @@ export function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOp
|
|
|
84
78
|
| 白名单轴 | 宿主 | 用 payload 的 `axis` 常量比对,`axis !== expected` 则丢弃 |
|
|
85
79
|
| 位置 / 另一轴 / z-order 锁定 | 宿主 | 下游无任何途径改变 → 杜绝全窗覆盖类点击劫持 |
|
|
86
80
|
|
|
87
|
-
**target 选错(反馈环)的防护——克制为主,不过度防御。** 关键事实:违反单轴所有权时,RAF 合并把「死循环」**封顶为每帧一次**——它退化成可见的抖动 / 每帧一次冗余 IPC,而**不是冻死 UI
|
|
81
|
+
**target 选错(反馈环)的防护——克制为主,不过度防御。** 关键事实:违反单轴所有权时,RAF 合并把「死循环」**封顶为每帧一次**——它退化成可见的抖动 / 每帧一次冗余 IPC,而**不是冻死 UI**。既然不是灾难性故障,就不在每帧路径上堆检测器。只保留:
|
|
88
82
|
|
|
89
83
|
- **一条构造期廉价守卫**:`target` 是 `<body>`/`<html>` 时 `console.warn`——这是「stable-but-wrong」(占位永远撑成视图高、不缩到内容)最常见的成因,一次性、零每帧成本。
|
|
90
84
|
- **其余靠文档**:JSDoc 与本节把「target 必须主导轴 shrink-to-fit、不被宿主灌入的尺寸反向决定」讲清。
|
|
@@ -137,15 +131,10 @@ export function createSizeAdvertiser(target: HTMLElement, opts: SizeAdvertiserOp
|
|
|
137
131
|
|
|
138
132
|
## 7. 与 Electron preferred-size 的关系(宿主侧可选数据源,非替代)
|
|
139
133
|
|
|
140
|
-
宿主若在 Electron 且能接受零下游代码,可用 `enablePreferredSizeMode` + `preferred-size-changed` 作为反向「**源**」喂给同一个 `decide`,省掉下游注入。但它是 Electron
|
|
141
|
-
|
|
142
|
-
view-anchor 的反向原语是**引擎无关/可移植**那条路(非 Electron / iframe 宿主、需选子 target 的场景)。两者并存:preferred-size 是 Electron 捷径,advertiser 是可移植机制,`decide` + §5 安全红线是二者共用的公共底座。这也是反向逻辑该留在 view-anchor、而非写死成 Electron 事件的理由。spike 结果只决定宿主选哪个**源**,不改变 view-anchor 要不要建反向原语。
|
|
143
|
-
|
|
144
|
-
## 8. 包定位守恒(仍是一个原语,不是框架)
|
|
134
|
+
宿主若在 Electron 且能接受零下游代码,可用 `enablePreferredSizeMode` + `preferred-size-changed` 作为反向「**源**」喂给同一个 `decide`,省掉下游注入。但它是 Electron 私有,且依赖两个前提:① 在 `WebContentsView` 上触发且上报值吸收 zoom;② `setBounds.height` 不回灌 layout(否则正反馈震荡)。
|
|
145
135
|
|
|
146
|
-
|
|
136
|
+
view-anchor 的反向原语是**引擎无关/可移植**那条路(非 Electron / iframe 宿主、需选子 target 的场景)。两者并存:preferred-size 是 Electron 捷径,advertiser 是可移植机制,`decide` + §5 安全红线是二者共用的公共底座。这也是反向逻辑留在 view-anchor、而非写死成 Electron 事件的理由——宿主选哪个**源**不改变反向原语本身。
|
|
147
137
|
|
|
148
|
-
##
|
|
138
|
+
## 8. 包定位守恒(一个原语,不是框架)
|
|
149
139
|
|
|
150
|
-
|
|
151
|
-
仍待定:React 反向适配 `useSizeAdvertiser` 是否要做(默认否,等出现真实 React 下游)。
|
|
140
|
+
view-anchor 是「DOM 几何 ↔ 跨进程视图」的**单一职责双向桥**。守住四条即不滑向框架:① 导出面只含 `createViewAnchor` / `createPlacementAnchor` / `createSizeAdvertiser` + 其类型;② `createMeasureLoop` 不导出;③ 不提供 React 反向适配(下游不保证是 React);④ `decide` 留宿主、不进包。
|
package/docs/mechanism.mdx
CHANGED
|
@@ -61,6 +61,27 @@ const handle = createViewAnchor(target, {
|
|
|
61
61
|
- **`{0,0,0,0}`(ZERO)** —— 收起信号。宿主把零面积读作「摘除子视图,但保留其 `WebContents` 存活」,即收起而非销毁,重新挂载瞬时且状态完整。
|
|
62
62
|
- **dispose 后保持静默** —— 不补发零矩形。元素真正消失时,应由调用方先发 ZERO 再 dispose(适配层已替你处理)。
|
|
63
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
|
+
|
|
64
85
|
## useViewAnchor(opts)
|
|
65
86
|
|
|
66
87
|
React 适配层,返回一个挂到占位元素上的 ref 回调。
|
|
@@ -88,9 +109,11 @@ return <div ref={ref} />
|
|
|
88
109
|
|
|
89
110
|
| 文件 | 作用 |
|
|
90
111
|
|---|---|
|
|
91
|
-
| `src/view-anchor.ts` |
|
|
92
|
-
| `src/react.ts` | React 适配层 `useViewAnchor
|
|
93
|
-
| `src/
|
|
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`。 |
|
|
94
117
|
| `src/index.ts` | 对外公开面。 |
|
|
95
118
|
|
|
96
|
-
正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize`
|
|
119
|
+
正向运行时依赖只有 `react`(仅适配层)和浏览器 API(`ResizeObserver`、`getBoundingClientRect`、`window` 的 `resize` 监听)。`requestAnimationFrame` 只在反向 `createSizeAdvertiser`(经 `createMeasureLoop`)和 `createPlacementAnchor` 的 opt-in `followGeometry` 哨兵里用;正向的 `createViewAnchor` 同步发布、不走 RAF。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/view-anchor",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.20260624040247",
|
|
4
4
|
"description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dimina",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@testing-library/react": "^16.3.2",
|
|
53
53
|
"@types/react": "^18.3.12",
|
|
54
54
|
"@vitejs/plugin-react": "^6.0.1",
|
|
55
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
55
56
|
"esbuild": "^0.28.0",
|
|
56
57
|
"eslint": "^10.2.1",
|
|
57
58
|
"jsdom": "^29.0.2",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"check-types": "tsc --noEmit",
|
|
72
73
|
"lint": "eslint . --max-warnings 0",
|
|
73
74
|
"test": "vitest run",
|
|
74
|
-
"test:dev": "vitest"
|
|
75
|
+
"test:dev": "vitest",
|
|
76
|
+
"test:coverage": "vitest run --coverage"
|
|
75
77
|
}
|
|
76
78
|
}
|
|
@@ -5,23 +5,20 @@ import type { Placement } from './types.js'
|
|
|
5
5
|
// ── press-pause-drag: the windowed RAF geometry sentinel must NOT close
|
|
6
6
|
// while the pointer is still held down ───────────────────────────────
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// `followGeometry` sentinel (view-anchor-following.md §2.D / §6).
|
|
8
|
+
// Locks the press-pause-drag behaviour of the `followGeometry` sentinel.
|
|
10
9
|
//
|
|
11
|
-
// The
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// drags) closes the window mid-press, and the entire subsequent drag is
|
|
18
|
-
// dropped (the native view freezes at its pre-drag position).
|
|
10
|
+
// The windowed sentinel only closes on *steady* frames AFTER release
|
|
11
|
+
// (pointerup), NOT after N consecutive identical frames unconditionally — a
|
|
12
|
+
// press that pauses before the drag actually starts (very common: user clicks
|
|
13
|
+
// the splitter, hesitates a frame or two, THEN drags) must not close the
|
|
14
|
+
// window mid-press and drop the entire subsequent drag (freezing the native
|
|
15
|
+
// view at its pre-drag position).
|
|
19
16
|
//
|
|
20
17
|
// This file pins:
|
|
21
18
|
// 1. press-pause-drag: pointerdown → ≥2 static frames (current close
|
|
22
19
|
// threshold) → rect starts moving → the movement is STILL followed.
|
|
23
|
-
//
|
|
24
|
-
// drag frames find
|
|
20
|
+
// The pointerHeld gate keeps the sentinel open through the static pause
|
|
21
|
+
// so the later drag frames still find a scheduled rAF.
|
|
25
22
|
// 2. no-regression: pointerup → static frames → the sentinel still
|
|
26
23
|
// eventually closes (we must not "fix" #1 by keeping the window open
|
|
27
24
|
// forever / spinning a rAF while idle).
|
|
@@ -124,7 +121,7 @@ function buildElement(rect: { x: number; y: number; w: number; h: number }): {
|
|
|
124
121
|
}
|
|
125
122
|
|
|
126
123
|
/** A `[role="separator"]` splitter (the drag handle): a capture-phase
|
|
127
|
-
* pointerdown matching it opens the sentinel window
|
|
124
|
+
* pointerdown matching it opens the sentinel window. */
|
|
128
125
|
function buildSplitter(): HTMLElement {
|
|
129
126
|
const sep = document.createElement('div')
|
|
130
127
|
sep.setAttribute('role', 'separator')
|
package/src/placement.test.ts
CHANGED
|
@@ -1,37 +1,35 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
|
|
3
3
|
// ─────────────────────────────────────────────────────────────────────
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// convention
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// Tests for the explicit `Placement` API, the alternative to encoding
|
|
5
|
+
// "hidden" as the magic geometric value `{x:0,y:0,width:0,height:0}` (the
|
|
6
|
+
// `present:false → ZERO bounds` convention in types.ts/view-anchor.ts). That
|
|
7
|
+
// ZERO convention leaks a compositor/host concept ("detach the view") into the
|
|
8
|
+
// geometry layer, and is ambiguous: a view that is genuinely 0-wide is
|
|
9
|
+
// indistinguishable from a hidden one.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// The replacement contract:
|
|
12
12
|
//
|
|
13
13
|
// type Placement =
|
|
14
14
|
// | { visible: true; bounds: Bounds }
|
|
15
15
|
// | { visible: false }
|
|
16
16
|
//
|
|
17
17
|
// measure produces a `Placement`; the sink consumes a `Placement`.
|
|
18
|
-
// Visibility is EXPLICIT (a discriminant), never inferred from a 0.
|
|
18
|
+
// Visibility is EXPLICIT (a discriminant), never inferred from a 0. The
|
|
19
|
+
// explicit API NEVER reintroduces a ZERO-bounds path for "hidden".
|
|
19
20
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// it throws) rather than a whole-file compile error that vitest would skip.
|
|
25
|
-
// The implementer makes them GREEN by adding the explicit API — WITHOUT
|
|
26
|
-
// reintroducing any ZERO-bounds path for "hidden".
|
|
21
|
+
// The symbols (`measurePlacement`, `createPlacementAnchor`, `Placement`) are
|
|
22
|
+
// imported dynamically so a missing export surfaces as a runtime/assertion
|
|
23
|
+
// failure (an `undefined` that throws when called) rather than a whole-file
|
|
24
|
+
// compile error that vitest would skip.
|
|
27
25
|
// ─────────────────────────────────────────────────────────────────────
|
|
28
26
|
|
|
29
27
|
import type { Bounds } from './types.js'
|
|
30
28
|
|
|
31
|
-
// The explicit API is imported through this indirection so that a
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
29
|
+
// The explicit API is imported through this indirection so that a missing
|
|
30
|
+
// export is observable as `undefined` at runtime (→ a failing assertion /
|
|
31
|
+
// TypeError), not a static "module has no exported member" compile error that
|
|
32
|
+
// would prevent the whole suite from running.
|
|
35
33
|
import * as viewAnchorModule from './view-anchor.js'
|
|
36
34
|
|
|
37
35
|
// The shape the explicit API must produce/consume. Mirrored locally (not
|
|
@@ -128,11 +126,10 @@ function buildElement(rect: {
|
|
|
128
126
|
}
|
|
129
127
|
}
|
|
130
128
|
|
|
131
|
-
// ── Resolve the
|
|
129
|
+
// ── Resolve the explicit API at call time ─────────
|
|
132
130
|
//
|
|
133
|
-
// Each
|
|
134
|
-
//
|
|
135
|
-
// a RED behavioural failure, which is what TDD wants here.
|
|
131
|
+
// Each helper asserts the export is a function before calling it, so an absent
|
|
132
|
+
// export surfaces as a clear assertion failure rather than a TypeError.
|
|
136
133
|
|
|
137
134
|
interface MaybeModule {
|
|
138
135
|
// measurePlacement(target): Placement — pure measure, no IPC.
|
|
@@ -154,7 +151,7 @@ function measurePlacement(target: HTMLElement): ExpectedPlacement {
|
|
|
154
151
|
const fn = mod.measurePlacement
|
|
155
152
|
expect(
|
|
156
153
|
typeof fn,
|
|
157
|
-
'explicit API `measurePlacement`
|
|
154
|
+
'explicit API `measurePlacement` must be a function export from view-anchor',
|
|
158
155
|
).toBe('function')
|
|
159
156
|
return fn!(target)
|
|
160
157
|
}
|
|
@@ -169,7 +166,7 @@ function createPlacementAnchor(
|
|
|
169
166
|
const fn = mod.createPlacementAnchor
|
|
170
167
|
expect(
|
|
171
168
|
typeof fn,
|
|
172
|
-
'explicit API `createPlacementAnchor`
|
|
169
|
+
'explicit API `createPlacementAnchor` must be a function export from view-anchor',
|
|
173
170
|
).toBe('function')
|
|
174
171
|
return fn!(target, opts)
|
|
175
172
|
}
|
package/src/react.test.tsx
CHANGED
|
@@ -160,10 +160,10 @@ describe('useViewAnchor — ref attach', () => {
|
|
|
160
160
|
// the leaf div conditionally rendered), failing to dispose leaks the RO
|
|
161
161
|
// and keeps publishing against a detached element.
|
|
162
162
|
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
163
|
+
// This contract ALSO requires emitting one ZERO on detach. The anchor's
|
|
164
|
+
// follower is a main-process WebContentsView; the host only collapses it on
|
|
165
|
+
// `{0,0,0,0}`. Without a ZERO the native view stays frozen at its last bounds
|
|
166
|
+
// and occludes content. See `react.ts`.
|
|
167
167
|
|
|
168
168
|
describe('useViewAnchor — ref null disposes', () => {
|
|
169
169
|
it('detaching the DOM node publishes ZERO once, disconnects the observer, and stops publishing', () => {
|
|
@@ -195,11 +195,9 @@ describe('useViewAnchor — ref null disposes', () => {
|
|
|
195
195
|
})
|
|
196
196
|
expect(ro.disconnected).toBe(true)
|
|
197
197
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// correct behaviour is: a vanished anchor MUST publish exactly one ZERO so
|
|
202
|
-
// the host collapses the native view.
|
|
198
|
+
// A vanished anchor MUST publish exactly one ZERO so the host collapses the
|
|
199
|
+
// native view — detach publishing nothing would strand the native
|
|
200
|
+
// WebContentsView at its old bounds.
|
|
203
201
|
expect(publish).toHaveBeenCalledTimes(1)
|
|
204
202
|
expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
205
203
|
publish.mockClear()
|
|
@@ -296,9 +294,9 @@ describe('useViewAnchor — opts/deps change re-publishes', () => {
|
|
|
296
294
|
// Bug it catches: a hook that does not dispose on unmount leaks the RO and
|
|
297
295
|
// can throw when a queued RAF fires against a torn-down IPC channel.
|
|
298
296
|
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
297
|
+
// Unmount must ALSO collapse the native view with one ZERO (same reasoning as
|
|
298
|
+
// Contract 9 — the follower is a main-process WebContentsView the host only
|
|
299
|
+
// collapses on `{0,0,0,0}`).
|
|
302
300
|
|
|
303
301
|
describe('useViewAnchor — unmount disposes', () => {
|
|
304
302
|
it('unmounting the component publishes ZERO once, disconnects the observer, and never publishes after', () => {
|
|
@@ -318,10 +316,8 @@ describe('useViewAnchor — unmount disposes', () => {
|
|
|
318
316
|
|
|
319
317
|
expect(ro.disconnected).toBe(true)
|
|
320
318
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// native view stranded. Correct behaviour: unmount publishes exactly one
|
|
324
|
-
// ZERO to collapse the native view.
|
|
319
|
+
// Unmount publishes exactly one ZERO to collapse the native view —
|
|
320
|
+
// publishing nothing would leave the native view stranded.
|
|
325
321
|
expect(publish).toHaveBeenCalledTimes(1)
|
|
326
322
|
expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
327
323
|
publish.mockClear()
|
|
@@ -380,7 +376,7 @@ describe('useViewAnchor — independent instances', () => {
|
|
|
380
376
|
})
|
|
381
377
|
})
|
|
382
378
|
|
|
383
|
-
// ── Remount with present transition
|
|
379
|
+
// ── Remount with present transition ──────────────
|
|
384
380
|
// Production coupling: the debug cell is *unmounted* when hidden and *remounted*
|
|
385
381
|
// when shown, so the element's mount/unmount and `options.present` flip together
|
|
386
382
|
// (present=false ⟺ unmounted, present=true ⟺ mounted). On "show", React commits
|
package/src/react.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type ViewAnchorRef = (el: HTMLElement | null) => void
|
|
|
28
28
|
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
29
29
|
* `update`; on unmount → publish ZERO then `dispose`.
|
|
30
30
|
*
|
|
31
|
-
* Why ZERO on disappearance
|
|
31
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
32
32
|
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
33
33
|
* `dispose()` only stops observing — it deliberately never publishes again
|
|
34
34
|
* (its Contract 6/7). But the host only collapses the native view when it
|
package/src/view-anchor.test.ts
CHANGED
|
@@ -499,12 +499,12 @@ void _boundsShape
|
|
|
499
499
|
|
|
500
500
|
// ── display:none / first-frame guard (opt-in) ───────────────────────
|
|
501
501
|
//
|
|
502
|
-
//
|
|
503
|
-
// `
|
|
504
|
-
//
|
|
502
|
+
// An ADDITIVE, OPT-IN hardening of `createPlacementAnchor`, gated behind the
|
|
503
|
+
// `guardDisplayNone?: boolean` option (default false = the unguarded
|
|
504
|
+
// behaviour).
|
|
505
505
|
//
|
|
506
|
-
// Two
|
|
507
|
-
//
|
|
506
|
+
// Two behaviours, ONLY when `guardDisplayNone: true`:
|
|
507
|
+
// First-frame guard — `visible:true` but the measured rect has
|
|
508
508
|
// `width === 0 || height === 0` (no geometry box: unmounted /
|
|
509
509
|
// display:none / unstable first layout) → publish `{ visible:false }`
|
|
510
510
|
// (a detach, NO bounds) instead of `{ visible:true, bounds:{...,0,0} }`.
|
|
@@ -512,27 +512,22 @@ void _boundsShape
|
|
|
512
512
|
// the native view flashing at (0,0). NOTE: only ZERO WIDTH OR HEIGHT
|
|
513
513
|
// triggers this — a NON-zero box at position (0,0) is still a normal
|
|
514
514
|
// `{ visible:true }`.
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
// `
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// `{ visible:true, bounds }`.
|
|
515
|
+
// display:none guard — the anchor attaches an `IntersectionObserver` on the
|
|
516
|
+
// target. When the target goes display:none (IO reports
|
|
517
|
+
// `isIntersecting:false` with a zero-area `boundingClientRect`) → publish
|
|
518
|
+
// `{ visible:false }`. When it comes back with a non-zero box (IO
|
|
519
|
+
// intersecting again / a subsequent measure yields non-zero
|
|
520
|
+
// width&height) → re-measure → publish `{ visible:true, bounds }`.
|
|
522
521
|
//
|
|
523
|
-
// Caller intent still wins
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
522
|
+
// Caller intent still wins: a caller-`visible:false` anchor is detached
|
|
523
|
+
// regardless — the guard NEVER flips a caller's `visible:false` to true, and
|
|
524
|
+
// NEVER detaches purely because a NON-zero box scrolled off-screen (a non-zero
|
|
525
|
+
// box at a negative/off-screen origin stays `visible:true` with its real,
|
|
526
|
+
// possibly negative-origin, bounds).
|
|
528
527
|
//
|
|
529
|
-
// This
|
|
530
|
-
// "never schedules a requestAnimationFrame" guard
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// These tests must be RED today: `guardDisplayNone` is not implemented, so
|
|
534
|
-
// no IntersectionObserver is wired and a 0×0 visible measure still publishes
|
|
535
|
-
// `{ visible:true, bounds:0×0 }`.
|
|
528
|
+
// This guard adds an IntersectionObserver, NOT a RAF — so the existing
|
|
529
|
+
// "never schedules a requestAnimationFrame" guard stays valid. We assert it
|
|
530
|
+
// here too (`rafSpy`/`cancelSpy` from the shared harness).
|
|
536
531
|
|
|
537
532
|
// A controllable fake IntersectionObserver (jsdom has none). Mirrors the
|
|
538
533
|
// FakeResizeObserver style: records observed elements + disconnect, captures
|
|
@@ -762,33 +757,26 @@ describe('createPlacementAnchor — display:none / first-frame guard (opt-in)',
|
|
|
762
757
|
})
|
|
763
758
|
})
|
|
764
759
|
|
|
765
|
-
// ── scroll + windowed RAF geometry sentinel (opt-in
|
|
760
|
+
// ── scroll + windowed RAF geometry sentinel (opt-in) ────
|
|
766
761
|
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
// §2.C / §2.D / §2.F / §6 / §7. Both default OFF (= today's behaviour).
|
|
762
|
+
// ADDITIVE, OPT-IN follow options on `createPlacementAnchor`. Both default
|
|
763
|
+
// OFF (= the unfollowed behaviour).
|
|
770
764
|
//
|
|
771
|
-
// followScroll?: boolean —
|
|
772
|
-
// followGeometry?: boolean —
|
|
773
|
-
// pulse(durationMs?) —
|
|
765
|
+
// followScroll?: boolean — ancestor-scroll capture listener.
|
|
766
|
+
// followGeometry?: boolean — windowed RAF geometry sentinel.
|
|
767
|
+
// pulse(durationMs?) — imperative "open the sentinel window".
|
|
774
768
|
//
|
|
775
769
|
// ────────────────────────────────────────────────────────────────────
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
// `
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
// tests below re-pin that the DEFAULT / event-driven paths still schedule
|
|
786
|
-
// NO rAF, so the original guarantee holds wherever the sentinel is off.
|
|
770
|
+
// The package-wide "never RAF" invariant is INTENTIONALLY NARROWED by
|
|
771
|
+
// `followGeometry` (opt-in) to "EVENT-driven publishes (ResizeObserver /
|
|
772
|
+
// window resize / scroll) are never RAF-deferred". The sentinel rAF is a
|
|
773
|
+
// POLLING mechanism that publishes SYNCHRONOUSLY within its own frame — it is
|
|
774
|
+
// NOT a deferral of an event-driven publish. The `createViewAnchor` "never
|
|
775
|
+
// schedules a requestAnimationFrame" test (above) stays valid, because
|
|
776
|
+
// `followGeometry` defaults off and the forward `createViewAnchor` core never
|
|
777
|
+
// opts in. The tests below re-pin that the DEFAULT / event-driven paths still
|
|
778
|
+
// schedule NO rAF, so the guarantee holds wherever the sentinel is off.
|
|
787
779
|
// ────────────────────────────────────────────────────────────────────
|
|
788
|
-
//
|
|
789
|
-
// These tests must be RED today: `followScroll` / `followGeometry` /
|
|
790
|
-
// `pulse` are unimplemented, so no capture scroll listener / pointerdown
|
|
791
|
-
// listener / RAF sentinel exists.
|
|
792
780
|
|
|
793
781
|
// A CONTROLLABLE fake requestAnimationFrame. The shared `rafSpy` only
|
|
794
782
|
// returns 0 and stores nothing (it's a regression guard, not a driver), so
|
|
@@ -842,7 +830,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
842
830
|
|
|
843
831
|
/** Build a `[role="separator"]` splitter element (the drag handle). A
|
|
844
832
|
* capture-phase pointerdown whose target matches `[role="separator"]`
|
|
845
|
-
* opens the sentinel window
|
|
833
|
+
* opens the sentinel window. */
|
|
846
834
|
function buildSplitter(): HTMLElement {
|
|
847
835
|
const sep = document.createElement('div')
|
|
848
836
|
sep.setAttribute('role', 'separator')
|
|
@@ -851,7 +839,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
851
839
|
}
|
|
852
840
|
|
|
853
841
|
/** Dispatch a capture-phase scroll on window (an ancestor scroll container
|
|
854
|
-
* scrolling —
|
|
842
|
+
* scrolling — scroll doesn't bubble, but reaches window in capture). */
|
|
855
843
|
function dispatchCaptureScroll(): void {
|
|
856
844
|
window.dispatchEvent(new Event('scroll'))
|
|
857
845
|
}
|
|
@@ -864,8 +852,8 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
864
852
|
}
|
|
865
853
|
|
|
866
854
|
/** Pointer release — ends a splitter drag so the sentinel may steady-close
|
|
867
|
-
* (
|
|
868
|
-
* window directly so a capture/bubble window listener sees it. */
|
|
855
|
+
* (close only happens after release). Dispatched bubbling from the target
|
|
856
|
+
* and on window directly so a capture/bubble window listener sees it. */
|
|
869
857
|
function dispatchPointerup(target: HTMLElement): void {
|
|
870
858
|
target.dispatchEvent(new Event('pointerup', { bubbles: true }))
|
|
871
859
|
window.dispatchEvent(new Event('pointerup'))
|
|
@@ -888,12 +876,11 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
888
876
|
): PulseHandle =>
|
|
889
877
|
createPlacementAnchor(el, o as FollowOpts) as PulseHandle
|
|
890
878
|
|
|
891
|
-
// ── A. followScroll — capture-phase ancestor scroll
|
|
879
|
+
// ── A. followScroll — capture-phase ancestor scroll ──────────
|
|
892
880
|
|
|
893
881
|
// A1: capture scroll re-publishes the freshly-measured rect (basic scroll
|
|
894
|
-
// follow works WITHOUT the geometry sentinel —
|
|
895
|
-
//
|
|
896
|
-
// emit()").
|
|
882
|
+
// follow works WITHOUT the geometry sentinel — with followGeometry off,
|
|
883
|
+
// the scroll callback still does a single synchronous emit()).
|
|
897
884
|
it('A1) followScroll (followGeometry off): a capture-phase scroll re-publishes the new measured rect synchronously', () => {
|
|
898
885
|
const publish = vi.fn<(p: Placement) => void>()
|
|
899
886
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
@@ -960,8 +947,8 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
960
947
|
removeSpy.mockRestore()
|
|
961
948
|
})
|
|
962
949
|
|
|
963
|
-
// A3:
|
|
964
|
-
//
|
|
950
|
+
// A3: a followScroll capture-scroll OPENS the RAF sentinel window when
|
|
951
|
+
// followGeometry is ALSO on (so it follows every frame of a scroll
|
|
965
952
|
// burst, not just the one synchronous emit).
|
|
966
953
|
it('A3) followScroll + followGeometry: a capture scroll opens the RAF sentinel (a frame becomes scheduled)', () => {
|
|
967
954
|
const publish = vi.fn<(p: Placement) => void>()
|
|
@@ -982,9 +969,9 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
982
969
|
expect(raf.pending).toBeGreaterThanOrEqual(1)
|
|
983
970
|
})
|
|
984
971
|
|
|
985
|
-
// ── B. followGeometry — windowed RAF geometry sentinel
|
|
972
|
+
// ── B. followGeometry — windowed RAF geometry sentinel ─────
|
|
986
973
|
|
|
987
|
-
// B-idle:
|
|
974
|
+
// B-idle: IDLE (no scroll / pointerdown / pulse) schedules NO rAF.
|
|
988
975
|
// The sentinel is windowed: static cost is exactly zero.
|
|
989
976
|
it('B-idle) followGeometry on but idle: NO rAF is ever scheduled (windowed = zero static cost)', () => {
|
|
990
977
|
const publish = vi.fn<(p: Placement) => void>()
|
|
@@ -1064,10 +1051,10 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1064
1051
|
})
|
|
1065
1052
|
|
|
1066
1053
|
// B-close: after the pointer is RELEASED, N=2 consecutive UNCHANGED frames
|
|
1067
|
-
// cancel the rAF (steady = stop) — no further frame scheduled.
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1054
|
+
// cancel the rAF (steady = stop) — no further frame scheduled. A steady run
|
|
1055
|
+
// while the pointer is still HELD is a mid-drag pause and must NOT close
|
|
1056
|
+
// (see follow-geometry-press-drag.fix.test.ts); close is gated on pointerup,
|
|
1057
|
+
// so this test releases before going steady.
|
|
1071
1058
|
it('B-close) pointerup then N=2 consecutive unchanged frames → sentinel stops (cancelAnimationFrame / no further frame scheduled)', () => {
|
|
1072
1059
|
const publish = vi.fn<(p: Placement) => void>()
|
|
1073
1060
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
@@ -1095,7 +1082,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1095
1082
|
expect(raf.request.mock.calls.length).toBe(requestsBefore)
|
|
1096
1083
|
})
|
|
1097
1084
|
|
|
1098
|
-
// ── F. pulse() — explicit window open
|
|
1085
|
+
// ── F. pulse() — explicit window open ────────────────────────
|
|
1099
1086
|
|
|
1100
1087
|
// F-open: pulse() opens the sentinel and it follows subsequent rect changes.
|
|
1101
1088
|
it('F-open) pulse() opens the sentinel; it follows rect changes on subsequent frames', () => {
|
|
@@ -1137,13 +1124,13 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1137
1124
|
expect(raf.request.mock.calls.length).toBe(requestsBefore)
|
|
1138
1125
|
})
|
|
1139
1126
|
|
|
1140
|
-
// ──
|
|
1141
|
-
//
|
|
1127
|
+
// ── Event-driven publishes stay SYNCHRONOUS even with the sentinel enabled;
|
|
1128
|
+
// the sentinel rAF publishes in-frame, not deferred. ──
|
|
1142
1129
|
|
|
1143
1130
|
// B-sync: a ResizeObserver tick still publishes SYNCHRONOUSLY (in the event
|
|
1144
1131
|
// stack) WITHOUT scheduling a rAF, even with followGeometry enabled. This
|
|
1145
|
-
// is the
|
|
1146
|
-
it('
|
|
1132
|
+
// is the narrowing made concrete: event-driven ≠ RAF-deferred.
|
|
1133
|
+
it('with followGeometry ENABLED, a ResizeObserver tick publishes synchronously and schedules NO rAF', () => {
|
|
1147
1134
|
const publish = vi.fn<(p: Placement) => void>()
|
|
1148
1135
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1149
1136
|
mk(el, { visible: true, followGeometry: true, publish })
|
|
@@ -1158,7 +1145,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1158
1145
|
visible: true,
|
|
1159
1146
|
bounds: { x: 3, y: 4, width: 100, height: 100 },
|
|
1160
1147
|
})
|
|
1161
|
-
// …and the event-driven path did NOT route through a rAF (the
|
|
1148
|
+
// …and the event-driven path did NOT route through a rAF (the invariant
|
|
1162
1149
|
// survives, narrowed: event-driven publishes are never RAF-deferred).
|
|
1163
1150
|
expect(raf.request).not.toHaveBeenCalled()
|
|
1164
1151
|
})
|
|
@@ -1166,7 +1153,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1166
1153
|
// Default-path "still no rAF": with followGeometry OFF / unset, NOTHING in
|
|
1167
1154
|
// the increment-2 surface schedules a rAF — the original package-wide
|
|
1168
1155
|
// "never RAF" guarantee holds for the default + event-driven paths.
|
|
1169
|
-
it('
|
|
1156
|
+
it('followGeometry OFF (default): RO + resize + scroll-less lifecycle schedules NO rAF', () => {
|
|
1170
1157
|
const publish = vi.fn<(p: Placement) => void>()
|
|
1171
1158
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1172
1159
|
const handle = mk(el, { visible: true, publish }) // followGeometry unset
|
package/src/view-anchor.ts
CHANGED
|
@@ -166,7 +166,7 @@ export interface PlacementAnchorOptions {
|
|
|
166
166
|
*/
|
|
167
167
|
guardDisplayNone?: boolean
|
|
168
168
|
/**
|
|
169
|
-
* Opt-in capture-phase ancestor-scroll follow
|
|
169
|
+
* Opt-in capture-phase ancestor-scroll follow. When true, the anchor
|
|
170
170
|
* listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
|
|
171
171
|
* bubble, but reach `window` while capturing), so an ancestor scroll
|
|
172
172
|
* container scrolling the target re-measures and re-publishes. With
|
|
@@ -176,7 +176,7 @@ export interface PlacementAnchorOptions {
|
|
|
176
176
|
*/
|
|
177
177
|
followScroll?: boolean
|
|
178
178
|
/**
|
|
179
|
-
* Opt-in windowed RAF geometry sentinel
|
|
179
|
+
* Opt-in windowed RAF geometry sentinel. Catches ancestor
|
|
180
180
|
* transform / reflow moves that no DOM event reports. The sentinel is
|
|
181
181
|
* NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
|
|
182
182
|
* splitter pointerdown, or an explicit `pulse()`), polls geometry once per
|
|
@@ -262,7 +262,7 @@ export function createPlacementAnchor(
|
|
|
262
262
|
let lastPublished: Placement | null = null
|
|
263
263
|
let disposed = false
|
|
264
264
|
|
|
265
|
-
// ── Windowed RAF geometry sentinel state
|
|
265
|
+
// ── Windowed RAF geometry sentinel state ──────────────────
|
|
266
266
|
// The sentinel is a windowed poll, opened on demand and auto-closing once
|
|
267
267
|
// the geometry goes steady. It publishes IN-FRAME (no nested defer): each
|
|
268
268
|
// frame measures, and either publishes a changed rect synchronously or
|
|
@@ -272,12 +272,12 @@ export function createPlacementAnchor(
|
|
|
272
272
|
let steadyFrames = 0
|
|
273
273
|
const STEADY_CLOSE_FRAMES = 2
|
|
274
274
|
// True while a [role="separator"] splitter drag is in progress: set on the
|
|
275
|
-
// capture-phase pointerdown that opened the window, cleared on pointerup
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
275
|
+
// capture-phase pointerdown that opened the window, cleared on pointerup.
|
|
276
|
+
// A held pointer means the drag may still resume after a static pause, so a
|
|
277
|
+
// steady run while held must NOT close the sentinel — it only closes once the
|
|
278
|
+
// pointer is released. Without this gate a press that pauses a couple of
|
|
279
|
+
// frames before the drag actually moves would close mid-press and drop the
|
|
280
|
+
// entire subsequent drag.
|
|
281
281
|
let pointerHeld = false
|
|
282
282
|
// Absolute time (performance.now()) past which a `pulse(durationMs)` window
|
|
283
283
|
// force-closes even if the geometry is still changing — the upper bound that
|
|
@@ -319,7 +319,7 @@ export function createPlacementAnchor(
|
|
|
319
319
|
sentinelDeadline = null
|
|
320
320
|
return
|
|
321
321
|
}
|
|
322
|
-
//
|
|
322
|
+
// Upper bound: a pulse window past its deadline closes regardless of
|
|
323
323
|
// motion, so a target that changes every frame can't keep the sentinel alive.
|
|
324
324
|
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
325
325
|
sentinelDeadline = null
|
|
@@ -328,7 +328,7 @@ export function createPlacementAnchor(
|
|
|
328
328
|
const p = computePlacement()
|
|
329
329
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
330
330
|
steadyFrames++
|
|
331
|
-
// Steady-close only fires once the pointer is RELEASED
|
|
331
|
+
// Steady-close only fires once the pointer is RELEASED: while a
|
|
332
332
|
// splitter drag is held, a static pause is a hesitation, not the end of
|
|
333
333
|
// the drag, so we keep polling (re-arm below) and let `steadyFrames`
|
|
334
334
|
// accrue — it converges to a close within N frames after pointerup.
|
|
@@ -342,7 +342,7 @@ export function createPlacementAnchor(
|
|
|
342
342
|
steadyFrames = 0
|
|
343
343
|
}
|
|
344
344
|
// `publish` may have synchronously disposed (or hidden) the anchor; re-read
|
|
345
|
-
// live state so a re-entrant teardown leaves ZERO scheduled frames
|
|
345
|
+
// live state so a re-entrant teardown leaves ZERO scheduled frames.
|
|
346
346
|
if (!disposed && visible) {
|
|
347
347
|
rafId = requestAnimationFrame(sentinelFrame) // keep polling
|
|
348
348
|
} else {
|
|
@@ -366,17 +366,17 @@ export function createPlacementAnchor(
|
|
|
366
366
|
pointerHeld = false
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// without it, a single synchronous emit() follows the new rect
|
|
369
|
+
// An ancestor scroll moved the target's screen rect. With the sentinel on,
|
|
370
|
+
// open the window so the whole scroll burst is followed frame-by-frame;
|
|
371
|
+
// without it, a single synchronous emit() follows the new rect.
|
|
372
372
|
const onScroll = (): void => {
|
|
373
373
|
if (followGeometry) openSentinel()
|
|
374
374
|
else emit()
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
377
|
+
// A capture-phase pointerdown on a [role="separator"] splitter handle marks
|
|
378
|
+
// the start of a drag that moves the target via ancestor reflow (no RO tick)
|
|
379
|
+
// → open the sentinel.
|
|
380
380
|
const onPointerDown = (e: Event): void => {
|
|
381
381
|
const t = e.target as Element | null
|
|
382
382
|
if (t && t.closest && t.closest('[role="separator"]')) {
|
|
@@ -385,7 +385,7 @@ export function createPlacementAnchor(
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
//
|
|
388
|
+
// Pointer released: the drag is over, so a steady run may now close the
|
|
389
389
|
// sentinel. Re-open it (a no-op if already polling) so the steady-close
|
|
390
390
|
// threshold is reached even if the geometry was already static at release.
|
|
391
391
|
const onPointerUp = (): void => {
|
|
@@ -469,7 +469,7 @@ export function createPlacementAnchor(
|
|
|
469
469
|
stopObserving()
|
|
470
470
|
},
|
|
471
471
|
pulse(durationMs?: number): void {
|
|
472
|
-
// Imperative window open
|
|
472
|
+
// Imperative window open: start the animation-follow window. It
|
|
473
473
|
// closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
|
|
474
474
|
// at that deadline — whichever comes first. The deadline is the upper bound
|
|
475
475
|
// that guarantees a still-animating target cannot keep the sentinel
|