view-anchor 0.2.2 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -87
- package/README.zh-CN.md +59 -64
- package/dist/index.d.ts +5 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/protocol-publisher.d.ts +7 -9
- package/dist/protocol-publisher.d.ts.map +1 -1
- package/dist/protocol-publisher.js +2 -8
- package/dist/protocol-types.d.ts +5 -8
- package/dist/protocol-types.d.ts.map +1 -1
- package/dist/protocol-types.js +3 -6
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +10 -2
- package/dist/react.d.ts +2 -15
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +43 -38
- package/dist/{size-advertiser.d.ts → size-anchor.d.ts} +6 -5
- package/dist/size-anchor.d.ts.map +1 -0
- package/dist/size-anchor.js +131 -0
- package/dist/types.d.ts +26 -32
- package/dist/types.d.ts.map +1 -1
- package/dist/view-anchor.d.ts +52 -33
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +186 -265
- package/docs/bidirectional-design.md +29 -34
- package/docs/index.html +656 -366
- package/docs/mechanism.md +35 -57
- package/docs/performance-report.md +12 -54
- package/docs/protocol.md +12 -3
- package/package.json +4 -6
- package/src/index.ts +5 -19
- package/src/protocol-publisher.ts +10 -16
- package/src/protocol-types.ts +5 -8
- package/src/protocol.ts +13 -7
- package/src/react.ts +51 -64
- package/src/size-anchor.ts +131 -0
- package/src/types.ts +29 -37
- package/src/view-anchor.ts +221 -287
- package/dist/measure-loop.d.ts +0 -24
- package/dist/measure-loop.d.ts.map +0 -1
- package/dist/measure-loop.js +0 -89
- package/dist/size-advertiser.d.ts.map +0 -1
- package/dist/size-advertiser.js +0 -94
- package/src/measure-loop.ts +0 -101
- package/src/size-advertiser.ts +0 -108
package/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
|
|
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:
|
|
55
|
-
perspective:
|
|
56
|
-
perspective-origin: 50%
|
|
55
|
+
height: 440px;
|
|
56
|
+
perspective: 1600px;
|
|
57
|
+
perspective-origin: 50% 30%;
|
|
57
58
|
display: flex; align-items: center; justify-content: center;
|
|
58
59
|
}
|
|
59
|
-
/*
|
|
60
|
-
|
|
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:
|
|
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(
|
|
69
|
+
.scene.exploded .deck { transform: rotateX(40deg) rotateZ(-20deg); }
|
|
68
70
|
|
|
69
|
-
/*
|
|
70
|
-
|
|
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:
|
|
102
|
+
.dom-area { position: absolute; left: 10px; right: 10px; top: 36px; bottom: 10px; display: flex; }
|
|
99
103
|
.dom-sidebar {
|
|
100
|
-
flex:
|
|
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:
|
|
106
|
+
display: flex; flex-direction: column; gap: 7px; padding: 10px 8px;
|
|
107
|
+
overflow: hidden;
|
|
103
108
|
}
|
|
104
|
-
.
|
|
105
|
-
.
|
|
106
|
-
.
|
|
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
|
-
.
|
|
109
|
-
|
|
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
|
-
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
/* no transition: slots move 1:1 with the page so the surfaces stay glued */
|
|
116
166
|
}
|
|
117
|
-
.placeholder
|
|
118
|
-
.placeholder
|
|
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
|
|
121
|
-
in the same 3D space; lifted in Z on explode to float above the
|
|
122
|
-
|
|
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
|
-
|
|
130
|
-
transition: transform 900ms cubic-bezier(0.6,0,0.2,1), box-shadow 900ms, opacity
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
.
|
|
138
|
-
|
|
139
|
-
|
|
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 .
|
|
142
|
-
transform: translateZ(
|
|
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
|
-
.
|
|
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
|
|
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:
|
|
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-
|
|
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;
|
|
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">
|
|
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
|
-
|
|
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"
|
|
233
|
-
|
|
234
|
-
<
|
|
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"><div ref={anchorRef} /></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
|
|
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
|
|
243
|
-
<div class="
|
|
244
|
-
<div class="
|
|
245
|
-
|
|
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="
|
|
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="
|
|
377
|
+
<label for="follow-geometry"><code>followGeometry</code></label>
|
|
257
378
|
</div>
|
|
258
379
|
<div class="ctl">
|
|
259
|
-
<
|
|
260
|
-
|
|
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
|
|
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>
|
|
274
|
-
<span><span class="sw p"></span> Placeholder
|
|
275
|
-
<span><span class="sw n"></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">
|
|
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
|
|
291
|
-
as an
|
|
292
|
-
rather than imported so the page can open from
|
|
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
|
|
310
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
476
|
-
let
|
|
477
|
-
let
|
|
478
|
-
const
|
|
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 (
|
|
518
|
+
if (treatZeroAreaAsHidden && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) return { visible: false };
|
|
482
519
|
return p;
|
|
483
520
|
};
|
|
484
|
-
const
|
|
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
|
|
534
|
+
const measureAndPublish = () => {
|
|
498
535
|
if (disposed || !visible) return;
|
|
499
|
-
const p =
|
|
536
|
+
const p = measureTarget();
|
|
500
537
|
if (!p) return;
|
|
501
|
-
if (lastPublished && samePlacement(lastPublished, p)) return;
|
|
502
|
-
|
|
538
|
+
if (dedupe && lastPublished && samePlacement(lastPublished, p)) return;
|
|
539
|
+
publishPlacement(p);
|
|
503
540
|
};
|
|
504
|
-
const
|
|
505
|
-
if (
|
|
506
|
-
|
|
541
|
+
const scheduleFollowFrame = () => {
|
|
542
|
+
if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(followFrame);
|
|
543
|
+
else followDeadline = null;
|
|
507
544
|
};
|
|
508
|
-
const
|
|
545
|
+
const followFrame = () => {
|
|
509
546
|
rafId = null;
|
|
510
|
-
if (disposed || !visible) {
|
|
511
|
-
|
|
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 =
|
|
551
|
+
const p = measureTarget();
|
|
519
552
|
if (!p) {
|
|
520
|
-
if (invalidFrames
|
|
521
|
-
|
|
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 (
|
|
531
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
549
|
-
else sentinelDeadline = null;
|
|
575
|
+
scheduleFollowFrame();
|
|
550
576
|
};
|
|
551
|
-
const
|
|
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(
|
|
584
|
+
rafId = requestAnimationFrame(followFrame);
|
|
557
585
|
}
|
|
558
586
|
};
|
|
559
|
-
const
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
595
|
+
lastFramePlacement = lastPublished;
|
|
596
|
+
followDeadline = null;
|
|
597
|
+
heldPointerIds.clear();
|
|
569
598
|
};
|
|
570
599
|
const onScroll = () => {
|
|
571
|
-
if (followGeometry)
|
|
572
|
-
else
|
|
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(
|
|
577
|
-
if (
|
|
578
|
-
|
|
579
|
-
|
|
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 = (
|
|
583
|
-
if (
|
|
584
|
-
if (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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 (
|
|
600
|
-
|
|
601
|
-
|
|
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
|
|
616
|
-
if (
|
|
617
|
-
|
|
618
|
-
|
|
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
|
|
634
|
-
if (
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
670
|
+
if (!followGeometry) stopFrameFollow();
|
|
650
671
|
};
|
|
651
672
|
const startObserving = () => {
|
|
652
673
|
if (observer) return;
|
|
653
|
-
observer = new ResizeObserver(
|
|
674
|
+
observer = new ResizeObserver(measureAndPublish);
|
|
654
675
|
observer.observe(targetRef);
|
|
655
|
-
window.addEventListener("resize",
|
|
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",
|
|
664
|
-
|
|
684
|
+
window.removeEventListener("resize", measureAndPublish);
|
|
685
|
+
stopIntersection();
|
|
686
|
+
stopScroll();
|
|
687
|
+
stopGeometryListeners();
|
|
688
|
+
stopFrameFollow();
|
|
665
689
|
};
|
|
666
|
-
const
|
|
690
|
+
const applyOptions = () => {
|
|
667
691
|
lastPublished = null;
|
|
668
692
|
if (visible) {
|
|
669
693
|
startObserving();
|
|
670
|
-
const placement =
|
|
671
|
-
if (placement)
|
|
694
|
+
const placement = measureTarget();
|
|
695
|
+
if (placement) publishPlacement(placement);
|
|
672
696
|
} else {
|
|
673
697
|
stopObserving();
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
followScroll = next.followScroll ??
|
|
700
|
-
followGeometry = next.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
|
-
|
|
734
|
+
stopDisabledObserving();
|
|
703
735
|
startOptionalObserving();
|
|
704
736
|
}
|
|
705
|
-
|
|
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
|
-
|
|
744
|
+
followDeadline = followDeadline === null ? next : Math.max(followDeadline, next);
|
|
713
745
|
}
|
|
714
|
-
|
|
746
|
+
startFrameFollow();
|
|
715
747
|
}
|
|
716
748
|
};
|
|
717
749
|
}
|
|
718
750
|
//#endregion
|
|
719
|
-
|
|
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
|
|
731
|
-
|
|
732
|
-
const
|
|
733
|
-
const
|
|
734
|
-
const
|
|
735
|
-
const
|
|
736
|
-
const
|
|
737
|
-
const
|
|
738
|
-
const
|
|
739
|
-
const
|
|
740
|
-
const readout
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
|
747
|
-
|
|
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
|
-
//
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
-
|
|
788
|
-
|
|
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>
|