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