view-anchor 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
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 — some allow negatives; a placeholder always stays on-screen,
9
+ // 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 ──────────────────
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
+ // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
186
+ const MAX_HIDDEN_FOLLOW_FRAMES = 30;
187
+ // True while a [role="separator"] splitter drag is in progress: set on the
188
+ // capture-phase pointerdown that opened the window, cleared on pointerup.
189
+ // A held pointer means the drag may still resume after a static pause, so a
190
+ // steady run while held must NOT close the sentinel — it only closes once the
191
+ // pointer is released. Without this gate a press that pauses a couple of
192
+ // frames before the drag actually moves would close mid-press and drop the
193
+ // entire subsequent drag.
194
+ let pointerHeld = false;
195
+ // Absolute time (performance.now()) past which a `pulse(durationMs)` window
196
+ // force-closes even if the geometry is still changing — the upper bound that
197
+ // prevents a perpetually-animating target from keeping the sentinel resident.
198
+ // null = no time bound (scroll/splitter opens rely on steady-close instead).
199
+ let sentinelDeadline = null;
200
+ // Measure the target, applying the opt-in first-frame / display:none guard:
201
+ // a zero-area box (no geometry to anchor) becomes a detach instead of a
202
+ // 0×0-visible Placement. Default off → byte-for-byte the plain measure.
203
+ const computePlacement = () => {
204
+ const p = measurePlacement(target);
205
+ if (guardDisplayNone &&
206
+ p.visible &&
207
+ (p.bounds.width === 0 || p.bounds.height === 0)) {
208
+ return { visible: false };
209
+ }
210
+ return p;
211
+ };
212
+ const emit = () => {
213
+ if (disposed || !visible)
214
+ return;
215
+ const p = computePlacement();
216
+ if (lastPublished && samePlacement(lastPublished, p))
217
+ return;
218
+ lastPublished = p;
219
+ publish(p);
220
+ };
221
+ // Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
222
+ // bounded follow run elapsed, else keep following (false); never publishes.
223
+ const shouldCloseOnHiddenPoll = () => {
224
+ if (lastPublished?.visible === false)
225
+ return true;
226
+ return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
227
+ };
228
+ // One sentinel frame: measure, publish-in-frame if changed, else count toward
229
+ // the steady-close threshold. Reads `disposed`/`visible` live so a frame
230
+ // outliving teardown is inert.
231
+ const sentinelFrame = () => {
232
+ rafId = null;
233
+ if (disposed || !visible) {
234
+ sentinelDeadline = null;
235
+ return;
236
+ }
237
+ // Upper bound: a pulse window past its deadline closes regardless of
238
+ // motion, so a target that changes every frame can't keep the sentinel alive.
239
+ if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
240
+ sentinelDeadline = null;
241
+ return; // duration elapsed → close (no re-arm)
242
+ }
243
+ const p = computePlacement();
244
+ // The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
245
+ // hidden poll is a relayout transient to follow until restore (the fix).
246
+ if (!p.visible) {
247
+ if (shouldCloseOnHiddenPoll()) {
248
+ sentinelDeadline = null;
249
+ return;
250
+ }
251
+ rafId = requestAnimationFrame(sentinelFrame);
252
+ return;
253
+ }
254
+ if (lastPublished && samePlacement(lastPublished, p)) {
255
+ steadyFrames++;
256
+ // Steady-close only fires once the pointer is RELEASED: while a
257
+ // splitter drag is held, a static pause is a hesitation, not the end of
258
+ // the drag, so we keep polling (re-arm below) and let `steadyFrames`
259
+ // accrue — it converges to a close within N frames after pointerup.
260
+ if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
261
+ sentinelDeadline = null;
262
+ return; // steady (and released) → close
263
+ }
264
+ }
265
+ else {
266
+ lastPublished = p;
267
+ publish(p); // publish synchronously in THIS frame
268
+ steadyFrames = 0;
269
+ }
270
+ // `publish` may have synchronously disposed (or hidden) the anchor; re-read
271
+ // live state so a re-entrant teardown leaves ZERO scheduled frames.
272
+ if (!disposed && visible) {
273
+ rafId = requestAnimationFrame(sentinelFrame); // keep polling
274
+ }
275
+ else {
276
+ sentinelDeadline = null;
277
+ }
278
+ };
279
+ const openSentinel = () => {
280
+ if (!followGeometry || disposed)
281
+ return;
282
+ steadyFrames = 0;
283
+ if (rafId === null)
284
+ rafId = requestAnimationFrame(sentinelFrame);
285
+ };
286
+ const closeSentinel = () => {
287
+ if (rafId !== null) {
288
+ cancelAnimationFrame(rafId);
289
+ rafId = null;
290
+ }
291
+ steadyFrames = 0;
292
+ sentinelDeadline = null;
293
+ pointerHeld = false;
294
+ };
295
+ // An ancestor scroll moved the target's screen rect. With the sentinel on,
296
+ // open the window so the whole scroll burst is followed frame-by-frame;
297
+ // without it, a single synchronous emit() follows the new rect.
298
+ const onScroll = () => {
299
+ if (followGeometry)
300
+ openSentinel();
301
+ else
302
+ emit();
303
+ };
304
+ // A capture-phase pointerdown on a [role="separator"] splitter handle marks
305
+ // the start of a drag that moves the target via ancestor reflow (no RO tick)
306
+ // → open the sentinel.
307
+ const onPointerDown = (e) => {
308
+ const t = e.target;
309
+ if (t && t.closest && t.closest('[role="separator"]')) {
310
+ pointerHeld = true;
311
+ openSentinel();
312
+ }
313
+ };
314
+ // Pointer released: the drag is over, so a steady run may now close the
315
+ // sentinel. Re-open it (a no-op if already polling) so the steady-close
316
+ // threshold is reached even if the geometry was already static at release.
317
+ const onPointerUp = () => {
318
+ if (!pointerHeld)
319
+ return;
320
+ pointerHeld = false;
321
+ openSentinel();
322
+ };
323
+ const startObserving = () => {
324
+ if (observer)
325
+ return;
326
+ observer = new ResizeObserver(emit);
327
+ observer.observe(target);
328
+ window.addEventListener('resize', emit);
329
+ // A display:none transition is invisible to ResizeObserver; an
330
+ // IntersectionObserver re-fires `emit`, which re-measures via
331
+ // `computePlacement` (now-zero box → detach, restored box → visible).
332
+ if (guardDisplayNone && typeof IntersectionObserver !== 'undefined') {
333
+ io = new IntersectionObserver(emit);
334
+ io.observe(target);
335
+ }
336
+ if (followScroll) {
337
+ window.addEventListener('scroll', onScroll, {
338
+ capture: true,
339
+ passive: true,
340
+ });
341
+ }
342
+ if (followGeometry) {
343
+ window.addEventListener('pointerdown', onPointerDown, { capture: true });
344
+ window.addEventListener('pointerup', onPointerUp, { capture: true });
345
+ }
346
+ };
347
+ const stopObserving = () => {
348
+ if (observer) {
349
+ observer.disconnect();
350
+ observer = null;
351
+ }
352
+ if (io) {
353
+ io.disconnect();
354
+ io = null;
355
+ }
356
+ window.removeEventListener('resize', emit);
357
+ window.removeEventListener('scroll', onScroll, {
358
+ capture: true,
359
+ });
360
+ window.removeEventListener('pointerdown', onPointerDown, {
361
+ capture: true,
362
+ });
363
+ window.removeEventListener('pointerup', onPointerUp, {
364
+ capture: true,
365
+ });
366
+ closeSentinel();
367
+ };
368
+ const apply = () => {
369
+ lastPublished = null;
370
+ if (visible) {
371
+ startObserving();
372
+ lastPublished = computePlacement();
373
+ publish(lastPublished);
374
+ }
375
+ else {
376
+ stopObserving();
377
+ const hidden = { visible: false };
378
+ lastPublished = hidden;
379
+ publish(hidden);
380
+ }
381
+ };
382
+ apply();
383
+ return {
384
+ update(next) {
385
+ if (disposed)
386
+ return;
387
+ publish = next.publish;
388
+ visible = next.visible;
389
+ apply();
390
+ },
391
+ dispose() {
392
+ if (disposed)
393
+ return;
394
+ disposed = true;
395
+ stopObserving();
396
+ },
397
+ pulse(durationMs) {
398
+ // Imperative window open: start the animation-follow window. It
399
+ // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
400
+ // at that deadline — whichever comes first. The deadline is the upper bound
401
+ // that guarantees a still-animating target cannot keep the sentinel
402
+ // resident; without it, only steady-close applies.
403
+ if (disposed || !followGeometry)
404
+ return;
405
+ if (durationMs !== undefined && durationMs > 0) {
406
+ const next = performance.now() + durationMs;
407
+ // Extend (never shorten) an existing window's deadline.
408
+ sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next);
409
+ }
410
+ openSentinel();
411
+ },
412
+ };
413
+ }