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,12 +1,7 @@
1
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.)
2
+ // Round to integer pixels. Width and height are clamped to >= 0 (0 represents
3
+ // a collapsed rect). Coordinates (x, y) can be negative when an element is
4
+ // scrolled out of view; clamping them to 0 would pin the view to the screen edge.
10
5
  const clampRect = (r) => ({
11
6
  x: Math.round(r.x),
12
7
  y: Math.round(r.y),
@@ -14,61 +9,67 @@ const clampRect = (r) => ({
14
9
  height: Math.max(0, Math.round(r.height)),
15
10
  });
16
11
  /**
17
- * Create an anchor binding ONE native view's bounds to `target`'s geometry.
12
+ * Bind a native view or external surface to the geometry of `target`.
18
13
  *
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.
14
+ * - `present === true`: measures `target.getBoundingClientRect()` and publishes
15
+ * immediately, then re-measures synchronously on ResizeObserver and window resize.
16
+ * - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
17
+ * and stops observing.
18
+ * - `update(opts)`: re-applies options immediately.
19
+ * - `dispose()`: stops observing and prevents any further publishes.
26
20
  *
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.
21
+ * Synchronous publishing: measurement and publishing occur directly in the
22
+ * observer tick. Cross-process setBounds calls already have a compositor delay;
23
+ * adding requestAnimationFrame would add a second frame of visual lag during drag
24
+ * operations. High-frequency updates are deduplicated against the last accepted rect.
44
25
  */
45
26
  export function createViewAnchor(target, opts) {
46
27
  let present = opts.present;
47
28
  let publish = opts.publish;
48
29
  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.
30
+ // Last rect sent to publish. Reset on apply() so state changes (such as zoom)
31
+ // force a re-publish even if the geometry did not change.
53
32
  let lastPublished = null;
33
+ let publicationRevision = 0;
54
34
  let disposed = false;
55
35
  const measure = () => {
56
36
  const r = target.getBoundingClientRect();
37
+ // Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
38
+ if (!Number.isFinite(r.left) ||
39
+ !Number.isFinite(r.top) ||
40
+ !Number.isFinite(r.width) ||
41
+ !Number.isFinite(r.height))
42
+ return null;
57
43
  return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height });
58
44
  };
59
45
  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).
46
+ const publishCandidate = (candidate) => {
47
+ const previous = lastPublished;
48
+ const attempt = ++publicationRevision;
49
+ lastPublished = candidate;
50
+ try {
51
+ const accepted = publish(candidate) !== false;
52
+ if (!accepted && publicationRevision === attempt)
53
+ lastPublished = previous;
54
+ return accepted;
55
+ }
56
+ catch (error) {
57
+ if (publicationRevision === attempt)
58
+ lastPublished = previous;
59
+ throw error;
60
+ }
61
+ };
62
+ // Measure and publish synchronously on each observer tick.
63
+ // Drops duplicate rects to coalesce same-frame resize events.
64
64
  const emit = () => {
65
65
  if (disposed || !present)
66
66
  return;
67
67
  const m = measure();
68
+ if (!m)
69
+ return;
68
70
  if (lastPublished && sameRect(lastPublished, m))
69
71
  return;
70
- lastPublished = m;
71
- publish(m);
72
+ publishCandidate(m);
72
73
  };
73
74
  const startObserving = () => {
74
75
  if (observer)
@@ -84,20 +85,19 @@ export function createViewAnchor(target, opts) {
84
85
  }
85
86
  window.removeEventListener('resize', emit);
86
87
  };
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.
88
+ // Apply current options synchronously. Reset lastPublished so state changes
89
+ // always re-publish even if dimensions have not changed.
91
90
  const apply = () => {
92
91
  lastPublished = null;
93
92
  if (present) {
94
93
  startObserving();
95
- lastPublished = measure();
96
- publish(lastPublished);
94
+ const measured = measure();
95
+ if (measured)
96
+ publishCandidate(measured);
97
97
  }
98
98
  else {
99
99
  stopObserving();
100
- publish(ZERO);
100
+ publishCandidate(ZERO);
101
101
  }
102
102
  };
