view-anchor 0.2.2 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +71 -87
  2. package/README.zh-CN.md +59 -64
  3. package/dist/index.d.ts +5 -7
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -3
  6. package/dist/protocol-publisher.d.ts +7 -9
  7. package/dist/protocol-publisher.d.ts.map +1 -1
  8. package/dist/protocol-publisher.js +2 -8
  9. package/dist/protocol-types.d.ts +5 -8
  10. package/dist/protocol-types.d.ts.map +1 -1
  11. package/dist/protocol-types.js +3 -6
  12. package/dist/protocol.d.ts +2 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js +10 -2
  15. package/dist/react.d.ts +2 -15
  16. package/dist/react.d.ts.map +1 -1
  17. package/dist/react.js +43 -38
  18. package/dist/{size-advertiser.d.ts → size-anchor.d.ts} +6 -5
  19. package/dist/size-anchor.d.ts.map +1 -0
  20. package/dist/size-anchor.js +131 -0
  21. package/dist/types.d.ts +26 -32
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/view-anchor.d.ts +52 -33
  24. package/dist/view-anchor.d.ts.map +1 -1
  25. package/dist/view-anchor.js +186 -265
  26. package/docs/bidirectional-design.md +29 -34
  27. package/docs/index.html +656 -366
  28. package/docs/mechanism.md +35 -57
  29. package/docs/performance-report.md +12 -54
  30. package/docs/protocol.md +12 -3
  31. package/package.json +4 -6
  32. package/src/index.ts +5 -19
  33. package/src/protocol-publisher.ts +10 -16
  34. package/src/protocol-types.ts +5 -8
  35. package/src/protocol.ts +13 -7
  36. package/src/react.ts +51 -64
  37. package/src/size-anchor.ts +131 -0
  38. package/src/types.ts +29 -37
  39. package/src/view-anchor.ts +221 -287
  40. package/dist/measure-loop.d.ts +0 -24
  41. package/dist/measure-loop.d.ts.map +0 -1
  42. package/dist/measure-loop.js +0 -89
  43. package/dist/size-advertiser.d.ts.map +0 -1
  44. package/dist/size-advertiser.js +0 -94
  45. package/src/measure-loop.ts +0 -101
  46. package/src/size-advertiser.ts +0 -108
package/docs/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>view-anchor 3D Demo: Keep External Surfaces Aligned with DOM Elements</title>
7
- <meta name="description" content="Interactive 3D demo showing view-anchor tracking DOM elements live in the browser, with zero frame lag and no external dependencies.">
7
+ <meta name="description" content="Interactive 3D demo showing view-anchor tracking DOM elements live in the browser, with no external dependencies.">
8
8
  <style>
9
9
  :root {
10
10
  --bg: hsl( 0 0% 7%);
@@ -20,6 +20,7 @@
20
20
  --native: hsl(160 40% 16%);
21
21
  --native-bd: hsl(160 72% 58%);
22
22
  --wire: hsl(160 72% 60%);
23
+ --reverse: hsl( 35 90% 62%);
23
24
  --accent: hsl(249 100% 70%);
24
25
  }
25
26
 
@@ -51,23 +52,28 @@
51
52
  /* ---- 3D scene ---- */
52
53
  .scene {
53
54
  position: relative;
54
- height: 380px;
55
- perspective: 1500px;
56
- perspective-origin: 50% 34%;
55
+ height: 440px;
56
+ perspective: 1600px;
57
+ perspective-origin: 50% 30%;
57
58
  display: flex; align-items: center; justify-content: center;
58
59
  }
59
- /* the deck tilts as a whole on "explode" — both the DOM window and the
60
- anchored view ride it, so they stay in registration in the 3D side view. */
60
+ /* The deck tilts as a whole on "explode": the page and the surfaces ride it,
61
+ so they stay in registration in the 3D view. Everything on the page stays
62
+ interactive while tilted — browsers hit-test through the 3D transform. */
61
63
  .deck {
62
64
  position: relative;
63
- width: 560px; height: 300px;
65
+ width: 600px; height: 360px;
64
66
  transform-style: preserve-3d;
65
67
  transition: transform 900ms cubic-bezier(0.6, 0, 0.2, 1);
66
68
  }
67
- .scene.exploded .deck { transform: rotateX(56deg) rotateZ(-30deg); }
69
+ .scene.exploded .deck { transform: rotateX(40deg) rotateZ(-20deg); }
68
70
 
69
- /* bottom layer: renderer / DOM window (web content). Stays flat within the
70
- deck (translateZ 0); view-anchor measures the placeholder inside it. */
71
+ /* 1px markers at known deck-local points; their projected screen positions
72
+ map pointer deltas back into the (possibly tilted) deck plane. */
73
+ .probe { position: absolute; width: 1px; height: 1px; pointer-events: none; }
74
+
75
+ /* bottom layer: the web page. Flat within the deck (translateZ 0);
76
+ view-anchor measures the placeholder slots inside it. */
71
77
  .window {
72
78
  position: absolute; inset: 0;
73
79
  background: var(--renderer);
@@ -76,12 +82,6 @@
76
82
  box-shadow: 0 22px 50px hsl(0 0% 0% / 0.5);
77
83
  transform: translateZ(0);
78
84
  }
79
- .window .caption {
80
- position: absolute; left: 14px; top: 34px;
81
- font-size: 11.5px; letter-spacing: 0.03em; color: var(--text-2); pointer-events: none;
82
- }
83
- .window .caption b { color: var(--text); font-weight: 600; }
84
-
85
85
  .chrome-bar {
86
86
  position: absolute; left: 0; right: 0; top: 0; height: 26px;
87
87
  background: hsl(0 0% 10%); border-bottom: 1px solid hsl(0 0% 22%);
@@ -94,58 +94,131 @@
94
94
  radial-gradient(circle, hsl(45 80% 55%) 0 3.4px, transparent 3.8px) 14px 0/14px 8px no-repeat,
95
95
  radial-gradient(circle, hsl(135 50% 55%) 0 3.4px, transparent 3.8px) 28px 0/14px 8px no-repeat;
96
96
  }
97
+ .chrome-bar .title {
98
+ position: absolute; left: 0; right: 0; top: 5px; text-align: center;
99
+ font-size: 11px; color: var(--text-3); pointer-events: none;
100
+ }
97
101
 
98
- .dom-area { position: absolute; left: 10px; right: 10px; top: 56px; bottom: 12px; display: flex; gap: 8px; }
102
+ .dom-area { position: absolute; left: 10px; right: 10px; top: 36px; bottom: 10px; display: flex; }
99
103
  .dom-sidebar {
100
- flex: 1 1 auto; min-width: 64px;
104
+ flex: 0 0 auto; width: 132px;
101
105
  background: hsl(0 0% 14%); border: 1px solid hsl(0 0% 22%); border-radius: 6px;
102
- display: flex; flex-direction: column; gap: 6px; padding: 8px;
106
+ display: flex; flex-direction: column; gap: 7px; padding: 10px 8px;
107
+ overflow: hidden;
103
108
  }
104
- .dom-sidebar i { display: block; height: 9px; border-radius: 3px; background: hsl(0 0% 24%); }
105
- .dom-sidebar i:nth-child(2) { width: 80%; } .dom-sidebar i:nth-child(3){ width: 60%; }
106
- .dom-sidebar i:nth-child(4) { width: 72%; } .dom-sidebar i:nth-child(5){ width: 45%; }
109
+ .bar { display: block; height: 8px; border-radius: 3px; background: hsl(0 0% 24%); flex: none; }
110
+ .bar.w90 { width: 90%; } .bar.w80 { width: 80%; } .bar.w70 { width: 70%; }
111
+ .bar.w60 { width: 60%; } .bar.w45 { width: 45%; } .bar.w30 { width: 30%; }
107
112
 
