view-anchor 0.1.2 → 0.2.0

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.
Files changed (44) hide show
  1. package/README.md +118 -34
  2. package/README.zh-CN.md +128 -44
  3. package/dist/index.d.ts +4 -16
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -14
  6. package/dist/measure-loop.d.ts +9 -28
  7. package/dist/measure-loop.d.ts.map +1 -1
  8. package/dist/measure-loop.js +37 -16
  9. package/dist/protocol-publisher.d.ts +41 -0
  10. package/dist/protocol-publisher.d.ts.map +1 -0
  11. package/dist/protocol-publisher.js +191 -0
  12. package/dist/protocol-types.d.ts +36 -0
  13. package/dist/protocol-types.d.ts.map +1 -0
  14. package/dist/protocol-types.js +10 -0
  15. package/dist/protocol.d.ts +35 -0
  16. package/dist/protocol.d.ts.map +1 -0
  17. package/dist/protocol.js +131 -0
  18. package/dist/react.d.ts +18 -30
  19. package/dist/react.d.ts.map +1 -1
  20. package/dist/react.js +125 -122
  21. package/dist/size-advertiser.d.ts +9 -14
  22. package/dist/size-advertiser.d.ts.map +1 -1
  23. package/dist/size-advertiser.js +20 -28
  24. package/dist/types.d.ts +29 -73
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/types.js +1 -15
  27. package/dist/view-anchor.d.ts +36 -77
  28. package/dist/view-anchor.d.ts.map +1 -1
  29. package/dist/view-anchor.js +206 -174
  30. package/docs/bidirectional-design.md +64 -96
  31. package/docs/{anchor-3d.html → index.html} +215 -73
  32. package/docs/mechanism.mdx +55 -49
  33. package/docs/performance-report.md +63 -0
  34. package/docs/protocol.md +79 -0
  35. package/package.json +30 -4
  36. package/src/index.ts +6 -15
  37. package/src/measure-loop.ts +36 -41
  38. package/src/protocol-publisher.ts +236 -0
  39. package/src/protocol-types.ts +43 -0
  40. package/src/protocol.ts +193 -0
  41. package/src/react.ts +186 -141
  42. package/src/size-advertiser.ts +24 -31
  43. package/src/types.ts +34 -79
  44. package/src/view-anchor.ts +228 -212
@@ -1,20 +1,16 @@
1
1
  import type {
2
2
  Bounds,
3
3
  Placement,
4
+ Publisher,
4
5
  ViewAnchorOptions,
5
6
  ViewAnchorHandle,
6
7
  } from './types.js'
7
8
 
8
9
  const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
