view-anchor 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -39
- package/README.zh-CN.md +119 -47
- package/dist/index.d.ts +6 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -15
- package/dist/measure-loop.d.ts +9 -28
- package/dist/measure-loop.d.ts.map +1 -1
- package/dist/measure-loop.js +57 -17
- package/dist/protocol-publisher.d.ts +41 -0
- package/dist/protocol-publisher.d.ts.map +1 -0
- package/dist/protocol-publisher.js +207 -0
- package/dist/protocol-types.d.ts +36 -0
- package/dist/protocol-types.d.ts.map +1 -0
- package/dist/protocol-types.js +10 -0
- package/dist/protocol.d.ts +35 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +128 -0
- package/dist/react.d.ts +18 -30
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +125 -122
- package/dist/size-advertiser.d.ts +9 -14
- package/dist/size-advertiser.d.ts.map +1 -1
- package/dist/size-advertiser.js +29 -28
- package/dist/types.d.ts +29 -73
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -15
- package/dist/view-anchor.d.ts +36 -77
- package/dist/view-anchor.d.ts.map +1 -1
- package/dist/view-anchor.js +230 -181
- package/docs/bidirectional-design.md +78 -106
- package/docs/index.html +772 -0
- package/docs/mechanism.md +116 -0
- package/docs/performance-report.md +63 -0
- package/docs/protocol.md +108 -0
- package/package.json +37 -14
- package/src/index.ts +8 -24
- package/src/measure-loop.ts +56 -42
- package/src/protocol-publisher.ts +254 -0
- package/src/protocol-types.ts +43 -0
- package/src/protocol.ts +181 -0
- package/src/react.ts +175 -139
- package/src/size-advertiser.ts +33 -31
- package/src/types.ts +35 -82
- package/src/view-anchor.ts +259 -236
- package/docs/anchor-3d.html +0 -615
- package/docs/mechanism.mdx +0 -119
package/docs/index.html
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>view-anchor 3D demo: align anything outside the DOM to a DOM element</title>
|
|
7
|
+
<meta name="description" content="view-anchor is a high-performance geometry bridge that keeps an anchored view — an Electron WebContentsView, a native webview, a cross-origin iframe — aligned to a DOM element. This interactive 3D demo runs the real core in your browser.">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: hsl( 0 0% 7%);
|
|
11
|
+
--bg-soft: hsl( 0 0% 11%);
|
|
12
|
+
--text: hsl( 0 0% 95%);
|
|
13
|
+
--text-2: hsl( 0 0% 68%);
|
|
14
|
+
--text-3: hsl( 0 0% 48%);
|
|
15
|
+
|
|
16
|
+
--renderer: hsl(220 16% 16%);
|
|
17
|
+
--renderer-bd: hsl(220 22% 56%);
|
|
18
|
+
--placeholder: hsl(249 100% 67% / 0.18);
|
|
19
|
+
--placeholder-bd: hsl(249 100% 72%);
|
|
20
|
+
--native: hsl(160 40% 16%);
|
|
21
|
+
--native-bd: hsl(160 72% 58%);
|
|
22
|
+
--wire: hsl(160 72% 60%);
|
|
23
|
+
--accent: hsl(249 100% 70%);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
* { box-sizing: border-box; }
|
|
27
|
+
html, body {
|
|
28
|
+
margin: 0; padding: 0;
|
|
29
|
+
background: radial-gradient(120% 120% at 50% 0%, hsl(0 0% 10%) 0%, var(--bg) 60%);
|
|
30
|
+
color: var(--text);
|
|
31
|
+
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
|
32
|
+
font-size: 14px; line-height: 1.5;
|
|
33
|
+
-webkit-font-smoothing: antialiased;
|
|
34
|
+
min-height: 100vh;
|
|
35
|
+
}
|
|
36
|
+
code, .mono { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
|
37
|
+
|
|
38
|
+
.wrap { max-width: 1040px; margin: 0 auto; padding: 32px 28px 56px; }
|
|
39
|
+
header h1 { font-size: 19px; font-weight: 600; margin: 0 0 4px; letter-spacing: 0.01em; }
|
|
40
|
+
header p { margin: 0; color: var(--text-2); font-size: 13.5px; max-width: 72ch; }
|
|
41
|
+
|
|
42
|
+
.stage-shell {
|
|
43
|
+
margin-top: 26px;
|
|
44
|
+
background: var(--bg-soft);
|
|
45
|
+
border: 1px solid hsl(0 0% 18%);
|
|
46
|
+
border-radius: 16px;
|
|
47
|
+
padding: 30px 24px 20px;
|
|
48
|
+
box-shadow: 0 24px 60px hsl(0 0% 0% / 0.45);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* ---- 3D scene ---- */
|
|
52
|
+
.scene {
|
|
53
|
+
position: relative;
|
|
54
|
+
height: 380px;
|
|
55
|
+
perspective: 1500px;
|
|
56
|
+
perspective-origin: 50% 34%;
|
|
57
|
+
display: flex; align-items: center; justify-content: center;
|
|
58
|
+
}
|
|
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. */
|
|
61
|
+
.deck {
|
|
62
|
+
position: relative;
|
|
63
|
+
width: 560px; height: 300px;
|
|
64
|
+
transform-style: preserve-3d;
|
|
65
|
+
transition: transform 900ms cubic-bezier(0.6, 0, 0.2, 1);
|
|
66
|
+
}
|
|
67
|
+
.scene.exploded .deck { transform: rotateX(56deg) rotateZ(-30deg); }
|
|
68
|
+
|
|
69
|
+
/* bottom layer: renderer / DOM window (web content). Stays flat within the
|
|
70
|
+
deck (translateZ 0); view-anchor measures the placeholder inside it. */
|
|
71
|
+
.window {
|
|
72
|
+
position: absolute; inset: 0;
|
|
73
|
+
background: var(--renderer);
|
|
74
|
+
border: 1.5px solid var(--renderer-bd);
|
|
75
|
+
border-radius: 10px;
|
|
76
|
+
box-shadow: 0 22px 50px hsl(0 0% 0% / 0.5);
|
|
77
|
+
transform: translateZ(0);
|
|
78
|
+
}
|
|
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
|
+
.chrome-bar {
|
|
86
|
+
position: absolute; left: 0; right: 0; top: 0; height: 26px;
|
|
87
|
+
background: hsl(0 0% 10%); border-bottom: 1px solid hsl(0 0% 22%);
|
|
88
|
+
border-radius: 10px 10px 0 0;
|
|
89
|
+
}
|
|
90
|
+
.traffic {
|
|
91
|
+
position: absolute; left: 12px; top: 9px; width: 42px; height: 8px;
|
|
92
|
+
background:
|
|
93
|
+
radial-gradient(circle, hsl(0 70% 60%) 0 3.4px, transparent 3.8px) 0 0/14px 8px no-repeat,
|
|
94
|
+
radial-gradient(circle, hsl(45 80% 55%) 0 3.4px, transparent 3.8px) 14px 0/14px 8px no-repeat,
|
|
95
|
+
radial-gradient(circle, hsl(135 50% 55%) 0 3.4px, transparent 3.8px) 28px 0/14px 8px no-repeat;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.dom-area { position: absolute; left: 10px; right: 10px; top: 56px; bottom: 12px; display: flex; gap: 8px; }
|
|
99
|
+
.dom-sidebar {
|
|
100
|
+
flex: 1 1 auto; min-width: 64px;
|
|
101
|
+
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;
|
|
103
|
+
}
|
|
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%; }
|
|
107
|
+
|
|
108
|
+
.placeholder {
|
|
109
|
+
flex: 0 0 58%;
|
|
110
|
+
position: relative;
|
|
111
|
+
border-radius: 6px;
|
|
112
|
+
background: var(--placeholder);
|
|
113
|
+
border: 1.5px dashed var(--placeholder-bd);
|
|
114
|
+
display: flex; align-items: center; justify-content: center;
|
|
115
|
+
/* no transition: track the slider 1:1 so the anchored view stays glued */
|
|
116
|
+
}
|
|
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; }
|
|
119
|
+
|
|
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 {
|
|
123
|
+
position: absolute;
|
|
124
|
+
border-radius: 6px;
|
|
125
|
+
background: linear-gradient(180deg, hsl(160 40% 20%), hsl(160 38% 13%));
|
|
126
|
+
border: 1.5px solid var(--native-bd);
|
|
127
|
+
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 (like a real setBounds) — 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;
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
display: flex; align-items: center; justify-content: center;
|
|
133
|
+
transform: translateZ(0.5px);
|
|
134
|
+
}
|
|
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);
|
|
140
|
+
}
|
|
141
|
+
.scene.exploded .follower {
|
|
142
|
+
transform: translateZ(120px);
|
|
143
|
+
box-shadow: 0 0 0 1px hsl(160 72% 60% / 0.4), 0 30px 60px hsl(0 0% 0% / 0.55),
|
|
144
|
+
0 0 44px hsl(160 72% 50% / 0.22);
|
|
145
|
+
}
|
|
146
|
+
.follower.collapsed { opacity: 0 !important; }
|
|
147
|
+
|
|
148
|
+
/* connector pillar standing along Z from the placeholder up to the view */
|
|
149
|
+
.pillar {
|
|
150
|
+
position: absolute; width: 0;
|
|
151
|
+
border-left: 1.5px dashed var(--native-bd);
|
|
152
|
+
transform-origin: top center;
|
|
153
|
+
transform: rotateX(-90deg);
|
|
154
|
+
opacity: 0; transition: opacity 400ms ease;
|
|
155
|
+
pointer-events: none;
|
|
156
|
+
}
|
|
157
|
+
.scene.exploded .pillar { opacity: 0.5; }
|
|
158
|
+
|
|
159
|
+
/* ---- controls ---- */
|
|
160
|
+
.controls {
|
|
161
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 18px;
|
|
162
|
+
margin-top: 22px; padding-top: 18px; border-top: 1px solid hsl(0 0% 17%);
|
|
163
|
+
}
|
|
164
|
+
.ctl { display: flex; align-items: center; gap: 9px; font-size: 12.5px; color: var(--text-2); }
|
|
165
|
+
.ctl label { user-select: none; }
|
|
166
|
+
|
|
167
|
+
.switch { position: relative; width: 42px; height: 24px; flex: none; }
|
|
168
|
+
.switch input { opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
|
|
169
|
+
.switch .track { position: absolute; inset: 0; border-radius: 999px; background: hsl(0 0% 24%); transition: background 200ms ease; pointer-events: none; }
|
|
170
|
+
.switch .thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: hsl(0 0% 92%); transition: transform 200ms ease; pointer-events: none; }
|
|
171
|
+
.switch input:checked ~ .track { background: var(--accent); }
|
|
172
|
+
.switch input:checked ~ .thumb { transform: translateX(18px); }
|
|
173
|
+
|
|
174
|
+
input[type=range] { accent-color: var(--accent); width: 150px; }
|
|
175
|
+
|
|
176
|
+
.readout {
|
|
177
|
+
margin-left: auto;
|
|
178
|
+
font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: var(--wire);
|
|
179
|
+
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;
|
|
181
|
+
}
|
|
182
|
+
.readout .k { color: var(--text-3); }
|
|
183
|
+
|
|
184
|
+
.legend { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 18px; font-size: 12px; color: var(--text-2); }
|
|
185
|
+
.legend span { display: inline-flex; align-items: center; gap: 7px; }
|
|
186
|
+
.sw { width: 13px; height: 13px; border-radius: 3px; flex: none; }
|
|
187
|
+
.sw.r { background: var(--renderer); border: 1.5px solid var(--renderer-bd); }
|
|
188
|
+
.sw.p { background: var(--placeholder); border: 1.5px dashed var(--placeholder-bd); }
|
|
189
|
+
.sw.n { background: var(--native); border: 1.5px solid var(--native-bd); }
|
|
190
|
+
|
|
191
|
+
footer.note { margin-top: 22px; color: var(--text-3); font-size: 12px; line-height: 1.6; }
|
|
192
|
+
footer.note code { color: var(--text-2); }
|
|
193
|
+
|
|
194
|
+
/* ---- landing intro ---- */
|
|
195
|
+
.badges { display: flex; gap: 8px; flex-wrap: wrap; margin: 0 0 16px; }
|
|
196
|
+
.badges img { display: block; }
|
|
197
|
+
.tag-en { color: var(--text-2); font-size: 14.5px; margin: 10px 0 0; max-width: 72ch; }
|
|
198
|
+
.actions { display: flex; gap: 18px; flex-wrap: wrap; margin-top: 12px; font-size: 13px; }
|
|
199
|
+
.actions a { color: var(--placeholder-bd); text-decoration: none; border-bottom: 1px solid hsl(249 100% 72% / 0.3); }
|
|
200
|
+
.actions a:hover { color: var(--text); border-bottom-color: var(--text-2); }
|
|
201
|
+
footer.note { display: flex; flex-direction: column; gap: 10px; }
|
|
202
|
+
footer.note .hint { color: var(--text-3); font-size: 12.5px; }
|
|
203
|
+
|
|
204
|
+
</style>
|
|
205
|
+
</head>
|
|
206
|
+
<body>
|
|
207
|
+
<div class="wrap">
|
|
208
|
+
<header>
|
|
209
|
+
<div class="badges">
|
|
210
|
+
<img alt="npm version" src="https://img.shields.io/npm/v/view-anchor">
|
|
211
|
+
<img alt="npm downloads" src="https://img.shields.io/npm/dm/view-anchor">
|
|
212
|
+
<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
|
+
</div>
|
|
215
|
+
<h1>view-anchor: interactive 3D demo</h1>
|
|
216
|
+
<p class="tag-en">Align an <b style="color:var(--native-bd)">anchored view</b> — an Electron <code>WebContentsView</code>, a native webview, a cross-origin iframe — to a DOM element's geometry. Resize the splitter, toggle <code>present</code>, or explode the 3D scene below: the green anchored view is positioned by the real view-anchor core running in this page.</p>
|
|
217
|
+
<div class="actions">
|
|
218
|
+
<a href="https://github.com/lbb00/view-anchor">GitHub</a>
|
|
219
|
+
<a href="https://www.npmjs.com/package/view-anchor">npm</a>
|
|
220
|
+
<a href="https://github.com/lbb00/view-anchor/blob/main/README.md">README</a>
|
|
221
|
+
</div>
|
|
222
|
+
</header>
|
|
223
|
+
|
|
224
|
+
<div class="stage-shell">
|
|
225
|
+
<div class="scene" id="scene">
|
|
226
|
+
<div class="deck" id="deck">
|
|
227
|
+
<!-- bottom layer: the renderer / DOM window -->
|
|
228
|
+
<div class="window">
|
|
229
|
+
<div class="chrome-bar"><div class="traffic"></div></div>
|
|
230
|
+
<div class="caption"><b>Renderer DOM</b></div>
|
|
231
|
+
<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"><div ref={anchorRef} /></div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
<!-- connector pillar: from the placeholder up to the anchored view along Z -->
|
|
240
|
+
<div class="pillar" id="pillar"></div>
|
|
241
|
+
|
|
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>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div class="controls">
|
|
251
|
+
<div class="ctl">
|
|
252
|
+
<span class="switch">
|
|
253
|
+
<input type="checkbox" id="present" checked>
|
|
254
|
+
<span class="track"></span><span class="thumb"></span>
|
|
255
|
+
</span>
|
|
256
|
+
<label for="present"><code>present</code></label>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="ctl">
|
|
259
|
+
<label for="split">Split</label>
|
|
260
|
+
<input type="range" id="split" min="34" max="78" value="58">
|
|
261
|
+
</div>
|
|
262
|
+
<div class="ctl">
|
|
263
|
+
<span class="switch">
|
|
264
|
+
<input type="checkbox" id="explode" checked>
|
|
265
|
+
<span class="track"></span><span class="thumb"></span>
|
|
266
|
+
</span>
|
|
267
|
+
<label for="explode">Explode</label>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="readout" id="readout"></div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<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>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<footer class="note">
|
|
280
|
+
<div class="actions">
|
|
281
|
+
<a href="https://github.com/lbb00/view-anchor">GitHub repository</a>
|
|
282
|
+
<a href="https://www.npmjs.com/package/view-anchor">npm · view-anchor</a>
|
|
283
|
+
<span style="color:var(--text-3)">MIT licensed</span>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="hint">Drag <code>Split</code> to resize the placeholder, toggle <code>present</code> to collapse or restore, and toggle <code>Explode</code> for the 3D perspective. The green anchored view's bounds come directly from view-anchor's <code>publish</code> callback.</div>
|
|
286
|
+
</footer>
|
|
287
|
+
|
|
288
|
+
</div>
|
|
289
|
+
|
|
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. -->
|
|
294
|
+
<script>
|
|
295
|
+
/* __VIEW_ANCHOR_CORE_START__ — generated from src/view-anchor.ts by `pnpm build:docs`; do not edit */
|
|
296
|
+
var __viewAnchorCore = (function(exports) {
|
|
297
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
298
|
+
//#region src/view-anchor.ts
|
|
299
|
+
const ZERO = {
|
|
300
|
+
x: 0,
|
|
301
|
+
y: 0,
|
|
302
|
+
width: 0,
|
|
303
|
+
height: 0
|
|
304
|
+
};
|
|
305
|
+
const NOOP_PUBLISH = () => false;
|
|
306
|
+
const clampRect = (r) => ({
|
|
307
|
+
x: Math.round(r.x),
|
|
308
|
+
y: Math.round(r.y),
|
|
309
|
+
width: Math.max(0, Math.round(r.width)),
|
|
310
|
+
height: Math.max(0, Math.round(r.height))
|
|
311
|
+
});
|
|
312
|
+
/**
|
|
313
|
+
* Bind a native view or external surface to the geometry of `target`.
|
|
314
|
+
*
|
|
315
|
+
* - `present === true`: measures `target.getBoundingClientRect()` and publishes
|
|
316
|
+
* immediately, then re-measures synchronously on ResizeObserver and window resize.
|
|
317
|
+
* - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
|
|
318
|
+
* and stops observing.
|
|
319
|
+
* - `update(opts)`: re-applies options immediately.
|
|
320
|
+
* - `dispose()`: stops observing and prevents any further publishes.
|
|
321
|
+
*
|
|
322
|
+
* Synchronous publishing: measurement and publishing occur directly in the
|
|
323
|
+
* observer tick. Cross-process setBounds calls already have a compositor delay;
|
|
324
|
+
* adding requestAnimationFrame would add a second frame of visual lag during drag
|
|
325
|
+
* operations. High-frequency updates are deduplicated against the last accepted rect.
|
|
326
|
+
*/
|
|
327
|
+
function createViewAnchor(target, opts) {
|
|
328
|
+
let present = opts.present;
|
|
329
|
+
let publish = opts.publish;
|
|
330
|
+
let observer = null;
|
|
331
|
+
let targetRef = target;
|
|
332
|
+
let lastPublished = null;
|
|
333
|
+
let publicationRevision = 0;
|
|
334
|
+
let disposed = false;
|
|
335
|
+
const measure = () => {
|
|
336
|
+
const r = targetRef.getBoundingClientRect();
|
|
337
|
+
if (!Number.isFinite(r.left) || !Number.isFinite(r.top) || !Number.isFinite(r.width) || !Number.isFinite(r.height)) return null;
|
|
338
|
+
return clampRect({
|
|
339
|
+
x: r.left,
|
|
340
|
+
y: r.top,
|
|
341
|
+
width: r.width,
|
|
342
|
+
height: r.height
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
const sameRect = (a, b) => a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
346
|
+
const publishCandidate = (candidate) => {
|
|
347
|
+
const previous = lastPublished;
|
|
348
|
+
const attempt = ++publicationRevision;
|
|
349
|
+
lastPublished = candidate;
|
|
350
|
+
try {
|
|
351
|
+
const accepted = publish(candidate) !== false;
|
|
352
|
+
if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous;
|
|
353
|
+
return accepted;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
if (publicationRevision === attempt && !disposed) lastPublished = previous;
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const emit = () => {
|
|
360
|
+
if (disposed || !present) return;
|
|
361
|
+
const m = measure();
|
|
362
|
+
if (!m) return;
|
|
363
|
+
if (lastPublished && sameRect(lastPublished, m)) return;
|
|
364
|
+
publishCandidate(m);
|
|
365
|
+
};
|
|
366
|
+
const startObserving = () => {
|
|
367
|
+
if (observer) return;
|
|
368
|
+
observer = new ResizeObserver(emit);
|
|
369
|
+
observer.observe(targetRef);
|
|
370
|
+
window.addEventListener("resize", emit);
|
|
371
|
+
};
|
|
372
|
+
const stopObserving = () => {
|
|
373
|
+
if (observer) {
|
|
374
|
+
observer.disconnect();
|
|
375
|
+
observer = null;
|
|
376
|
+
}
|
|
377
|
+
window.removeEventListener("resize", emit);
|
|
378
|
+
};
|
|
379
|
+
const apply = () => {
|
|
380
|
+
lastPublished = null;
|
|
381
|
+
if (present) {
|
|
382
|
+
startObserving();
|
|
383
|
+
const measured = measure();
|
|
384
|
+
if (measured) publishCandidate(measured);
|
|
385
|
+
} else {
|
|
386
|
+
stopObserving();
|
|
387
|
+
publishCandidate(ZERO);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
apply();
|
|
391
|
+
return {
|
|
392
|
+
update(next) {
|
|
393
|
+
if (disposed) return;
|
|
394
|
+
publish = next.publish;
|
|
395
|
+
present = next.present;
|
|
396
|
+
apply();
|
|
397
|
+
},
|
|
398
|
+
dispose() {
|
|
399
|
+
if (disposed) return;
|
|
400
|
+
disposed = true;
|
|
401
|
+
stopObserving();
|
|
402
|
+
targetRef = null;
|
|
403
|
+
publish = NOOP_PUBLISH;
|
|
404
|
+
lastPublished = null;
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Read target's current rect and return { visible: true, bounds }.
|
|
410
|
+
* Does not infer visibility from dimensions.
|
|
411
|
+
*/
|
|
412
|
+
function measurePlacement(target) {
|
|
413
|
+
const r = target.getBoundingClientRect();
|
|
414
|
+
return {
|
|
415
|
+
visible: true,
|
|
416
|
+
bounds: clampRect({
|
|
417
|
+
x: r.left,
|
|
418
|
+
y: r.top,
|
|
419
|
+
width: r.width,
|
|
420
|
+
height: r.height
|
|
421
|
+
})
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
const samePlacement = (a, b) => {
|
|
425
|
+
if (a.visible !== b.visible) return false;
|
|
426
|
+
if (a.visible && b.visible) return a.bounds.x === b.bounds.x && a.bounds.y === b.bounds.y && a.bounds.width === b.bounds.width && a.bounds.height === b.bounds.height;
|
|
427
|
+
return true;
|
|
428
|
+
};
|
|
429
|
+
/**
|
|
430
|
+
* Explicit-visibility variant of createViewAnchor.
|
|
431
|
+
*/
|
|
432
|
+
function createPlacementAnchor(target, opts) {
|
|
433
|
+
let visible = opts.visible;
|
|
434
|
+
let publish = opts.publish;
|
|
435
|
+
let guardDisplayNone = opts.guardDisplayNone ?? false;
|
|
436
|
+
let followScroll = opts.followScroll ?? false;
|
|
437
|
+
let followGeometry = opts.followGeometry ?? false;
|
|
438
|
+
let targetRef = target;
|
|
439
|
+
let observer = null;
|
|
440
|
+
let io = null;
|
|
441
|
+
let scrollListening = false;
|
|
442
|
+
let geometryListening = false;
|
|
443
|
+
const capture = { capture: true };
|
|
444
|
+
const passiveCapture = {
|
|
445
|
+
capture: true,
|
|
446
|
+
passive: true
|
|
447
|
+
};
|
|
448
|
+
let lastPublished = null;
|
|
449
|
+
let publicationRevision = 0;
|
|
450
|
+
let disposed = false;
|
|
451
|
+
let rafId = null;
|
|
452
|
+
let steadyFrames = 0;
|
|
453
|
+
const STEADY_CLOSE_FRAMES = 2;
|
|
454
|
+
const MAX_HIDDEN_FOLLOW_FRAMES = 30;
|
|
455
|
+
const MAX_INVALID_FOLLOW_FRAMES = 30;
|
|
456
|
+
let invalidFrames = 0;
|
|
457
|
+
let pointerHeld = false;
|
|
458
|
+
let activePointerId = null;
|
|
459
|
+
let sentinelDeadline = null;
|
|
460
|
+
const computePlacement = () => {
|
|
461
|
+
const p = measurePlacement(targetRef);
|
|
462
|
+
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;
|
|
463
|
+
if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) return { visible: false };
|
|
464
|
+
return p;
|
|
465
|
+
};
|
|
466
|
+
const publishCandidate = (candidate) => {
|
|
467
|
+
const previous = lastPublished;
|
|
468
|
+
const attempt = ++publicationRevision;
|
|
469
|
+
lastPublished = candidate;
|
|
470
|
+
try {
|
|
471
|
+
const accepted = publish(candidate) !== false;
|
|
472
|
+
if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous;
|
|
473
|
+
return accepted;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (publicationRevision === attempt && !disposed) lastPublished = previous;
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
const emit = () => {
|
|
480
|
+
if (disposed || !visible) return;
|
|
481
|
+
const p = computePlacement();
|
|
482
|
+
if (!p) return;
|
|
483
|
+
if (lastPublished && samePlacement(lastPublished, p)) return;
|
|
484
|
+
publishCandidate(p);
|
|
485
|
+
};
|
|
486
|
+
const shouldCloseOnHiddenPoll = () => {
|
|
487
|
+
if (lastPublished?.visible === false) return true;
|
|
488
|
+
return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES;
|
|
489
|
+
};
|
|
490
|
+
const sentinelFrame = () => {
|
|
491
|
+
rafId = null;
|
|
492
|
+
if (disposed || !visible) {
|
|
493
|
+
sentinelDeadline = null;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
497
|
+
sentinelDeadline = null;
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const p = computePlacement();
|
|
501
|
+
if (!p) {
|
|
502
|
+
if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
|
|
503
|
+
sentinelDeadline = null;
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(sentinelFrame);
|
|
507
|
+
else sentinelDeadline = null;
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
invalidFrames = 0;
|
|
511
|
+
if (!p.visible) {
|
|
512
|
+
if (shouldCloseOnHiddenPoll()) {
|
|
513
|
+
sentinelDeadline = null;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(sentinelFrame);
|
|
517
|
+
else sentinelDeadline = null;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
521
|
+
steadyFrames++;
|
|
522
|
+
if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
|
|
523
|
+
sentinelDeadline = null;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
publishCandidate(p);
|
|
528
|
+
steadyFrames = 0;
|
|
529
|
+
}
|
|
530
|
+
if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(sentinelFrame);
|
|
531
|
+
else sentinelDeadline = null;
|
|
532
|
+
};
|
|
533
|
+
const openSentinel = () => {
|
|
534
|
+
if (!followGeometry || disposed) return;
|
|
535
|
+
steadyFrames = 0;
|
|
536
|
+
if (rafId === null) {
|
|
537
|
+
invalidFrames = 0;
|
|
538
|
+
rafId = requestAnimationFrame(sentinelFrame);
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
const closeSentinel = () => {
|
|
542
|
+
if (rafId !== null) {
|
|
543
|
+
cancelAnimationFrame(rafId);
|
|
544
|
+
rafId = null;
|
|
545
|
+
}
|
|
546
|
+
steadyFrames = 0;
|
|
547
|
+
invalidFrames = 0;
|
|
548
|
+
sentinelDeadline = null;
|
|
549
|
+
pointerHeld = false;
|
|
550
|
+
activePointerId = null;
|
|
551
|
+
};
|
|
552
|
+
const onScroll = () => {
|
|
553
|
+
if (followGeometry) openSentinel();
|
|
554
|
+
else emit();
|
|
555
|
+
};
|
|
556
|
+
const onPointerDown = (e) => {
|
|
557
|
+
const t = e.target;
|
|
558
|
+
if (t && t.closest && t.closest("[role=\"separator\"]")) {
|
|
559
|
+
if (!pointerHeld) activePointerId = e.pointerId;
|
|
560
|
+
pointerHeld = true;
|
|
561
|
+
openSentinel();
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
const releasePointer = (e) => {
|
|
565
|
+
if (!pointerHeld) return;
|
|
566
|
+
if (e && activePointerId !== e.pointerId) return;
|
|
567
|
+
pointerHeld = false;
|
|
568
|
+
activePointerId = null;
|
|
569
|
+
openSentinel();
|
|
570
|
+
};
|
|
571
|
+
const onPointerUp = (e) => {
|
|
572
|
+
releasePointer(e);
|
|
573
|
+
};
|
|
574
|
+
const onPointerCancel = (e) => {
|
|
575
|
+
releasePointer(e);
|
|
576
|
+
};
|
|
577
|
+
const onWindowBlur = () => {
|
|
578
|
+
releasePointer();
|
|
579
|
+
};
|
|
580
|
+
const startOptionalObserving = () => {
|
|
581
|
+
if (guardDisplayNone && !io && typeof IntersectionObserver !== "undefined") {
|
|
582
|
+
io = new IntersectionObserver(emit);
|
|
583
|
+
io.observe(targetRef);
|
|
584
|
+
}
|
|
585
|
+
if (followScroll && !scrollListening) {
|
|
586
|
+
window.addEventListener("scroll", onScroll, passiveCapture);
|
|
587
|
+
scrollListening = true;
|
|
588
|
+
}
|
|
589
|
+
if (followGeometry && !geometryListening) {
|
|
590
|
+
window.addEventListener("pointerdown", onPointerDown, capture);
|
|
591
|
+
window.addEventListener("pointerup", onPointerUp, capture);
|
|
592
|
+
window.addEventListener("pointercancel", onPointerCancel, capture);
|
|
593
|
+
window.addEventListener("blur", onWindowBlur);
|
|
594
|
+
geometryListening = true;
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
const stopOptionalObserving = () => {
|
|
598
|
+
if (io && !guardDisplayNone) {
|
|
599
|
+
io.disconnect();
|
|
600
|
+
io = null;
|
|
601
|
+
}
|
|
602
|
+
if (scrollListening && !followScroll) {
|
|
603
|
+
window.removeEventListener("scroll", onScroll, passiveCapture);
|
|
604
|
+
scrollListening = false;
|
|
605
|
+
}
|
|
606
|
+
if (geometryListening && !followGeometry) {
|
|
607
|
+
window.removeEventListener("pointerdown", onPointerDown, capture);
|
|
608
|
+
window.removeEventListener("pointerup", onPointerUp, capture);
|
|
609
|
+
window.removeEventListener("pointercancel", onPointerCancel, capture);
|
|
610
|
+
window.removeEventListener("blur", onWindowBlur);
|
|
611
|
+
geometryListening = false;
|
|
612
|
+
closeSentinel();
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const stopAllOptionalObserving = () => {
|
|
616
|
+
if (io) {
|
|
617
|
+
io.disconnect();
|
|
618
|
+
io = null;
|
|
619
|
+
}
|
|
620
|
+
if (scrollListening) {
|
|
621
|
+
window.removeEventListener("scroll", onScroll, passiveCapture);
|
|
622
|
+
scrollListening = false;
|
|
623
|
+
}
|
|
624
|
+
if (geometryListening) {
|
|
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
|
+
}
|
|
631
|
+
closeSentinel();
|
|
632
|
+
};
|
|
633
|
+
const startObserving = () => {
|
|
634
|
+
if (observer) return;
|
|
635
|
+
observer = new ResizeObserver(emit);
|
|
636
|
+
observer.observe(targetRef);
|
|
637
|
+
window.addEventListener("resize", emit);
|
|
638
|
+
startOptionalObserving();
|
|
639
|
+
};
|
|
640
|
+
const stopObserving = () => {
|
|
641
|
+
if (observer) {
|
|
642
|
+
observer.disconnect();
|
|
643
|
+
observer = null;
|
|
644
|
+
}
|
|
645
|
+
window.removeEventListener("resize", emit);
|
|
646
|
+
stopAllOptionalObserving();
|
|
647
|
+
};
|
|
648
|
+
const apply = () => {
|
|
649
|
+
lastPublished = null;
|
|
650
|
+
if (visible) {
|
|
651
|
+
startObserving();
|
|
652
|
+
const placement = computePlacement();
|
|
653
|
+
if (placement) publishCandidate(placement);
|
|
654
|
+
} else {
|
|
655
|
+
stopObserving();
|
|
656
|
+
publishCandidate({ visible: false });
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
apply();
|
|
660
|
+
return {
|
|
661
|
+
update(next) {
|
|
662
|
+
if (disposed) return;
|
|
663
|
+
publish = next.publish;
|
|
664
|
+
visible = next.visible;
|
|
665
|
+
guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone;
|
|
666
|
+
followScroll = next.followScroll ?? followScroll;
|
|
667
|
+
followGeometry = next.followGeometry ?? followGeometry;
|
|
668
|
+
if (visible && observer) {
|
|
669
|
+
stopOptionalObserving();
|
|
670
|
+
startOptionalObserving();
|
|
671
|
+
}
|
|
672
|
+
apply();
|
|
673
|
+
},
|
|
674
|
+
dispose() {
|
|
675
|
+
if (disposed) return;
|
|
676
|
+
disposed = true;
|
|
677
|
+
stopObserving();
|
|
678
|
+
targetRef = null;
|
|
679
|
+
publish = NOOP_PUBLISH;
|
|
680
|
+
lastPublished = null;
|
|
681
|
+
},
|
|
682
|
+
pulse(durationMs) {
|
|
683
|
+
if (disposed || !followGeometry) return;
|
|
684
|
+
if (durationMs !== void 0 && durationMs > 0) {
|
|
685
|
+
const next = performance.now() + durationMs;
|
|
686
|
+
sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next);
|
|
687
|
+
}
|
|
688
|
+
openSentinel();
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
//#endregion
|
|
693
|
+
exports.createPlacementAnchor = createPlacementAnchor;
|
|
694
|
+
exports.createViewAnchor = createViewAnchor;
|
|
695
|
+
exports.measurePlacement = measurePlacement;
|
|
696
|
+
return exports;
|
|
697
|
+
})({});
|
|
698
|
+
/* __VIEW_ANCHOR_CORE_END__ */
|
|
699
|
+
</script>
|
|
700
|
+
|
|
701
|
+
<script>
|
|
702
|
+
const { createViewAnchor } = __viewAnchorCore
|
|
703
|
+
|
|
704
|
+
const ZERO = { x: 0, y: 0, width: 0, height: 0 }
|
|
705
|
+
|
|
706
|
+
const deck = document.getElementById('deck')
|
|
707
|
+
const placeholder = document.getElementById('placeholder')
|
|
708
|
+
const follower = document.getElementById('follower')
|
|
709
|
+
const pillar = document.getElementById('pillar')
|
|
710
|
+
const presentEl = document.getElementById('present')
|
|
711
|
+
const splitEl = document.getElementById('split')
|
|
712
|
+
const explodeEl = document.getElementById('explode')
|
|
713
|
+
const scene = document.getElementById('scene')
|
|
714
|
+
const readout = document.getElementById('readout')
|
|
715
|
+
|
|
716
|
+
// The placeholder's rect inside the deck (accumulated along the offsetParent
|
|
717
|
+
// chain, independent of the 3D transform) — flat or tilted, the anchored view
|
|
718
|
+
// stays registered directly above the placeholder.
|
|
719
|
+
function deckLocalRect(el) {
|
|
720
|
+
let x = 0, y = 0, n = el
|
|
721
|
+
while (n && n !== deck) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent }
|
|
722
|
+
return { x, y, width: el.offsetWidth, height: el.offsetHeight }
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// The host-side publish — view-anchor hands it the measured rect. A real app
|
|
726
|
+
// would go IPC → WebContentsView.setBounds; here we place the view in the
|
|
727
|
+
// deck's local coordinates.
|
|
728
|
+
function publish(bounds) {
|
|
729
|
+
if (bounds.width === 0 || bounds.height === 0) {
|
|
730
|
+
follower.classList.add('collapsed')
|
|
731
|
+
renderReadout(ZERO, false)
|
|
732
|
+
return
|
|
733
|
+
}
|
|
734
|
+
follower.classList.remove('collapsed')
|
|
735
|
+
const r = deckLocalRect(placeholder)
|
|
736
|
+
follower.style.left = r.x + 'px'
|
|
737
|
+
follower.style.top = r.y + 'px'
|
|
738
|
+
follower.style.width = r.width + 'px'
|
|
739
|
+
follower.style.height = r.height + 'px'
|
|
740
|
+
// Pillar stands on the placeholder's centerline, rising along Z to the
|
|
741
|
+
// view's height (120px).
|
|
742
|
+
pillar.style.left = (r.x + r.width / 2) + 'px'
|
|
743
|
+
pillar.style.top = (r.y + r.height / 2) + 'px'
|
|
744
|
+
pillar.style.height = '120px'
|
|
745
|
+
renderReadout(r, true)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function renderReadout(b, present) {
|
|
749
|
+
readout.innerHTML =
|
|
750
|
+
'<span class="k">publish</span>({ x:' + b.x + ', y:' + b.y +
|
|
751
|
+
', w:' + b.width + ', h:' + b.height + ' })' +
|
|
752
|
+
(present ? '' : ' <span class="k">// detached</span>')
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Start the real anchor; split resizes are driven by view-anchor's own ResizeObserver.
|
|
756
|
+
const handle = createViewAnchor(placeholder, { present: presentEl.checked, publish })
|
|
757
|
+
|
|
758
|
+
presentEl.addEventListener('change', () => {
|
|
759
|
+
handle.update({ present: presentEl.checked, publish })
|
|
760
|
+
})
|
|
761
|
+
splitEl.addEventListener('input', () => {
|
|
762
|
+
placeholder.style.flexBasis = splitEl.value + '%'
|
|
763
|
+
})
|
|
764
|
+
// Explode defaults ON: apply the exploded pose at boot — uncheck to see the flat view.
|
|
765
|
+
if (explodeEl.checked) scene.classList.add('exploded')
|
|
766
|
+
|
|
767
|
+
explodeEl.addEventListener('change', () => {
|
|
768
|
+
scene.classList.toggle('exploded', explodeEl.checked)
|
|
769
|
+
})
|
|
770
|
+
</script>
|
|
771
|
+
</body>
|
|
772
|
+
</html>
|