108
- .placeholder {
109
- flex: 0 0 58%;
113
+ .dom-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
114
+ .page-header {
115
+ flex: none; height: 44px; overflow: hidden;
116
+ background: hsl(0 0% 14%); border: 1px solid hsl(0 0% 22%); border-radius: 6px;
117
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
118
+ padding: 5px 8px;
119
+ }
120
+ .page-header .bars { display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto; padding-top: 4px; }
121
+
122
+ .btn {
123
+ flex: none;
124
+ font: 500 10.5px/1 ui-monospace, Menlo, monospace; color: var(--text-2);
125
+ background: hsl(0 0% 20%); border: 1px solid hsl(0 0% 34%); border-radius: 4px;
126
+ padding: 4px 7px; cursor: pointer;
127
+ }
128
+ .btn:hover { border-color: var(--accent); color: var(--text); }
129
+ .btn:active { background: hsl(0 0% 26%); }
130
+
131
+ /* role="separator" matches view-anchor's default holdSelector: pressing one
132
+ keeps the followGeometry frame loop open for the whole drag. */
133
+ .splitter { flex: none; position: relative; touch-action: none; }
134
+ .splitter::after {
135
+ content: ""; position: absolute; border-radius: 2px;
136
+ background: hsl(0 0% 36%); transition: background 150ms ease;
137
+ }
138
+ .splitter:hover::after, .splitter.dragging::after { background: var(--accent); }
139
+ .splitter.v { width: 12px; cursor: col-resize; }
140
+ .splitter.v::after { left: 5px; width: 2px; top: 30%; bottom: 30%; }
141
+ .splitter.h { height: 12px; cursor: row-resize; }
142
+ .splitter.h::after { top: 5px; height: 2px; left: 30%; right: 30%; }
143
+
144
+ /* the scrolling page body; positioned so it is the slots' offsetParent */
145
+ .scroller {
110
146
  position: relative;
147
+ flex: 1 1 auto; min-height: 0; overflow-y: auto;
148
+ display: flex; flex-direction: column; gap: 8px;
149
+ padding: 8px 10px 10px;
150
+ background: hsl(0 0% 12%); border: 1px solid hsl(0 0% 20%); border-radius: 6px;
151
+ scrollbar-width: thin; scrollbar-color: hsl(0 0% 32%) transparent;
152
+ }
153
+ .slot-row {
154
+ flex: none; display: flex; align-items: center; justify-content: space-between; gap: 8px;
155
+ font-size: 10.5px; color: var(--text-3);
156
+ }
157
+
158
+ .placeholder {
159
+ flex: none; position: relative;
111
160
  border-radius: 6px;
112
161
  background: var(--placeholder);
113
162
  border: 1.5px dashed var(--placeholder-bd);
114
163
  display: flex; align-items: center; justify-content: center;
115
- /* no transition: track the slider 1:1 so the anchored view stays glued */
164
+ overflow: hidden;
165
+ /* no transition: slots move 1:1 with the page so the surfaces stay glued */
116
166
  }
117
- .placeholder .ph-tag { font-size: 11px; color: var(--placeholder-bd); text-align: center; padding: 0 8px; }
118
- .placeholder .ph-tag small { color: var(--text-3); display:block; margin-top:2px; font-size:10px; }
167
+ .placeholder.slot-a { height: 116px; }
168
+ .placeholder.slot-b { height: 36px; border-color: var(--reverse); background: hsl(35 90% 60% / 0.1); }
169
+ .placeholder .ph-tag { font-size: 10.5px; color: var(--placeholder-bd); text-align: center; padding: 0 8px; white-space: nowrap; }
170
+ .placeholder.slot-b .ph-tag { color: var(--reverse); }
119
171
 
120
- /* top layer: the anchored view. A direct child of the deck so it lives
121
- in the same 3D space; lifted in Z on explode to float above the DOM. */
122
- .follower {
172
+ /* top layer: the external surfaces. Direct children of the deck so they
173
+ live in the same 3D space; lifted in Z on explode to float above the page.
174
+ The host clips each one to the page's scroll viewport. */
175
+ .surface {
123
176
  position: absolute;
124
177
  border-radius: 6px;
125
- background: linear-gradient(180deg, hsl(160 40% 20%), hsl(160 38% 13%));
126
178
  border: 1.5px solid var(--native-bd);
179
+ background: linear-gradient(180deg, hsl(160 40% 20%), hsl(160 38% 13%));
127
180
  box-shadow: 0 0 0 1px hsl(160 72% 60% / 0.25), inset 0 0 24px hsl(160 72% 50% / 0.12);
128
- /* position/size snap each frame — only the
129
- explode lift + collapse fade animate; otherwise tracking rubber-bands. */
130
- transition: transform 900ms cubic-bezier(0.6,0,0.2,1), box-shadow 900ms, opacity 300ms;
181
+ /* position/size snap each frame — only the explode lift and the fade
182
+ animate; otherwise tracking rubber-bands. */
183
+ transition: transform 900ms cubic-bezier(0.6,0,0.2,1), box-shadow 900ms, opacity 250ms;
131
184
  overflow: hidden;
132
- display: flex; align-items: center; justify-content: center;
133
185
  transform: translateZ(0.5px);
186
+ /* never swallow presses meant for the page underneath; only surface B's
187
+ own buttons take input */
188
+ pointer-events: none;
189
+ }
190
+ .surface.a { display: flex; align-items: center; justify-content: center; }
191
+ .surface .label { font-size: 11px; color: var(--native-bd); letter-spacing: 0.02em; text-align: center; }
192
+ .surface .label small { display: block; color: hsl(160 30% 62%); font-size: 10px; margin-top: 2px; }
193
+ .surface.b {
194
+ border-color: var(--reverse);
195
+ background: linear-gradient(180deg, hsl(35 40% 20%), hsl(35 34% 13%));
196
+ box-shadow: 0 0 0 1px hsl(35 90% 60% / 0.25), inset 0 0 24px hsl(35 90% 50% / 0.1);
134
197
  }
135
- .follower .nv-label { font-size: 11px; color: var(--native-bd); letter-spacing: 0.02em; text-align:center; }
136
- .follower .nv-label small { display:block; color: hsl(160 30% 62%); font-size:10px; margin-top:2px; }
137
- .follower .row-marker {
138
- position:absolute; left:0; right:0; top:0; height:18px; background: hsl(160 30% 22% / 0.55);
139
- border-bottom:1px solid hsl(160 50% 40% / 0.4);
198
+ /* surface B's own content: shrink-to-fit on the block axis, so its height
199
+ is what createSizeAnchor reports back to the page. */
200
+ .b-content { position: absolute; left: 0; right: 0; top: 0; padding: 5px 6px 6px; }
201
+ .b-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 10.5px; color: var(--reverse); }
202
+ .b-head .btn { pointer-events: auto; padding: 2px 7px; color: var(--reverse); border-color: hsl(35 80% 55% / 0.5); background: hsl(35 50% 22%); }
203
+ .b-items { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
204
+ .b-items:empty { display: none; }
205
+ .b-items b {
206
+ display: block; height: 16px; padding: 0 6px; border-radius: 4px;
207
+ font: 500 9.5px/16px ui-monospace, Menlo, monospace; color: hsl(35 90% 78%);
208
+ background: hsl(35 60% 30% / 0.55); border: 1px solid hsl(35 80% 55% / 0.4);
140
209
  }
141
- .scene.exploded .follower {
142
- transform: translateZ(120px);
210
+ .scene.exploded .surface {
211
+ transform: translateZ(90px);
143
212
  box-shadow: 0 0 0 1px hsl(160 72% 60% / 0.4), 0 30px 60px hsl(0 0% 0% / 0.55),
144
213
  0 0 44px hsl(160 72% 50% / 0.22);
145
214
  }
146
- .follower.collapsed { opacity: 0 !important; }
215
+ .scene.exploded .surface.b {
216
+ box-shadow: 0 0 0 1px hsl(35 90% 60% / 0.4), 0 30px 60px hsl(0 0% 0% / 0.55),
217
+ 0 0 44px hsl(35 90% 50% / 0.2);
218
+ }
219
+ .surface.off { opacity: 0 !important; }
147
220
 
148
- /* connector pillar standing along Z from the placeholder up to the view */
221
+ /* connector pillars standing along Z from each slot up to its surface */
149
222
  .pillar {
150
223
  position: absolute; width: 0;
151
224
  border-left: 1.5px dashed var(--native-bd);
@@ -154,11 +227,13 @@
154
227
  opacity: 0; transition: opacity 400ms ease;
155
228
  pointer-events: none;
156
229
  }
230
+ .pillar.b { border-left-color: var(--reverse); }
157
231
  .scene.exploded .pillar { opacity: 0.5; }
232
+ .pillar.off { opacity: 0 !important; }
158
233
 
159
234
  /* ---- controls ---- */
160
235
  .controls {
161
- display: flex; flex-wrap: wrap; align-items: center; gap: 18px;
236
+ display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px;
162
237
  margin-top: 22px; padding-top: 18px; border-top: 1px solid hsl(0 0% 17%);
163
238
  }
164
239
  .ctl { display: flex; align-items: center; gap: 9px; font-size: 12.5px; color: var(--text-2); }
@@ -171,15 +246,15 @@
171
246
  .switch input:checked ~ .track { background: var(--accent); }
172
247
  .switch input:checked ~ .thumb { transform: translateX(18px); }
173
248
 
174
- input[type=range] { accent-color: var(--accent); width: 150px; }
175
-
176
249
  .readout {
177
- margin-left: auto;
178
- font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--wire);
250
+ margin-top: 16px;
251
+ font-family: ui-monospace, Menlo, monospace; font-size: 12px; line-height: 1.7; color: var(--wire);
179
252
  background: hsl(160 40% 12% / 0.5); border: 1px solid hsl(160 50% 40% / 0.35);
180
- border-radius: 8px; padding: 8px 12px; min-width: 232px; text-align: center;
253
+ border-radius: 8px; padding: 8px 12px;
254
+ overflow-x: auto; white-space: pre;
181
255
  }
182
256
  .readout .k { color: var(--text-3); }
257
+ .readout .rev { color: var(--reverse); }
183
258
 
184
259
  .legend { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 18px; font-size: 12px; color: var(--text-2); }
185
260
  .legend span { display: inline-flex; align-items: center; gap: 7px; }
@@ -187,6 +262,7 @@
187
262
  .sw.r { background: var(--renderer); border: 1.5px solid var(--renderer-bd); }
188
263
  .sw.p { background: var(--placeholder); border: 1.5px dashed var(--placeholder-bd); }
189
264
  .sw.n { background: var(--native); border: 1.5px solid var(--native-bd); }
265
+ .sw.t { background: hsl(35 40% 18%); border: 1.5px solid var(--reverse); }
190
266
 
191
267
  footer.note { margin-top: 22px; color: var(--text-3); font-size: 12px; line-height: 1.6; }
192
268
  footer.note code { color: var(--text-2); }
@@ -200,6 +276,8 @@
200
276
  .actions a:hover { color: var(--text); border-bottom-color: var(--text-2); }
201
277
  footer.note { display: flex; flex-direction: column; gap: 10px; }
202
278
  footer.note .hint { color: var(--text-3); font-size: 12.5px; }
279
+ footer.note .hint ul { margin: 6px 0 0; padding-left: 18px; }
280
+ footer.note .hint li { margin: 3px 0; }
203
281
 
204
282
  </style>
205
283
  </head>
@@ -210,10 +288,9 @@
210
288
  <img alt="npm version" src="https://img.shields.io/npm/v/view-anchor">
211
289
  <img alt="npm downloads" src="https://img.shields.io/npm/dm/view-anchor">
212
290
  <img alt="License" src="https://img.shields.io/npm/l/view-anchor">
213
- <img alt="Node" src="https://img.shields.io/badge/node-%3E%3D24-339933">
214
291
  </div>
215
292
  <h1>view-anchor: interactive 3D demo</h1>
216
- <p class="tag-en">Align external surfaces (native views, canvases, iframes) to DOM elements in real time. Drag the splitter or toggle controls below: the green surface is positioned live by view-anchor.</p>
293
+ <p class="tag-en">Keep external surfaces (native views, canvases, iframes) aligned with DOM placeholders, and let a surface report its content size back to the page. The page below stays live while tilted: drag, scroll and click it directly.</p>
217
294
  <div class="actions">
218
295
  <a href="https://github.com/lbb00/view-anchor">GitHub</a>
219
296
  <a href="https://www.npmjs.com/package/view-anchor">npm</a>
@@ -224,25 +301,62 @@
224
301
  <div class="stage-shell">
225
302
  <div class="scene" id="scene">
226
303
  <div class="deck" id="deck">
227
- <!-- bottom layer: the renderer / DOM window -->
304
+ <i class="probe" id="probe-o" style="left:0;top:0"></i>
305
+ <i class="probe" id="probe-x" style="left:100px;top:0"></i>
306
+ <i class="probe" id="probe-y" style="left:0;top:100px"></i>
307
+
308
+ <!-- bottom layer: the web page -->
228
309
  <div class="window">
229
- <div class="chrome-bar"><div class="traffic"></div></div>
230
- <div class="caption"><b>Renderer DOM</b></div>
310
+ <div class="chrome-bar"><div class="traffic"></div><div class="title">web page</div></div>
231
311
  <div class="dom-area">
232
- <div class="dom-sidebar"><i></i><i></i><i></i><i></i><i></i></div>
233
- <div class="placeholder" id="placeholder">
234
- <div class="ph-tag">&lt;div ref={anchorRef} /&gt;</div>
312
+ <div class="dom-sidebar" id="sidebar">
313
+ <i class="bar w80"></i><i class="bar w60"></i><i class="bar w70"></i>
314
+ <i class="bar w45"></i><i class="bar w80"></i><i class="bar w30"></i>
315
+ </div>
316
+ <div class="splitter v" id="split-side" role="separator" aria-orientation="vertical" aria-label="Resize sidebar"></div>
317
+ <div class="dom-main">
318
+ <div class="page-header" id="page-header">
319
+ <div class="bars"><i class="bar w45"></i><i class="bar w70"></i><i class="bar w30"></i></div>
320
+ <button class="btn" id="collapse" type="button">collapse</button>
321
+ </div>
322
+ <div class="splitter h" id="split-header" role="separator" aria-orientation="horizontal" aria-label="Resize header"></div>
323
+ <div class="scroller" id="scroller">
324
+ <i class="bar w90"></i><i class="bar w70"></i>
325
+ <div class="placeholder slot-b" id="slot-b">
326
+ <div class="ph-tag">slot B · height set by surface B</div>
327
+ </div>
328
+ <i class="bar w80"></i><i class="bar w60"></i>
329
+ <div class="slot-row">
330
+ <span>slot A</span>
331
+ <button class="btn" id="hide-a" type="button">hide A</button>
332
+ </div>
333
+ <div class="placeholder slot-a" id="slot-a">
334
+ <div class="ph-tag">&lt;div ref={anchorRef} /&gt;</div>
335
+ </div>
336
+ <i class="bar w90"></i><i class="bar w70"></i><i class="bar w80"></i>
337
+ <i class="bar w45"></i><i class="bar w90"></i><i class="bar w60"></i>
338
+ <i class="bar w80"></i><i class="bar w30"></i>
339
+ </div>
235
340
  </div>
236
341
  </div>
237
342
  </div>
238
343
 
239
- <!-- connector pillar: from the placeholder up to the anchored view along Z -->
240
- <div class="pillar" id="pillar"></div>
344
+ <!-- connector pillars: from each slot up to its surface along Z -->
345
+ <div class="pillar" id="pillar-a"></div>
346
+ <div class="pillar b" id="pillar-b"></div>
241
347
 
242
- <!-- top layer: the anchored view, driven by view-anchor -->
243
- <div class="follower" id="follower">
244
- <div class="row-marker"></div>
245
- <div class="nv-label">Anchored view</div>
348
+ <!-- top layer: the external surfaces, positioned by view-anchor -->
349
+ <div class="surface a" id="surface-a">
350
+ <div class="label">Surface A<small>DOM → surface</small></div>
351
+ </div>
352
+ <div class="surface b" id="surface-b">
353
+ <div class="b-content" id="b-content">
354
+ <div class="b-head">
355
+ <span>Surface B · surface → DOM</span>
356
+ <span><button class="btn" id="b-less" type="button">−</button> <button class="btn" id="b-more" type="button">+</button></span>
357
+ </div>
358
+ <div class="b-items" id="b-items"></div>
359
+ </div>
246
360
  </div>
247
361
  </div>
248
362
  </div>
@@ -250,29 +364,41 @@
250
364
  <div class="controls">
251
365
  <div class="ctl">
252
366
  <span class="switch">
253
- <input type="checkbox" id="present" checked>
367
+ <input type="checkbox" id="visible" checked>
368
+ <span class="track"></span><span class="thumb"></span>
369
+ </span>
370
+ <label for="visible"><code>visible</code></label>
371
+ </div>
372
+ <div class="ctl">
373
+ <span class="switch">
374
+ <input type="checkbox" id="follow-geometry" checked>
254
375
  <span class="track"></span><span class="thumb"></span>
255
376
  </span>
256
- <label for="present">Mount (<code>present</code>)</label>
377
+ <label for="follow-geometry"><code>followGeometry</code></label>
257
378
  </div>
258
379
  <div class="ctl">
259
- <label for="split">Split</label>
260
- <input type="range" id="split" min="34" max="78" value="58">
380
+ <span class="switch">
381
+ <input type="checkbox" id="follow-scroll" checked>
382
+ <span class="track"></span><span class="thumb"></span>
383
+ </span>
384
+ <label for="follow-scroll"><code>followScroll</code></label>
261
385
  </div>
262
386
  <div class="ctl">
263
387
  <span class="switch">
264
388
  <input type="checkbox" id="explode" checked>
265
389
  <span class="track"></span><span class="thumb"></span>
266
390
  </span>
267
- <label for="explode">3D Explode</label>
391
+ <label for="explode">3D view</label>
268
392
  </div>
269
- <div class="readout" id="readout"></div>
270
393
  </div>
271
394
 
395
+ <div class="readout" id="readout"></div>
396
+
272
397
  <div class="legend">
273
- <span><span class="sw r"></span> DOM window</span>
274
- <span><span class="sw p"></span> Placeholder div</span>
275
- <span><span class="sw n"></span> Anchored view</span>
398
+ <span><span class="sw r"></span> Web page</span>
399
+ <span><span class="sw p"></span> Placeholder slot</span>
400
+ <span><span class="sw n"></span> Surface A (DOM → surface)</span>
401
+ <span><span class="sw t"></span> Surface B (surface → DOM)</span>
276
402
  </div>
277
403
  </div>
278
404
 
@@ -282,17 +408,27 @@
282
408
  <a href="https://www.npmjs.com/package/view-anchor">npm · view-anchor</a>
283
409
  <span style="color:var(--text-3)">MIT licensed</span>
284
410
  </div>
285
- <div class="hint">Drag <b>Split</b> to resize the placeholder, or toggle <b>3D Explode</b> to inspect the Z-space layering. The green overlay tracks the layout synchronously via the <code>publish</code> callback.</div>
411
+ <div class="hint">
412
+ Each surface follows its slot through <code>createViewAnchor</code>. Everything below happens on the page itself, tilted or flat:
413
+ <ul>
414
+ <li><b>Drag the vertical splitter.</b> Both slots resize, so a <code>ResizeObserver</code> sees it. Surface B's items rewrap, and <code>createSizeAnchor</code> reports the new height back to the page.</li>
415
+ <li><b>Drag the header's lower edge.</b> The slots move but keep their size, so no observer fires. Pressing a <code>role="separator"</code> (the default <code>holdSelector</code>) keeps the <code>followGeometry</code> frame loop open for the whole drag. Turn <code>followGeometry</code> off and try again.</li>
416
+ <li><b>Scroll the page body.</b> <code>followScroll</code> re-measures on ancestor scrolls; the page clips each surface to the scroll viewport. Turn <code>followScroll</code> off and scroll again.</li>
417
+ <li><b>Click <code>collapse</code>.</b> The page moves the slots itself, so it calls <code>pulse()</code> to re-measure on the next frames.</li>
418
+ <li><b>Click <code>+</code> / <code>−</code> on surface B.</b> Its content changes height; the page resizes slot B. Slot A moves without resizing, so the page calls <code>pulse()</code> on A.</li>
419
+ <li><b>Click <code>hide A</code>.</b> The slot gets <code>display: none</code>; <code>treatZeroAreaAsHidden</code> publishes <code>{ visible: false }</code> instead of a 0×0 rect.</li>
420
+ </ul>
421
+ </div>
286
422
  </footer>
287
423
 
288
424
  </div>
289
425
 
290
- <!-- Bootstrapped from src/view-anchor.ts by `pnpm build:docs` and inlined here
291
- as an esbuild IIFE — this block is GENERATED, do not hand-edit. Inlined
292
- rather than imported so the page can open from file://, where browsers
293
- block cross-origin ES module imports. -->
426
+ <!-- Bootstrapped from src/view-anchor.ts and src/size-anchor.ts by
427
+ `pnpm build:docs` and inlined here as an IIFE — this block is GENERATED,
428
+ do not hand-edit. Inlined rather than imported so the page can open from
429
+ file://, where browsers block cross-origin ES module imports. -->
294
430
  <script>
295
- /* __VIEW_ANCHOR_CORE_START__ — generated from src/view-anchor.ts by `pnpm build:docs`; do not edit */
431
+ /* __VIEW_ANCHOR_CORE_START__ — generated from src/view-anchor.ts and src/size-anchor.ts by `pnpm build:docs`; do not edit */
296
432
  var __viewAnchorCore = (function(exports) {
297
433
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
298
434
  //#region src/abort.ts
@@ -306,13 +442,8 @@ var __viewAnchorCore = (function(exports) {
306
442
  }
307
443
  //#endregion
308
444
  //#region src/view-anchor.ts
309
- const ZERO = {
310
- x: 0,
311
- y: 0,
312
- width: 0,
313
- height: 0
314
- };
315
- const NOOP_PUBLISH = () => false;
445
+ const NOOP_PUBLISH$1 = () => false;
446
+ const DEFAULT_HOLD_SELECTOR = "[role=\"separator\"]";
316
447
  const clampRect = (r) => ({
317
448
  x: Math.round(r.x),
318
449
  y: Math.round(r.y),
@@ -320,110 +451,6 @@ var __viewAnchorCore = (function(exports) {
320
451
  height: Math.max(0, Math.round(r.height))
321
452
  });
322
453
  /**
323
- * Bind a native view or external surface to the geometry of `target`.
324
- *
325
- * - `present === true`: measures `target.getBoundingClientRect()` and publishes
326
- * immediately, then re-measures synchronously on ResizeObserver and window resize.
327
- * - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
328
- * and stops observing.
329
- * - `update(opts)`: re-applies options immediately.
330
- * - `dispose()`: stops observing and prevents any further publishes.
331
- *
332
- * Synchronous publishing: measurement and publishing occur directly in the
333
- * observer tick. Applying geometry outside the DOM may already be delayed;
334
- * adding requestAnimationFrame would add another frame of visual lag during drag
335
- * operations. High-frequency updates are deduplicated against the last accepted rect.
336
- */
337
- function createViewAnchor(target, opts) {
338
- let present = opts.present;
339
- let publish = opts.publish;
340
- let observer = null;
341
- let targetRef = target;
342
- let lastPublished = null;
343
- let publicationRevision = 0;
344
- let disposed = false;
345
- const measure = () => {
346
- const r = targetRef.getBoundingClientRect();
347
- if (!Number.isFinite(r.left) || !Number.isFinite(r.top) || !Number.isFinite(r.width) || !Number.isFinite(r.height)) return null;
348
- return clampRect({
349
- x: r.left,
350
- y: r.top,
351
- width: r.width,
352
- height: r.height
353
- });
354
- };
355
- const sameRect = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
356
- const publishCandidate = (candidate) => {
357
- const previous = lastPublished;
358
- const attempt = ++publicationRevision;
359
- lastPublished = candidate;
360
- try {
361
- const accepted = publish(candidate) !== false;
362
- if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous;
363
- return accepted;
364
- } catch (error) {
365
- if (publicationRevision === attempt && !disposed) lastPublished = previous;
366
- throw error;
367
- }
368
- };
369
- const emit = () => {
370
- if (disposed || !present) return;
371
- const m = measure();
372
- if (!m) return;
373
- if (lastPublished && sameRect(lastPublished, m)) return;
374
- publishCandidate(m);
375
- };
376
- const startObserving = () => {
377
- if (observer) return;
378
- observer = new ResizeObserver(emit);
379
- observer.observe(targetRef);
380
- window.addEventListener("resize", emit);
381
- };
382
- const stopObserving = () => {
383
- if (observer) {
384
- observer.disconnect();
385
- observer = null;
386
- }
387
- window.removeEventListener("resize", emit);
388
- };
389
- const apply = () => {
390
- lastPublished = null;
391
- if (present) {
392
- startObserving();
393
- const measured = measure();
394
- if (measured) publishCandidate(measured);
395
- } else {
396
- stopObserving();
397
- publishCandidate(ZERO);
398
- }
399
- };
400
- let removeAbortListener = () => {};
401
- const dispose = () => {
402
- if (disposed) return;
403
- disposed = true;
404
- removeAbortListener();
405
- removeAbortListener = () => {};
406
- stopObserving();
407
- targetRef = null;
408
- publish = NOOP_PUBLISH;
409
- lastPublished = null;
410
- };
411
- if (opts.signal?.aborted) dispose();
412
- else {
413
- removeAbortListener = watchAbort(opts.signal, dispose);
414
- apply();
415
- }
416
- return {
417
- update(next) {
418
- if (disposed) return;
419
- publish = next.publish;
420
- present = next.present;
421
- apply();
422
- },
423
- dispose
424
- };
425
- }
426
- /**
427
454
  * Read target's current rect and return { visible: true, bounds }.
428
455
  * Does not infer visibility from dimensions.
429
456
  */
@@ -445,17 +472,26 @@ var __viewAnchorCore = (function(exports) {
445
472
  return true;
446
473
  };
447
474
  /**
448
- * Explicit-visibility variant of createViewAnchor.
475
+ * Bind a native view or external surface to the geometry of `target`.
476
+ *
477
+ * When `visible === true`, publishes { visible: true, bounds } immediately,
478
+ * then re-measures synchronously on ResizeObserver and window resize.
479
+ * When `visible === false`, publishes { visible: false } and stops observing.
480
+ * Publishes are deduplicated against the last accepted Placement by default
481
+ * (see the `dedupe` option).
449
482
  */
450
- function createPlacementAnchor(target, opts) {
483
+ function createViewAnchor(target, opts) {
484
+ if (opts.holdSelector) target.matches(opts.holdSelector);
451
485
  let visible = opts.visible;
452
486
  let publish = opts.publish;
453
- let guardDisplayNone = opts.guardDisplayNone ?? false;
487
+ let treatZeroAreaAsHidden = opts.treatZeroAreaAsHidden ?? false;
454
488
  let followScroll = opts.followScroll ?? false;
455
489
  let followGeometry = opts.followGeometry ?? false;
490
+ let holdSelector = opts.holdSelector === void 0 ? DEFAULT_HOLD_SELECTOR : opts.holdSelector;
491
+ let dedupe = opts.dedupe ?? true;
456
492
  let targetRef = target;
457
493
  let observer = null;
458
- let io = null;
494
+ let intersectionObserver = null;
459
495
  let scrollListening = false;
460
496
  let geometryListening = false;
461
497
  const capture = { capture: true };
@@ -471,17 +507,18 @@ var __viewAnchorCore = (function(exports) {
471
507
  const STEADY_CLOSE_FRAMES = 2;
472
508
  const MAX_HIDDEN_FOLLOW_FRAMES = 30;
473
509
  const MAX_INVALID_FOLLOW_FRAMES = 30;
510
+ let hiddenFrames = 0;
474
511
  let invalidFrames = 0;
475
- let pointerHeld = false;
476
- let activePointerId = null;
477
- let sentinelDeadline = null;
478
- const computePlacement = () => {
512
+ const heldPointerIds = /* @__PURE__ */ new Set();
513
+ let lastFramePlacement = null;
514
+ let followDeadline = null;
515
+ const measureTarget = () => {
479
516
  const p = measurePlacement(targetRef);
480
517
  if (p.visible && (!Number.isFinite(p.bounds.x) || !Number.isFinite(p.bounds.y) || !Number.isFinite(p.bounds.width) || !Number.isFinite(p.bounds.height))) return null;
481
- if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) return { visible: false };
518
+ if (treatZeroAreaAsHidden && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) return { visible: false };
482
519
  return p;
483
520
  };
484
- const publishCandidate = (candidate) => {
521
+ const publishPlacement = (candidate) => {
485
522
  const previous = lastPublished;
486
523
  const attempt = ++publicationRevision;
487
524
  lastPublished = candidate;
@@ -494,117 +531,110 @@ var __viewAnchorCore = (function(exports) {
494
531
  throw error;
495
532
  }
496
533
  };
497
- const emit = () => {
534
+ const measureAndPublish = () => {
498
535
  if (disposed || !visible) return;
499
- const p = computePlacement();
536
+ const p = measureTarget();
500
537
  if (!p) return;
501
- if (lastPublished && samePlacement(lastPublished, p)) return;
502
- publishCandidate(p);
538
+ if (dedupe && lastPublished && samePlacement(lastPublished, p)) return;
539
+ publishPlacement(p);
503
540
  };
504
- const shouldCloseOnHiddenPoll = () => {
505
- if (lastPublished?.visible === false) return true;
506
- return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
541
+ const scheduleFollowFrame = () => {
542
+ if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(followFrame);
543
+ else followDeadline = null;
507
544
  };
508
- const sentinelFrame = () => {
545
+ const followFrame = () => {
509
546
  rafId = null;
510
- if (disposed || !visible) {
511
- sentinelDeadline = null;
512
- return;
513
- }
514
- if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
515
- sentinelDeadline = null;
547
+ if (disposed || !visible || heldPointerIds.size === 0 && followDeadline !== null && performance.now() >= followDeadline) {
548
+ followDeadline = null;
516
549
  return;
517
550
  }
518
- const p = computePlacement();
551
+ const p = measureTarget();
519
552
  if (!p) {
520
- if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
521
- sentinelDeadline = null;
522
- return;
523
- }
524
- if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(sentinelFrame);
525
- else sentinelDeadline = null;
553
+ if (++invalidFrames >= MAX_INVALID_FOLLOW_FRAMES) followDeadline = null;
554
+ else scheduleFollowFrame();
526
555
  return;
527
556
  }
528
557
  invalidFrames = 0;
529
558
  if (!p.visible) {
530
- if (shouldCloseOnHiddenPoll()) {
531
- sentinelDeadline = null;
532
- return;
533
- }
534
- if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(sentinelFrame);
535
- else sentinelDeadline = null;
559
+ if (lastPublished?.visible === false || ++hiddenFrames >= MAX_HIDDEN_FOLLOW_FRAMES) followDeadline = null;
560
+ else scheduleFollowFrame();
536
561
  return;
537
562
  }
538
- if (lastPublished && samePlacement(lastPublished, p)) {
539
- steadyFrames++;
540
- if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
541
- sentinelDeadline = null;
563
+ const steadyFrame = lastFramePlacement !== null && samePlacement(lastFramePlacement, p);
564
+ lastFramePlacement = p;
565
+ if (!dedupe || !lastPublished || !samePlacement(lastPublished, p)) publishPlacement(p);
566
+ if (steadyFrame) {
567
+ if (++steadyFrames >= STEADY_CLOSE_FRAMES && heldPointerIds.size === 0) {
568
+ followDeadline = null;
542
569
  return;
543
570
  }
544
571
  } else {
545
- publishCandidate(p);
546
572
  steadyFrames = 0;
573
+ hiddenFrames = 0;
547
574
  }
548
- if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(sentinelFrame);
549
- else sentinelDeadline = null;
575
+ scheduleFollowFrame();
550
576
  };
551
- const openSentinel = () => {
577
+ const startFrameFollow = () => {
552
578
  if (!followGeometry || disposed) return;
553
579
  steadyFrames = 0;
580
+ hiddenFrames = 0;
581
+ lastFramePlacement = lastPublished;
554
582
  if (rafId === null) {
555
583
  invalidFrames = 0;
556
- rafId = requestAnimationFrame(sentinelFrame);
584
+ rafId = requestAnimationFrame(followFrame);
557
585
  }
558
586
  };
559
- const closeSentinel = () => {
587
+ const stopFrameFollow = () => {
560
588
  if (rafId !== null) {
561
589
  cancelAnimationFrame(rafId);
562
590
  rafId = null;
563
591
  }
564
592
  steadyFrames = 0;
593
+ hiddenFrames = 0;
565
594
  invalidFrames = 0;
566
- sentinelDeadline = null;
567
- pointerHeld = false;
568
- activePointerId = null;
595
+ lastFramePlacement = lastPublished;
596
+ followDeadline = null;
597
+ heldPointerIds.clear();
569
598
  };
570
599
  const onScroll = () => {
571
- if (followGeometry) openSentinel();
572
- else emit();
600
+ if (followGeometry) startFrameFollow();
601
+ else measureAndPublish();
573
602
  };
574
603
  const onPointerDown = (e) => {
575
604
  const t = e.target;
576
- if (t && t.closest && t.closest("[role=\"separator\"]")) {
577
- if (!pointerHeld) activePointerId = e.pointerId;
578
- pointerHeld = true;
579
- openSentinel();
605
+ if (holdSelector && t && t.closest && t.closest(holdSelector)) {
606
+ if (heldPointerIds.size === 0) followDeadline = null;
607
+ heldPointerIds.add(e.pointerId);
608
+ startFrameFollow();
580
609
  }
581
610
  };
582
- const releasePointer = (e) => {
583
- if (!pointerHeld) return;
584
- if (e && activePointerId !== e.pointerId) return;
585
- pointerHeld = false;
586
- activePointerId = null;
587
- openSentinel();
611
+ const releasePointer = (pointerId) => {
612
+ if (heldPointerIds.size === 0) return;
613
+ if (pointerId !== void 0 && !heldPointerIds.delete(pointerId)) return;
614
+ if (pointerId === void 0) heldPointerIds.clear();
615
+ if (heldPointerIds.size > 0) return;
616
+ followDeadline = null;
617
+ startFrameFollow();
588
618
  };
589
619
  const onPointerUp = (e) => {
590
- releasePointer(e);
620
+ releasePointer(e.pointerId);
591
621
  };
592
622
  const onPointerCancel = (e) => {
593
- releasePointer(e);
623
+ releasePointer(e.pointerId);
594
624
  };
595
625
  const onWindowBlur = () => {
596
626
  releasePointer();
597
627
  };
598
628
  const startOptionalObserving = () => {
599
- if (guardDisplayNone && !io && typeof IntersectionObserver !== "undefined") {
600
- io = new IntersectionObserver(emit);
601
- io.observe(targetRef);
629
+ if (treatZeroAreaAsHidden && !intersectionObserver && typeof IntersectionObserver !== "undefined") {
630
+ intersectionObserver = new IntersectionObserver(measureAndPublish);
631
+ intersectionObserver.observe(targetRef);
602
632
  }
603
633
  if (followScroll && !scrollListening) {
604
634
  window.addEventListener("scroll", onScroll, passiveCapture);
605
635
  scrollListening = true;
606
636
  }
607
- if (followGeometry && !geometryListening) {
637
+ if (followGeometry && holdSelector && !geometryListening) {
608
638
  window.addEventListener("pointerdown", onPointerDown, capture);
609
639
  window.addEventListener("pointerup", onPointerUp, capture);
610
640
  window.addEventListener("pointercancel", onPointerCancel, capture);
@@ -612,47 +642,38 @@ var __viewAnchorCore = (function(exports) {
612
642
  geometryListening = true;
613
643
  }
614
644
  };
615
- const stopOptionalObserving = () => {
616
- if (io && !guardDisplayNone) {
617
- io.disconnect();
618
- io = null;
619
- }
620
- if (scrollListening && !followScroll) {
621
- window.removeEventListener("scroll", onScroll, passiveCapture);
622
- scrollListening = false;
623
- }
624
- if (geometryListening && !followGeometry) {
625
- window.removeEventListener("pointerdown", onPointerDown, capture);
626
- window.removeEventListener("pointerup", onPointerUp, capture);
627
- window.removeEventListener("pointercancel", onPointerCancel, capture);
628
- window.removeEventListener("blur", onWindowBlur);
629
- geometryListening = false;
630
- closeSentinel();
631
- }
645
+ const stopIntersection = () => {
646
+ if (!intersectionObserver) return;
647
+ intersectionObserver.disconnect();
648
+ intersectionObserver = null;
632
649
  };
633
- const stopAllOptionalObserving = () => {
634
- if (io) {
635
- io.disconnect();
636
- io = null;
637
- }
638
- if (scrollListening) {
639
- window.removeEventListener("scroll", onScroll, passiveCapture);
640
- scrollListening = false;
641
- }
642
- if (geometryListening) {
643
- window.removeEventListener("pointerdown", onPointerDown, capture);
644
- window.removeEventListener("pointerup", onPointerUp, capture);
645
- window.removeEventListener("pointercancel", onPointerCancel, capture);
646
- window.removeEventListener("blur", onWindowBlur);
647
- geometryListening = false;
650
+ const stopScroll = () => {
651
+ if (!scrollListening) return;
652
+ window.removeEventListener("scroll", onScroll, passiveCapture);
653
+ scrollListening = false;
654
+ };
655
+ const stopGeometryListeners = () => {
656
+ if (!geometryListening) return;
657
+ window.removeEventListener("pointerdown", onPointerDown, capture);
658
+ window.removeEventListener("pointerup", onPointerUp, capture);
659
+ window.removeEventListener("pointercancel", onPointerCancel, capture);
660
+ window.removeEventListener("blur", onWindowBlur);
661
+ geometryListening = false;
662
+ };
663
+ const stopDisabledObserving = () => {
664
+ if (!treatZeroAreaAsHidden) stopIntersection();
665
+ if (!followScroll) stopScroll();
666
+ if ((!followGeometry || !holdSelector) && geometryListening) {
667
+ stopGeometryListeners();
668
+ heldPointerIds.clear();
648
669
  }
649
- closeSentinel();
670
+ if (!followGeometry) stopFrameFollow();
650
671
  };
651
672
  const startObserving = () => {
652
673
  if (observer) return;
653
- observer = new ResizeObserver(emit);
674
+ observer = new ResizeObserver(measureAndPublish);
654
675
  observer.observe(targetRef);
655
- window.addEventListener("resize", emit);
676
+ window.addEventListener("resize", measureAndPublish);
656
677
  startOptionalObserving();
657
678
  };
658
679
  const stopObserving = () => {
@@ -660,18 +681,21 @@ var __viewAnchorCore = (function(exports) {
660
681
  observer.disconnect();
661
682
  observer = null;
662
683
  }
663
- window.removeEventListener("resize", emit);
664
- stopAllOptionalObserving();
684
+ window.removeEventListener("resize", measureAndPublish);
685
+ stopIntersection();
686
+ stopScroll();
687
+ stopGeometryListeners();
688
+ stopFrameFollow();
665
689
  };
666
- const apply = () => {
690
+ const applyOptions = () => {
667
691
  lastPublished = null;
668
692
  if (visible) {
669
693
  startObserving();
670
- const placement = computePlacement();
671
- if (placement) publishCandidate(placement);
694
+ const placement = measureTarget();
695
+ if (placement) publishPlacement(placement);
672
696
  } else {
673
697
  stopObserving();
674
- publishCandidate({ visible: false });
698
+ publishPlacement({ visible: false });
675
699
  }
676
700
  };
677
701
  let removeAbortListener = () => {};
@@ -682,41 +706,149 @@ var __viewAnchorCore = (function(exports) {
682
706
  removeAbortListener = () => {};
683
707
  stopObserving();
684
708
  targetRef = null;
685
- publish = NOOP_PUBLISH;
709
+ publish = NOOP_PUBLISH$1;
686
710
  lastPublished = null;
687
711
  };
688
712
  if (opts.signal?.aborted) dispose();
689
713
  else {
690
714
  removeAbortListener = watchAbort(opts.signal, dispose);
691
- apply();
715
+ try {
716
+ applyOptions();
717
+ } catch (error) {
718
+ dispose();
719
+ throw error;
720
+ }
692
721
  }
693
722
  return {
694
723
  update(next) {
695
724
  if (disposed) return;
725
+ if (next.holdSelector) targetRef.matches(next.holdSelector);
696
726
  publish = next.publish;
697
727
  visible = next.visible;
698
- guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone;
699
- followScroll = next.followScroll ?? followScroll;
700
- followGeometry = next.followGeometry ?? followGeometry;
728
+ treatZeroAreaAsHidden = next.treatZeroAreaAsHidden ?? false;
729
+ followScroll = next.followScroll ?? false;
730
+ followGeometry = next.followGeometry ?? false;
731
+ holdSelector = next.holdSelector === void 0 ? DEFAULT_HOLD_SELECTOR : next.holdSelector;
732
+ dedupe = next.dedupe ?? true;
701
733
  if (visible && observer) {
702
- stopOptionalObserving();
734
+ stopDisabledObserving();
703
735
  startOptionalObserving();
704
736
  }
705
- apply();
737
+ applyOptions();
706
738
  },
707
739
  dispose,
708
740
  pulse(durationMs) {
709
741
  if (disposed || !followGeometry) return;
710
742
  if (durationMs !== void 0 && durationMs > 0) {
711
743
  const next = performance.now() + durationMs;
712
- sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next);
744
+ followDeadline = followDeadline === null ? next : Math.max(followDeadline, next);
713
745
  }
714
- openSentinel();
746
+ startFrameFollow();
715
747
  }
716
748
  };
717
749
  }
718
750
  //#endregion
719
- exports.createPlacementAnchor = createPlacementAnchor;
751
+ //#region src/size-anchor.ts
752
+ const NOOP_PUBLISH = () => false;
753
+ /**
754
+ * Report content size for a single axis back to the host.
755
+ *
756
+ * Runs in a downstream document, reads the content size from
757
+ * `ResizeObserverEntry.borderBoxSize` without triggering reflow, and
758
+ * publishes at most once per animation frame, skipping unchanged extents by
759
+ * default (see the `dedupe` option).
760
+ *
761
+ * Measurements are rounded to integer pixels and clamped to >= 0.
762
+ *
763
+ * `target` must be a shrink-to-fit wrapper on the owned axis.
764
+ * If its size is driven by the host view itself (such as `<body>` or `<html>`),
765
+ * updates will not shrink back to content size.
766
+ */
767
+ function createSizeAnchor(target, opts) {
768
+ const axis = opts.axis;
769
+ let dedupe = opts.dedupe ?? true;
770
+ let publish = opts.publish;
771
+ let observer = null;
772
+ let rafId = null;
773
+ let disposed = false;
774
+ let latestBoxSize = null;
775
+ let lastPublished = null;
776
+ let publicationRevision = 0;
777
+ const measure = () => {
778
+ if (!latestBoxSize) return null;
779
+ const raw = axis === "block" ? latestBoxSize.blockSize : latestBoxSize.inlineSize;
780
+ if (!Number.isFinite(raw)) return null;
781
+ return Math.max(0, Math.round(raw));
782
+ };
783
+ const publishExtent = (extent) => {
784
+ const previous = lastPublished;
785
+ const attempt = ++publicationRevision;
786
+ lastPublished = extent;
787
+ try {
788
+ if (!(publish({
789
+ axis,
790
+ extent
791
+ }) !== false) && publicationRevision === attempt && !disposed) lastPublished = previous;
792
+ } catch (error) {
793
+ if (publicationRevision === attempt && !disposed) lastPublished = previous;
794
+ throw error;
795
+ }
796
+ };
797
+ const publishFrame = () => {
798
+ rafId = null;
799
+ if (disposed) return;
800
+ const extent = measure();
801
+ if (extent === null) return;
802
+ if (dedupe && extent === lastPublished) return;
803
+ publishExtent(extent);
804
+ };
805
+ const onResize = (entries) => {
806
+ if (disposed) return;
807
+ const entry = entries[entries.length - 1];
808
+ if (entry) latestBoxSize = entry.borderBoxSize?.[0] ?? entry.contentBoxSize?.[0] ?? latestBoxSize;
809
+ if (rafId === null) rafId = requestAnimationFrame(publishFrame);
810
+ };
811
+ const doc = target.ownerDocument;
812
+ if (target === doc.body || target === doc.documentElement) console.warn(`[view-anchor] size-anchor: <${target === doc.body ? "body" : "html"}>'s ${axis} size is the view size, not content size. The size anchor will never shrink to content; measure a shrink-to-fit wrapper instead.`);
813
+ let removeAbortListener = () => {};
814
+ const dispose = () => {
815
+ if (disposed) return;
816
+ disposed = true;
817
+ removeAbortListener();
818
+ removeAbortListener = () => {};
819
+ if (rafId !== null) {
820
+ cancelAnimationFrame(rafId);
821
+ rafId = null;
822
+ }
823
+ if (observer) {
824
+ observer.disconnect();
825
+ observer = null;
826
+ }
827
+ publish = NOOP_PUBLISH;
828
+ latestBoxSize = null;
829
+ lastPublished = null;
830
+ };
831
+ if (opts.signal?.aborted) dispose();
832
+ else {
833
+ removeAbortListener = watchAbort(opts.signal, dispose);
834
+ observer = new ResizeObserver(onResize);
835
+ observer.observe(target);
836
+ }
837
+ return {
838
+ update(next) {
839
+ if (disposed) return;
840
+ publish = next.publish;
841
+ dedupe = next.dedupe ?? true;
842
+ lastPublished = null;
843
+ const extent = measure();
844
+ if (extent !== null) publishExtent(extent);
845
+ },
846
+ dispose
847
+ };
848
+ }
849
+ //#endregion
850
+ exports.DEFAULT_HOLD_SELECTOR = DEFAULT_HOLD_SELECTOR;
851
+ exports.createSizeAnchor = createSizeAnchor;
720
852
  exports.createViewAnchor = createViewAnchor;
721
853
  exports.measurePlacement = measurePlacement;
722
854
  return exports;
@@ -725,74 +857,232 @@ var __viewAnchorCore = (function(exports) {
725
857
  </script>
726
858
 
727
859
  <script>
728
- const { createViewAnchor } = __viewAnchorCore
729
-
730
- const ZERO = { x: 0, y: 0, width: 0, height: 0 }
731
-
732
- const deck = document.getElementById('deck')
733
- const placeholder = document.getElementById('placeholder')
734
- const follower = document.getElementById('follower')
735
- const pillar = document.getElementById('pillar')
736
- const presentEl = document.getElementById('present')
737
- const splitEl = document.getElementById('split')
738
- const explodeEl = document.getElementById('explode')
739
- const scene = document.getElementById('scene')
740
- const readout = document.getElementById('readout')
741
-
742
- // The placeholder's rect inside the deck (accumulated along the offsetParent
743
- // chain, independent of the 3D transform) — flat or tilted, the anchored view
744
- // stays registered directly above the placeholder.
860
+ const { createViewAnchor, createSizeAnchor } = __viewAnchorCore
861
+
862
+ const $ = (id) => document.getElementById(id)
863
+ const deck = $('deck')
864
+ const scene = $('scene')
865
+ const sidebar = $('sidebar')
866
+ const header = $('page-header')
867
+ const scroller = $('scroller')
868
+ const slotA = $('slot-a')
869
+ const slotB = $('slot-b')
870
+ const bItems = $('b-items')
871
+ const hideA = $('hide-a')
872
+ const readout = $('readout')
873
+ const visibleEl = $('visible')
874
+ const geometryEl = $('follow-geometry')
875
+ const scrollEl = $('follow-scroll')
876
+ const explodeEl = $('explode')
877
+
878
+ const HEADER_OPEN = 44
879
+ const HEADER_COLLAPSED = 24
880
+ const PILLAR_HEIGHT = 90
881
+ const DECK_FIT_WIDTH = 640
882
+ const SCENE_HEIGHT = 440
883
+
884
+ // An element's rect in the deck's own coordinates: offsets along the
885
+ // offsetParent chain (plus each parent's border), minus ancestor scroll.
886
+ // The published bounds are viewport coordinates, which a tilted deck
887
+ // distorts; a real host would use them directly.
745
888
  function deckLocalRect(el) {
746
- let x = 0, y = 0, n = el
747
- while (n && n !== deck) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent }
889
+ let x = 0, y = 0
890
+ for (let n = el; n && n !== deck; n = n.offsetParent) {
891
+ x += n.offsetLeft; y += n.offsetTop
892
+ const p = n.offsetParent
893
+ if (p && p !== deck) { x += p.clientLeft; y += p.clientTop }
894
+ }
895
+ for (let p = el.parentElement; p && p !== deck; p = p.parentElement) {
896
+ x -= p.scrollLeft; y -= p.scrollTop
897
+ }
748
898
  return { x, y, width: el.offsetWidth, height: el.offsetHeight }
749
899
  }
750
900
 
751
- // The host-side publish — view-anchor hands it the measured rect. A real app
752
- // application code would use the bounds here; this demo places the view in the
753
- // deck's local coordinates.
754
- function publish(bounds) {
755
- if (bounds.width === 0 || bounds.height === 0) {
756
- follower.classList.add('collapsed')
757
- renderReadout(ZERO, false)
758
- return
901
+ // ---- readout ----
902
+ const lines = { a: '', b: '', size: '', last: '' }
903
+ function renderReadout() {
904
+ readout.innerHTML = [lines.a, lines.b, lines.size, lines.last].join('\n')
905
+ }
906
+ function note(text) {
907
+ lines.last = '<span class="k">// last: ' + text + '</span>'
908
+ renderReadout()
909
+ }
910
+ function describe(name, placement, r) {
911
+ const head = '<span class="k">' + name + '.publish</span>('
912
+ if (!placement.visible) return head + '{ visible: false })'
913
+ return head + '{ visible: true, x:' + r.x + ', y:' + r.y + ', w:' + r.width + ', h:' + r.height + ' })' +
914
+ ' <span class="k">// deck-local</span>'
915
+ }
916
+
917
+ // ---- DOM → surface ----
918
+ // The page's publish for one surface: show or hide it, move it over its
919
+ // slot, and clip it to the page's scroll viewport.
920
+ function placeSurface(name, slot) {
921
+ const surface = $('surface-' + name)
922
+ const pillar = $('pillar-' + name)
923
+ return (placement) => {
924
+ lines[name] = describe(name.toUpperCase(), placement, placement.visible && deckLocalRect(slot))
925
+ renderReadout()
926
+ if (!placement.visible) {
927
+ surface.classList.add('off')
928
+ pillar.classList.add('off')
929
+ return
930
+ }
931
+ const r = deckLocalRect(slot)
932
+ const view = deckLocalRect(scroller)
933
+ const top = Math.max(0, view.y - r.y)
934
+ const left = Math.max(0, view.x - r.x)
935
+ const bottom = Math.max(0, r.y + r.height - (view.y + view.height))
936
+ const right = Math.max(0, r.x + r.width - (view.x + view.width))
937
+ const clippedAway = top + bottom >= r.height || left + right >= r.width
938
+ surface.classList.toggle('off', clippedAway)
939
+ pillar.classList.toggle('off', clippedAway || top > 0 || bottom > 0)
940
+ surface.style.left = r.x + 'px'
941
+ surface.style.top = r.y + 'px'
942
+ surface.style.width = r.width + 'px'
943
+ surface.style.height = r.height + 'px'
944
+ surface.style.clipPath = 'inset(' + top + 'px ' + right + 'px ' + bottom + 'px ' + left + 'px)'
945
+ pillar.style.left = (r.x + r.width / 2) + 'px'
946
+ pillar.style.top = (r.y + r.height / 2) + 'px'
947
+ pillar.style.height = PILLAR_HEIGHT + 'px'
759
948
  }
760
- follower.classList.remove('collapsed')
761
- const r = deckLocalRect(placeholder)
762
- follower.style.left = r.x + 'px'
763
- follower.style.top = r.y + 'px'
764
- follower.style.width = r.width + 'px'
765
- follower.style.height = r.height + 'px'
766
- // Pillar stands on the placeholder's centerline, rising along Z to the
767
- // view's height (120px).
768
- pillar.style.left = (r.x + r.width / 2) + 'px'
769
- pillar.style.top = (r.y + r.height / 2) + 'px'
770
- pillar.style.height = '120px'
771
- renderReadout(r, true)
772
- }
773
-
774
- function renderReadout(b, present) {
775
- readout.innerHTML =
776
- '<span class="k">publish</span>({ x:' + b.x + ', y:' + b.y +
777
- ', w:' + b.width + ', h:' + b.height + ' })' +
778
- (present ? '' : ' <span class="k">// detached</span>')
779
- }
780
-
781
- // Start the real anchor; split resizes are driven by view-anchor's own ResizeObserver.
782
- const handle = createViewAnchor(placeholder, { present: presentEl.checked, publish })
783
-
784
- presentEl.addEventListener('change', () => {
785
- handle.update({ present: presentEl.checked, publish })
949
+ }
950
+
951
+ const followOptions = () => ({
952
+ visible: visibleEl.checked,
953
+ followGeometry: geometryEl.checked,
954
+ followScroll: scrollEl.checked,
786
955
  })
787
- splitEl.addEventListener('input', () => {
788
- placeholder.style.flexBasis = splitEl.value + '%'
956
+ const publishA = placeSurface('a', slotA)
957
+ const publishB = placeSurface('b', slotB)
958
+ // holdSelector is left at its default, '[role="separator"]'.
959
+ const anchorA = createViewAnchor(slotA, { ...followOptions(), treatZeroAreaAsHidden: true, publish: publishA })
960
+ const anchorB = createViewAnchor(slotB, { ...followOptions(), publish: publishB })
961
+ const pulseAll = () => { anchorA.pulse(300); anchorB.pulse(300) }
962
+
963
+ // ---- surface → DOM ----
964
+ // Surface B owns its content. Its items wrap to whatever width the page
965
+ // gave it; the size anchor reports the resulting height, and the page sizes
966
+ // slot B to match. That moves slot A without resizing it, so the page
967
+ // pulses A.
968
+ const MAX_ITEMS = 24
969
+ let itemCount = 5
970
+ function renderItems() {
971
+ bItems.innerHTML = Array.from({ length: itemCount }, (_, i) => '<b>item ' + (i + 1) + '</b>').join('')
972
+ }
973
+ renderItems()
974
+ createSizeAnchor($('b-content'), {
975
+ axis: 'block',
976
+ publish: ({ axis, extent }) => {
977
+ slotB.style.height = extent + 'px'
978
+ anchorA.pulse(300)
979
+ lines.size = '<span class="rev">B.size.publish</span>({ axis: \'' + axis + '\', extent: ' + extent + ' })' +
980
+ ' <span class="k">// page sets slot B to this height</span>'
981
+ renderReadout()
982
+ },
983
+ })
984
+ // At either limit the count does not change, so nothing is re-rendered
985
+ // and the readout keeps its last real event.
986
+ $('b-more').addEventListener('click', () => {
987
+ if (itemCount === MAX_ITEMS) return
988
+ itemCount = Math.min(MAX_ITEMS, itemCount + 3)
989
+ renderItems()
990
+ note('surface B grew its content → size publish → slot B resized → pulse() on A')
991
+ })
992
+ $('b-less').addEventListener('click', () => {
993
+ if (itemCount === 0) return
994
+ itemCount = Math.max(0, itemCount - 3)
995
+ renderItems()
996
+ note('surface B shrank its content → size publish → slot B resized → pulse() on A')
789
997
  })
790
- // Explode defaults ON: apply the exploded pose at boot — uncheck to see the flat view.
791
- if (explodeEl.checked) scene.classList.add('exploded')
792
998
 
999
+ // ---- direct manipulation on the page ----
1000
+ // Pointer deltas are in screen space; on a tilted deck they are mapped back
1001
+ // into the deck plane with an affine fit taken from three probe points.
1002
+ function screenToDeck() {
1003
+ const c = (id) => { const b = $(id).getBoundingClientRect(); return { x: b.left + b.width / 2, y: b.top + b.height / 2 } }
1004
+ const o = c('probe-o'), px = c('probe-x'), py = c('probe-y')
1005
+ const a = (px.x - o.x) / 100, b = (py.x - o.x) / 100
1006
+ const cc = (px.y - o.y) / 100, d = (py.y - o.y) / 100
1007
+ const det = a * d - b * cc || 1
1008
+ return (dx, dy) => ({ x: (d * dx - b * dy) / det, y: (-cc * dx + a * dy) / det })
1009
+ }
1010
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
1011
+ function makeDraggable(handle, read, apply) {
1012
+ handle.addEventListener('pointerdown', (e) => {
1013
+ if (e.button !== 0) return
1014
+ e.preventDefault()
1015
+ handle.setPointerCapture(e.pointerId)
1016
+ handle.classList.add('dragging')
1017
+ const toDeck = screenToDeck()
1018
+ const start = read()
1019
+ const sx = e.clientX, sy = e.clientY
1020
+ const move = (ev) => apply(start, toDeck(ev.clientX - sx, ev.clientY - sy))
1021
+ const end = () => {
1022
+ handle.classList.remove('dragging')
1023
+ handle.removeEventListener('pointermove', move)
1024
+ handle.removeEventListener('pointerup', end)
1025
+ handle.removeEventListener('pointercancel', end)
1026
+ }
1027
+ handle.addEventListener('pointermove', move)
1028
+ handle.addEventListener('pointerup', end)
1029
+ handle.addEventListener('pointercancel', end)
1030
+ })
1031
+ }
1032
+ makeDraggable($('split-side'), () => sidebar.offsetWidth, (start, d) => {
1033
+ sidebar.style.width = clamp(Math.round(start + d.x), 40, 300) + 'px'
1034
+ note('vertical splitter: slots resized → ResizeObserver')
1035
+ })
1036
+ makeDraggable($('split-header'), () => header.offsetHeight, (start, d) => {
1037
+ header.style.height = clamp(Math.round(start + d.y), HEADER_COLLAPSED, 120) + 'px'
1038
+ note('header edge: slots moved, same size → followGeometry while the separator is held')
1039
+ })
1040
+ scroller.addEventListener('scroll', () => {
1041
+ note(scrollEl.checked ? 'scroll → followScroll re-measured' : 'scroll with followScroll off → surfaces left behind')
1042
+ }, { passive: true })
1043
+ $('collapse').addEventListener('click', (e) => {
1044
+ const collapsed = header.offsetHeight <= HEADER_COLLAPSED
1045
+ header.style.height = (collapsed ? HEADER_OPEN : HEADER_COLLAPSED) + 'px'
1046
+ e.currentTarget.textContent = collapsed ? 'collapse' : 'expand'
1047
+ pulseAll()
1048
+ note('header ' + (collapsed ? 'expanded' : 'collapsed') + ' by the page → pulse()' +
1049
+ (geometryEl.checked ? '' : ' (no-op: followGeometry is off)'))
1050
+ })
1051
+ hideA.addEventListener('click', () => {
1052
+ const hidden = slotA.style.display === 'none'
1053
+ slotA.style.display = hidden ? '' : 'none'
1054
+ hideA.textContent = hidden ? 'hide A' : 'show A'
1055
+ note(hidden ? 'slot A shown again' : 'slot A display: none → treatZeroAreaAsHidden publishes { visible: false }')
1056
+ })
1057
+
1058
+ // ---- scene controls ----
1059
+ function updateAnchors() {
1060
+ // update() applies a full configuration — an omitted option resets to
1061
+ // its default, so slot A's treatZeroAreaAsHidden must be re-passed on
1062
+ // every call or the "hide A" demo would silently stop working.
1063
+ anchorA.update({ ...followOptions(), treatZeroAreaAsHidden: true, publish: publishA })
1064
+ anchorB.update({ ...followOptions(), publish: publishB })
1065
+ }
1066
+ visibleEl.addEventListener('change', updateAnchors)
1067
+ geometryEl.addEventListener('change', updateAnchors)
1068
+ scrollEl.addEventListener('change', updateAnchors)
1069
+
1070
+ // Narrow screens: scale the whole deck down (offsets and probes are
1071
+ // unaffected, so tracking and drag mapping keep working).
1072
+ function fitScene() {
1073
+ const s = Math.min(1, scene.clientWidth / DECK_FIT_WIDTH)
1074
+ deck.style.scale = String(s)
1075
+ scene.style.height = Math.round(SCENE_HEIGHT * Math.max(s, 0.7)) + 'px'
1076
+ }
1077
+ fitScene()
1078
+ window.addEventListener('resize', fitScene)
1079
+
1080
+ // The 3D view is on by default; uncheck it to see the page flat.
1081
+ if (explodeEl.checked) scene.classList.add('exploded')
793
1082
  explodeEl.addEventListener('change', () => {
794
1083
  scene.classList.toggle('exploded', explodeEl.checked)
795
1084
  })
1085
+ note('drag, scroll or click the page')
796
1086
  </script>
797
1087
  </body>
798
1088
  </html>