view-anchor 0.2.2 → 1.0.0-beta.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 +71 -87
- package/README.zh-CN.md +59 -64
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/protocol-publisher.d.ts +7 -9
- package/dist/protocol-publisher.d.ts.map +1 -1
- package/dist/protocol-publisher.js +2 -8
- package/dist/protocol-types.d.ts +5 -8
- package/dist/protocol-types.d.ts.map +1 -1
- package/dist/protocol-types.js +3 -6
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +10 -2
- package/dist/react.d.ts +2 -15
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +43 -38
- package/dist/{size-advertiser.d.ts → size-anchor.d.ts} +6 -5
- package/dist/size-anchor.d.ts.map +1 -0
- package/dist/size-anchor.js +131 -0
- package/dist/types.d.ts +26 -32
- package/dist/types.d.ts.map +1 -1
- package/dist/view-anchor.d.ts +52 -33
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +186 -265
- package/docs/bidirectional-design.md +29 -34
- package/docs/index.html +656 -366
- package/docs/mechanism.md +35 -57
- package/docs/performance-report.md +12 -54
- package/docs/protocol.md +12 -3
- package/package.json +4 -6
- package/src/index.ts +5 -19
- package/src/protocol-publisher.ts +10 -16
- package/src/protocol-types.ts +5 -8
- package/src/protocol.ts +13 -7
- package/src/react.ts +51 -64
- package/src/size-anchor.ts +131 -0
- package/src/types.ts +29 -37
- package/src/view-anchor.ts +221 -287
- package/dist/measure-loop.d.ts +0 -24
- package/dist/measure-loop.d.ts.map +0 -1
- package/dist/measure-loop.js +0 -89
- package/dist/size-advertiser.d.ts.map +0 -1
- package/dist/size-advertiser.js +0 -94
- package/src/measure-loop.ts +0 -101
- package/src/size-advertiser.ts +0 -108
package/dist/view-anchor.js
CHANGED
|
@@ -1,143 +1,17 @@
|
|
|
1
1
|
import { watchAbort } from './abort.js';
|
|
2
|
-
const ZERO = { x: 0, y: 0, width: 0, height: 0 };
|
|
3
2
|
// Replaces a disposed instance's publish callback so a retained handle does
|
|
4
3
|
// not keep the caller's original callback (and whatever it captured) alive.
|
|
5
4
|
const NOOP_PUBLISH = () => false;
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
//
|
|
5
|
+
// Default holdSelector: omitting the option keeps tracking presses on a role=separator splitter.
|
|
6
|
+
export const DEFAULT_HOLD_SELECTOR = '[role="separator"]';
|
|
7
|
+
// Round to integer pixels. Width and height are clamped to >= 0; x and y can be
|
|
8
|
+
// negative, so a view scrolled out of sight is not pinned to the screen edge.
|
|
9
9
|
const clampRect = (r) => ({
|
|
10
10
|
x: Math.round(r.x),
|
|
11
11
|
y: Math.round(r.y),
|
|
12
12
|
width: Math.max(0, Math.round(r.width)),
|
|
13
13
|
height: Math.max(0, Math.round(r.height)),
|
|
14
14
|
});
|
|
15
|
-
/**
|
|
16
|
-
* Bind a native view or external surface to the geometry of `target`.
|
|
17
|
-
*
|
|
18
|
-
* - `present === true`: measures `target.getBoundingClientRect()` and publishes
|
|
19
|
-
* immediately, then re-measures synchronously on ResizeObserver and window resize.
|
|
20
|
-
* - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
|
|
21
|
-
* and stops observing.
|
|
22
|
-
* - `update(opts)`: re-applies options immediately.
|
|
23
|
-
* - `dispose()`: stops observing and prevents any further publishes.
|
|
24
|
-
*
|
|
25
|
-
* Synchronous publishing: measurement and publishing occur directly in the
|
|
26
|
-
* observer tick. Applying geometry outside the DOM may already be delayed;
|
|
27
|
-
* adding requestAnimationFrame would add another frame of visual lag during drag
|
|
28
|
-
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
29
|
-
*/
|
|
30
|
-
export function createViewAnchor(target, opts) {
|
|
31
|
-
let present = opts.present;
|
|
32
|
-
let publish = opts.publish;
|
|
33
|
-
let observer = null;
|
|
34
|
-
// Local, clearable alias for the `target` parameter so dispose() can drop
|
|
35
|
-
// the strong reference without widening the public parameter's type.
|
|
36
|
-
let targetRef = target;
|
|
37
|
-
// Last rect sent to publish. Reset on apply() so state changes (such as zoom)
|
|
38
|
-
// force a re-publish even if the geometry did not change.
|
|
39
|
-
let lastPublished = null;
|
|
40
|
-
let publicationRevision = 0;
|
|
41
|
-
let disposed = false;
|
|
42
|
-
const measure = () => {
|
|
43
|
-
const r = targetRef.getBoundingClientRect();
|
|
44
|
-
// Drop ticks with non-finite values (NaN / Infinity are not usable geometry).
|
|
45
|
-
if (!Number.isFinite(r.left) ||
|
|
46
|
-
!Number.isFinite(r.top) ||
|
|
47
|
-
!Number.isFinite(r.width) ||
|
|
48
|
-
!Number.isFinite(r.height))
|
|
49
|
-
return null;
|
|
50
|
-
return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height });
|
|
51
|
-
};
|
|
52
|
-
const sameRect = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
53
|
-
const publishCandidate = (candidate) => {
|
|
54
|
-
const previous = lastPublished;
|
|
55
|
-
const attempt = ++publicationRevision;
|
|
56
|
-
lastPublished = candidate;
|
|
57
|
-
try {
|
|
58
|
-
const accepted = publish(candidate) !== false;
|
|
59
|
-
// A reentrant dispose() during publish() already cleared lastPublished;
|
|
60
|
-
// do not resurrect the pre-dispose value over that terminal state.
|
|
61
|
-
if (!accepted && publicationRevision === attempt && !disposed)
|
|
62
|
-
lastPublished = previous;
|
|
63
|
-
return accepted;
|
|
64
|
-
}
|
|
65
|
-
catch (error) {
|
|
66
|
-
if (publicationRevision === attempt && !disposed)
|
|
67
|
-
lastPublished = previous;
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
};
|
|
71
|
-
// Measure and publish synchronously on each observer tick.
|
|
72
|
-
// Drops duplicate rects to coalesce same-frame resize events.
|
|
73
|
-
const emit = () => {
|
|
74
|
-
if (disposed || !present)
|
|
75
|
-
return;
|
|
76
|
-
const m = measure();
|
|
77
|
-
if (!m)
|
|
78
|
-
return;
|
|
79
|
-
if (lastPublished && sameRect(lastPublished, m))
|
|
80
|
-
return;
|
|
81
|
-
publishCandidate(m);
|
|
82
|
-
};
|
|
83
|
-
const startObserving = () => {
|
|
84
|
-
if (observer)
|
|
85
|
-
return;
|
|
86
|
-
observer = new ResizeObserver(emit);
|
|
87
|
-
observer.observe(targetRef);
|
|
88
|
-
window.addEventListener('resize', emit);
|
|
89
|
-
};
|
|
90
|
-
const stopObserving = () => {
|
|
91
|
-
if (observer) {
|
|
92
|
-
observer.disconnect();
|
|
93
|
-
observer = null;
|
|
94
|
-
}
|
|
95
|
-
window.removeEventListener('resize', emit);
|
|
96
|
-
};
|
|
97
|
-
// Apply current options synchronously. Reset lastPublished so state changes
|
|
98
|
-
// always re-publish even if dimensions have not changed.
|
|
99
|
-
const apply = () => {
|
|
100
|
-
lastPublished = null;
|
|
101
|
-
if (present) {
|
|
102
|
-
startObserving();
|
|
103
|
-
const measured = measure();
|
|
104
|
-
if (measured)
|
|
105
|
-
publishCandidate(measured);
|
|
106
|
-
}
|
|
107
|
-
else {
|
|
108
|
-
stopObserving();
|
|
109
|
-
publishCandidate(ZERO);
|
|
110
|
-
}
|
|
111
|
-
};
|
|
112
|
-
let removeAbortListener = () => { };
|
|
113
|
-
const dispose = () => {
|
|
114
|
-
if (disposed)
|
|
115
|
-
return;
|
|
116
|
-
disposed = true;
|
|
117
|
-
removeAbortListener();
|
|
118
|
-
removeAbortListener = () => { };
|
|
119
|
-
stopObserving();
|
|
120
|
-
targetRef = null;
|
|
121
|
-
publish = NOOP_PUBLISH;
|
|
122
|
-
lastPublished = null;
|
|
123
|
-
};
|
|
124
|
-
if (opts.signal?.aborted)
|
|
125
|
-
dispose();
|
|
126
|
-
else {
|
|
127
|
-
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
128
|
-
apply();
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
update(next) {
|
|
132
|
-
if (disposed)
|
|
133
|
-
return;
|
|
134
|
-
publish = next.publish;
|
|
135
|
-
present = next.present;
|
|
136
|
-
apply();
|
|
137
|
-
},
|
|
138
|
-
dispose,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
15
|
/**
|
|
142
16
|
* Read target's current rect and return { visible: true, bounds }.
|
|
143
17
|
* Does not infer visibility from dimensions.
|
|
@@ -161,19 +35,31 @@ const samePlacement = (a, b) => {
|
|
|
161
35
|
return true;
|
|
162
36
|
};
|
|
163
37
|
/**
|
|
164
|
-
*
|
|
38
|
+
* Bind a native view or external surface to the geometry of `target`.
|
|
39
|
+
*
|
|
40
|
+
* When `visible === true`, publishes { visible: true, bounds } immediately,
|
|
41
|
+
* then re-measures synchronously on ResizeObserver and window resize.
|
|
42
|
+
* When `visible === false`, publishes { visible: false } and stops observing.
|
|
43
|
+
* Publishes are deduplicated against the last accepted Placement by default
|
|
44
|
+
* (see the `dedupe` option).
|
|
165
45
|
*/
|
|
166
|
-
export function
|
|
46
|
+
export function createViewAnchor(target, opts) {
|
|
47
|
+
// A non-empty holdSelector must be a syntactically valid CSS selector
|
|
48
|
+
// (matches() throws SyntaxError otherwise). Validate before any state is
|
|
49
|
+
// created so a bad selector never partially applies.
|
|
50
|
+
if (opts.holdSelector)
|
|
51
|
+
target.matches(opts.holdSelector);
|
|
167
52
|
let visible = opts.visible;
|
|
168
53
|
let publish = opts.publish;
|
|
169
|
-
let
|
|
54
|
+
let treatZeroAreaAsHidden = opts.treatZeroAreaAsHidden ?? false;
|
|
170
55
|
let followScroll = opts.followScroll ?? false;
|
|
171
56
|
let followGeometry = opts.followGeometry ?? false;
|
|
172
|
-
|
|
173
|
-
|
|
57
|
+
let holdSelector = opts.holdSelector === undefined ? DEFAULT_HOLD_SELECTOR : opts.holdSelector;
|
|
58
|
+
let dedupe = opts.dedupe ?? true;
|
|
59
|
+
// Clearable alias so dispose() can drop the reference.
|
|
174
60
|
let targetRef = target;
|
|
175
61
|
let observer = null;
|
|
176
|
-
let
|
|
62
|
+
let intersectionObserver = null;
|
|
177
63
|
let scrollListening = false;
|
|
178
64
|
let geometryListening = false;
|
|
179
65
|
const capture = { capture: true };
|
|
@@ -181,20 +67,25 @@ export function createPlacementAnchor(target, opts) {
|
|
|
181
67
|
let lastPublished = null;
|
|
182
68
|
let publicationRevision = 0;
|
|
183
69
|
let disposed = false;
|
|
184
|
-
// ---
|
|
185
|
-
//
|
|
70
|
+
// --- Frame following (followGeometry) state ---
|
|
71
|
+
// Frame following polls per frame during active movement and auto-closes once
|
|
186
72
|
// geometry settles. While closed, no frame is scheduled (zero idle overhead).
|
|
187
73
|
let rafId = null;
|
|
188
74
|
let steadyFrames = 0;
|
|
189
75
|
const STEADY_CLOSE_FRAMES = 2;
|
|
190
76
|
const MAX_HIDDEN_FOLLOW_FRAMES = 30;
|
|
191
77
|
const MAX_INVALID_FOLLOW_FRAMES = 30;
|
|
78
|
+
let hiddenFrames = 0;
|
|
192
79
|
let invalidFrames = 0;
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
80
|
+
// Pointer ids currently matching holdSelector and held down. Non-empty
|
|
81
|
+
// keeps frame following open regardless of deadline or steady frames.
|
|
82
|
+
const heldPointerIds = new Set();
|
|
83
|
+
// The rect measured on the previous followed frame, independent of whether
|
|
84
|
+
// publish() accepted it. Drives the steady/moving decision so a publish()
|
|
85
|
+
// that keeps rejecting an unchanged measurement cannot keep resetting it.
|
|
86
|
+
let lastFramePlacement = null;
|
|
87
|
+
let followDeadline = null;
|
|
88
|
+
const measureTarget = () => {
|
|
198
89
|
const p = measurePlacement(targetRef);
|
|
199
90
|
if (p.visible &&
|
|
200
91
|
(!Number.isFinite(p.bounds.x) ||
|
|
@@ -202,12 +93,12 @@ export function createPlacementAnchor(target, opts) {
|
|
|
202
93
|
!Number.isFinite(p.bounds.width) ||
|
|
203
94
|
!Number.isFinite(p.bounds.height)))
|
|
204
95
|
return null;
|
|
205
|
-
if (
|
|
96
|
+
if (treatZeroAreaAsHidden && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
|
|
206
97
|
return { visible: false };
|
|
207
98
|
}
|
|
208
99
|
return p;
|
|
209
100
|
};
|
|
210
|
-
const
|
|
101
|
+
const publishPlacement = (candidate) => {
|
|
211
102
|
const previous = lastPublished;
|
|
212
103
|
const attempt = ++publicationRevision;
|
|
213
104
|
lastPublished = candidate;
|
|
@@ -225,140 +116,151 @@ export function createPlacementAnchor(target, opts) {
|
|
|
225
116
|
throw error;
|
|
226
117
|
}
|
|
227
118
|
};
|
|
228
|
-
const
|
|
119
|
+
const measureAndPublish = () => {
|
|
229
120
|
if (disposed || !visible)
|
|
230
121
|
return;
|
|
231
|
-
const p =
|
|
122
|
+
const p = measureTarget();
|
|
232
123
|
if (!p)
|
|
233
124
|
return;
|
|
234
|
-
if (lastPublished && samePlacement(lastPublished, p))
|
|
125
|
+
if (dedupe && lastPublished && samePlacement(lastPublished, p))
|
|
235
126
|
return;
|
|
236
|
-
|
|
127
|
+
publishPlacement(p);
|
|
237
128
|
};
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
129
|
+
// Polls again next frame if following is still wanted after this frame's work.
|
|
130
|
+
const scheduleFollowFrame = () => {
|
|
131
|
+
if (!disposed && visible && followGeometry)
|
|
132
|
+
rafId = requestAnimationFrame(followFrame);
|
|
133
|
+
else
|
|
134
|
+
followDeadline = null;
|
|
242
135
|
};
|
|
243
|
-
const
|
|
136
|
+
const followFrame = () => {
|
|
244
137
|
rafId = null;
|
|
245
|
-
if (disposed ||
|
|
246
|
-
|
|
138
|
+
if (disposed ||
|
|
139
|
+
!visible ||
|
|
140
|
+
(heldPointerIds.size === 0 && followDeadline !== null && performance.now() >= followDeadline)) {
|
|
141
|
+
followDeadline = null;
|
|
247
142
|
return;
|
|
248
143
|
}
|
|
249
|
-
|
|
250
|
-
sentinelDeadline = null;
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
const p = computePlacement();
|
|
144
|
+
const p = measureTarget();
|
|
254
145
|
if (!p) {
|
|
255
|
-
if (invalidFrames
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
if (!disposed && visible && followGeometry) {
|
|
260
|
-
rafId = requestAnimationFrame(sentinelFrame);
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
sentinelDeadline = null;
|
|
264
|
-
}
|
|
146
|
+
if (++invalidFrames >= MAX_INVALID_FOLLOW_FRAMES)
|
|
147
|
+
followDeadline = null;
|
|
148
|
+
else
|
|
149
|
+
scheduleFollowFrame();
|
|
265
150
|
return;
|
|
266
151
|
}
|
|
267
152
|
invalidFrames = 0;
|
|
268
153
|
if (!p.visible) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!disposed && visible && followGeometry) {
|
|
274
|
-
rafId = requestAnimationFrame(sentinelFrame);
|
|
154
|
+
// Hidden frames are never published here; stop once hidden is already
|
|
155
|
+
// published or the target stays hidden past its budget.
|
|
156
|
+
if (lastPublished?.visible === false || ++hiddenFrames >= MAX_HIDDEN_FOLLOW_FRAMES) {
|
|
157
|
+
followDeadline = null;
|
|
275
158
|
}
|
|
276
159
|
else {
|
|
277
|
-
|
|
160
|
+
scheduleFollowFrame();
|
|
278
161
|
}
|
|
279
162
|
return;
|
|
280
163
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
164
|
+
// Steadiness is judged against what was measured last frame, not against
|
|
165
|
+
// what publish() accepted: a candidate that publish() keeps rejecting
|
|
166
|
+
// must not keep looking "different" forever just because publishPlacement
|
|
167
|
+
// rolls lastPublished back on rejection, which would spin the frame loop.
|
|
168
|
+
const steadyFrame = lastFramePlacement !== null && samePlacement(lastFramePlacement, p);
|
|
169
|
+
lastFramePlacement = p;
|
|
170
|
+
if (!dedupe || !lastPublished || !samePlacement(lastPublished, p))
|
|
171
|
+
publishPlacement(p);
|
|
172
|
+
if (steadyFrame) {
|
|
173
|
+
if (++steadyFrames >= STEADY_CLOSE_FRAMES && heldPointerIds.size === 0) {
|
|
174
|
+
followDeadline = null;
|
|
285
175
|
return;
|
|
286
176
|
}
|
|
287
177
|
}
|
|
288
178
|
else {
|
|
289
|
-
publishCandidate(p);
|
|
290
179
|
steadyFrames = 0;
|
|
180
|
+
hiddenFrames = 0;
|
|
291
181
|
}
|
|
292
|
-
|
|
293
|
-
rafId = requestAnimationFrame(sentinelFrame);
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
sentinelDeadline = null;
|
|
297
|
-
}
|
|
182
|
+
scheduleFollowFrame();
|
|
298
183
|
};
|
|
299
|
-
const
|
|
184
|
+
const startFrameFollow = () => {
|
|
300
185
|
if (!followGeometry || disposed)
|
|
301
186
|
return;
|
|
302
187
|
steadyFrames = 0;
|
|
188
|
+
hiddenFrames = 0;
|
|
189
|
+
// Seed from the last accepted placement so the first post-open frame
|
|
190
|
+
// doesn't look unsteady on an unchanged rect.
|
|
191
|
+
lastFramePlacement = lastPublished;
|
|
303
192
|
if (rafId === null) {
|
|
304
193
|
invalidFrames = 0;
|
|
305
|
-
rafId = requestAnimationFrame(
|
|
194
|
+
rafId = requestAnimationFrame(followFrame);
|
|
306
195
|
}
|
|
307
196
|
};
|
|
308
|
-
const
|
|
197
|
+
const stopFrameFollow = () => {
|
|
309
198
|
if (rafId !== null) {
|
|
310
199
|
cancelAnimationFrame(rafId);
|
|
311
200
|
rafId = null;
|
|
312
201
|
}
|
|
313
202
|
steadyFrames = 0;
|
|
203
|
+
hiddenFrames = 0;
|
|
314
204
|
invalidFrames = 0;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
205
|
+
lastFramePlacement = lastPublished;
|
|
206
|
+
followDeadline = null;
|
|
207
|
+
heldPointerIds.clear();
|
|
318
208
|
};
|
|
319
209
|
const onScroll = () => {
|
|
320
210
|
if (followGeometry)
|
|
321
|
-
|
|
211
|
+
startFrameFollow();
|
|
322
212
|
else
|
|
323
|
-
|
|
213
|
+
measureAndPublish();
|
|
324
214
|
};
|
|
325
215
|
const onPointerDown = (e) => {
|
|
326
216
|
const t = e.target;
|
|
327
|
-
if (t && t.closest && t.closest(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
217
|
+
if (holdSelector && t && t.closest && t.closest(holdSelector)) {
|
|
218
|
+
// The first of possibly several concurrently-held pointers drops any
|
|
219
|
+
// pulse() deadline: a held press has no time limit.
|
|
220
|
+
if (heldPointerIds.size === 0)
|
|
221
|
+
followDeadline = null;
|
|
222
|
+
heldPointerIds.add(e.pointerId);
|
|
223
|
+
startFrameFollow();
|
|
332
224
|
}
|
|
333
225
|
};
|
|
334
|
-
|
|
335
|
-
|
|
226
|
+
// pointerId undefined means "release everything" (window blur, or an event
|
|
227
|
+
// that never carried a pointerId, matching the single-pointer tests that
|
|
228
|
+
// dispatch plain Events). Otherwise only that one id's hold ends, and the
|
|
229
|
+
// frame following only leaves its held (no-deadline) state once none remain.
|
|
230
|
+
const releasePointer = (pointerId) => {
|
|
231
|
+
if (heldPointerIds.size === 0)
|
|
336
232
|
return;
|
|
337
|
-
if (
|
|
233
|
+
if (pointerId !== undefined && !heldPointerIds.delete(pointerId))
|
|
338
234
|
return;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
235
|
+
if (pointerId === undefined)
|
|
236
|
+
heldPointerIds.clear();
|
|
237
|
+
if (heldPointerIds.size > 0)
|
|
238
|
+
return;
|
|
239
|
+
followDeadline = null;
|
|
240
|
+
startFrameFollow();
|
|
342
241
|
};
|
|
343
242
|
const onPointerUp = (e) => {
|
|
344
|
-
releasePointer(e);
|
|
243
|
+
releasePointer(e.pointerId);
|
|
345
244
|
};
|
|
346
245
|
const onPointerCancel = (e) => {
|
|
347
|
-
releasePointer(e);
|
|
246
|
+
releasePointer(e.pointerId);
|
|
348
247
|
};
|
|
349
248
|
const onWindowBlur = () => {
|
|
350
249
|
releasePointer();
|
|
351
250
|
};
|
|
352
251
|
const startOptionalObserving = () => {
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
252
|
+
if (treatZeroAreaAsHidden &&
|
|
253
|
+
!intersectionObserver &&
|
|
254
|
+
typeof IntersectionObserver !== 'undefined') {
|
|
255
|
+
intersectionObserver = new IntersectionObserver(measureAndPublish);
|
|
256
|
+
intersectionObserver.observe(targetRef);
|
|
356
257
|
}
|
|
357
258
|
if (followScroll && !scrollListening) {
|
|
358
259
|
window.addEventListener('scroll', onScroll, passiveCapture);
|
|
359
260
|
scrollListening = true;
|
|
360
261
|
}
|
|
361
|
-
|
|
262
|
+
// Mounted only while both followGeometry and holdSelector are set.
|
|
263
|
+
if (followGeometry && holdSelector && !geometryListening) {
|
|
362
264
|
window.addEventListener('pointerdown', onPointerDown, capture);
|
|
363
265
|
window.addEventListener('pointerup', onPointerUp, capture);
|
|
364
266
|
window.addEventListener('pointercancel', onPointerCancel, capture);
|
|
@@ -366,48 +268,50 @@ export function createPlacementAnchor(target, opts) {
|
|
|
366
268
|
geometryListening = true;
|
|
367
269
|
}
|
|
368
270
|
};
|
|
369
|
-
const
|
|
370
|
-
if (
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (scrollListening && !followScroll) {
|
|
375
|
-
window.removeEventListener('scroll', onScroll, passiveCapture);
|
|
376
|
-
scrollListening = false;
|
|
377
|
-
}
|
|
378
|
-
if (geometryListening && !followGeometry) {
|
|
379
|
-
window.removeEventListener('pointerdown', onPointerDown, capture);
|
|
380
|
-
window.removeEventListener('pointerup', onPointerUp, capture);
|
|
381
|
-
window.removeEventListener('pointercancel', onPointerCancel, capture);
|
|
382
|
-
window.removeEventListener('blur', onWindowBlur);
|
|
383
|
-
geometryListening = false;
|
|
384
|
-
closeSentinel();
|
|
385
|
-
}
|
|
271
|
+
const stopIntersection = () => {
|
|
272
|
+
if (!intersectionObserver)
|
|
273
|
+
return;
|
|
274
|
+
intersectionObserver.disconnect();
|
|
275
|
+
intersectionObserver = null;
|
|
386
276
|
};
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
277
|
+
const stopScroll = () => {
|
|
278
|
+
if (!scrollListening)
|
|
279
|
+
return;
|
|
280
|
+
window.removeEventListener('scroll', onScroll, passiveCapture);
|
|
281
|
+
scrollListening = false;
|
|
282
|
+
};
|
|
283
|
+
const stopGeometryListeners = () => {
|
|
284
|
+
if (!geometryListening)
|
|
285
|
+
return;
|
|
286
|
+
window.removeEventListener('pointerdown', onPointerDown, capture);
|
|
287
|
+
window.removeEventListener('pointerup', onPointerUp, capture);
|
|
288
|
+
window.removeEventListener('pointercancel', onPointerCancel, capture);
|
|
289
|
+
window.removeEventListener('blur', onWindowBlur);
|
|
290
|
+
geometryListening = false;
|
|
291
|
+
};
|
|
292
|
+
// Detaches only the optional observers whose flag has been turned off.
|
|
293
|
+
const stopDisabledObserving = () => {
|
|
294
|
+
if (!treatZeroAreaAsHidden)
|
|
295
|
+
stopIntersection();
|
|
296
|
+
if (!followScroll)
|
|
297
|
+
stopScroll();
|
|
298
|
+
// The pointer listeners are only useful while both flags hold; drop them
|
|
299
|
+
// (and any held-pointer state they were tracking) as soon as either lapses.
|
|
300
|
+
if ((!followGeometry || !holdSelector) && geometryListening) {
|
|
301
|
+
stopGeometryListeners();
|
|
302
|
+
heldPointerIds.clear();
|
|
402
303
|
}
|
|
403
|
-
|
|
304
|
+
// Independent of whether the listeners were mounted: turning followGeometry
|
|
305
|
+
// off must always stop frame following, not just when holdSelector was set.
|
|
306
|
+
if (!followGeometry)
|
|
307
|
+
stopFrameFollow();
|
|
404
308
|
};
|
|
405
309
|
const startObserving = () => {
|
|
406
310
|
if (observer)
|
|
407
311
|
return;
|
|
408
|
-
observer = new ResizeObserver(
|
|
312
|
+
observer = new ResizeObserver(measureAndPublish);
|
|
409
313
|
observer.observe(targetRef);
|
|
410
|
-
window.addEventListener('resize',
|
|
314
|
+
window.addEventListener('resize', measureAndPublish);
|
|
411
315
|
startOptionalObserving();
|
|
412
316
|
};
|
|
413
317
|
const stopObserving = () => {
|
|
@@ -415,21 +319,24 @@ export function createPlacementAnchor(target, opts) {
|
|
|
415
319
|
observer.disconnect();
|
|
416
320
|
observer = null;
|
|
417
321
|
}
|
|
418
|
-
window.removeEventListener('resize',
|
|
419
|
-
|
|
322
|
+
window.removeEventListener('resize', measureAndPublish);
|
|
323
|
+
stopIntersection();
|
|
324
|
+
stopScroll();
|
|
325
|
+
stopGeometryListeners();
|
|
326
|
+
stopFrameFollow();
|
|
420
327
|
};
|
|
421
|
-
const
|
|
328
|
+
const applyOptions = () => {
|
|
422
329
|
lastPublished = null;
|
|
423
330
|
if (visible) {
|
|
424
331
|
startObserving();
|
|
425
|
-
const placement =
|
|
332
|
+
const placement = measureTarget();
|
|
426
333
|
if (placement)
|
|
427
|
-
|
|
334
|
+
publishPlacement(placement);
|
|
428
335
|
}
|
|
429
336
|
else {
|
|
430
337
|
stopObserving();
|
|
431
338
|
const hidden = { visible: false };
|
|
432
|
-
|
|
339
|
+
publishPlacement(hidden);
|
|
433
340
|
}
|
|
434
341
|
};
|
|
435
342
|
let removeAbortListener = () => { };
|
|
@@ -448,24 +355,38 @@ export function createPlacementAnchor(target, opts) {
|
|
|
448
355
|
dispose();
|
|
449
356
|
else {
|
|
450
357
|
removeAbortListener = watchAbort(opts.signal, dispose);
|
|
451
|
-
|
|
358
|
+
// A throwing first publish must not leave the observer/listeners it just
|
|
359
|
+
// started (startObserving() runs before the first publishPlacement())
|
|
360
|
+
// mounted on a handle the caller never got to dispose.
|
|
361
|
+
try {
|
|
362
|
+
applyOptions();
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
dispose();
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
452
368
|
}
|
|
453
369
|
return {
|
|
454
370
|
update(next) {
|
|
455
371
|
if (disposed)
|
|
456
372
|
return;
|
|
373
|
+
// Validate before touching any state so a bad selector throws
|
|
374
|
+
// atomically and the previous holdSelector stays in effect.
|
|
375
|
+
if (next.holdSelector)
|
|
376
|
+
targetRef.matches(next.holdSelector);
|
|
457
377
|
publish = next.publish;
|
|
458
378
|
visible = next.visible;
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
379
|
+
// Full configuration: omitted flags reset to defaults.
|
|
380
|
+
treatZeroAreaAsHidden = next.treatZeroAreaAsHidden ?? false;
|
|
381
|
+
followScroll = next.followScroll ?? false;
|
|
382
|
+
followGeometry = next.followGeometry ?? false;
|
|
383
|
+
holdSelector = next.holdSelector === undefined ? DEFAULT_HOLD_SELECTOR : next.holdSelector;
|
|
384
|
+
dedupe = next.dedupe ?? true;
|
|
464
385
|
if (visible && observer) {
|
|
465
|
-
|
|
386
|
+
stopDisabledObserving();
|
|
466
387
|
startOptionalObserving();
|
|
467
388
|
}
|
|
468
|
-
|
|
389
|
+
applyOptions();
|
|
469
390
|
},
|
|
470
391
|
dispose,
|
|
471
392
|
pulse(durationMs) {
|
|
@@ -473,9 +394,9 @@ export function createPlacementAnchor(target, opts) {
|
|
|
473
394
|
return;
|
|
474
395
|
if (durationMs !== undefined && durationMs > 0) {
|
|
475
396
|
const next = performance.now() + durationMs;
|
|
476
|
-
|
|
397
|
+
followDeadline = followDeadline === null ? next : Math.max(followDeadline, next);
|
|
477
398
|
}
|
|
478
|
-
|
|
399
|
+
startFrameFollow();
|
|
479
400
|
},
|
|
480
401
|
};
|
|
481
402
|
}
|