9
10
 
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.)
11
+ // Round to integer pixels. Width and height are clamped to >= 0 (0 represents
12
+ // a collapsed rect). Coordinates (x, y) can be negative when an element is
13
+ // scrolled out of view; clamping them to 0 would pin the view to the screen edge.
18
14
  const clampRect = (r: {
19
15
  x: number
20
16
  y: number
@@ -28,33 +24,19 @@ const clampRect = (r: {
28
24
  })
29
25
 
30
26
  /**
31
- * Create an anchor binding ONE native view's bounds to `target`'s geometry.
27
+ * Bind a native view or external surface to the geometry of `target`.
32
28
  *
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.
29
+ * - `present === true`: measures `target.getBoundingClientRect()` and publishes
30
+ * immediately, then re-measures synchronously on ResizeObserver and window resize.
31
+ * - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
32
+ * and stops observing.
33
+ * - `update(opts)`: re-applies options immediately.
34
+ * - `dispose()`: stops observing and prevents any further publishes.
40
35
  *
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.
36
+ * Synchronous publishing: measurement and publishing occur directly in the
37
+ * observer tick. Cross-process setBounds calls already have a compositor delay;
38
+ * adding requestAnimationFrame would add a second frame of visual lag during drag
39
+ * operations. High-frequency updates are deduplicated against the last accepted rect.
58
40
  */
59
41
  export function createViewAnchor(
60
42
  target: HTMLElement,
@@ -63,31 +45,49 @@ export function createViewAnchor(
63
45
  let present = opts.present
64
46
  let publish = opts.publish
65
47
  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.
48
+ // Last rect sent to publish. Reset on apply() so state changes (such as zoom)
49
+ // force a re-publish even if the geometry did not change.
70
50
  let lastPublished: Bounds | null = null
51
+ let publicationRevision = 0
71
52
  let disposed = false
72
53
 
73
- const measure = (): Bounds => {
54
+ const measure = (): Bounds | null => {
74
55
  const r = target.getBoundingClientRect()
56
+ // Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
57
+ if (
58
+ !Number.isFinite(r.left) ||
59
+ !Number.isFinite(r.top) ||
60
+ !Number.isFinite(r.width) ||
61
+ !Number.isFinite(r.height)
62
+ ) return null
75
63
  return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
76
64
  }
77
65
 
78
66
  const sameRect = (a: Bounds, b: Bounds): boolean =>
79
67
  a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
80
68
 
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).
69
+ const publishCandidate = (candidate: Bounds): boolean => {
70
+ const previous = lastPublished
71
+ const attempt = ++publicationRevision
72
+ lastPublished = candidate
73
+ try {
74
+ const accepted = publish(candidate) !== false
75
+ if (!accepted && publicationRevision === attempt) lastPublished = previous
76
+ return accepted
77
+ } catch (error) {
78
+ if (publicationRevision === attempt) lastPublished = previous
79
+ throw error
80
+ }
81
+ }
82
+
83
+ // Measure and publish synchronously on each observer tick.
84
+ // Drops duplicate rects to coalesce same-frame resize events.
85
85
  const emit = (): void => {
86
86
  if (disposed || !present) return
87
87
  const m = measure()
88
+ if (!m) return
88
89
  if (lastPublished && sameRect(lastPublished, m)) return
89
- lastPublished = m
90
- publish(m)
90
+ publishCandidate(m)
91
91
  }
92
92
 
93
93
  const startObserving = (): void => {
@@ -105,19 +105,17 @@ export function createViewAnchor(
105
105
  window.removeEventListener('resize', emit)
106
106
  }
107
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.
108
+ // Apply current options synchronously. Reset lastPublished so state changes
109
+ // always re-publish even if dimensions have not changed.
112
110
  const apply = (): void => {
113
111
  lastPublished = null
114
112
  if (present) {
115
113
  startObserving()
116
- lastPublished = measure()
117
- publish(lastPublished)
114
+ const measured = measure()
115
+ if (measured) publishCandidate(measured)
118
116
  } else {
119
117
  stopObserving()
120
- publish(ZERO)
118
+ publishCandidate(ZERO)
121
119
  }
122
120
  }
123
121
 
@@ -138,74 +136,56 @@ export function createViewAnchor(
138
136
  }
139
137
  }
140
138
 
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`.
139
+ // --- Explicit Placement API ---
147
140
 
148
141
  export interface PlacementAnchorOptions {
149
142
  /**
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`.
143
+ * Whether the native view should be visible. When true, publishes
144
+ * { visible: true, bounds }; when false, publishes { visible: false }.
155
145
  */
156
146
  visible: boolean
157
- /** Receives each explicit Placement. Owns IPC → host. */
158
- publish: (placement: Placement) => void
147
+ /** Receives each explicit Placement. */
148
+ publish: Publisher<Placement>
159
149
  /**
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.
150
+ * When true, targets with zero area (such as display: none or unmounted elements)
151
+ * publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
152
+ * IntersectionObserver tracks display: none transitions. Default is false.
153
+ * Sticky across update(): omitting it preserves the current setting.
166
154
  */
167
155
  guardDisplayNone?: boolean
168
156
  /**
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.
157
+ * When true, listens for capture-phase scroll events on window to re-measure
158
+ * when an ancestor container scrolls. Default is false.
159
+ * Sticky across update(): omitting it preserves the current setting.
176
160
  */
177
161
  followScroll?: boolean
178
162
  /**
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.
163
+ * When true, polls geometry per animation frame during active motion (scrolls,
164
+ * splitter dragging, or pulse()) and auto-closes when steady. Zero idle overhead.
165
+ * Sticky across update(): omitting it preserves the current setting.
186
166
  */
187
167
  followGeometry?: boolean
188
168
  }
189
169
 
190
170
  export interface PlacementAnchorHandle {
191
- /** Apply new options; re-publishes immediately (mirrors `createViewAnchor`). */
171
+ /**
172
+ * Apply new options and re-publish immediately.
173
+ * guardDisplayNone, followScroll, and followGeometry are sticky: omitting a flag
174
+ * preserves its current value. Pass an explicit false to disable one.
175
+ */
192
176
  update(opts: PlacementAnchorOptions): void
193
177
  /** Stop observing; never publish again. */
194
178
  dispose(): void
195
179
  /**
196
- * Open the RAF sentinel window (animation follow); auto-closes after going
197
- * steady or after `durationMs`. No-op when `followGeometry` is false.
180
+ * Open the animation frame sentinel window. Auto-closes once stable
181
+ * or after durationMs. No-op if followGeometry is false.
198
182
  */
199
183
  pulse(durationMs?: number): void
200
184
  }
201
185
 
202
186
  /**
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.
187
+ * Read target's current rect and return { visible: true, bounds }.
188
+ * Does not infer visibility from dimensions.
209
189
  */
210
190
  export function measurePlacement(target: HTMLElement): Placement {
211
191
  const r = target.getBoundingClientRect()
@@ -216,8 +196,6 @@ export function measurePlacement(target: HTMLElement): Placement {
216
196
  }
217
197
 
218
198
  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
199
  if (a.visible !== b.visible) return false
222
200
  if (a.visible && b.visible) {
223
201
  return (
@@ -231,21 +209,7 @@ const samePlacement = (a: Placement, b: Placement): boolean => {
231
209
  }
232
210
 
233
211
  /**
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.
212
+ * Explicit-visibility variant of createViewAnchor.
249
213
  */
250
214
  export function createPlacementAnchor(
251
215
  target: HTMLElement,
@@ -253,45 +217,42 @@ export function createPlacementAnchor(
253
217
  ): PlacementAnchorHandle {
254
218
  let visible = opts.visible
255
219
  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
220
+ let guardDisplayNone = opts.guardDisplayNone ?? false
221
+ let followScroll = opts.followScroll ?? false
222
+ let followGeometry = opts.followGeometry ?? false
260
223
  let observer: ResizeObserver | null = null
261
224
  let io: IntersectionObserver | null = null
225
+ let scrollListening = false
226
+ let geometryListening = false
227
+ const capture = { capture: true }
228
+ const passiveCapture = { capture: true, passive: true }
262
229
  let lastPublished: Placement | null = null
230
+ let publicationRevision = 0
263
231
  let disposed = false
264
232
 
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.
233
+ // --- Windowed RAF geometry sentinel state ---
234
+ // The sentinel polls per frame during active movement and auto-closes once
235
+ // geometry settles. While closed, no frame is scheduled (zero idle overhead).
271
236
  let rafId: number | null = null
272
237
  let steadyFrames = 0
273
238
  const STEADY_CLOSE_FRAMES = 2
274
- // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
275
239
  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.
240
+ const MAX_INVALID_FOLLOW_FRAMES = 30
241
+ let invalidFrames = 0
242
+ // True while a [role="separator"] splitter drag is held.
283
243
  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).
244
+ let activePointerId: number | undefined | null = null
288
245
  let sentinelDeadline: number | null = null
289
246
 
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 => {
247
+ const computePlacement = (): Placement | null => {
294
248
  const p = measurePlacement(target)
249
+ if (
250
+ p.visible &&
251
+ (!Number.isFinite(p.bounds.x) ||
252
+ !Number.isFinite(p.bounds.y) ||
253
+ !Number.isFinite(p.bounds.width) ||
254
+ !Number.isFinite(p.bounds.height))
255
+ ) return null
295
256
  if (
296
257
  guardDisplayNone &&
297
258
  p.visible &&
@@ -302,63 +263,78 @@ export function createPlacementAnchor(
302
263
  return p
303
264
  }
304
265
 
266
+ const publishCandidate = (candidate: Placement): boolean => {
267
+ const previous = lastPublished
268
+ const attempt = ++publicationRevision
269
+ lastPublished = candidate
270
+ try {
271
+ const accepted = publish(candidate) !== false
272
+ if (!accepted && publicationRevision === attempt) lastPublished = previous
273
+ return accepted
274
+ } catch (error) {
275
+ if (publicationRevision === attempt) lastPublished = previous
276
+ throw error
277
+ }
278
+ }
279
+
305
280
  const emit = (): void => {
306
281
  if (disposed || !visible) return
307
282
  const p = computePlacement()
283
+ if (!p) return
308
284
  if (lastPublished && samePlacement(lastPublished, p)) return
309
- lastPublished = p
310
- publish(p)
285
+ publishCandidate(p)
311
286
  }
312
287
 
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
288
  const shouldCloseOnHiddenPoll = (): boolean => {
316
289
  if (lastPublished?.visible === false) return true
317
290
  return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
318
291
  }
319
292
 
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
293
  const sentinelFrame = (): void => {
324
294
  rafId = null
325
295
  if (disposed || !visible) {
326
296
  sentinelDeadline = null
327
297
  return
328
298
  }
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
299
  if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
332
300
  sentinelDeadline = null
333
- return // duration elapsed → close (no re-arm)
301
+ return
334
302
  }
335
303
  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).
304
+ if (!p) {
305
+ if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
306
+ sentinelDeadline = null
307
+ return
308
+ }
309
+ if (!disposed && visible && followGeometry) {
310
+ rafId = requestAnimationFrame(sentinelFrame)
311
+ } else {
312
+ sentinelDeadline = null
313
+ }
314
+ return
315
+ }
316
+ invalidFrames = 0
338
317
  if (!p.visible) {
339
318
  if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
340
- rafId = requestAnimationFrame(sentinelFrame)
319
+ if (!disposed && visible && followGeometry) {
320
+ rafId = requestAnimationFrame(sentinelFrame)
321
+ } else {
322
+ sentinelDeadline = null
323
+ }
341
324
  return
342
325
  }
343
326
  if (lastPublished && samePlacement(lastPublished, p)) {
344
327
  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
328
  if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
350
329
  sentinelDeadline = null
351
- return // steady (and released) → close
330
+ return
352
331
  }
353
332
  } else {
354
- lastPublished = p
355
- publish(p) // publish synchronously in THIS frame
333
+ publishCandidate(p)
356
334
  steadyFrames = 0
357
335
  }
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
336
+ if (!disposed && visible && followGeometry) {
337
+ rafId = requestAnimationFrame(sentinelFrame)
362
338
  } else {
363
339
  sentinelDeadline = null
364
340
  }
@@ -367,7 +343,10 @@ export function createPlacementAnchor(
367
343
  const openSentinel = (): void => {
368
344
  if (!followGeometry || disposed) return
369
345
  steadyFrames = 0
370
- if (rafId === null) rafId = requestAnimationFrame(sentinelFrame)
346
+ if (rafId === null) {
347
+ invalidFrames = 0
348
+ rafId = requestAnimationFrame(sentinelFrame)
349
+ }
371
350
  }
372
351
 
373
352
  const closeSentinel = (): void => {
@@ -376,95 +355,129 @@ export function createPlacementAnchor(
376
355
  rafId = null
377
356
  }
378
357
  steadyFrames = 0
358
+ invalidFrames = 0
379
359
  sentinelDeadline = null
380
360
  pointerHeld = false
361
+ activePointerId = null
381
362
  }
382
363
 
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
364
  const onScroll = (): void => {
387
365
  if (followGeometry) openSentinel()
388
366
  else emit()
389
367
  }
390
368
 
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
369
  const onPointerDown = (e: Event): void => {
395
370
  const t = e.target as Element | null
396
371
  if (t && t.closest && t.closest('[role="separator"]')) {
372
+ if (!pointerHeld) activePointerId = (e as PointerEvent).pointerId
397
373
  pointerHeld = true
398
374
  openSentinel()
399
375
  }
400
376
  }
401
377
 
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 => {
378
+ const releasePointer = (e?: Event): void => {
406
379
  if (!pointerHeld) return
380
+ if (e && activePointerId !== (e as PointerEvent).pointerId) return
407
381
  pointerHeld = false
382
+ activePointerId = null
408
383
  openSentinel()
409
384
  }
410
385
 
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') {
386
+ const onPointerUp = (e: Event): void => {
387
+ releasePointer(e)
388
+ }
389
+
390
+ const onPointerCancel = (e: Event): void => {
391
+ releasePointer(e)
392
+ }
393
+
394
+ const onWindowBlur = (): void => {
395
+ releasePointer()
396
+ }
397
+
398
+ const startOptionalObserving = (): void => {
399
+ if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
420
400
  io = new IntersectionObserver(emit)
421
401
  io.observe(target)
422
402
  }
423
- if (followScroll) {
424
- window.addEventListener('scroll', onScroll, {
425
- capture: true,
426
- passive: true,
427
- })
403
+ if (followScroll && !scrollListening) {
404
+ window.addEventListener('scroll', onScroll, passiveCapture)
405
+ scrollListening = true
428
406
  }
429
- if (followGeometry) {
430
- window.addEventListener('pointerdown', onPointerDown, { capture: true })
431
- window.addEventListener('pointerup', onPointerUp, { capture: true })
407
+ if (followGeometry && !geometryListening) {
408
+ window.addEventListener('pointerdown', onPointerDown, capture)
409
+ window.addEventListener('pointerup', onPointerUp, capture)
410
+ window.addEventListener('pointercancel', onPointerCancel, capture)
411
+ window.addEventListener('blur', onWindowBlur)
412
+ geometryListening = true
432
413
  }
433
414
  }
434
415
 
435
- const stopObserving = (): void => {
436
- if (observer) {
437
- observer.disconnect()
438
- observer = null
416
+ const stopOptionalObserving = (): void => {
417
+ if (io && !guardDisplayNone) {
418
+ io.disconnect()
419
+ io = null
420
+ }
421
+ if (scrollListening && !followScroll) {
422
+ window.removeEventListener('scroll', onScroll, passiveCapture)
423
+ scrollListening = false
439
424
  }
425
+ if (geometryListening && !followGeometry) {
426
+ window.removeEventListener('pointerdown', onPointerDown, capture)
427
+ window.removeEventListener('pointerup', onPointerUp, capture)
428
+ window.removeEventListener('pointercancel', onPointerCancel, capture)
429
+ window.removeEventListener('blur', onWindowBlur)
430
+ geometryListening = false
431
+ closeSentinel()
432
+ }
433
+ }
434
+
435
+ const stopAllOptionalObserving = (): void => {
440
436
  if (io) {
441
437
  io.disconnect()
442
438
  io = null
443
439
  }
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)
440
+ if (scrollListening) {
441
+ window.removeEventListener('scroll', onScroll, passiveCapture)
442
+ scrollListening = false
443
+ }
444
+ if (geometryListening) {
445
+ window.removeEventListener('pointerdown', onPointerDown, capture)
446
+ window.removeEventListener('pointerup', onPointerUp, capture)
447
+ window.removeEventListener('pointercancel', onPointerCancel, capture)
448
+ window.removeEventListener('blur', onWindowBlur)
449
+ geometryListening = false
450
+ }
454
451
  closeSentinel()
455
452
  }
456
453
 
454
+ const startObserving = (): void => {
455
+ if (observer) return
456
+ observer = new ResizeObserver(emit)
457
+ observer.observe(target)
458
+ window.addEventListener('resize', emit)
459
+ startOptionalObserving()
460
+ }
461
+
462
+ const stopObserving = (): void => {
463
+ if (observer) {
464
+ observer.disconnect()
465
+ observer = null
466
+ }
467
+ window.removeEventListener('resize', emit)
468
+ stopAllOptionalObserving()
469
+ }
470
+
457
471
  const apply = (): void => {
458
472
  lastPublished = null
459
473
  if (visible) {
460
474
  startObserving()
461
- lastPublished = computePlacement()
462
- publish(lastPublished)
475
+ const placement = computePlacement()
476
+ if (placement) publishCandidate(placement)
463
477
  } else {
464
478
  stopObserving()
465
479
  const hidden: Placement = { visible: false }
466
- lastPublished = hidden
467
- publish(hidden)
480
+ publishCandidate(hidden)
468
481
  }
469
482
  }
470
483
 
@@ -475,6 +488,15 @@ export function createPlacementAnchor(
475
488
  if (disposed) return
476
489
  publish = next.publish
477
490
  visible = next.visible
491
+ // Omitting a flag preserves its current value so callers that only
492
+ // pass { visible, publish } don't inadvertently disable enabled flags.
493
+ guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone
494
+ followScroll = next.followScroll ?? followScroll
495
+ followGeometry = next.followGeometry ?? followGeometry
496
+ if (visible && observer) {
497
+ stopOptionalObserving()
498
+ startOptionalObserving()
499
+ }
478
500
  apply()
479
501
  },
480
502
  dispose(): void {
@@ -483,15 +505,9 @@ export function createPlacementAnchor(
483
505
  stopObserving()
484
506
  },
485
507
  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
508
  if (disposed || !followGeometry) return
492
509
  if (durationMs !== undefined && durationMs > 0) {
493
510
  const next = performance.now() + durationMs
494
- // Extend (never shorten) an existing window's deadline.
495
511
  sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
496
512
  }
497
513
  openSentinel()