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/src/view-anchor.ts
CHANGED
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Bounds,
|
|
3
3
|
Placement,
|
|
4
|
+
Publisher,
|
|
4
5
|
ViewAnchorOptions,
|
|
5
6
|
ViewAnchorHandle,
|
|
6
7
|
} from './types.js'
|
|
7
8
|
|
|
8
9
|
const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
|
|
9
10
|
|
|
10
|
-
// Round to
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// scrolled past the top/left edge has a legitimately NEGATIVE origin, and
|
|
14
|
-
// flooring it to 0 would pin the native view at the edge instead of letting it
|
|
15
|
-
// track its element off-screen. (Each consumer's IPC schema enforces its own
|
|
16
|
-
// origin policy — some allow negatives; a placeholder always stays on-screen,
|
|
17
|
-
// so its NonNegInt schema is unaffected.)
|
|
11
|
+
// Round to integer pixels. Width and height are clamped to >= 0 (0 represents
|
|
12
|
+
// a collapsed rect). Coordinates (x, y) can be negative when an element is
|
|
13
|
+
// scrolled out of view; clamping them to 0 would pin the view to the screen edge.
|
|
18
14
|
const clampRect = (r: {
|
|
19
15
|
x: number
|
|
20
16
|
y: number
|
|
@@ -28,33 +24,19 @@ const clampRect = (r: {
|
|
|
28
24
|
})
|
|
29
25
|
|
|
30
26
|
/**
|
|
31
|
-
*
|
|
27
|
+
* Bind a native view or external surface to the geometry of `target`.
|
|
32
28
|
*
|
|
33
|
-
*
|
|
34
|
-
* -
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* - `dispose()`: stop observing, never publish again.
|
|
29
|
+
* - `present === true`: measures `target.getBoundingClientRect()` and publishes
|
|
30
|
+
* immediately, then re-measures synchronously on ResizeObserver and window resize.
|
|
31
|
+
* - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
|
|
32
|
+
* and stops observing.
|
|
33
|
+
* - `update(opts)`: re-applies options immediately.
|
|
34
|
+
* - `dispose()`: stops observing and prevents any further publishes.
|
|
40
35
|
*
|
|
41
|
-
* Synchronous
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* a height/splitter drag that read as the overlay visibly trailing the region
|
|
46
|
-
* edge (worst when GROWING, where the not-yet-followed edge exposes background).
|
|
47
|
-
* Publishing in the observer tick itself removes that self-inflicted frame and
|
|
48
|
-
* leaves only the unavoidable cross-process frame (masked by matching the
|
|
49
|
-
* placeholder/desk background colour). The anti-flood role the RAF used to play
|
|
50
|
-
* — collapsing a burst of RO+resize ticks in one frame into one publish — is now
|
|
51
|
-
* served by `lastPublished` dedup: a tick whose measured rect is byte-identical
|
|
52
|
-
* to the last published one is dropped, so a continuous drag still emits at most
|
|
53
|
-
* one publish per distinct rect.
|
|
54
|
-
*
|
|
55
|
-
* Teardown safety: there is no queued frame to outrun a state change — every
|
|
56
|
-
* emit reads `disposed`/`present` synchronously, so a tick after
|
|
57
|
-
* `update`/`dispose` can never write a stale rect over the live one.
|
|
36
|
+
* Synchronous publishing: measurement and publishing occur directly in the
|
|
37
|
+
* observer tick. Cross-process setBounds calls already have a compositor delay;
|
|
38
|
+
* adding requestAnimationFrame would add a second frame of visual lag during drag
|
|
39
|
+
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
58
40
|
*/
|
|
59
41
|
export function createViewAnchor(
|
|
60
42
|
target: HTMLElement,
|
|
@@ -63,31 +45,49 @@ export function createViewAnchor(
|
|
|
63
45
|
let present = opts.present
|
|
64
46
|
let publish = opts.publish
|
|
65
47
|
let observer: ResizeObserver | null = null
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// the `publish` closure, not in `Bounds`) always forces one fresh publish
|
|
69
|
-
// even when the geometry is unchanged.
|
|
48
|
+
// Last rect sent to publish. Reset on apply() so state changes (such as zoom)
|
|
49
|
+
// force a re-publish even if the geometry did not change.
|
|
70
50
|
let lastPublished: Bounds | null = null
|
|
51
|
+
let publicationRevision = 0
|
|
71
52
|
let disposed = false
|
|
72
53
|
|
|
73
|
-
const measure = (): Bounds => {
|
|
54
|
+
const measure = (): Bounds | null => {
|
|
74
55
|
const r = target.getBoundingClientRect()
|
|
56
|
+
// Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
|
|
57
|
+
if (
|
|
58
|
+
!Number.isFinite(r.left) ||
|
|
59
|
+
!Number.isFinite(r.top) ||
|
|
60
|
+
!Number.isFinite(r.width) ||
|
|
61
|
+
!Number.isFinite(r.height)
|
|
62
|
+
) return null
|
|
75
63
|
return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
|
|
76
64
|
}
|
|
77
65
|
|
|
78
66
|
const sameRect = (a: Bounds, b: Bounds): boolean =>
|
|
79
67
|
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
|
80
68
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
69
|
+
const publishCandidate = (candidate: Bounds): boolean => {
|
|
70
|
+
const previous = lastPublished
|
|
71
|
+
const attempt = ++publicationRevision
|
|
72
|
+
lastPublished = candidate
|
|
73
|
+
try {
|
|
74
|
+
const accepted = publish(candidate) !== false
|
|
75
|
+
if (!accepted && publicationRevision === attempt) lastPublished = previous
|
|
76
|
+
return accepted
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (publicationRevision === attempt) lastPublished = previous
|
|
79
|
+
throw error
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Measure and publish synchronously on each observer tick.
|
|
84
|
+
// Drops duplicate rects to coalesce same-frame resize events.
|
|
85
85
|
const emit = (): void => {
|
|
86
86
|
if (disposed || !present) return
|
|
87
87
|
const m = measure()
|
|
88
|
+
if (!m) return
|
|
88
89
|
if (lastPublished && sameRect(lastPublished, m)) return
|
|
89
|
-
|
|
90
|
-
publish(m)
|
|
90
|
+
publishCandidate(m)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
const startObserving = (): void => {
|
|
@@ -105,19 +105,17 @@ export function createViewAnchor(
|
|
|
105
105
|
window.removeEventListener('resize', emit)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// Apply
|
|
109
|
-
//
|
|
110
|
-
// present flip, new publish target) must always re-emit even if the geometry
|
|
111
|
-
// is byte-identical to the previous emit.
|
|
108
|
+
// Apply current options synchronously. Reset lastPublished so state changes
|
|
109
|
+
// always re-publish even if dimensions have not changed.
|
|
112
110
|
const apply = (): void => {
|
|
113
111
|
lastPublished = null
|
|
114
112
|
if (present) {
|
|
115
113
|
startObserving()
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
const measured = measure()
|
|
115
|
+
if (measured) publishCandidate(measured)
|
|
118
116
|
} else {
|
|
119
117
|
stopObserving()
|
|
120
|
-
|
|
118
|
+
publishCandidate(ZERO)
|
|
121
119
|
}
|
|
122
120
|
}
|
|
123
121
|
|
|
@@ -138,74 +136,56 @@ export function createViewAnchor(
|
|
|
138
136
|
}
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// The modern surface that replaces the magic-`{0,0,0,0}` "hidden" value.
|
|
144
|
-
// Visibility is an explicit discriminant, NEVER inferred from geometry — a
|
|
145
|
-
// real 0×0-but-on-screen view is `{ visible:true, bounds:{...,width:0} }`
|
|
146
|
-
// and a hidden one is `{ visible:false }` (no `bounds`). See `Placement`.
|
|
139
|
+
// --- Explicit Placement API ---
|
|
147
140
|
|
|
148
141
|
export interface PlacementAnchorOptions {
|
|
149
142
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* publish `{ visible:false }`. Crucially, hiddenness comes from this
|
|
153
|
-
* flag, not from a measured zero size — so a legitimately 0-sized but
|
|
154
|
-
* visible target still publishes `visible:true`.
|
|
143
|
+
* Whether the native view should be visible. When true, publishes
|
|
144
|
+
* { visible: true, bounds }; when false, publishes { visible: false }.
|
|
155
145
|
*/
|
|
156
146
|
visible: boolean
|
|
157
|
-
/** Receives each explicit Placement.
|
|
158
|
-
publish:
|
|
147
|
+
/** Receives each explicit Placement. */
|
|
148
|
+
publish: Publisher<Placement>
|
|
159
149
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* transition (which ResizeObserver does not report) re-publishes. Default
|
|
165
|
-
* false keeps the legitimate 0×0-visible semantics.
|
|
150
|
+
* When true, targets with zero area (such as display: none or unmounted elements)
|
|
151
|
+
* publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
|
|
152
|
+
* IntersectionObserver tracks display: none transitions. Default is false.
|
|
153
|
+
* Sticky across update(): omitting it preserves the current setting.
|
|
166
154
|
*/
|
|
167
155
|
guardDisplayNone?: boolean
|
|
168
156
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
* container scrolling the target re-measures and re-publishes. With
|
|
173
|
-
* `followGeometry` off, the scroll callback does a single synchronous
|
|
174
|
-
* `emit()`; with it on, the scroll OPENS the RAF sentinel window so the
|
|
175
|
-
* follow tracks every frame of a scroll burst. Default false.
|
|
157
|
+
* When true, listens for capture-phase scroll events on window to re-measure
|
|
158
|
+
* when an ancestor container scrolls. Default is false.
|
|
159
|
+
* Sticky across update(): omitting it preserves the current setting.
|
|
176
160
|
*/
|
|
177
161
|
followScroll?: boolean
|
|
178
162
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* splitter pointerdown, or an explicit `pulse()`), polls geometry once per
|
|
183
|
-
* animation frame publishing IN-FRAME, and AUTO-CLOSES once the rect goes
|
|
184
|
-
* steady (a few unchanged frames). While closed it schedules no frame, so the
|
|
185
|
-
* static cost when idle is exactly zero. Default false.
|
|
163
|
+
* When true, polls geometry per animation frame during active motion (scrolls,
|
|
164
|
+
* splitter dragging, or pulse()) and auto-closes when steady. Zero idle overhead.
|
|
165
|
+
* Sticky across update(): omitting it preserves the current setting.
|
|
186
166
|
*/
|
|
187
167
|
followGeometry?: boolean
|
|
188
168
|
}
|
|
189
169
|
|
|
190
170
|
export interface PlacementAnchorHandle {
|
|
191
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Apply new options and re-publish immediately.
|
|
173
|
+
* guardDisplayNone, followScroll, and followGeometry are sticky: omitting a flag
|
|
174
|
+
* preserves its current value. Pass an explicit false to disable one.
|
|
175
|
+
*/
|
|
192
176
|
update(opts: PlacementAnchorOptions): void
|
|
193
177
|
/** Stop observing; never publish again. */
|
|
194
178
|
dispose(): void
|
|
195
179
|
/**
|
|
196
|
-
* Open the
|
|
197
|
-
*
|
|
180
|
+
* Open the animation frame sentinel window. Auto-closes once stable
|
|
181
|
+
* or after durationMs. No-op if followGeometry is false.
|
|
198
182
|
*/
|
|
199
183
|
pulse(durationMs?: number): void
|
|
200
184
|
}
|
|
201
185
|
|
|
202
186
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* (see `createPlacementAnchor`), so this never returns `{ visible:false }`
|
|
206
|
-
* and never infers visibility from a 0 size. A collapsed (0×0) but present
|
|
207
|
-
* element therefore yields `{ visible:true, bounds:{...,width:0,height:0} }`,
|
|
208
|
-
* distinct from any hidden Placement.
|
|
187
|
+
* Read target's current rect and return { visible: true, bounds }.
|
|
188
|
+
* Does not infer visibility from dimensions.
|
|
209
189
|
*/
|
|
210
190
|
export function measurePlacement(target: HTMLElement): Placement {
|
|
211
191
|
const r = target.getBoundingClientRect()
|
|
@@ -216,8 +196,6 @@ export function measurePlacement(target: HTMLElement): Placement {
|
|
|
216
196
|
}
|
|
217
197
|
|
|
218
198
|
const samePlacement = (a: Placement, b: Placement): boolean => {
|
|
219
|
-
// Discriminant-aware dedup: a visibility flip is always a change, even when
|
|
220
|
-
// the geometry would otherwise look identical.
|
|
221
199
|
if (a.visible !== b.visible) return false
|
|
222
200
|
if (a.visible && b.visible) {
|
|
223
201
|
return (
|
|
@@ -231,21 +209,7 @@ const samePlacement = (a: Placement, b: Placement): boolean => {
|
|
|
231
209
|
}
|
|
232
210
|
|
|
233
211
|
/**
|
|
234
|
-
*
|
|
235
|
-
* teardown machinery, but the sink receives a `Placement`:
|
|
236
|
-
* - `visible === true` → publish `measurePlacement(target)` and re-publish
|
|
237
|
-
* SYNCHRONOUSLY on every `ResizeObserver`/`resize` tick.
|
|
238
|
-
* - `visible === false` → publish `{ visible:false }` (NOT a ZERO bounds);
|
|
239
|
-
* do not observe.
|
|
240
|
-
*
|
|
241
|
-
* Dedup carries the discriminant (`samePlacement`), so a visibility flip is
|
|
242
|
-
* never coalesced away.
|
|
243
|
-
*
|
|
244
|
-
* Opt-in `guardDisplayNone` (default false): when on, a measured zero-area
|
|
245
|
-
* target (display:none / unmounted / unstable first layout) publishes
|
|
246
|
-
* `{ visible:false }` instead of `{ visible:true, bounds:0×0 }`, and an
|
|
247
|
-
* IntersectionObserver is attached so a display:none transition (which
|
|
248
|
-
* ResizeObserver does not report) re-publishes.
|
|
212
|
+
* Explicit-visibility variant of createViewAnchor.
|
|
249
213
|
*/
|
|
250
214
|
export function createPlacementAnchor(
|
|
251
215
|
target: HTMLElement,
|
|
@@ -253,45 +217,42 @@ export function createPlacementAnchor(
|
|
|
253
217
|
): PlacementAnchorHandle {
|
|
254
218
|
let visible = opts.visible
|
|
255
219
|
let publish = opts.publish
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const followGeometry = opts.followGeometry ?? false
|
|
220
|
+
let guardDisplayNone = opts.guardDisplayNone ?? false
|
|
221
|
+
let followScroll = opts.followScroll ?? false
|
|
222
|
+
let followGeometry = opts.followGeometry ?? false
|
|
260
223
|
let observer: ResizeObserver | null = null
|
|
261
224
|
let io: IntersectionObserver | null = null
|
|
225
|
+
let scrollListening = false
|
|
226
|
+
let geometryListening = false
|
|
227
|
+
const capture = { capture: true }
|
|
228
|
+
const passiveCapture = { capture: true, passive: true }
|
|
262
229
|
let lastPublished: Placement | null = null
|
|
230
|
+
let publicationRevision = 0
|
|
263
231
|
let disposed = false
|
|
264
232
|
|
|
265
|
-
//
|
|
266
|
-
// The sentinel
|
|
267
|
-
//
|
|
268
|
-
// frame measures, and either publishes a changed rect synchronously or
|
|
269
|
-
// counts toward the steady-close threshold. While closed `rafId` is null
|
|
270
|
-
// and no frame is scheduled — zero static cost when idle.
|
|
233
|
+
// --- Windowed RAF geometry sentinel state ---
|
|
234
|
+
// The sentinel polls per frame during active movement and auto-closes once
|
|
235
|
+
// geometry settles. While closed, no frame is scheduled (zero idle overhead).
|
|
271
236
|
let rafId: number | null = null
|
|
272
237
|
let steadyFrames = 0
|
|
273
238
|
const STEADY_CLOSE_FRAMES = 2
|
|
274
|
-
// Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
|
|
275
239
|
const MAX_HIDDEN_FOLLOW_FRAMES = 30
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
// steady run while held must NOT close the sentinel — it only closes once the
|
|
280
|
-
// pointer is released. Without this gate a press that pauses a couple of
|
|
281
|
-
// frames before the drag actually moves would close mid-press and drop the
|
|
282
|
-
// entire subsequent drag.
|
|
240
|
+
const MAX_INVALID_FOLLOW_FRAMES = 30
|
|
241
|
+
let invalidFrames = 0
|
|
242
|
+
// True while a [role="separator"] splitter drag is held.
|
|
283
243
|
let pointerHeld = false
|
|
284
|
-
|
|
285
|
-
// force-closes even if the geometry is still changing — the upper bound that
|
|
286
|
-
// prevents a perpetually-animating target from keeping the sentinel resident.
|
|
287
|
-
// null = no time bound (scroll/splitter opens rely on steady-close instead).
|
|
244
|
+
let activePointerId: number | undefined | null = null
|
|
288
245
|
let sentinelDeadline: number | null = null
|
|
289
246
|
|
|
290
|
-
|
|
291
|
-
// a zero-area box (no geometry to anchor) becomes a detach instead of a
|
|
292
|
-
// 0×0-visible Placement. Default off → byte-for-byte the plain measure.
|
|
293
|
-
const computePlacement = (): Placement => {
|
|
247
|
+
const computePlacement = (): Placement | null => {
|
|
294
248
|
const p = measurePlacement(target)
|
|
249
|
+
if (
|
|
250
|
+
p.visible &&
|
|
251
|
+
(!Number.isFinite(p.bounds.x) ||
|
|
252
|
+
!Number.isFinite(p.bounds.y) ||
|
|
253
|
+
!Number.isFinite(p.bounds.width) ||
|
|
254
|
+
!Number.isFinite(p.bounds.height))
|
|
255
|
+
) return null
|
|
295
256
|
if (
|
|
296
257
|
guardDisplayNone &&
|
|
297
258
|
p.visible &&
|
|
@@ -302,63 +263,78 @@ export function createPlacementAnchor(
|
|
|
302
263
|
return p
|
|
303
264
|
}
|
|
304
265
|
|
|
266
|
+
const publishCandidate = (candidate: Placement): boolean => {
|
|
267
|
+
const previous = lastPublished
|
|
268
|
+
const attempt = ++publicationRevision
|
|
269
|
+
lastPublished = candidate
|
|
270
|
+
try {
|
|
271
|
+
const accepted = publish(candidate) !== false
|
|
272
|
+
if (!accepted && publicationRevision === attempt) lastPublished = previous
|
|
273
|
+
return accepted
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (publicationRevision === attempt) lastPublished = previous
|
|
276
|
+
throw error
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
305
280
|
const emit = (): void => {
|
|
306
281
|
if (disposed || !visible) return
|
|
307
282
|
const p = computePlacement()
|
|
283
|
+
if (!p) return
|
|
308
284
|
if (lastPublished && samePlacement(lastPublished, p)) return
|
|
309
|
-
|
|
310
|
-
publish(p)
|
|
285
|
+
publishCandidate(p)
|
|
311
286
|
}
|
|
312
287
|
|
|
313
|
-
// Hidden sentinel poll → close (true) once RO/IO recorded the real hide or the
|
|
314
|
-
// bounded follow run elapsed, else keep following (false); never publishes.
|
|
315
288
|
const shouldCloseOnHiddenPoll = (): boolean => {
|
|
316
289
|
if (lastPublished?.visible === false) return true
|
|
317
290
|
return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
|
|
318
291
|
}
|
|
319
292
|
|
|
320
|
-
// One sentinel frame: measure, publish-in-frame if changed, else count toward
|
|
321
|
-
// the steady-close threshold. Reads `disposed`/`visible` live so a frame
|
|
322
|
-
// outliving teardown is inert.
|
|
323
293
|
const sentinelFrame = (): void => {
|
|
324
294
|
rafId = null
|
|
325
295
|
if (disposed || !visible) {
|
|
326
296
|
sentinelDeadline = null
|
|
327
297
|
return
|
|
328
298
|
}
|
|
329
|
-
// Upper bound: a pulse window past its deadline closes regardless of
|
|
330
|
-
// motion, so a target that changes every frame can't keep the sentinel alive.
|
|
331
299
|
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
332
300
|
sentinelDeadline = null
|
|
333
|
-
return
|
|
301
|
+
return
|
|
334
302
|
}
|
|
335
303
|
const p = computePlacement()
|
|
336
|
-
|
|
337
|
-
|
|
304
|
+
if (!p) {
|
|
305
|
+
if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
|
|
306
|
+
sentinelDeadline = null
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
if (!disposed && visible && followGeometry) {
|
|
310
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
311
|
+
} else {
|
|
312
|
+
sentinelDeadline = null
|
|
313
|
+
}
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
invalidFrames = 0
|
|
338
317
|
if (!p.visible) {
|
|
339
318
|
if (shouldCloseOnHiddenPoll()) { sentinelDeadline = null; return }
|
|
340
|
-
|
|
319
|
+
if (!disposed && visible && followGeometry) {
|
|
320
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
321
|
+
} else {
|
|
322
|
+
sentinelDeadline = null
|
|
323
|
+
}
|
|
341
324
|
return
|
|
342
325
|
}
|
|
343
326
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
344
327
|
steadyFrames++
|
|
345
|
-
// Steady-close only fires once the pointer is RELEASED: while a
|
|
346
|
-
// splitter drag is held, a static pause is a hesitation, not the end of
|
|
347
|
-
// the drag, so we keep polling (re-arm below) and let `steadyFrames`
|
|
348
|
-
// accrue — it converges to a close within N frames after pointerup.
|
|
349
328
|
if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
|
|
350
329
|
sentinelDeadline = null
|
|
351
|
-
return
|
|
330
|
+
return
|
|
352
331
|
}
|
|
353
332
|
} else {
|
|
354
|
-
|
|
355
|
-
publish(p) // publish synchronously in THIS frame
|
|
333
|
+
publishCandidate(p)
|
|
356
334
|
steadyFrames = 0
|
|
357
335
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (!disposed && visible) {
|
|
361
|
-
rafId = requestAnimationFrame(sentinelFrame) // keep polling
|
|
336
|
+
if (!disposed && visible && followGeometry) {
|
|
337
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
362
338
|
} else {
|
|
363
339
|
sentinelDeadline = null
|
|
364
340
|
}
|
|
@@ -367,7 +343,10 @@ export function createPlacementAnchor(
|
|
|
367
343
|
const openSentinel = (): void => {
|
|
368
344
|
if (!followGeometry || disposed) return
|
|
369
345
|
steadyFrames = 0
|
|
370
|
-
if (rafId === null)
|
|
346
|
+
if (rafId === null) {
|
|
347
|
+
invalidFrames = 0
|
|
348
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
349
|
+
}
|
|
371
350
|
}
|
|
372
351
|
|
|
373
352
|
const closeSentinel = (): void => {
|
|
@@ -376,95 +355,129 @@ export function createPlacementAnchor(
|
|
|
376
355
|
rafId = null
|
|
377
356
|
}
|
|
378
357
|
steadyFrames = 0
|
|
358
|
+
invalidFrames = 0
|
|
379
359
|
sentinelDeadline = null
|
|
380
360
|
pointerHeld = false
|
|
361
|
+
activePointerId = null
|
|
381
362
|
}
|
|
382
363
|
|
|
383
|
-
// An ancestor scroll moved the target's screen rect. With the sentinel on,
|
|
384
|
-
// open the window so the whole scroll burst is followed frame-by-frame;
|
|
385
|
-
// without it, a single synchronous emit() follows the new rect.
|
|
386
364
|
const onScroll = (): void => {
|
|
387
365
|
if (followGeometry) openSentinel()
|
|
388
366
|
else emit()
|
|
389
367
|
}
|
|
390
368
|
|
|
391
|
-
// A capture-phase pointerdown on a [role="separator"] splitter handle marks
|
|
392
|
-
// the start of a drag that moves the target via ancestor reflow (no RO tick)
|
|
393
|
-
// → open the sentinel.
|
|
394
369
|
const onPointerDown = (e: Event): void => {
|
|
395
370
|
const t = e.target as Element | null
|
|
396
371
|
if (t && t.closest && t.closest('[role="separator"]')) {
|
|
372
|
+
if (!pointerHeld) activePointerId = (e as PointerEvent).pointerId
|
|
397
373
|
pointerHeld = true
|
|
398
374
|
openSentinel()
|
|
399
375
|
}
|
|
400
376
|
}
|
|
401
377
|
|
|
402
|
-
|
|
403
|
-
// sentinel. Re-open it (a no-op if already polling) so the steady-close
|
|
404
|
-
// threshold is reached even if the geometry was already static at release.
|
|
405
|
-
const onPointerUp = (): void => {
|
|
378
|
+
const releasePointer = (e?: Event): void => {
|
|
406
379
|
if (!pointerHeld) return
|
|
380
|
+
if (e && activePointerId !== (e as PointerEvent).pointerId) return
|
|
407
381
|
pointerHeld = false
|
|
382
|
+
activePointerId = null
|
|
408
383
|
openSentinel()
|
|
409
384
|
}
|
|
410
385
|
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
386
|
+
const onPointerUp = (e: Event): void => {
|
|
387
|
+
releasePointer(e)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const onPointerCancel = (e: Event): void => {
|
|
391
|
+
releasePointer(e)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const onWindowBlur = (): void => {
|
|
395
|
+
releasePointer()
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const startOptionalObserving = (): void => {
|
|
399
|
+
if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
|
|
420
400
|
io = new IntersectionObserver(emit)
|
|
421
401
|
io.observe(target)
|
|
422
402
|
}
|
|
423
|
-
if (followScroll) {
|
|
424
|
-
window.addEventListener('scroll', onScroll,
|
|
425
|
-
|
|
426
|
-
passive: true,
|
|
427
|
-
})
|
|
403
|
+
if (followScroll && !scrollListening) {
|
|
404
|
+
window.addEventListener('scroll', onScroll, passiveCapture)
|
|
405
|
+
scrollListening = true
|
|
428
406
|
}
|
|
429
|
-
if (followGeometry) {
|
|
430
|
-
window.addEventListener('pointerdown', onPointerDown,
|
|
431
|
-
window.addEventListener('pointerup', onPointerUp,
|
|
407
|
+
if (followGeometry && !geometryListening) {
|
|
408
|
+
window.addEventListener('pointerdown', onPointerDown, capture)
|
|
409
|
+
window.addEventListener('pointerup', onPointerUp, capture)
|
|
410
|
+
window.addEventListener('pointercancel', onPointerCancel, capture)
|
|
411
|
+
window.addEventListener('blur', onWindowBlur)
|
|
412
|
+
geometryListening = true
|
|
432
413
|
}
|
|
433
414
|
}
|
|
434
415
|
|
|
435
|
-
const
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
416
|
+
const stopOptionalObserving = (): void => {
|
|
417
|
+
if (io && !guardDisplayNone) {
|
|
418
|
+
io.disconnect()
|
|
419
|
+
io = null
|
|
420
|
+
}
|
|
421
|
+
if (scrollListening && !followScroll) {
|
|
422
|
+
window.removeEventListener('scroll', onScroll, passiveCapture)
|
|
423
|
+
scrollListening = false
|
|
439
424
|
}
|
|
425
|
+
if (geometryListening && !followGeometry) {
|
|
426
|
+
window.removeEventListener('pointerdown', onPointerDown, capture)
|
|
427
|
+
window.removeEventListener('pointerup', onPointerUp, capture)
|
|
428
|
+
window.removeEventListener('pointercancel', onPointerCancel, capture)
|
|
429
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
430
|
+
geometryListening = false
|
|
431
|
+
closeSentinel()
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const stopAllOptionalObserving = (): void => {
|
|
440
436
|
if (io) {
|
|
441
437
|
io.disconnect()
|
|
442
438
|
io = null
|
|
443
439
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
440
|
+
if (scrollListening) {
|
|
441
|
+
window.removeEventListener('scroll', onScroll, passiveCapture)
|
|
442
|
+
scrollListening = false
|
|
443
|
+
}
|
|
444
|
+
if (geometryListening) {
|
|
445
|
+
window.removeEventListener('pointerdown', onPointerDown, capture)
|
|
446
|
+
window.removeEventListener('pointerup', onPointerUp, capture)
|
|
447
|
+
window.removeEventListener('pointercancel', onPointerCancel, capture)
|
|
448
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
449
|
+
geometryListening = false
|
|
450
|
+
}
|
|
454
451
|
closeSentinel()
|
|
455
452
|
}
|
|
456
453
|
|
|
454
|
+
const startObserving = (): void => {
|
|
455
|
+
if (observer) return
|
|
456
|
+
observer = new ResizeObserver(emit)
|
|
457
|
+
observer.observe(target)
|
|
458
|
+
window.addEventListener('resize', emit)
|
|
459
|
+
startOptionalObserving()
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const stopObserving = (): void => {
|
|
463
|
+
if (observer) {
|
|
464
|
+
observer.disconnect()
|
|
465
|
+
observer = null
|
|
466
|
+
}
|
|
467
|
+
window.removeEventListener('resize', emit)
|
|
468
|
+
stopAllOptionalObserving()
|
|
469
|
+
}
|
|
470
|
+
|
|
457
471
|
const apply = (): void => {
|
|
458
472
|
lastPublished = null
|
|
459
473
|
if (visible) {
|
|
460
474
|
startObserving()
|
|
461
|
-
|
|
462
|
-
|
|
475
|
+
const placement = computePlacement()
|
|
476
|
+
if (placement) publishCandidate(placement)
|
|
463
477
|
} else {
|
|
464
478
|
stopObserving()
|
|
465
479
|
const hidden: Placement = { visible: false }
|
|
466
|
-
|
|
467
|
-
publish(hidden)
|
|
480
|
+
publishCandidate(hidden)
|
|
468
481
|
}
|
|
469
482
|
}
|
|
470
483
|
|
|
@@ -475,6 +488,15 @@ export function createPlacementAnchor(
|
|
|
475
488
|
if (disposed) return
|
|
476
489
|
publish = next.publish
|
|
477
490
|
visible = next.visible
|
|
491
|
+
// Omitting a flag preserves its current value so callers that only
|
|
492
|
+
// pass { visible, publish } don't inadvertently disable enabled flags.
|
|
493
|
+
guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone
|
|
494
|
+
followScroll = next.followScroll ?? followScroll
|
|
495
|
+
followGeometry = next.followGeometry ?? followGeometry
|
|
496
|
+
if (visible && observer) {
|
|
497
|
+
stopOptionalObserving()
|
|
498
|
+
startOptionalObserving()
|
|
499
|
+
}
|
|
478
500
|
apply()
|
|
479
501
|
},
|
|
480
502
|
dispose(): void {
|
|
@@ -483,15 +505,9 @@ export function createPlacementAnchor(
|
|
|
483
505
|
stopObserving()
|
|
484
506
|
},
|
|
485
507
|
pulse(durationMs?: number): void {
|
|
486
|
-
// Imperative window open: start the animation-follow window. It
|
|
487
|
-
// closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
|
|
488
|
-
// at that deadline — whichever comes first. The deadline is the upper bound
|
|
489
|
-
// that guarantees a still-animating target cannot keep the sentinel
|
|
490
|
-
// resident; without it, only steady-close applies.
|
|
491
508
|
if (disposed || !followGeometry) return
|
|
492
509
|
if (durationMs !== undefined && durationMs > 0) {
|
|
493
510
|
const next = performance.now() + durationMs
|
|
494
|
-
// Extend (never shorten) an existing window's deadline.
|
|
495
511
|
sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
|
|
496
512
|
}
|
|
497
513
|
openSentinel()
|