103
103
  apply();
@@ -118,12 +118,8 @@ export function createViewAnchor(target, opts) {
118
118
  };
119
119
  }
120
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.
121
+ * Read target's current rect and return { visible: true, bounds }.
122
+ * Does not infer visibility from dimensions.
127
123
  */
128
124
  export function measurePlacement(target) {
129
125
  const r = target.getBoundingClientRect();
@@ -133,8 +129,6 @@ export function measurePlacement(target) {
133
129
  };
134
130
  }
135
131
  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
132
  if (a.visible !== b.visible)
139
133
  return false;
140
134
  if (a.visible && b.visible) {
@@ -146,62 +140,44 @@ const samePlacement = (a, b) => {
146
140
  return true;
147
141
  };
148
142
  /**
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.
143
+ * Explicit-visibility variant of createViewAnchor.
164
144
  */
165
145
  export function createPlacementAnchor(target, opts) {
166
146
  let visible = opts.visible;
167
147
  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;
148
+ let guardDisplayNone = opts.guardDisplayNone ?? false;
149
+ let followScroll = opts.followScroll ?? false;
150
+ let followGeometry = opts.followGeometry ?? false;
172
151
  let observer = null;
173
152
  let io = null;
153
+ let scrollListening = false;
154
+ let geometryListening = false;
155
+ const capture = { capture: true };
156
+ const passiveCapture = { capture: true, passive: true };
174
157
  let lastPublished = null;
158
+ let publicationRevision = 0;
175
159
  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.
160
+ // --- Windowed RAF geometry sentinel state ---
161
+ // The sentinel polls per frame during active movement and auto-closes once
162
+ // geometry settles. While closed, no frame is scheduled (zero idle overhead).
182
163
  let rafId = null;
183
164
  let steadyFrames = 0;
184
165
  const STEADY_CLOSE_FRAMES = 2;
185
- // Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
186
166
  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.
167
+ const MAX_INVALID_FOLLOW_FRAMES = 30;
168
+ let invalidFrames = 0;
169
+ // True while a [role="separator"] splitter drag is held.
194
170
  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).
171
+ let activePointerId = null;
199
172
  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
173
  const computePlacement = () => {
204
174
  const p = measurePlacement(target);
175
+ if (p.visible &&
176
+ (!Number.isFinite(p.bounds.x) ||
177
+ !Number.isFinite(p.bounds.y) ||
178
+ !Number.isFinite(p.bounds.width) ||
179
+ !Number.isFinite(p.bounds.height)))
180
+ return null;
205
181
  if (guardDisplayNone &&
206
182
  p.visible &&
207
183
  (p.bounds.width === 0 || p.bounds.height === 0)) {
@@ -209,68 +185,88 @@ export function createPlacementAnchor(target, opts) {
209
185
  }
210
186
  return p;
211
187
  };
188
+ const publishCandidate = (candidate) => {
189
+ const previous = lastPublished;
190
+ const attempt = ++publicationRevision;
191
+ lastPublished = candidate;
192
+ try {
193
+ const accepted = publish(candidate) !== false;
194
+ if (!accepted && publicationRevision === attempt)
195
+ lastPublished = previous;
196
+ return accepted;
197
+ }
198
+ catch (error) {
199
+ if (publicationRevision === attempt)
200
+ lastPublished = previous;
201
+ throw error;
202
+ }
203
+ };
212
204
  const emit = () => {
213
205
  if (disposed || !visible)
214
206
  return;
215
207
  const p = computePlacement();
208
+ if (!p)
209
+ return;
216
210
  if (lastPublished && samePlacement(lastPublished, p))
217
211
  return;
218
- lastPublished = p;
219
- publish(p);
212
+ publishCandidate(p);
220
213
  };
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
214
  const shouldCloseOnHiddenPoll = () => {
224
215
  if (lastPublished?.visible === false)
225
216
  return true;
226
217
  return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
227
218
  };
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
219
  const sentinelFrame = () => {
232
220
  rafId = null;
233
221
  if (disposed || !visible) {
234
222
  sentinelDeadline = null;
235
223
  return;
236
224
  }
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
225
  if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
240
226
  sentinelDeadline = null;
241
- return; // duration elapsed → close (no re-arm)
227
+ return;
242
228
  }
243
229
  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).
230
+ if (!p) {
231
+ if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
232
+ sentinelDeadline = null;
233
+ return;
234
+ }
235
+ if (!disposed && visible && followGeometry) {
236
+ rafId = requestAnimationFrame(sentinelFrame);
237
+ }
238
+ else {
239
+ sentinelDeadline = null;
240
+ }
241
+ return;
242
+ }
243
+ invalidFrames = 0;
246
244
  if (!p.visible) {
247
245
  if (shouldCloseOnHiddenPoll()) {
248
246
  sentinelDeadline = null;
249
247
  return;
250
248
  }
251
- rafId = requestAnimationFrame(sentinelFrame);
249
+ if (!disposed && visible && followGeometry) {
250
+ rafId = requestAnimationFrame(sentinelFrame);
251
+ }
252
+ else {
253
+ sentinelDeadline = null;
254
+ }
252
255
  return;
253
256
  }
