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.
- package/README.md +118 -34
- package/README.zh-CN.md +128 -44
- package/dist/index.d.ts +4 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -14
- package/dist/measure-loop.d.ts +9 -28
- package/dist/measure-loop.d.ts.map +1 -1
- package/dist/measure-loop.js +37 -16
- package/dist/protocol-publisher.d.ts +41 -0
- package/dist/protocol-publisher.d.ts.map +1 -0
- package/dist/protocol-publisher.js +191 -0
- package/dist/protocol-types.d.ts +36 -0
- package/dist/protocol-types.d.ts.map +1 -0
- package/dist/protocol-types.js +10 -0
- package/dist/protocol.d.ts +35 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +131 -0
- package/dist/react.d.ts +18 -30
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +125 -122
- package/dist/size-advertiser.d.ts +9 -14
- package/dist/size-advertiser.d.ts.map +1 -1
- package/dist/size-advertiser.js +20 -28
- package/dist/types.d.ts +29 -73
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -15
- package/dist/view-anchor.d.ts +36 -77
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +206 -174
- package/docs/bidirectional-design.md +64 -96
- package/docs/{anchor-3d.html → index.html} +215 -73
- package/docs/mechanism.mdx +55 -49
- package/docs/performance-report.md +63 -0
- package/docs/protocol.md +79 -0
- package/package.json +30 -4
- package/src/index.ts +6 -15
- package/src/measure-loop.ts +36 -41
- package/src/protocol-publisher.ts +236 -0
- package/src/protocol-types.ts +43 -0
- package/src/protocol.ts +193 -0
- package/src/react.ts +186 -141
- package/src/size-advertiser.ts +24 -31
- package/src/types.ts +34 -79
- package/src/view-anchor.ts +228 -212
package/dist/view-anchor.js
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
const ZERO = { x: 0, y: 0, width: 0, height: 0 };
|
|
2
|
-
// Round to
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
*
|
|
12
|
+
* Bind a native view or external surface to the geometry of `target`.
|
|
18
13
|
*
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
//
|
|
50
|
-
//
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
94
|
+
const measured = measure();
|
|
95
|
+
if (measured)
|
|
96
|
+
publishCandidate(measured);
|
|
97
97
|
}
|
|
98
98
|
else {
|
|
99
99
|
stopObserving();
|
|
100
|
-
|
|
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
|
-
*
|
|
122
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
//
|
|
177
|
-
// The sentinel
|
|
178
|
-
//
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
227
|
+
return;
|
|
242
228
|
}
|
|
243
229
|
const p = computePlacement();
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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;
|
|
261
|
+
return;
|
|
263
262
|
}
|
|
264
263
|
}
|
|
265
264
|
else {
|
|
266
|
-
|
|
267
|
-
publish(p); // publish synchronously in THIS frame
|
|
265
|
+
publishCandidate(p);
|
|
268
266
|
steadyFrames = 0;
|
|
269
267
|
}
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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();
|