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,500 @@
1
+ import type {
2
+ Bounds,
3
+ Placement,
4
+ ViewAnchorOptions,
5
+ ViewAnchorHandle,
6
+ } from './types.js'
7
+
8
+ const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
9
+
10
+ // Round to integers (setBounds rejects fractionals). Width/height are clamped
11
+ // to ≥0 (negative area is meaningless and `0` is the canonical "hidden"
12
+ // signal). x/y are NOT clamped: a position is a position — an anchored overlay
13
+ // scrolled past the top/left edge has a legitimately NEGATIVE origin, and
14
+ // flooring it to 0 would pin the native view at the edge instead of letting it
15
+ // track its element off-screen. (Each consumer's IPC schema enforces its own
16
+ // origin policy — some allow negatives; a placeholder always stays on-screen,
17
+ // so its NonNegInt schema is unaffected.)
18
+ const clampRect = (r: {
19
+ x: number
20
+ y: number
21
+ width: number
22
+ height: number
23
+ }): Bounds => ({
24
+ x: Math.round(r.x),
25
+ y: Math.round(r.y),
26
+ width: Math.max(0, Math.round(r.width)),
27
+ height: Math.max(0, Math.round(r.height)),
28
+ })
29
+
30
+ /**
31
+ * Create an anchor binding ONE native view's bounds to `target`'s geometry.
32
+ *
33
+ * Imperative core — no React, no Electron. Behaviour:
34
+ * - `present === true`: publish `target.getBoundingClientRect()` (x/y rounded,
35
+ * width/height `Math.max(0, Math.round(...))`) immediately, then re-publish
36
+ * SYNCHRONOUSLY on every `ResizeObserver` tick and window `resize`.
37
+ * - `present === false`: publish `{0,0,0,0}` immediately; do not observe.
38
+ * - `update(opts)`: re-apply synchronously.
39
+ * - `dispose()`: stop observing, never publish again.
40
+ *
41
+ * Synchronous, NOT RAF-deferred: the native overlay is a cross-process
42
+ * `WebContentsView` whose `setBounds` already lands ~1 compositor frame behind
43
+ * the renderer's DOM paint (the two processes composite on different frames).
44
+ * Deferring the measure+publish to a RAF stacked a SECOND frame on top — during
45
+ * a height/splitter drag that read as the overlay visibly trailing the region
46
+ * edge (worst when GROWING, where the not-yet-followed edge exposes background).
47
+ * Publishing in the observer tick itself removes that self-inflicted frame and
48
+ * leaves only the unavoidable cross-process frame (masked by matching the
49
+ * placeholder/desk background colour). The anti-flood role the RAF used to play
50
+ * — collapsing a burst of RO+resize ticks in one frame into one publish — is now
51
+ * served by `lastPublished` dedup: a tick whose measured rect is byte-identical
52
+ * to the last published one is dropped, so a continuous drag still emits at most
53
+ * one publish per distinct rect.
54
+ *
55
+ * Teardown safety: there is no queued frame to outrun a state change — every
56
+ * emit reads `disposed`/`present` synchronously, so a tick after
57
+ * `update`/`dispose` can never write a stale rect over the live one.
58
+ */
59
+ export function createViewAnchor(
60
+ target: HTMLElement,
61
+ opts: ViewAnchorOptions,
62
+ ): ViewAnchorHandle {
63
+ let present = opts.present
64
+ let publish = opts.publish
65
+ let observer: ResizeObserver | null = null
66
+ // The last rect handed to `publish`, for dedup-coalescing (see header). Reset
67
+ // to `null` on every `apply()` so a state change (e.g. zoom, which rides in
68
+ // the `publish` closure, not in `Bounds`) always forces one fresh publish
69
+ // even when the geometry is unchanged.
70
+ let lastPublished: Bounds | null = null
71
+ let disposed = false
72
+
73
+ const measure = (): Bounds => {
74
+ const r = target.getBoundingClientRect()
75
+ return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
76
+ }
77
+
78
+ const sameRect = (a: Bounds, b: Bounds): boolean =>
79
+ a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
80
+
81
+ // Measure + publish SYNCHRONOUSLY on the triggering tick — no RAF defer (see
82
+ // header for why). Bail if torn down or detached, and dedup a rect
83
+ // byte-identical to the last published one (collapses a same-frame RO+resize
84
+ // burst, and a steady drag that re-fires the same final rect, into one).
85
+ const emit = (): void => {
86
+ if (disposed || !present) return
87
+ const m = measure()
88
+ if (lastPublished && sameRect(lastPublished, m)) return
89
+ lastPublished = m
90
+ publish(m)
91
+ }
92
+
93
+ const startObserving = (): void => {
94
+ if (observer) return
95
+ observer = new ResizeObserver(emit)
96
+ observer.observe(target)
97
+ window.addEventListener('resize', emit)
98
+ }
99
+
100
+ const stopObserving = (): void => {
101
+ if (observer) {
102
+ observer.disconnect()
103
+ observer = null
104
+ }
105
+ window.removeEventListener('resize', emit)
106
+ }
107
+
108
+ // Apply the current (present, publish) synchronously. Reset `lastPublished`
109
+ // first so the publish below is never dedup-skipped — a state change (zoom,
110
+ // present flip, new publish target) must always re-emit even if the geometry
111
+ // is byte-identical to the previous emit.
112
+ const apply = (): void => {
113
+ lastPublished = null
114
+ if (present) {
115
+ startObserving()
116
+ lastPublished = measure()
117
+ publish(lastPublished)
118
+ } else {
119
+ stopObserving()
120
+ publish(ZERO)
121
+ }
122
+ }
123
+
124
+ apply()
125
+
126
+ return {
127
+ update(next: ViewAnchorOptions): void {
128
+ if (disposed) return
129
+ publish = next.publish
130
+ present = next.present
131
+ apply()
132
+ },
133
+ dispose(): void {
134
+ if (disposed) return
135
+ disposed = true
136
+ stopObserving()
137
+ },
138
+ }
139
+ }
140
+
141
+ // ── Explicit Placement API ────────────────────────────────────────────
142
+ //
143
+ // The modern surface that replaces the magic-`{0,0,0,0}` "hidden" value.
144
+ // Visibility is an explicit discriminant, NEVER inferred from geometry — a
145
+ // real 0×0-but-on-screen view is `{ visible:true, bounds:{...,width:0} }`
146
+ // and a hidden one is `{ visible:false }` (no `bounds`). See `Placement`.
147
+
148
+ export interface PlacementAnchorOptions {
149
+ /**
150
+ * Caller's INTENT: should the native view be on-screen? `true` →
151
+ * publish the measured rect as `{ visible:true, bounds }`; `false` →
152
+ * publish `{ visible:false }`. Crucially, hiddenness comes from this
153
+ * flag, not from a measured zero size — so a legitimately 0-sized but
154
+ * visible target still publishes `visible:true`.
155
+ */
156
+ visible: boolean
157
+ /** Receives each explicit Placement. Owns IPC → host. */
158
+ publish: (placement: Placement) => void
159
+ /**
160
+ * Opt-in geometry detach. When true, a measured zero-area target (no
161
+ * geometry box — display:none / unmounted / unstable first layout) publishes
162
+ * `{ visible:false }` (detach-but-keep) instead of `{ visible:true,
163
+ * bounds:0×0 }`, and an IntersectionObserver is attached so a display:none
164
+ * transition (which ResizeObserver does not report) re-publishes. Default
165
+ * false keeps the legitimate 0×0-visible semantics.
166
+ */
167
+ guardDisplayNone?: boolean
168
+ /**
169
+ * Opt-in capture-phase ancestor-scroll follow. When true, the anchor
170
+ * listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
171
+ * bubble, but reach `window` while capturing), so an ancestor scroll
172
+ * container scrolling the target re-measures and re-publishes. With
173
+ * `followGeometry` off, the scroll callback does a single synchronous
174
+ * `emit()`; with it on, the scroll OPENS the RAF sentinel window so the
175
+ * follow tracks every frame of a scroll burst. Default false.
176
+ */
177
+ followScroll?: boolean
178
+ /**
179
+ * Opt-in windowed RAF geometry sentinel. Catches ancestor
180
+ * transform / reflow moves that no DOM event reports. The sentinel is
181
+ * NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
182
+ * splitter pointerdown, or an explicit `pulse()`), polls geometry once per
183
+ * animation frame publishing IN-FRAME, and AUTO-CLOSES once the rect goes
184
+ * steady (a few unchanged frames). While closed it schedules no frame, so the
185
+ * static cost when idle is exactly zero. Default false.
186
+ */
187
+ followGeometry?: boolean
188
+ }
189
+
190
+ export interface PlacementAnchorHandle {
191
+ /** Apply new options; re-publishes immediately (mirrors `createViewAnchor`). */
192
+ update(opts: PlacementAnchorOptions): void
193
+ /** Stop observing; never publish again. */
194
+ dispose(): void
195
+ /**
196
+ * Open the RAF sentinel window (animation follow); auto-closes after going
197
+ * steady or after `durationMs`. No-op when `followGeometry` is false.
198
+ */
199
+ pulse(durationMs?: number): void
200
+ }
201
+
202
+ /**
203
+ * Pure measure: read `target`'s rect and wrap it as an explicit visible
204
+ * Placement. Always `{ visible:true }` — hiddenness is a caller decision
205
+ * (see `createPlacementAnchor`), so this never returns `{ visible:false }`
206
+ * and never infers visibility from a 0 size. A collapsed (0×0) but present
207
+ * element therefore yields `{ visible:true, bounds:{...,width:0,height:0} }`,
208
+ * distinct from any hidden Placement.
209
+ */
210
+ export function measurePlacement(target: HTMLElement): Placement {
211
+ const r = target.getBoundingClientRect()
212
+ return {
213
+ visible: true,
214
+ bounds: clampRect({ x: r.left, y: r.top, width: r.width, height: r.height }),
215
+ }
216
+ }
217
+
218
+ const samePlacement = (a: Placement, b: Placement): boolean => {
219
+ // Discriminant-aware dedup: a visibility flip is always a change, even when
220
+ // the geometry would otherwise look identical.
221
+ if (a.visible !== b.visible) return false
222
+ if (a.visible && b.visible) {
223
+ return (
224
+ a.bounds.x === b.bounds.x &&
225
+ a.bounds.y === b.bounds.y &&
226
+ a.bounds.width === b.bounds.width &&
227
+ a.bounds.height === b.bounds.height
228
+ )
229
+ }
230
+ return true
231
+ }
232
+
233
+ /**
234
+ * The explicit-Placement mirror of `createViewAnchor`. Same observer/dedup/
235
+ * teardown machinery, but the sink receives a `Placement`:
236
+ * - `visible === true` → publish `measurePlacement(target)` and re-publish
237
+ * SYNCHRONOUSLY on every `ResizeObserver`/`resize` tick.
238
+ * - `visible === false` → publish `{ visible:false }` (NOT a ZERO bounds);
239
+ * do not observe.
240
+ *
241
+ * Dedup carries the discriminant (`samePlacement`), so a visibility flip is
242
+ * never coalesced away.
243
+ *
244
+ * Opt-in `guardDisplayNone` (default false): when on, a measured zero-area
245
+ * target (display:none / unmounted / unstable first layout) publishes
246
+ * `{ visible:false }` instead of `{ visible:true, bounds:0×0 }`, and an
247
+ * IntersectionObserver is attached so a display:none transition (which
248
+ * ResizeObserver does not report) re-publishes.
249
+ */
250
+ export function createPlacementAnchor(
251
+ target: HTMLElement,
252
+ opts: PlacementAnchorOptions,
253
+ ): PlacementAnchorHandle {
254
+ let visible = opts.visible
255
+ let publish = opts.publish
256
+ const guardDisplayNone = opts.guardDisplayNone ?? false
257
+ // Captured at creation; the follow options are never re-set via update().
258
+ const followScroll = opts.followScroll ?? false
259
+ const followGeometry = opts.followGeometry ?? false
260
+ let observer: ResizeObserver | null = null
261
+ let io: IntersectionObserver | null = null
262
+ let lastPublished: Placement | null = null
263
+ let disposed = false
264
+
265
+ // ── Windowed RAF geometry sentinel state ──────────────────
266
+ // The sentinel is a windowed poll, opened on demand and auto-closing once
267
+ // the geometry goes steady. It publishes IN-FRAME (no nested defer): each
268
+ // frame measures, and either publishes a changed rect synchronously or
269
+ // counts toward the steady-close threshold. While closed `rafId` is null
270
+ // and no frame is scheduled — zero static cost when idle.
271
+ let rafId: number | null = null
272
+ let steadyFrames = 0
273
+ const STEADY_CLOSE_FRAMES = 2
274
+ // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
275
+ const MAX_HIDDEN_FOLLOW_FRAMES = 30
276
+ // True while a [role="separator"] splitter drag is in progress: set on the
277
+ // capture-phase pointerdown that opened the window, cleared on pointerup.
278
+ // A held pointer means the drag may still resume after a static pause, so a
279
+ // steady run while held must NOT close the sentinel — it only closes once the
280
+ // pointer is released. Without this gate a press that pauses a couple of
281
+ // frames before the drag actually moves would close mid-press and drop the
282
+ // entire subsequent drag.
283
+ let pointerHeld = false
284
+ // Absolute time (performance.now()) past which a `pulse(durationMs)` window
285
+ // force-closes even if the geometry is still changing — the upper bound that
286
+ // prevents a perpetually-animating target from keeping the sentinel resident.
287
+ // null = no time bound (scroll/splitter opens rely on steady-close instead).
288
+ let sentinelDeadline: number | null = null
289
+
290
+ // Measure the target, applying the opt-in first-frame / display:none guard:
291
+ // a zero-area box (no geometry to anchor) becomes a detach instead of a
292
+ // 0×0-visible Placement. Default off → byte-for-byte the plain measure.
293
+ const computePlacement = (): Placement => {
294
+ const p = measurePlacement(target)
295
+ if (
296
+ guardDisplayNone &&
297
+ p.visible &&
298
+ (p.bounds.width === 0 || p.bounds.height === 0)
299
+ ) {
300
+ return { visible: false }
301
+ }
302
+ return p
303
+ }
304
+
305
+ const emit = (): void => {
306
+ if (disposed || !visible) return
307
+ const p = computePlacement()
308
+ if (lastPublished && samePlacement(lastPublished, p)) return
309
+ lastPublished = p
310
+ publish(p)
311
+ }
312
+
313
+ // Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
314
+ // bounded follow run elapsed, else keep following (false); never publishes.
315
+ const shouldCloseOnHiddenPoll = (): boolean => {
316
+ if (lastPublished?.visible === false) return true
317
+ return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
318
+ }
319
+
320
+ // One sentinel frame: measure, publish-in-frame if changed, else count toward
321
+ // the steady-close threshold. Reads `disposed`/`visible` live so a frame
322
+ // outliving teardown is inert.
323
+ const sentinelFrame = (): void => {
324
+ rafId = null
325
+ if (disposed || !visible) {
326
+ sentinelDeadline = null
327
+ return
328
+ }
329
+ // Upper bound: a pulse window past its deadline closes regardless of
330
+ // motion, so a target that changes every frame can't keep the sentinel alive.
331
+ if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
332
+ sentinelDeadline = null
333
+ return // duration elapsed → close (no re-arm)
334
+ }
335
+ const p = computePlacement()
336
+ // The sentinel FOLLOWS visible geometry and NEVER publishes a detach — a
337
+ // hidden poll is a relayout transient to follow until restore (the fix).
338
+ if (!p.visible) {
339
+ if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
340
+ rafId = requestAnimationFrame(sentinelFrame)
341
+ return
342
+ }
343
+ if (lastPublished && samePlacement(lastPublished, p)) {
344
+ steadyFrames++
345
+ // Steady-close only fires once the pointer is RELEASED: while a
346
+ // splitter drag is held, a static pause is a hesitation, not the end of
347
+ // the drag, so we keep polling (re-arm below) and let `steadyFrames`
348
+ // accrue — it converges to a close within N frames after pointerup.
349
+ if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
350
+ sentinelDeadline = null
351
+ return // steady (and released) → close
352
+ }
353
+ } else {
354
+ lastPublished = p
355
+ publish(p) // publish synchronously in THIS frame
356
+ steadyFrames = 0
357
+ }
358
+ // `publish` may have synchronously disposed (or hidden) the anchor; re-read
359
+ // live state so a re-entrant teardown leaves ZERO scheduled frames.
360
+ if (!disposed && visible) {
361
+ rafId = requestAnimationFrame(sentinelFrame) // keep polling
362
+ } else {
363
+ sentinelDeadline = null
364
+ }
365
+ }
366
+
367
+ const openSentinel = (): void => {
368
+ if (!followGeometry || disposed) return
369
+ steadyFrames = 0
370
+ if (rafId === null) rafId = requestAnimationFrame(sentinelFrame)
371
+ }
372
+
373
+ const closeSentinel = (): void => {
374
+ if (rafId !== null) {
375
+ cancelAnimationFrame(rafId)
376
+ rafId = null
377
+ }
378
+ steadyFrames = 0
379
+ sentinelDeadline = null
380
+ pointerHeld = false
381
+ }
382
+
383
+ // An ancestor scroll moved the target's screen rect. With the sentinel on,
384
+ // open the window so the whole scroll burst is followed frame-by-frame;
385
+ // without it, a single synchronous emit() follows the new rect.
386
+ const onScroll = (): void => {
387
+ if (followGeometry) openSentinel()
388
+ else emit()
389
+ }
390
+
391
+ // A capture-phase pointerdown on a [role="separator"] splitter handle marks
392
+ // the start of a drag that moves the target via ancestor reflow (no RO tick)
393
+ // → open the sentinel.
394
+ const onPointerDown = (e: Event): void => {
395
+ const t = e.target as Element | null
396
+ if (t && t.closest && t.closest('[role="separator"]')) {
397
+ pointerHeld = true
398
+ openSentinel()
399
+ }
400
+ }
401
+
402
+ // Pointer released: the drag is over, so a steady run may now close the
403
+ // sentinel. Re-open it (a no-op if already polling) so the steady-close
404
+ // threshold is reached even if the geometry was already static at release.
405
+ const onPointerUp = (): void => {
406
+ if (!pointerHeld) return
407
+ pointerHeld = false
408
+ openSentinel()
409
+ }
410
+
411
+ const startObserving = (): void => {
412
+ if (observer) return
413
+ observer = new ResizeObserver(emit)
414
+ observer.observe(target)
415
+ window.addEventListener('resize', emit)
416
+ // A display:none transition is invisible to ResizeObserver; an
417
+ // IntersectionObserver re-fires `emit`, which re-measures via
418
+ // `computePlacement` (now-zero box → detach, restored box → visible).
419
+ if (guardDisplayNone && typeof IntersectionObserver !== 'undefined') {
420
+ io = new IntersectionObserver(emit)
421
+ io.observe(target)
422
+ }
423
+ if (followScroll) {
424
+ window.addEventListener('scroll', onScroll, {
425
+ capture: true,
426
+ passive: true,
427
+ })
428
+ }
429
+ if (followGeometry) {
430
+ window.addEventListener('pointerdown', onPointerDown, { capture: true })
431
+ window.addEventListener('pointerup', onPointerUp, { capture: true })
432
+ }
433
+ }
434
+
435
+ const stopObserving = (): void => {
436
+ if (observer) {
437
+ observer.disconnect()
438
+ observer = null
439
+ }
440
+ if (io) {
441
+ io.disconnect()
442
+ io = null
443
+ }
444
+ window.removeEventListener('resize', emit)
445
+ window.removeEventListener('scroll', onScroll, {
446
+ capture: true,
447
+ } as EventListenerOptions)
448
+ window.removeEventListener('pointerdown', onPointerDown, {
449
+ capture: true,
450
+ } as EventListenerOptions)
451
+ window.removeEventListener('pointerup', onPointerUp, {
452
+ capture: true,
453
+ } as EventListenerOptions)
454
+ closeSentinel()
455
+ }
456
+
457
+ const apply = (): void => {
458
+ lastPublished = null
459
+ if (visible) {
460
+ startObserving()
461
+ lastPublished = computePlacement()
462
+ publish(lastPublished)
463
+ } else {
464
+ stopObserving()
465
+ const hidden: Placement = { visible: false }
466
+ lastPublished = hidden
467
+ publish(hidden)
468
+ }
469
+ }
470
+
471
+ apply()
472
+
473
+ return {
474
+ update(next: PlacementAnchorOptions): void {
475
+ if (disposed) return
476
+ publish = next.publish
477
+ visible = next.visible
478
+ apply()
479
+ },
480
+ dispose(): void {
481
+ if (disposed) return
482
+ disposed = true
483
+ stopObserving()
484
+ },
485
+ pulse(durationMs?: number): void {
486
+ // Imperative window open: start the animation-follow window. It
487
+ // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
488
+ // at that deadline — whichever comes first. The deadline is the upper bound
489
+ // that guarantees a still-animating target cannot keep the sentinel
490
+ // resident; without it, only steady-close applies.
491
+ if (disposed || !followGeometry) return
492
+ if (durationMs !== undefined && durationMs > 0) {
493
+ const next = performance.now() + durationMs
494
+ // Extend (never shorten) an existing window's deadline.
495
+ sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
496
+ }
497
+ openSentinel()
498
+ },
499
+ }
500
+ }