254
257
  if (lastPublished && samePlacement(lastPublished, p)) {
255
258
  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
259
  if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
261
260
  sentinelDeadline = null;
262
- return; // steady (and released) → close
261
+ return;
263
262
  }
264
263
  }
265
264
  else {
266
- lastPublished = p;
267
- publish(p); // publish synchronously in THIS frame
265
+ publishCandidate(p);
268
266
  steadyFrames = 0;
269
267
  }
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
268
+ if (!disposed && visible && followGeometry) {
269
+ rafId = requestAnimationFrame(sentinelFrame);
274
270
  }
275
271
  else {
276
272
  sentinelDeadline = null;
@@ -280,8 +276,10 @@ export function createPlacementAnchor(target, opts) {
280
276
  if (!followGeometry || disposed)
281
277
  return;
282
278
  steadyFrames = 0;
283
- if (rafId === null)
279
+ if (rafId === null) {
280
+ invalidFrames = 0;
284
281
  rafId = requestAnimationFrame(sentinelFrame);
282
+ }
285
283
  };
286
284
  const closeSentinel = () => {
287
285
  if (rafId !== null) {
@@ -289,94 +287,125 @@ export function createPlacementAnchor(target, opts) {
289
287
  rafId = null;
290
288
  }
291
289
  steadyFrames = 0;
290
+ invalidFrames = 0;
292
291
  sentinelDeadline = null;
293
292
  pointerHeld = false;
293
+ activePointerId = null;
294
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
295
  const onScroll = () => {
299
296
  if (followGeometry)
300
297
  openSentinel();
301
298
  else
302
299
  emit();
303
300
  };
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
301
  const onPointerDown = (e) => {
308
302
  const t = e.target;
309
303
  if (t && t.closest && t.closest('[role="separator"]')) {
304
+ if (!pointerHeld)
305
+ activePointerId = e.pointerId;
310
306
  pointerHeld = true;
311
307
  openSentinel();
312
308
  }
313
309
  };
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 = () => {
310
+ const releasePointer = (e) => {
318
311
  if (!pointerHeld)
319
312
  return;
313
+ if (e && activePointerId !== e.pointerId)
314
+ return;
320
315
  pointerHeld = false;
316
+ activePointerId = null;
321
317
  openSentinel();
322
318
  };
319
+ const onPointerUp = (e) => {
320
+ releasePointer(e);
321
+ };
322
+ const onPointerCancel = (e) => {
323
+ releasePointer(e);
324
+ };
325
+ const onWindowBlur = () => {
326
+ releasePointer();
327
+ };
328
+ const startOptionalObserving = () => {
329
+ if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
330
+ io = new IntersectionObserver(emit);
331
+ io.observe(target);
332
+ }
333
+ if (followScroll && !scrollListening) {
334
+ window.addEventListener('scroll', onScroll, passiveCapture);
335
+ scrollListening = true;
336
+ }
337
+ if (followGeometry && !geometryListening) {
338
+ window.addEventListener('pointerdown', onPointerDown, capture);
339
+ window.addEventListener('pointerup', onPointerUp, capture);
340
+ window.addEventListener('pointercancel', onPointerCancel, capture);
341
+ window.addEventListener('blur', onWindowBlur);
342
+ geometryListening = true;
343
+ }
344
+ };
345
+ const stopOptionalObserving = () => {
346
+ if (io && !guardDisplayNone) {
347
+ io.disconnect();
348
+ io = null;
349
+ }
350
+ if (scrollListening && !followScroll) {
351
+ window.removeEventListener('scroll', onScroll, passiveCapture);
352
+ scrollListening = false;
353
+ }
354
+ if (geometryListening && !followGeometry) {
355
+ window.removeEventListener('pointerdown', onPointerDown, capture);
356
+ window.removeEventListener('pointerup', onPointerUp, capture);
357
+ window.removeEventListener('pointercancel', onPointerCancel, capture);
358
+ window.removeEventListener('blur', onWindowBlur);
359
+ geometryListening = false;
360
+ closeSentinel();
361
+ }
362
+ };
363
+ const stopAllOptionalObserving = () => {
364
+ if (io) {
365
+ io.disconnect();
366
+ io = null;
367
+ }
368
+ if (scrollListening) {
369
+ window.removeEventListener('scroll', onScroll, passiveCapture);
370
+ scrollListening = false;
371
+ }
372
+ if (geometryListening) {
373
+ window.removeEventListener('pointerdown', onPointerDown, capture);
374
+ window.removeEventListener('pointerup', onPointerUp, capture);
375
+ window.removeEventListener('pointercancel', onPointerCancel, capture);
376
+ window.removeEventListener('blur', onWindowBlur);
377
+ geometryListening = false;
378
+ }
379
+ closeSentinel();
380
+ };
323
381
  const startObserving = () => {
324
382
  if (observer)
325
383
  return;
326
384
  observer = new ResizeObserver(emit);
327
385
  observer.observe(target);
328
386
  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
- }
387
+ startOptionalObserving();
346
388
  };
347
389
  const stopObserving = () => {
348
390
  if (observer) {
349
391
  observer.disconnect();
350
392
  observer = null;
351
393
  }
352
- if (io) {
353
- io.disconnect();
354
- io = null;
355
- }
356
394
  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();
395
+ stopAllOptionalObserving();
367
396
  };
368
397
  const apply = () => {
369
398
  lastPublished = null;
370
399
  if (visible) {
371
400
  startObserving();
372
- lastPublished = computePlacement();
373
- publish(lastPublished);
401
+ const placement = computePlacement();
402
+ if (placement)
403
+ publishCandidate(placement);
374
404
  }
375
405
  else {
376
406
  stopObserving();
377
407
  const hidden = { visible: false };
378
- lastPublished = hidden;
379
- publish(hidden);
408
+ publishCandidate(hidden);
380
409
  }
381
410
  };
382
411
  apply();
@@ -386,6 +415,15 @@ export function createPlacementAnchor(target, opts) {
386
415
  return;
387
416
  publish = next.publish;
388
417
  visible = next.visible;
418
+ // Omitting a flag preserves its current value so callers that only
419
+ // pass { visible, publish } don't inadvertently disable enabled flags.
420
+ guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone;
421
+ followScroll = next.followScroll ?? followScroll;
422
+ followGeometry = next.followGeometry ?? followGeometry;
423
+ if (visible && observer) {
424
+ stopOptionalObserving();
425
+ startOptionalObserving();
426
+ }
389
427
  apply();
390
428
  },
391
429
  dispose() {
@@ -395,16 +433,10 @@ export function createPlacementAnchor(target, opts) {
395
433
  stopObserving();
396
434
  },
397
435
  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
436
  if (disposed || !followGeometry)
404
437
  return;
405
438
  if (durationMs !== undefined && durationMs > 0) {
406
439
  const next = performance.now() + durationMs;
407
- // Extend (never shorten) an existing window's deadline.
408
440
  sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next);
409
441
  }
410
442
  openSentinel();