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