@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,486 @@
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 — the simulator's allows negative; the DevTools placeholder
17
+ // never goes negative, 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 (§2.C). 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 (§2.D/§6). 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 (§2.D/§6) ──────────────────
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
+ // 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
+ // (§2.D). A held pointer means the drag may still resume after a static
277
+ // pause, so a steady run while held must NOT close the sentinel — it only
278
+ // closes once the pointer is released (松手后 2~3 帧内判静止→关窗). Without
279
+ // this gate a press that pauses a couple of frames before the drag actually
280
+ // moves would close mid-press and drop the entire subsequent drag.
281
+ let pointerHeld = false
282
+ // Absolute time (performance.now()) past which a `pulse(durationMs)` window
283
+ // force-closes even if the geometry is still changing — the upper bound that
284
+ // prevents a perpetually-animating target from keeping the sentinel resident.
285
+ // null = no time bound (scroll/splitter opens rely on steady-close instead).
286
+ let sentinelDeadline: number | null = null
287
+
288
+ // Measure the target, applying the opt-in first-frame / display:none guard:
289
+ // a zero-area box (no geometry to anchor) becomes a detach instead of a
290
+ // 0×0-visible Placement. Default off → byte-for-byte the plain measure.
291
+ const computePlacement = (): Placement => {
292
+ const p = measurePlacement(target)
293
+ if (
294
+ guardDisplayNone &&
295
+ p.visible &&
296
+ (p.bounds.width === 0 || p.bounds.height === 0)
297
+ ) {
298
+ return { visible: false }
299
+ }
300
+ return p
301
+ }
302
+
303
+ const emit = (): void => {
304
+ if (disposed || !visible) return
305
+ const p = computePlacement()
306
+ if (lastPublished && samePlacement(lastPublished, p)) return
307
+ lastPublished = p
308
+ publish(p)
309
+ }
310
+
311
+ // One sentinel frame: measure, publish-in-frame if changed, else count toward
312
+ // the steady-close threshold. A changed frame re-arms; a steady frame re-arms
313
+ // until STEADY_CLOSE_FRAMES consecutive unchanged frames, then closes (no
314
+ // re-arm). Reads `disposed`/`visible` live so a frame outliving teardown is
315
+ // inert.
316
+ const sentinelFrame = (): void => {
317
+ rafId = null
318
+ if (disposed || !visible) {
319
+ sentinelDeadline = null
320
+ return
321
+ }
322
+ // §2.F upper bound: a pulse window past its deadline closes regardless of
323
+ // motion, so a target that changes every frame can't keep the sentinel alive.
324
+ if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
325
+ sentinelDeadline = null
326
+ return // duration elapsed → close (no re-arm)
327
+ }
328
+ const p = computePlacement()
329
+ if (lastPublished && samePlacement(lastPublished, p)) {
330
+ steadyFrames++
331
+ // Steady-close only fires once the pointer is RELEASED (§2.D): while a
332
+ // splitter drag is held, a static pause is a hesitation, not the end of
333
+ // the drag, so we keep polling (re-arm below) and let `steadyFrames`
334
+ // accrue — it converges to a close within N frames after pointerup.
335
+ if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
336
+ sentinelDeadline = null
337
+ return // steady (and released) → close
338
+ }
339
+ } else {
340
+ lastPublished = p
341
+ publish(p) // publish synchronously in THIS frame
342
+ steadyFrames = 0
343
+ }
344
+ // `publish` may have synchronously disposed (or hidden) the anchor; re-read
345
+ // live state so a re-entrant teardown leaves ZERO scheduled frames (B2).
346
+ if (!disposed && visible) {
347
+ rafId = requestAnimationFrame(sentinelFrame) // keep polling
348
+ } else {
349
+ sentinelDeadline = null
350
+ }
351
+ }
352
+
353
+ const openSentinel = (): void => {
354
+ if (!followGeometry || disposed) return
355
+ steadyFrames = 0
356
+ if (rafId === null) rafId = requestAnimationFrame(sentinelFrame)
357
+ }
358
+
359
+ const closeSentinel = (): void => {
360
+ if (rafId !== null) {
361
+ cancelAnimationFrame(rafId)
362
+ rafId = null
363
+ }
364
+ steadyFrames = 0
365
+ sentinelDeadline = null
366
+ pointerHeld = false
367
+ }
368
+
369
+ // §2.C — an ancestor scroll moved the target's screen rect. With the sentinel
370
+ // on, open the window so the whole scroll burst is followed frame-by-frame;
371
+ // without it, a single synchronous emit() follows the new rect (A1).
372
+ const onScroll = (): void => {
373
+ if (followGeometry) openSentinel()
374
+ else emit()
375
+ }
376
+
377
+ // §2.D — a capture-phase pointerdown on a [role="separator"] splitter handle
378
+ // marks the start of a drag that moves the target via ancestor reflow (no RO
379
+ // tick) → open the sentinel.
380
+ const onPointerDown = (e: Event): void => {
381
+ const t = e.target as Element | null
382
+ if (t && t.closest && t.closest('[role="separator"]')) {
383
+ pointerHeld = true
384
+ openSentinel()
385
+ }
386
+ }
387
+
388
+ // §2.D — pointer released: the drag is over, so a steady run may now close the
389
+ // sentinel. Re-open it (a no-op if already polling) so the steady-close
390
+ // threshold is reached even if the geometry was already static at release.
391
+ const onPointerUp = (): void => {
392
+ if (!pointerHeld) return
393
+ pointerHeld = false
394
+ openSentinel()
395
+ }
396
+
397
+ const startObserving = (): void => {
398
+ if (observer) return
399
+ observer = new ResizeObserver(emit)
400
+ observer.observe(target)
401
+ window.addEventListener('resize', emit)
402
+ // A display:none transition is invisible to ResizeObserver; an
403
+ // IntersectionObserver re-fires `emit`, which re-measures via
404
+ // `computePlacement` (now-zero box → detach, restored box → visible).
405
+ if (guardDisplayNone && typeof IntersectionObserver !== 'undefined') {
406
+ io = new IntersectionObserver(emit)
407
+ io.observe(target)
408
+ }
409
+ if (followScroll) {
410
+ window.addEventListener('scroll', onScroll, {
411
+ capture: true,
412
+ passive: true,
413
+ })
414
+ }
415
+ if (followGeometry) {
416
+ window.addEventListener('pointerdown', onPointerDown, { capture: true })
417
+ window.addEventListener('pointerup', onPointerUp, { capture: true })
418
+ }
419
+ }
420
+
421
+ const stopObserving = (): void => {
422
+ if (observer) {
423
+ observer.disconnect()
424
+ observer = null
425
+ }
426
+ if (io) {
427
+ io.disconnect()
428
+ io = null
429
+ }
430
+ window.removeEventListener('resize', emit)
431
+ window.removeEventListener('scroll', onScroll, {
432
+ capture: true,
433
+ } as EventListenerOptions)
434
+ window.removeEventListener('pointerdown', onPointerDown, {
435
+ capture: true,
436
+ } as EventListenerOptions)
437
+ window.removeEventListener('pointerup', onPointerUp, {
438
+ capture: true,
439
+ } as EventListenerOptions)
440
+ closeSentinel()
441
+ }
442
+
443
+ const apply = (): void => {
444
+ lastPublished = null
445
+ if (visible) {
446
+ startObserving()
447
+ lastPublished = computePlacement()
448
+ publish(lastPublished)
449
+ } else {
450
+ stopObserving()
451
+ const hidden: Placement = { visible: false }
452
+ lastPublished = hidden
453
+ publish(hidden)
454
+ }
455
+ }
456
+
457
+ apply()
458
+
459
+ return {
460
+ update(next: PlacementAnchorOptions): void {
461
+ if (disposed) return
462
+ publish = next.publish
463
+ visible = next.visible
464
+ apply()
465
+ },
466
+ dispose(): void {
467
+ if (disposed) return
468
+ disposed = true
469
+ stopObserving()
470
+ },
471
+ pulse(durationMs?: number): void {
472
+ // Imperative window open (§2.F): start the animation-follow window. It
473
+ // closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
474
+ // at that deadline — whichever comes first. The deadline is the upper bound
475
+ // that guarantees a still-animating target cannot keep the sentinel
476
+ // resident; without it, only steady-close applies.
477
+ if (disposed || !followGeometry) return
478
+ if (durationMs !== undefined && durationMs > 0) {
479
+ const next = performance.now() + durationMs
480
+ // Extend (never shorten) an existing window's deadline.
481
+ sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
482
+ }
483
+ openSentinel()
484
+ },
485
+ }
486
+ }