@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 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH;yEACyE;AACzE,MAAM,WAAW,MAAM;IACrB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,SAAS,GACjB;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACjC;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAA;AAEtB,MAAM,WAAW,iBAAiB;IAChC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,sEAAsE;IACtE,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,MAAM,CAAC,IAAI,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC;;iDAE6C;IAC7C,OAAO,IAAI,IAAI,CAAA;CAChB;AAUD;gEACgE;AAChE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAA;AAE/C;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B;4DACwD;IACxD,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAA;IAC7B;qBACiB;IACjB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,qBAAqB;IACpC,6EAA6E;IAC7E,IAAI,EAAE,cAAc,CAAA;IACpB;kFAC8E;IAC9E,OAAO,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAA;CACxC;AAED,MAAM,WAAW,oBAAoB;IACnC;;;;;;;;;OASG;IACH,MAAM,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,GAAG,IAAI,CAAA;IACrD;;;;;;;OAOG;IACH,OAAO,IAAI,IAAI,CAAA;CAChB"}
package/dist/types.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * view-anchor — sync a main-process native view's bounds to a DOM element.
3
+ *
4
+ * Self-contained, engine-agnostic primitive. It knows nothing about
5
+ * Electron (the `publish` callback owns the IPC → `setBounds`), nothing
6
+ * about React (the core is imperative; see `react.ts` for the adapter),
7
+ * and nothing about the host layout engine (the `target` element may come
8
+ * from our own `compile`/`FrameTree`, or — later — a dockview panel's
9
+ * `content.element`; the mechanism is identical).
10
+ *
11
+ * It is the modern, alive replacement for the archived
12
+ * `react-electron-browser-view`: the cross-process bridge that DOM layout
13
+ * libraries (dockview included) deliberately do not provide. dockview's
14
+ * internal `OverlayRenderContainer` does the same getBoundingClientRect →
15
+ * RAF → reposition dance, but its follower is a DOM node; ours is a native
16
+ * `WebContentsView` positioned via the injected `publish`.
17
+ */
18
+ export {};
@@ -0,0 +1,111 @@
1
+ import type { Placement, ViewAnchorOptions, ViewAnchorHandle } from './types.js';
2
+ /**
3
+ * Create an anchor binding ONE native view's bounds to `target`'s geometry.
4
+ *
5
+ * Imperative core — no React, no Electron. Behaviour:
6
+ * - `present === true`: publish `target.getBoundingClientRect()` (x/y rounded,
7
+ * width/height `Math.max(0, Math.round(...))`) immediately, then re-publish
8
+ * SYNCHRONOUSLY on every `ResizeObserver` tick and window `resize`.
9
+ * - `present === false`: publish `{0,0,0,0}` immediately; do not observe.
10
+ * - `update(opts)`: re-apply synchronously.
11
+ * - `dispose()`: stop observing, never publish again.
12
+ *
13
+ * Synchronous, NOT RAF-deferred: the native overlay is a cross-process
14
+ * `WebContentsView` whose `setBounds` already lands ~1 compositor frame behind
15
+ * the renderer's DOM paint (the two processes composite on different frames).
16
+ * Deferring the measure+publish to a RAF stacked a SECOND frame on top — during
17
+ * a height/splitter drag that read as the overlay visibly trailing the region
18
+ * edge (worst when GROWING, where the not-yet-followed edge exposes background).
19
+ * Publishing in the observer tick itself removes that self-inflicted frame and
20
+ * leaves only the unavoidable cross-process frame (masked by matching the
21
+ * placeholder/desk background colour). The anti-flood role the RAF used to play
22
+ * — collapsing a burst of RO+resize ticks in one frame into one publish — is now
23
+ * served by `lastPublished` dedup: a tick whose measured rect is byte-identical
24
+ * to the last published one is dropped, so a continuous drag still emits at most
25
+ * one publish per distinct rect.
26
+ *
27
+ * Teardown safety: there is no queued frame to outrun a state change — every
28
+ * emit reads `disposed`/`present` synchronously, so a tick after
29
+ * `update`/`dispose` can never write a stale rect over the live one.
30
+ */
31
+ export declare function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle;
32
+ export interface PlacementAnchorOptions {
33
+ /**
34
+ * Caller's INTENT: should the native view be on-screen? `true` →
35
+ * publish the measured rect as `{ visible:true, bounds }`; `false` →
36
+ * publish `{ visible:false }`. Crucially, hiddenness comes from this
37
+ * flag, not from a measured zero size — so a legitimately 0-sized but
38
+ * visible target still publishes `visible:true`.
39
+ */
40
+ visible: boolean;
41
+ /** Receives each explicit Placement. Owns IPC → host. */
42
+ publish: (placement: Placement) => void;
43
+ /**
44
+ * Opt-in geometry detach. When true, a measured zero-area target (no
45
+ * geometry box — display:none / unmounted / unstable first layout) publishes
46
+ * `{ visible:false }` (detach-but-keep) instead of `{ visible:true,
47
+ * bounds:0×0 }`, and an IntersectionObserver is attached so a display:none
48
+ * transition (which ResizeObserver does not report) re-publishes. Default
49
+ * false keeps the legitimate 0×0-visible semantics.
50
+ */
51
+ guardDisplayNone?: boolean;
52
+ /**
53
+ * Opt-in capture-phase ancestor-scroll follow (§2.C). When true, the anchor
54
+ * listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
55
+ * bubble, but reach `window` while capturing), so an ancestor scroll
56
+ * container scrolling the target re-measures and re-publishes. With
57
+ * `followGeometry` off, the scroll callback does a single synchronous
58
+ * `emit()`; with it on, the scroll OPENS the RAF sentinel window so the
59
+ * follow tracks every frame of a scroll burst. Default false.
60
+ */
61
+ followScroll?: boolean;
62
+ /**
63
+ * Opt-in windowed RAF geometry sentinel (§2.D/§6). Catches ancestor
64
+ * transform / reflow moves that no DOM event reports. The sentinel is
65
+ * NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
66
+ * splitter pointerdown, or an explicit `pulse()`), polls geometry once per
67
+ * animation frame publishing IN-FRAME, and AUTO-CLOSES once the rect goes
68
+ * steady (a few unchanged frames). While closed it schedules no frame, so the
69
+ * static cost when idle is exactly zero. Default false.
70
+ */
71
+ followGeometry?: boolean;
72
+ }
73
+ export interface PlacementAnchorHandle {
74
+ /** Apply new options; re-publishes immediately (mirrors `createViewAnchor`). */
75
+ update(opts: PlacementAnchorOptions): void;
76
+ /** Stop observing; never publish again. */
77
+ dispose(): void;
78
+ /**
79
+ * Open the RAF sentinel window (animation follow); auto-closes after going
80
+ * steady or after `durationMs`. No-op when `followGeometry` is false.
81
+ */
82
+ pulse(durationMs?: number): void;
83
+ }
84
+ /**
85
+ * Pure measure: read `target`'s rect and wrap it as an explicit visible
86
+ * Placement. Always `{ visible:true }` — hiddenness is a caller decision
87
+ * (see `createPlacementAnchor`), so this never returns `{ visible:false }`
88
+ * and never infers visibility from a 0 size. A collapsed (0×0) but present
89
+ * element therefore yields `{ visible:true, bounds:{...,width:0,height:0} }`,
90
+ * distinct from any hidden Placement.
91
+ */
92
+ export declare function measurePlacement(target: HTMLElement): Placement;
93
+ /**
94
+ * The explicit-Placement mirror of `createViewAnchor`. Same observer/dedup/
95
+ * teardown machinery, but the sink receives a `Placement`:
96
+ * - `visible === true` → publish `measurePlacement(target)` and re-publish
97
+ * SYNCHRONOUSLY on every `ResizeObserver`/`resize` tick.
98
+ * - `visible === false` → publish `{ visible:false }` (NOT a ZERO bounds);
99
+ * do not observe.
100
+ *
101
+ * Dedup carries the discriminant (`samePlacement`), so a visibility flip is
102
+ * never coalesced away.
103
+ *
104
+ * Opt-in `guardDisplayNone` (default false): when on, a measured zero-area
105
+ * target (display:none / unmounted / unstable first layout) publishes
106
+ * `{ visible:false }` instead of `{ visible:true, bounds:0×0 }`, and an
107
+ * IntersectionObserver is attached so a display:none transition (which
108
+ * ResizeObserver does not report) re-publishes.
109
+ */
110
+ export declare function createPlacementAnchor(target: HTMLElement, opts: PlacementAnchorOptions): PlacementAnchorHandle;
111
+ //# sourceMappingURL=view-anchor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view-anchor.d.ts","sourceRoot":"","sources":["../src/view-anchor.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,SAAS,EACT,iBAAiB,EACjB,gBAAgB,EACjB,MAAM,YAAY,CAAA;AAwBnB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,iBAAiB,GACtB,gBAAgB,CA6ElB;AASD,MAAM,WAAW,sBAAsB;IACrC;;;;;;OAMG;IACH,OAAO,EAAE,OAAO,CAAA;IAChB,yDAAyD;IACzD,OAAO,EAAE,CAAC,SAAS,EAAE,SAAS,KAAK,IAAI,CAAA;IACvC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAA;IAC1B;;;;;;;;OAQG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,qBAAqB;IACpC,gFAAgF;IAChF,MAAM,CAAC,IAAI,EAAE,sBAAsB,GAAG,IAAI,CAAA;IAC1C,2CAA2C;IAC3C,OAAO,IAAI,IAAI,CAAA;IACf;;;OAGG;IACH,KAAK,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,CAM/D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,sBAAsB,GAC3B,qBAAqB,CAyOvB"}
@@ -0,0 +1,396 @@
1
+ const ZERO = { x: 0, y: 0, width: 0, height: 0 };
2
+ // Round to integers (setBounds rejects fractionals). Width/height are clamped
3
+ // to ≥0 (negative area is meaningless and `0` is the canonical "hidden"
4
+ // signal). x/y are NOT clamped: a position is a position — an anchored overlay
5
+ // scrolled past the top/left edge has a legitimately NEGATIVE origin, and
6
+ // flooring it to 0 would pin the native view at the edge instead of letting it
7
+ // track its element off-screen. (Each consumer's IPC schema enforces its own
8
+ // origin policy — the simulator's allows negative; the DevTools placeholder
9
+ // never goes negative, so its NonNegInt schema is unaffected.)
10
+ const clampRect = (r) => ({
11
+ x: Math.round(r.x),
12
+ y: Math.round(r.y),
13
+ width: Math.max(0, Math.round(r.width)),
14
+ height: Math.max(0, Math.round(r.height)),
15
+ });
16
+ /**
17
+ * Create an anchor binding ONE native view's bounds to `target`'s geometry.
18
+ *
19
+ * Imperative core — no React, no Electron. Behaviour:
20
+ * - `present === true`: publish `target.getBoundingClientRect()` (x/y rounded,
21
+ * width/height `Math.max(0, Math.round(...))`) immediately, then re-publish
22
+ * SYNCHRONOUSLY on every `ResizeObserver` tick and window `resize`.
23
+ * - `present === false`: publish `{0,0,0,0}` immediately; do not observe.
24
+ * - `update(opts)`: re-apply synchronously.
25
+ * - `dispose()`: stop observing, never publish again.
26
+ *
27
+ * Synchronous, NOT RAF-deferred: the native overlay is a cross-process
28
+ * `WebContentsView` whose `setBounds` already lands ~1 compositor frame behind
29
+ * the renderer's DOM paint (the two processes composite on different frames).
30
+ * Deferring the measure+publish to a RAF stacked a SECOND frame on top — during
31
+ * a height/splitter drag that read as the overlay visibly trailing the region
32
+ * edge (worst when GROWING, where the not-yet-followed edge exposes background).
33
+ * Publishing in the observer tick itself removes that self-inflicted frame and
34
+ * leaves only the unavoidable cross-process frame (masked by matching the
35
+ * placeholder/desk background colour). The anti-flood role the RAF used to play
36
+ * — collapsing a burst of RO+resize ticks in one frame into one publish — is now
37
+ * served by `lastPublished` dedup: a tick whose measured rect is byte-identical
38
+ * to the last published one is dropped, so a continuous drag still emits at most
39
+ * one publish per distinct rect.
40
+ *
41
+ * Teardown safety: there is no queued frame to outrun a state change — every
42
+ * emit reads `disposed`/`present` synchronously, so a tick after
43
+ * `update`/`dispose` can never write a stale rect over the live one.
44
+ */
45
+ export function createViewAnchor(target, opts) {
46
+ let present = opts.present;
47
+ let publish = opts.publish;
48
+ let observer = null;
49
+ // The last rect handed to `publish`, for dedup-coalescing (see header). Reset
50
+ // to `null` on every `apply()` so a state change (e.g. zoom, which rides in
51
+ // the `publish` closure, not in `Bounds`) always forces one fresh publish
52
+ // even when the geometry is unchanged.
53
+ let lastPublished = null;
54
+ let disposed = false;
55
+ const measure = () => {
56
+ const r = target.getBoundingClientRect();
57
+ return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height });
58
+ };
59
+ const sameRect = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
60
+ // Measure + publish SYNCHRONOUSLY on the triggering tick — no RAF defer (see
61
+ // header for why). Bail if torn down or detached, and dedup a rect
62
+ // byte-identical to the last published one (collapses a same-frame RO+resize
63
+ // burst, and a steady drag that re-fires the same final rect, into one).
64
+ const emit = () => {
65
+ if (disposed || !present)
66
+ return;
67
+ const m = measure();
68
+ if (lastPublished && sameRect(lastPublished, m))
69
+ return;
70
+ lastPublished = m;
71
+ publish(m);
72
+ };
73
+ const startObserving = () => {
74
+ if (observer)
75
+ return;
76
+ observer = new ResizeObserver(emit);
77
+ observer.observe(target);
78
+ window.addEventListener('resize', emit);
79
+ };
80
+ const stopObserving = () => {
81
+ if (observer) {
82
+ observer.disconnect();
83
+ observer = null;
84
+ }
85
+ window.removeEventListener('resize', emit);
86
+ };
87
+ // Apply the current (present, publish) synchronously. Reset `lastPublished`
88
+ // first so the publish below is never dedup-skipped — a state change (zoom,
89
+ // present flip, new publish target) must always re-emit even if the geometry
90
+ // is byte-identical to the previous emit.
91
+ const apply = () => {
92
+ lastPublished = null;
93
+ if (present) {
94
+ startObserving();
95
+ lastPublished = measure();
96
+ publish(lastPublished);
97
+ }
98
+ else {
99
+ stopObserving();
100
+ publish(ZERO);
101
+ }
102
+ };
103
+ apply();
104
+ return {
105
+ update(next) {
106
+ if (disposed)
107
+ return;
108
+ publish = next.publish;
109
+ present = next.present;
110
+ apply();
111
+ },
112
+ dispose() {
113
+ if (disposed)
114
+ return;
115
+ disposed = true;
116
+ stopObserving();
117
+ },
118
+ };
119
+ }
120
+ /**
121
+ * Pure measure: read `target`'s rect and wrap it as an explicit visible
122
+ * Placement. Always `{ visible:true }` — hiddenness is a caller decision
123
+ * (see `createPlacementAnchor`), so this never returns `{ visible:false }`
124
+ * and never infers visibility from a 0 size. A collapsed (0×0) but present
125
+ * element therefore yields `{ visible:true, bounds:{...,width:0,height:0} }`,
126
+ * distinct from any hidden Placement.
127
+ */
128
+ export function measurePlacement(target) {
129
+ const r = target.getBoundingClientRect();
130
+ return {
131
+ visible: true,
132
+ bounds: clampRect({ x: r.left, y: r.top, width: r.width, height: r.height }),
133
+ };
134
+ }
135
+ const samePlacement = (a, b) => {
136
+ // Discriminant-aware dedup: a visibility flip is always a change, even when
137
+ // the geometry would otherwise look identical.
138
+ if (a.visible !== b.visible)
139
+ return false;
140
+ if (a.visible && b.visible) {
141
+ return (a.bounds.x === b.bounds.x &&
142
+ a.bounds.y === b.bounds.y &&
143
+ a.bounds.width === b.bounds.width &&
144
+ a.bounds.height === b.bounds.height);
145
+ }
146
+ return true;
147
+ };
148
+ /**
149
+ * The explicit-Placement mirror of `createViewAnchor`. Same observer/dedup/
150
+ * teardown machinery, but the sink receives a `Placement`:
151
+ * - `visible === true` → publish `measurePlacement(target)` and re-publish
152
+ * SYNCHRONOUSLY on every `ResizeObserver`/`resize` tick.
153
+ * - `visible === false` → publish `{ visible:false }` (NOT a ZERO bounds);
154
+ * do not observe.
155
+ *
156
+ * Dedup carries the discriminant (`samePlacement`), so a visibility flip is
157
+ * never coalesced away.
158
+ *
159
+ * Opt-in `guardDisplayNone` (default false): when on, a measured zero-area
160
+ * target (display:none / unmounted / unstable first layout) publishes
161
+ * `{ visible:false }` instead of `{ visible:true, bounds:0×0 }`, and an
162
+ * IntersectionObserver is attached so a display:none transition (which
163
+ * ResizeObserver does not report) re-publishes.
164
+ */
165
+ export function createPlacementAnchor(target, opts) {
166
+ let visible = opts.visible;
167
+ let publish = opts.publish;
168
+ const guardDisplayNone = opts.guardDisplayNone ?? false;
169
+ // Captured at creation; the follow options are never re-set via update().
170
+ const followScroll = opts.followScroll ?? false;
171
+ const followGeometry = opts.followGeometry ?? false;
172
+ let observer = null;
173
+ let io = null;
174
+ let lastPublished = null;
175
+ let disposed = false;
176
+ // ── Windowed RAF geometry sentinel state (§2.D/§6) ──────────────────
177
+ // The sentinel is a windowed poll, opened on demand and auto-closing once
178
+ // the geometry goes steady. It publishes IN-FRAME (no nested defer): each
179
+ // frame measures, and either publishes a changed rect synchronously or
180
+ // counts toward the steady-close threshold. While closed `rafId` is null
181
+ // and no frame is scheduled — zero static cost when idle.
182
+ let rafId = null;
183
+ let steadyFrames = 0;
184
+ const STEADY_CLOSE_FRAMES = 2;
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
+ // (§2.D). A held pointer means the drag may still resume after a static
188
+ // pause, so a steady run while held must NOT close the sentinel — it only
189
+ // closes once the pointer is released (松手后 2~3 帧内判静止→关窗). Without
190
+ // this gate a press that pauses a couple of frames before the drag actually
191
+ // moves would close mid-press and drop the entire subsequent drag.
192
+ let pointerHeld = false;
193
+ // Absolute time (performance.now()) past which a `pulse(durationMs)` window
194
+ // force-closes even if the geometry is still changing — the upper bound that
195
+ // prevents a perpetually-animating target from keeping the sentinel resident.
196
+ // null = no time bound (scroll/splitter opens rely on steady-close instead).
197
+ let sentinelDeadline = null;
198
+ // Measure the target, applying the opt-in first-frame / display:none guard:
199
+ // a zero-area box (no geometry to anchor) becomes a detach instead of a
200
+ // 0×0-visible Placement. Default off → byte-for-byte the plain measure.
201
+ const computePlacement = () => {
202
+ const p = measurePlacement(target);
203
+ if (guardDisplayNone &&
204
+ p.visible &&
205
+ (p.bounds.width === 0 || p.bounds.height === 0)) {
206
+ return { visible: false };
207
+ }
208
+ return p;
209
+ };
210
+ const emit = () => {
211
+ if (disposed || !visible)
212
+ return;
213
+ const p = computePlacement();
214
+ if (lastPublished && samePlacement(lastPublished, p))
215
+ return;
216
+ lastPublished = p;
217
+ publish(p);
218
+ };
219
+ // One sentinel frame: measure, publish-in-frame if changed, else count toward
220
+ // the steady-close threshold. A changed frame re-arms; a steady frame re-arms
221
+ // until STEADY_CLOSE_FRAMES consecutive unchanged frames, then closes (no
222
+ // re-arm). Reads `disposed`/`visible` live so a frame outliving teardown is
223
+ // inert.
224
+ const sentinelFrame = () => {
225
+ rafId = null;
226
+ if (disposed || !visible) {
227
+ sentinelDeadline = null;
228
+ return;
229
+ }
230
+ // §2.F upper bound: a pulse window past its deadline closes regardless of
231
+ // motion, so a target that changes every frame can't keep the sentinel alive.
232
+ if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
233
+ sentinelDeadline = null;
234
+ return; // duration elapsed → close (no re-arm)
235
+ }
236
+ const p = computePlacement();
237
+ if (lastPublished && samePlacement(lastPublished, p)) {
238
+ steadyFrames++;
239
+ // Steady-close only fires once the pointer is RELEASED (§2.D): while a
240
+ // splitter drag is held, a static pause is a hesitation, not the end of
241
+ // the drag, so we keep polling (re-arm below) and let `steadyFrames`
242
+ // accrue — it converges to a close within N frames after pointerup.
243
+ if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
244
+ sentinelDeadline = null;
245
+ return; // steady (and released) → close
246
+ }
247
+ }
248
+ else {
249
+ lastPublished = p;
250
+ publish(p); // publish synchronously in THIS frame
251
+ steadyFrames = 0;
252
+ }
253
+ // `publish` may have synchronously disposed (or hidden) the anchor; re-read
254
+ // live state so a re-entrant teardown leaves ZERO scheduled frames (B2).
255
+ if (!disposed && visible) {
256
+ rafId = requestAnimationFrame(sentinelFrame); // keep polling
257
+ }
258
+ else {
259
+ sentinelDeadline = null;
260
+ }
261
+ };
262
+ const openSentinel = () => {
263
+ if (!followGeometry || disposed)
264
+ return;
265
+ steadyFrames = 0;
266
+ if (rafId === null)
267
+ rafId = requestAnimationFrame(sentinelFrame);
268
+ };
269
+ const closeSentinel = () => {
270
+ if (rafId !== null) {
271
+ cancelAnimationFrame(rafId);
272
+ rafId = null;
273
+ }
274
+ steadyFrames = 0;
275
+ sentinelDeadline = null;
276
+ pointerHeld = false;
277
+ };
278
+ // §2.C — an ancestor scroll moved the target's screen rect. With the sentinel
279
+ // on, open the window so the whole scroll burst is followed frame-by-frame;
280
+ // without it, a single synchronous emit() follows the new rect (A1).
281
+ const onScroll = () => {
282
+ if (followGeometry)
283
+ openSentinel();
284
+ else
285
+ emit();
286
+ };
287
+ // §2.D — a capture-phase pointerdown on a [role="separator"] splitter handle
288
+ // marks the start of a drag that moves the target via ancestor reflow (no RO
289
+ // tick) → open the sentinel.
290
+ const onPointerDown = (e) => {
291
+ const t = e.target;
292
+ if (t && t.closest && t.closest('[role="separator"]')) {
293
+ pointerHeld = true;
294
+ openSentinel();
295
+ }
296
+ };
297
+ // §2.D — pointer released: the drag is over, so a steady run may now close the
298
+ // sentinel. Re-open it (a no-op if already polling) so the steady-close
299
+ // threshold is reached even if the geometry was already static at release.
300
+ const onPointerUp = () => {
301
+ if (!pointerHeld)
302
+ return;
303
+ pointerHeld = false;
304
+ openSentinel();
305
+ };
306
+ const startObserving = () => {
307
+ if (observer)
308
+ return;
309
+ observer = new ResizeObserver(emit);
310
+ observer.observe(target);
311
+ window.addEventListener('resize', emit);
312
+ // A display:none transition is invisible to ResizeObserver; an
313
+ // IntersectionObserver re-fires `emit`, which re-measures via
314
+ // `computePlacement` (now-zero box → detach, restored box → visible).
315
+ if (guardDisplayNone && typeof IntersectionObserver !== 'undefined') {
316
+ io = new IntersectionObserver(emit);
317
+ io.observe(target);
318
+ }
319
+ if (followScroll) {
320
+ window.addEventListener('scroll', onScroll, {
321
+ capture: true,
322
+ passive: true,
323
+ });
324
+ }
325
+ if (followGeometry) {
326
+ window.addEventListener('pointerdown', onPointerDown, { capture: true });
327
+ window.addEventListener('pointerup', onPointerUp, { capture: true });
328
+ }
329
+ };
330
+ const stopObserving = () => {
331
+ if (observer) {
332
+ observer.disconnect();
333
+ observer = null;
334
+ }
335
+ if (io) {
336
+ io.disconnect();
337
+ io = null;
338
+ }
339
+ window.removeEventListener('resize', emit);
340
+ window.removeEventListener('scroll', onScroll, {
341
+ capture: true,
342
+ });
343
+ window.removeEventListener('pointerdown', onPointerDown, {
344
+ capture: true,
345
+ });
346
+ window.removeEventListener('pointerup', onPointerUp, {
347
+ capture: true,
348
+ });
349
+ closeSentinel();
350
+ };
351
+ const apply = () => {
352
+ lastPublished = null;
353
+ if (visible) {
354
+ startObserving();
355
+ lastPublished = computePlacement();
356
+ publish(lastPublished);
357
+ }
358
+ else {
359
+ stopObserving();
360
+ const hidden = { visible: false };
361
+ lastPublished = hidden;
362
+ publish(hidden);
363
+ }
364
+ };
365
+ apply();
366
+ return {
367
+ update(next) {
368
+ if (disposed)
369
+ return;
370
+ publish = next.publish;
371
+ visible = next.visible;
372
+ apply();
373
+ },
374
+ dispose() {
375
+ if (disposed)
376
+ return;
377
+ disposed = true;
378
+ stopObserving();
379
+ },
380
+ pulse(durationMs) {
381
+ // Imperative window open (§2.F): start the animation-follow window. It
382
+ // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
383
+ // at that deadline — whichever comes first. The deadline is the upper bound
384
+ // that guarantees a still-animating target cannot keep the sentinel
385
+ // resident; without it, only steady-close applies.
386
+ if (disposed || !followGeometry)
387
+ return;
388
+ if (durationMs !== undefined && durationMs > 0) {
389
+ const next = performance.now() + durationMs;
390
+ // Extend (never shorten) an existing window's deadline.
391
+ sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next);
392
+ }
393
+ openSentinel();
394
+ },
395
+ };
396
+ }