view-anchor 0.1.2 → 0.2.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 +111 -39
- package/README.zh-CN.md +119 -47
- package/dist/index.d.ts +6 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -15
- package/dist/measure-loop.d.ts +9 -28
- package/dist/measure-loop.d.ts.map +1 -1
- package/dist/measure-loop.js +57 -17
- package/dist/protocol-publisher.d.ts +41 -0
- package/dist/protocol-publisher.d.ts.map +1 -0
- package/dist/protocol-publisher.js +207 -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 +128 -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 +29 -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 +230 -181
- package/docs/bidirectional-design.md +78 -106
- package/docs/index.html +772 -0
- package/docs/mechanism.md +116 -0
- package/docs/performance-report.md +63 -0
- package/docs/protocol.md +108 -0
- package/package.json +37 -14
- package/src/index.ts +8 -24
- package/src/measure-loop.ts +56 -42
- package/src/protocol-publisher.ts +254 -0
- package/src/protocol-types.ts +43 -0
- package/src/protocol.ts +181 -0
- package/src/react.ts +175 -139
- package/src/size-advertiser.ts +33 -31
- package/src/types.ts +35 -82
- package/src/view-anchor.ts +259 -236
- package/docs/anchor-3d.html +0 -615
- package/docs/mechanism.mdx +0 -119
package/src/view-anchor.ts
CHANGED
|
@@ -1,26 +1,15 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
Bounds,
|
|
3
|
-
Placement,
|
|
4
|
-
ViewAnchorOptions,
|
|
5
|
-
ViewAnchorHandle,
|
|
6
|
-
} from './types.js'
|
|
1
|
+
import type { Bounds, Placement, Publisher, ViewAnchorOptions, ViewAnchorHandle } from './types.js'
|
|
7
2
|
|
|
8
3
|
const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
|
|
9
4
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
const clampRect = (r: {
|
|
19
|
-
x: number
|
|
20
|
-
y: number
|
|
21
|
-
width: number
|
|
22
|
-
height: number
|
|
23
|
-
}): Bounds => ({
|
|
5
|
+
// Replaces a disposed instance's publish callback so a retained handle does
|
|
6
|
+
// not keep the caller's original callback (and whatever it captured) alive.
|
|
7
|
+
const NOOP_PUBLISH = (): false => false
|
|
8
|
+
|
|
9
|
+
// Round to integer pixels. Width and height are clamped to >= 0 (0 represents
|
|
10
|
+
// a collapsed rect). Coordinates (x, y) can be negative when an element is
|
|
11
|
+
// scrolled out of view; clamping them to 0 would pin the view to the screen edge.
|
|
12
|
+
const clampRect = (r: { x: number; y: number; width: number; height: number }): Bounds => ({
|
|
24
13
|
x: Math.round(r.x),
|
|
25
14
|
y: Math.round(r.y),
|
|
26
15
|
width: Math.max(0, Math.round(r.width)),
|
|
@@ -28,72 +17,79 @@ const clampRect = (r: {
|
|
|
28
17
|
})
|
|
29
18
|
|
|
30
19
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* Imperative core — no React, no Electron. Behaviour:
|
|
34
|
-
* - `present === true`: publish `target.getBoundingClientRect()` (x/y rounded,
|
|
35
|
-
* width/height `Math.max(0, Math.round(...))`) immediately, then re-publish
|
|
36
|
-
* SYNCHRONOUSLY on every `ResizeObserver` tick and window `resize`.
|
|
37
|
-
* - `present === false`: publish `{0,0,0,0}` immediately; do not observe.
|
|
38
|
-
* - `update(opts)`: re-apply synchronously.
|
|
39
|
-
* - `dispose()`: stop observing, never publish again.
|
|
20
|
+
* Bind a native view or external surface to the geometry of `target`.
|
|
40
21
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
|
22
|
+
* - `present === true`: measures `target.getBoundingClientRect()` and publishes
|
|
23
|
+
* immediately, then re-measures synchronously on ResizeObserver and window resize.
|
|
24
|
+
* - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
|
|
25
|
+
* and stops observing.
|
|
26
|
+
* - `update(opts)`: re-applies options immediately.
|
|
27
|
+
* - `dispose()`: stops observing and prevents any further publishes.
|
|
54
28
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
29
|
+
* Synchronous publishing: measurement and publishing occur directly in the
|
|
30
|
+
* observer tick. Cross-process setBounds calls already have a compositor delay;
|
|
31
|
+
* adding requestAnimationFrame would add a second frame of visual lag during drag
|
|
32
|
+
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
58
33
|
*/
|
|
59
|
-
export function createViewAnchor(
|
|
60
|
-
target: HTMLElement,
|
|
61
|
-
opts: ViewAnchorOptions,
|
|
62
|
-
): ViewAnchorHandle {
|
|
34
|
+
export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle {
|
|
63
35
|
let present = opts.present
|
|
64
36
|
let publish = opts.publish
|
|
65
37
|
let observer: ResizeObserver | null = null
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
//
|
|
38
|
+
// Local, clearable alias for the `target` parameter so dispose() can drop
|
|
39
|
+
// the strong reference without widening the public parameter's type.
|
|
40
|
+
let targetRef: HTMLElement | null = target
|
|
41
|
+
// Last rect sent to publish. Reset on apply() so state changes (such as zoom)
|
|
42
|
+
// force a re-publish even if the geometry did not change.
|
|
70
43
|
let lastPublished: Bounds | null = null
|
|
44
|
+
let publicationRevision = 0
|
|
71
45
|
let disposed = false
|
|
72
46
|
|
|
73
|
-
const measure = (): Bounds => {
|
|
74
|
-
const r =
|
|
47
|
+
const measure = (): Bounds | null => {
|
|
48
|
+
const r = targetRef!.getBoundingClientRect()
|
|
49
|
+
// Drop ticks with non-finite values (NaN / Infinity cannot be sent over IPC).
|
|
50
|
+
if (
|
|
51
|
+
!Number.isFinite(r.left) ||
|
|
52
|
+
!Number.isFinite(r.top) ||
|
|
53
|
+
!Number.isFinite(r.width) ||
|
|
54
|
+
!Number.isFinite(r.height)
|
|
55
|
+
)
|
|
56
|
+
return null
|
|
75
57
|
return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
|
|
76
58
|
}
|
|
77
59
|
|
|
78
60
|
const sameRect = (a: Bounds, b: Bounds): boolean =>
|
|
79
61
|
a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
|
|
80
62
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
63
|
+
const publishCandidate = (candidate: Bounds): boolean => {
|
|
64
|
+
const previous = lastPublished
|
|
65
|
+
const attempt = ++publicationRevision
|
|
66
|
+
lastPublished = candidate
|
|
67
|
+
try {
|
|
68
|
+
const accepted = publish(candidate) !== false
|
|
69
|
+
// A reentrant dispose() during publish() already cleared lastPublished;
|
|
70
|
+
// do not resurrect the pre-dispose value over that terminal state.
|
|
71
|
+
if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
|
|
72
|
+
return accepted
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (publicationRevision === attempt && !disposed) lastPublished = previous
|
|
75
|
+
throw error
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Measure and publish synchronously on each observer tick.
|
|
80
|
+
// Drops duplicate rects to coalesce same-frame resize events.
|
|
85
81
|
const emit = (): void => {
|
|
86
82
|
if (disposed || !present) return
|
|
87
83
|
const m = measure()
|
|
84
|
+
if (!m) return
|
|
88
85
|
if (lastPublished && sameRect(lastPublished, m)) return
|
|
89
|
-
|
|
90
|
-
publish(m)
|
|
86
|
+
publishCandidate(m)
|
|
91
87
|
}
|
|
92
88
|
|
|
93
89
|
const startObserving = (): void => {
|
|
94
90
|
if (observer) return
|
|
95
91
|
observer = new ResizeObserver(emit)
|
|
96
|
-
observer.observe(
|
|
92
|
+
observer.observe(targetRef!)
|
|
97
93
|
window.addEventListener('resize', emit)
|
|
98
94
|
}
|
|
99
95
|
|
|
@@ -105,19 +101,17 @@ export function createViewAnchor(
|
|
|
105
101
|
window.removeEventListener('resize', emit)
|
|
106
102
|
}
|
|
107
103
|
|
|
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.
|
|
104
|
+
// Apply current options synchronously. Reset lastPublished so state changes
|
|
105
|
+
// always re-publish even if dimensions have not changed.
|
|
112
106
|
const apply = (): void => {
|
|
113
107
|
lastPublished = null
|
|
114
108
|
if (present) {
|
|
115
109
|
startObserving()
|
|
116
|
-
|
|
117
|
-
|
|
110
|
+
const measured = measure()
|
|
111
|
+
if (measured) publishCandidate(measured)
|
|
118
112
|
} else {
|
|
119
113
|
stopObserving()
|
|
120
|
-
|
|
114
|
+
publishCandidate(ZERO)
|
|
121
115
|
}
|
|
122
116
|
}
|
|
123
117
|
|
|
@@ -134,78 +128,63 @@ export function createViewAnchor(
|
|
|
134
128
|
if (disposed) return
|
|
135
129
|
disposed = true
|
|
136
130
|
stopObserving()
|
|
131
|
+
targetRef = null
|
|
132
|
+
publish = NOOP_PUBLISH
|
|
133
|
+
lastPublished = null
|
|
137
134
|
},
|
|
138
135
|
}
|
|
139
136
|
}
|
|
140
137
|
|
|
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`.
|
|
138
|
+
// --- Explicit Placement API ---
|
|
147
139
|
|
|
148
140
|
export interface PlacementAnchorOptions {
|
|
149
141
|
/**
|
|
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`.
|
|
142
|
+
* Whether the native view should be visible. When true, publishes
|
|
143
|
+
* { visible: true, bounds }; when false, publishes { visible: false }.
|
|
155
144
|
*/
|
|
156
145
|
visible: boolean
|
|
157
|
-
/** Receives each explicit Placement.
|
|
158
|
-
publish:
|
|
146
|
+
/** Receives each explicit Placement. */
|
|
147
|
+
publish: Publisher<Placement>
|
|
159
148
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* transition (which ResizeObserver does not report) re-publishes. Default
|
|
165
|
-
* false keeps the legitimate 0×0-visible semantics.
|
|
149
|
+
* When true, targets with zero area (such as display: none or unmounted elements)
|
|
150
|
+
* publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
|
|
151
|
+
* IntersectionObserver tracks display: none transitions. Default is false.
|
|
152
|
+
* Sticky across update(): omitting it preserves the current setting.
|
|
166
153
|
*/
|
|
167
154
|
guardDisplayNone?: boolean
|
|
168
155
|
/**
|
|
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.
|
|
156
|
+
* When true, listens for capture-phase scroll events on window to re-measure
|
|
157
|
+
* when an ancestor container scrolls. Default is false.
|
|
158
|
+
* Sticky across update(): omitting it preserves the current setting.
|
|
176
159
|
*/
|
|
177
160
|
followScroll?: boolean
|
|
178
161
|
/**
|
|
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.
|
|
162
|
+
* When true, polls geometry per animation frame during active motion (scrolls,
|
|
163
|
+
* splitter dragging, or pulse()) and auto-closes when steady. Zero idle overhead.
|
|
164
|
+
* Sticky across update(): omitting it preserves the current setting.
|
|
186
165
|
*/
|
|
187
166
|
followGeometry?: boolean
|
|
188
167
|
}
|
|
189
168
|
|
|
190
169
|
export interface PlacementAnchorHandle {
|
|
191
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Apply new options and re-publish immediately.
|
|
172
|
+
* guardDisplayNone, followScroll, and followGeometry are sticky: omitting a flag
|
|
173
|
+
* preserves its current value. Pass an explicit false to disable one.
|
|
174
|
+
*/
|
|
192
175
|
update(opts: PlacementAnchorOptions): void
|
|
193
176
|
/** Stop observing; never publish again. */
|
|
194
177
|
dispose(): void
|
|
195
178
|
/**
|
|
196
|
-
* Open the
|
|
197
|
-
*
|
|
179
|
+
* Open the animation frame sentinel window. Auto-closes once stable
|
|
180
|
+
* or after durationMs. No-op if followGeometry is false.
|
|
198
181
|
*/
|
|
199
182
|
pulse(durationMs?: number): void
|
|
200
183
|
}
|
|
201
184
|
|
|
202
185
|
/**
|
|
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.
|
|
186
|
+
* Read target's current rect and return { visible: true, bounds }.
|
|
187
|
+
* Does not infer visibility from dimensions.
|
|
209
188
|
*/
|
|
210
189
|
export function measurePlacement(target: HTMLElement): Placement {
|
|
211
190
|
const r = target.getBoundingClientRect()
|
|
@@ -216,8 +195,6 @@ export function measurePlacement(target: HTMLElement): Placement {
|
|
|
216
195
|
}
|
|
217
196
|
|
|
218
197
|
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
198
|
if (a.visible !== b.visible) return false
|
|
222
199
|
if (a.visible && b.visible) {
|
|
223
200
|
return (
|
|
@@ -231,21 +208,7 @@ const samePlacement = (a: Placement, b: Placement): boolean => {
|
|
|
231
208
|
}
|
|
232
209
|
|
|
233
210
|
/**
|
|
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.
|
|
211
|
+
* Explicit-visibility variant of createViewAnchor.
|
|
249
212
|
*/
|
|
250
213
|
export function createPlacementAnchor(
|
|
251
214
|
target: HTMLElement,
|
|
@@ -253,112 +216,129 @@ export function createPlacementAnchor(
|
|
|
253
216
|
): PlacementAnchorHandle {
|
|
254
217
|
let visible = opts.visible
|
|
255
218
|
let publish = opts.publish
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
219
|
+
let guardDisplayNone = opts.guardDisplayNone ?? false
|
|
220
|
+
let followScroll = opts.followScroll ?? false
|
|
221
|
+
let followGeometry = opts.followGeometry ?? false
|
|
222
|
+
// Local, clearable alias for the `target` parameter so dispose() can drop
|
|
223
|
+
// the strong reference without widening the public parameter's type.
|
|
224
|
+
let targetRef: HTMLElement | null = target
|
|
260
225
|
let observer: ResizeObserver | null = null
|
|
261
226
|
let io: IntersectionObserver | null = null
|
|
227
|
+
let scrollListening = false
|
|
228
|
+
let geometryListening = false
|
|
229
|
+
const capture = { capture: true }
|
|
230
|
+
const passiveCapture = { capture: true, passive: true }
|
|
262
231
|
let lastPublished: Placement | null = null
|
|
232
|
+
let publicationRevision = 0
|
|
263
233
|
let disposed = false
|
|
264
234
|
|
|
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.
|
|
235
|
+
// --- Windowed RAF geometry sentinel state ---
|
|
236
|
+
// The sentinel polls per frame during active movement and auto-closes once
|
|
237
|
+
// geometry settles. While closed, no frame is scheduled (zero idle overhead).
|
|
271
238
|
let rafId: number | null = null
|
|
272
239
|
let steadyFrames = 0
|
|
273
240
|
const STEADY_CLOSE_FRAMES = 2
|
|
274
|
-
// Bounds hidden polls the sentinel FOLLOWS so a no-deadline window can't spin.
|
|
275
241
|
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.
|
|
242
|
+
const MAX_INVALID_FOLLOW_FRAMES = 30
|
|
243
|
+
let invalidFrames = 0
|
|
244
|
+
// True while a [role="separator"] splitter drag is held.
|
|
283
245
|
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).
|
|
246
|
+
let activePointerId: number | undefined | null = null
|
|
288
247
|
let sentinelDeadline: number | null = null
|
|
289
248
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
// 0×0-visible Placement. Default off → byte-for-byte the plain measure.
|
|
293
|
-
const computePlacement = (): Placement => {
|
|
294
|
-
const p = measurePlacement(target)
|
|
249
|
+
const computePlacement = (): Placement | null => {
|
|
250
|
+
const p = measurePlacement(targetRef!)
|
|
295
251
|
if (
|
|
296
|
-
guardDisplayNone &&
|
|
297
252
|
p.visible &&
|
|
298
|
-
(p.bounds.
|
|
299
|
-
|
|
253
|
+
(!Number.isFinite(p.bounds.x) ||
|
|
254
|
+
!Number.isFinite(p.bounds.y) ||
|
|
255
|
+
!Number.isFinite(p.bounds.width) ||
|
|
256
|
+
!Number.isFinite(p.bounds.height))
|
|
257
|
+
)
|
|
258
|
+
return null
|
|
259
|
+
if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
|
|
300
260
|
return { visible: false }
|
|
301
261
|
}
|
|
302
262
|
return p
|
|
303
263
|
}
|
|
304
264
|
|
|
265
|
+
const publishCandidate = (candidate: Placement): boolean => {
|
|
266
|
+
const previous = lastPublished
|
|
267
|
+
const attempt = ++publicationRevision
|
|
268
|
+
lastPublished = candidate
|
|
269
|
+
try {
|
|
270
|
+
const accepted = publish(candidate) !== false
|
|
271
|
+
// A reentrant dispose() during publish() already cleared lastPublished;
|
|
272
|
+
// do not resurrect the pre-dispose value over that terminal state.
|
|
273
|
+
if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
|
|
274
|
+
return accepted
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (publicationRevision === attempt && !disposed) lastPublished = previous
|
|
277
|
+
throw error
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
305
281
|
const emit = (): void => {
|
|
306
282
|
if (disposed || !visible) return
|
|
307
283
|
const p = computePlacement()
|
|
284
|
+
if (!p) return
|
|
308
285
|
if (lastPublished && samePlacement(lastPublished, p)) return
|
|
309
|
-
|
|
310
|
-
publish(p)
|
|
286
|
+
publishCandidate(p)
|
|
311
287
|
}
|
|
312
288
|
|
|
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
289
|
const shouldCloseOnHiddenPoll = (): boolean => {
|
|
316
290
|
if (lastPublished?.visible === false) return true
|
|
317
291
|
return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
|
|
318
292
|
}
|
|
319
293
|
|
|
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
294
|
const sentinelFrame = (): void => {
|
|
324
295
|
rafId = null
|
|
325
296
|
if (disposed || !visible) {
|
|
326
297
|
sentinelDeadline = null
|
|
327
298
|
return
|
|
328
299
|
}
|
|
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
300
|
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
332
301
|
sentinelDeadline = null
|
|
333
|
-
return
|
|
302
|
+
return
|
|
334
303
|
}
|
|
335
304
|
const p = computePlacement()
|
|
336
|
-
|
|
337
|
-
|
|
305
|
+
if (!p) {
|
|
306
|
+
if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
|
|
307
|
+
sentinelDeadline = null
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (!disposed && visible && followGeometry) {
|
|
311
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
312
|
+
} else {
|
|
313
|
+
sentinelDeadline = null
|
|
314
|
+
}
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
invalidFrames = 0
|
|
338
318
|
if (!p.visible) {
|
|
339
|
-
if (shouldCloseOnHiddenPoll()) {
|
|
340
|
-
|
|
319
|
+
if (shouldCloseOnHiddenPoll()) {
|
|
320
|
+
sentinelDeadline = null
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (!disposed && visible && followGeometry) {
|
|
324
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
325
|
+
} else {
|
|
326
|
+
sentinelDeadline = null
|
|
327
|
+
}
|
|
341
328
|
return
|
|
342
329
|
}
|
|
343
330
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
344
331
|
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
332
|
if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
|
|
350
333
|
sentinelDeadline = null
|
|
351
|
-
return
|
|
334
|
+
return
|
|
352
335
|
}
|
|
353
336
|
} else {
|
|
354
|
-
|
|
355
|
-
publish(p) // publish synchronously in THIS frame
|
|
337
|
+
publishCandidate(p)
|
|
356
338
|
steadyFrames = 0
|
|
357
339
|
}
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (!disposed && visible) {
|
|
361
|
-
rafId = requestAnimationFrame(sentinelFrame) // keep polling
|
|
340
|
+
if (!disposed && visible && followGeometry) {
|
|
341
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
362
342
|
} else {
|
|
363
343
|
sentinelDeadline = null
|
|
364
344
|
}
|
|
@@ -367,7 +347,10 @@ export function createPlacementAnchor(
|
|
|
367
347
|
const openSentinel = (): void => {
|
|
368
348
|
if (!followGeometry || disposed) return
|
|
369
349
|
steadyFrames = 0
|
|
370
|
-
if (rafId === null)
|
|
350
|
+
if (rafId === null) {
|
|
351
|
+
invalidFrames = 0
|
|
352
|
+
rafId = requestAnimationFrame(sentinelFrame)
|
|
353
|
+
}
|
|
371
354
|
}
|
|
372
355
|
|
|
373
356
|
const closeSentinel = (): void => {
|
|
@@ -376,95 +359,129 @@ export function createPlacementAnchor(
|
|
|
376
359
|
rafId = null
|
|
377
360
|
}
|
|
378
361
|
steadyFrames = 0
|
|
362
|
+
invalidFrames = 0
|
|
379
363
|
sentinelDeadline = null
|
|
380
364
|
pointerHeld = false
|
|
365
|
+
activePointerId = null
|
|
381
366
|
}
|
|
382
367
|
|
|
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
368
|
const onScroll = (): void => {
|
|
387
369
|
if (followGeometry) openSentinel()
|
|
388
370
|
else emit()
|
|
389
371
|
}
|
|
390
372
|
|
|
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
373
|
const onPointerDown = (e: Event): void => {
|
|
395
374
|
const t = e.target as Element | null
|
|
396
375
|
if (t && t.closest && t.closest('[role="separator"]')) {
|
|
376
|
+
if (!pointerHeld) activePointerId = (e as PointerEvent).pointerId
|
|
397
377
|
pointerHeld = true
|
|
398
378
|
openSentinel()
|
|
399
379
|
}
|
|
400
380
|
}
|
|
401
381
|
|
|
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 => {
|
|
382
|
+
const releasePointer = (e?: Event): void => {
|
|
406
383
|
if (!pointerHeld) return
|
|
384
|
+
if (e && activePointerId !== (e as PointerEvent).pointerId) return
|
|
407
385
|
pointerHeld = false
|
|
386
|
+
activePointerId = null
|
|
408
387
|
openSentinel()
|
|
409
388
|
}
|
|
410
389
|
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
390
|
+
const onPointerUp = (e: Event): void => {
|
|
391
|
+
releasePointer(e)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const onPointerCancel = (e: Event): void => {
|
|
395
|
+
releasePointer(e)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const onWindowBlur = (): void => {
|
|
399
|
+
releasePointer()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const startOptionalObserving = (): void => {
|
|
403
|
+
if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
|
|
420
404
|
io = new IntersectionObserver(emit)
|
|
421
|
-
io.observe(
|
|
405
|
+
io.observe(targetRef!)
|
|
422
406
|
}
|
|
423
|
-
if (followScroll) {
|
|
424
|
-
window.addEventListener('scroll', onScroll,
|
|
425
|
-
|
|
426
|
-
passive: true,
|
|
427
|
-
})
|
|
407
|
+
if (followScroll && !scrollListening) {
|
|
408
|
+
window.addEventListener('scroll', onScroll, passiveCapture)
|
|
409
|
+
scrollListening = true
|
|
428
410
|
}
|
|
429
|
-
if (followGeometry) {
|
|
430
|
-
window.addEventListener('pointerdown', onPointerDown,
|
|
431
|
-
window.addEventListener('pointerup', onPointerUp,
|
|
411
|
+
if (followGeometry && !geometryListening) {
|
|
412
|
+
window.addEventListener('pointerdown', onPointerDown, capture)
|
|
413
|
+
window.addEventListener('pointerup', onPointerUp, capture)
|
|
414
|
+
window.addEventListener('pointercancel', onPointerCancel, capture)
|
|
415
|
+
window.addEventListener('blur', onWindowBlur)
|
|
416
|
+
geometryListening = true
|
|
432
417
|
}
|
|
433
418
|
}
|
|
434
419
|
|
|
435
|
-
const
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
420
|
+
const stopOptionalObserving = (): void => {
|
|
421
|
+
if (io && !guardDisplayNone) {
|
|
422
|
+
io.disconnect()
|
|
423
|
+
io = null
|
|
424
|
+
}
|
|
425
|
+
if (scrollListening && !followScroll) {
|
|
426
|
+
window.removeEventListener('scroll', onScroll, passiveCapture)
|
|
427
|
+
scrollListening = false
|
|
439
428
|
}
|
|
429
|
+
if (geometryListening && !followGeometry) {
|
|
430
|
+
window.removeEventListener('pointerdown', onPointerDown, capture)
|
|
431
|
+
window.removeEventListener('pointerup', onPointerUp, capture)
|
|
432
|
+
window.removeEventListener('pointercancel', onPointerCancel, capture)
|
|
433
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
434
|
+
geometryListening = false
|
|
435
|
+
closeSentinel()
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const stopAllOptionalObserving = (): void => {
|
|
440
440
|
if (io) {
|
|
441
441
|
io.disconnect()
|
|
442
442
|
io = null
|
|
443
443
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
444
|
+
if (scrollListening) {
|
|
445
|
+
window.removeEventListener('scroll', onScroll, passiveCapture)
|
|
446
|
+
scrollListening = false
|
|
447
|
+
}
|
|
448
|
+
if (geometryListening) {
|
|
449
|
+
window.removeEventListener('pointerdown', onPointerDown, capture)
|
|
450
|
+
window.removeEventListener('pointerup', onPointerUp, capture)
|
|
451
|
+
window.removeEventListener('pointercancel', onPointerCancel, capture)
|
|
452
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
453
|
+
geometryListening = false
|
|
454
|
+
}
|
|
454
455
|
closeSentinel()
|
|
455
456
|
}
|
|
456
457
|
|
|
458
|
+
const startObserving = (): void => {
|
|
459
|
+
if (observer) return
|
|
460
|
+
observer = new ResizeObserver(emit)
|
|
461
|
+
observer.observe(targetRef!)
|
|
462
|
+
window.addEventListener('resize', emit)
|
|
463
|
+
startOptionalObserving()
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const stopObserving = (): void => {
|
|
467
|
+
if (observer) {
|
|
468
|
+
observer.disconnect()
|
|
469
|
+
observer = null
|
|
470
|
+
}
|
|
471
|
+
window.removeEventListener('resize', emit)
|
|
472
|
+
stopAllOptionalObserving()
|
|
473
|
+
}
|
|
474
|
+
|
|
457
475
|
const apply = (): void => {
|
|
458
476
|
lastPublished = null
|
|
459
477
|
if (visible) {
|
|
460
478
|
startObserving()
|
|
461
|
-
|
|
462
|
-
|
|
479
|
+
const placement = computePlacement()
|
|
480
|
+
if (placement) publishCandidate(placement)
|
|
463
481
|
} else {
|
|
464
482
|
stopObserving()
|
|
465
483
|
const hidden: Placement = { visible: false }
|
|
466
|
-
|
|
467
|
-
publish(hidden)
|
|
484
|
+
publishCandidate(hidden)
|
|
468
485
|
}
|
|
469
486
|
}
|
|
470
487
|
|
|
@@ -475,23 +492,29 @@ export function createPlacementAnchor(
|
|
|
475
492
|
if (disposed) return
|
|
476
493
|
publish = next.publish
|
|
477
494
|
visible = next.visible
|
|
495
|
+
// Omitting a flag preserves its current value so callers that only
|
|
496
|
+
// pass { visible, publish } don't inadvertently disable enabled flags.
|
|
497
|
+
guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone
|
|
498
|
+
followScroll = next.followScroll ?? followScroll
|
|
499
|
+
followGeometry = next.followGeometry ?? followGeometry
|
|
500
|
+
if (visible && observer) {
|
|
501
|
+
stopOptionalObserving()
|
|
502
|
+
startOptionalObserving()
|
|
503
|
+
}
|
|
478
504
|
apply()
|
|
479
505
|
},
|
|
480
506
|
dispose(): void {
|
|
481
507
|
if (disposed) return
|
|
482
508
|
disposed = true
|
|
483
509
|
stopObserving()
|
|
510
|
+
targetRef = null
|
|
511
|
+
publish = NOOP_PUBLISH
|
|
512
|
+
lastPublished = null
|
|
484
513
|
},
|
|
485
514
|
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
515
|
if (disposed || !followGeometry) return
|
|
492
516
|
if (durationMs !== undefined && durationMs > 0) {
|
|
493
517
|
const next = performance.now() + durationMs
|
|
494
|
-
// Extend (never shorten) an existing window's deadline.
|
|
495
518
|
sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
|
|
496
519
|
}
|
|
497
520
|
openSentinel()
|