@displayxr/inline3d 1.0.0 → 1.1.0
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/CHANGELOG.md +132 -0
- package/README.md +10 -1
- package/index.d.ts +62 -5
- package/js/inline3d-model.js +169 -0
- package/js/inline3d-splat.js +333 -0
- package/js/inline3d-viewer.js +553 -0
- package/js/inline3d.js +628 -19
- package/model.d.ts +64 -0
- package/package.json +25 -2
- package/splat.d.ts +91 -0
- package/viewer.d.ts +94 -0
package/js/inline3d.js
CHANGED
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
// { supported:false } and your page shows its normal 2D content — inline-3D is progressive
|
|
25
25
|
// enhancement, never a hard dependency.
|
|
26
26
|
|
|
27
|
+
// The document's single live manager. The browser's per-frame element-rect report is a
|
|
28
|
+
// WHOLE-WIDGET setter — each live session pushes the complete list of rects to weave — so two
|
|
29
|
+
// managers in one document overwrite each other frame by frame and neither one's tiles hold
|
|
30
|
+
// still. Tracked here only to warn: sequential sessions (a route change that closes one
|
|
31
|
+
// manager and opens the next) are legitimate and the common case, so nothing is refused.
|
|
32
|
+
let liveManager = null;
|
|
33
|
+
|
|
27
34
|
const hasWebXR = () => typeof navigator !== 'undefined' && !!navigator.xr;
|
|
28
35
|
const hasLayer = () =>
|
|
29
36
|
typeof window !== 'undefined' && typeof window.XRDisplayLayer === 'function';
|
|
@@ -34,6 +41,81 @@ const hasLayer = () =>
|
|
|
34
41
|
// no-op — the page still works, the overlay just weaves like before.
|
|
35
42
|
const hasExclusion = () =>
|
|
36
43
|
hasLayer() && 'excludeElement' in window.XRDisplayLayer.prototype;
|
|
44
|
+
// ── draw-order occlusion (browser Phase 2, browser patches 0063/0064) ─────────────────────
|
|
45
|
+
//
|
|
46
|
+
// The browser composites ANY 2D content over a woven tile per-pixel BY DRAW ORDER — headers,
|
|
47
|
+
// badges, dropdowns, translucent scrims, even a full-tile plate — with nothing declared by the
|
|
48
|
+
// page. Every exclusion mechanism in this file (auto-chrome, page-global overlays,
|
|
49
|
+
// data-inline3d-overlay, handle.exclude) exists only to fake that on browsers without it, so
|
|
50
|
+
// where it is on, all of it stands down.
|
|
51
|
+
//
|
|
52
|
+
// DETECTION IS A CAPABILITY READ, NEVER A VERSION. `excludeElement` is still fully present on a
|
|
53
|
+
// Phase-2 browser (the browser change is viz-side; it touches no Blink file, and the declarations
|
|
54
|
+
// are collected as before and simply have no effect downstream), so `hasExclusion()` cannot tell
|
|
55
|
+
// the generations apart — and a UA/version gate is worthless for a page that pins an SDK for
|
|
56
|
+
// years. The signal is a capability flag the browser exposes; ABSENT today, so this reads false
|
|
57
|
+
// on everything currently shipping and the legacy path below runs unchanged.
|
|
58
|
+
//
|
|
59
|
+
// TWO THINGS THE FLAG'S SHAPE HAS TO RESPECT, both learned the hard way:
|
|
60
|
+
// 1. NEVER read the value off `XRDisplayLayer.prototype`. A Blink IDL attribute getter throws
|
|
61
|
+
// `TypeError: Illegal invocation` when its receiver is the prototype instead of an instance,
|
|
62
|
+
// so the "obvious" probe `!!XRDisplayLayer.prototype.occlusionByDrawOrder` would THROW on
|
|
63
|
+
// precisely the browser it is meant to detect. `'x' in prototype` is safe (no getter call)
|
|
64
|
+
// but reports only presence.
|
|
65
|
+
// 2. Presence is not the answer. The browser's split is switch-gated
|
|
66
|
+
// (`--inline-3d-occlusion`, off by default until it becomes the default), so a build that
|
|
67
|
+
// HAS the attribute can still legitimately report false, and the truth is process-wide
|
|
68
|
+
// rather than per-layer.
|
|
69
|
+
// Hence the shape asked of the browser: a STATIC readonly boolean on the interface object,
|
|
70
|
+
// `XRDisplayLayer.occlusionByDrawOrder` — process-wide like the switch it reflects, readable
|
|
71
|
+
// with no session and no layer (a page decides its DOM before it ever creates one), and immune
|
|
72
|
+
// to (1) because there is no prototype receiver involved. Should the flag instead land as a
|
|
73
|
+
// per-instance attribute, sampleDrawOrderOcclusion() below picks it up off the first live layer.
|
|
74
|
+
let drawOrderOcclusion = null; // null = not decided yet for this document
|
|
75
|
+
function hasDrawOrderOcclusion() {
|
|
76
|
+
if (drawOrderOcclusion !== null) return drawOrderOcclusion;
|
|
77
|
+
if (!hasLayer()) return false;
|
|
78
|
+
try {
|
|
79
|
+
const v = window.XRDisplayLayer.occlusionByDrawOrder;
|
|
80
|
+
if (typeof v === 'boolean') return (drawOrderOcclusion = v); // static flag: authoritative
|
|
81
|
+
} catch {
|
|
82
|
+
/* a capability flag that throws is no capability — fall through to the instance path */
|
|
83
|
+
}
|
|
84
|
+
return false; // undecided reads as false: the legacy path is the safe default
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Per-instance fallback: read the flag off a real layer, once, if that is the shape it landed
|
|
89
|
+
* in. Presence is probed on the prototype with `in` (safe) and the VALUE is read from the
|
|
90
|
+
* instance (the only legal receiver). Returns the decided value, or null if the browser exposes
|
|
91
|
+
* no flag at all — in which case nothing is cached and the legacy path stays on.
|
|
92
|
+
*/
|
|
93
|
+
function sampleDrawOrderOcclusion(layer) {
|
|
94
|
+
if (drawOrderOcclusion !== null) return drawOrderOcclusion;
|
|
95
|
+
if (!layer || !hasLayer()) return null;
|
|
96
|
+
if (!('occlusionByDrawOrder' in window.XRDisplayLayer.prototype)) return null;
|
|
97
|
+
try {
|
|
98
|
+
return (drawOrderOcclusion = !!layer.occlusionByDrawOrder);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The "you don't need this any more" notice, at most once per document: the legacy calls stay
|
|
105
|
+
// live API (they must, so one page runs on both generations), so this is not a warning — it is
|
|
106
|
+
// the one line that stops an author debugging an exclusion that is correctly doing nothing.
|
|
107
|
+
let notedAutomaticOcclusion = false;
|
|
108
|
+
function noteAutomaticOcclusion() {
|
|
109
|
+
if (notedAutomaticOcclusion) return;
|
|
110
|
+
notedAutomaticOcclusion = true;
|
|
111
|
+
console.info(
|
|
112
|
+
'[inline3d] This browser composites 2D over woven 3D automatically, per-pixel by draw ' +
|
|
113
|
+
'order — overlay exclusion is obsolete here, so exclude()/addGlobalOverlay()/' +
|
|
114
|
+
'data-inline3d-overlay/autoChrome are accepted and ignored. Your 2D chrome already ' +
|
|
115
|
+
'occludes the tiles correctly. The calls are harmless (keep them if you also ship to ' +
|
|
116
|
+
'older DisplayXR Browsers); gate on inline3dOcclusionByDrawOrder() to drop them.'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
37
119
|
|
|
38
120
|
/**
|
|
39
121
|
* Cheap, synchronous "can this browser even attempt inline-3D?" gate — true only in the
|
|
@@ -49,14 +131,43 @@ export function inline3DAvailable() {
|
|
|
49
131
|
}
|
|
50
132
|
|
|
51
133
|
/**
|
|
52
|
-
* True when
|
|
53
|
-
* 2D
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
134
|
+
* True when 2D painted ON a woven tile (hover plate, badge, sticky header) composites as
|
|
135
|
+
* crisp 2D over the woven 3D instead of being woven — by declaration (browser#18 overlay
|
|
136
|
+
* exclusion) or, on a newer browser, automatically. Use it to choose the on-image overlay
|
|
137
|
+
* path when available and a weave-safe fallback (e.g. a caption band below the tile)
|
|
138
|
+
* otherwise. That question has the same answer on both generations, so this stays true on a
|
|
139
|
+
* draw-order-occlusion browser; ask inline3dOcclusionByDrawOrder() when you need to know
|
|
140
|
+
* WHICH mechanism you are on. Implies inline3DAvailable(). Sync + cheap.
|
|
57
141
|
*/
|
|
58
142
|
export function inline3dOverlaySupported() {
|
|
59
|
-
return hasExclusion();
|
|
143
|
+
return hasExclusion() || hasDrawOrderOcclusion();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* True when the browser occludes woven tiles with 2D content AUTOMATICALLY — any 2D that
|
|
148
|
+
* paints over a tile (header, badge, dropdown, translucent scrim) composites per-pixel by
|
|
149
|
+
* draw order, with nothing declared. When true, this SDK's exclusion machinery is off:
|
|
150
|
+
* `autoChrome` does not scan, `data-inline3d-overlay` is not watched, and
|
|
151
|
+
* `exclude()`/`addGlobalOverlay()` are accepted (so one page runs on both generations) but do
|
|
152
|
+
* nothing — including the `will-change` promotion they used to force on your elements.
|
|
153
|
+
*
|
|
154
|
+
* Pages need not branch on this at all: the legacy calls are harmless where it is true, and
|
|
155
|
+
* still required where it is false. Branch only to skip work of your own — a `data-` attribute
|
|
156
|
+
* you would otherwise maintain, a full-tile plate the legacy path has to refuse, or a
|
|
157
|
+
* near-solid background you only keep because a translucent bar used to be risky.
|
|
158
|
+
*
|
|
159
|
+
* Sync + cheap. Reads a readonly capability flag on `XRDisplayLayer`, never a version or UA
|
|
160
|
+
* string; false on every browser that has not exposed the flag, which is the safe answer (the
|
|
161
|
+
* SDK then runs the legacy exclusion path, which is what such a browser needs).
|
|
162
|
+
*
|
|
163
|
+
* One caveat if the flag lands as a per-layer attribute rather than the static one this SDK asks
|
|
164
|
+
* for: it can only be read once a layer exists, so a call made before the first window activates
|
|
165
|
+
* answers false and the same call answers true a frame later. Nothing in the SDK depends on the
|
|
166
|
+
* early answer, but a page that wants to branch its DOM up front should re-check (or just leave
|
|
167
|
+
* the legacy calls in — they are harmless).
|
|
168
|
+
*/
|
|
169
|
+
export function inline3dOcclusionByDrawOrder() {
|
|
170
|
+
return hasDrawOrderOcclusion();
|
|
60
171
|
}
|
|
61
172
|
|
|
62
173
|
/**
|
|
@@ -70,10 +181,18 @@ export function inline3dOverlaySupported() {
|
|
|
70
181
|
* @param {string} [opts.rootMargin='50% 0px'] IntersectionObserver margin for lazy mode;
|
|
71
182
|
* the default pre-arms a window half a viewport early so a fast scroll never shows a
|
|
72
183
|
* raw (un-woven) frame.
|
|
184
|
+
* @param {boolean} [opts.autoChrome=true] Auto-exclude page chrome: sticky/fixed elements
|
|
185
|
+
* near the top of the DOM (headers, toolbars) are registered as page-global overlays
|
|
186
|
+
* automatically — the bar itself plus its text/replaced descendants — so woven
|
|
187
|
+
* windows scroll UNDER the chrome without any per-app wiring. Opt an element (and
|
|
188
|
+
* its subtree) out with `data-inline3d-no-overlay`; set false to manage chrome
|
|
189
|
+
* exclusively via addGlobalOverlay()/data-inline3d-overlay. Ignored (nothing is
|
|
190
|
+
* scanned, no `will-change` is set on your DOM) on a browser with draw-order
|
|
191
|
+
* occlusion, where chrome occludes tiles by itself.
|
|
73
192
|
* @returns {Promise<Inline3D | {supported:false, error?:Error}>}
|
|
74
193
|
*/
|
|
75
194
|
export async function createInline3D(opts = {}) {
|
|
76
|
-
const { referenceSpace = 'viewer', lazy = true, rootMargin = '50% 0px' } = opts;
|
|
195
|
+
const { referenceSpace = 'viewer', lazy = true, rootMargin = '50% 0px', autoChrome = true } = opts;
|
|
77
196
|
if (!inline3DAvailable()) return { supported: false };
|
|
78
197
|
let session;
|
|
79
198
|
try {
|
|
@@ -89,7 +208,7 @@ export async function createInline3D(opts = {}) {
|
|
|
89
208
|
} catch {
|
|
90
209
|
/* rAF still fires without a ref space; views are just null (fine for image/video). */
|
|
91
210
|
}
|
|
92
|
-
return new Inline3D(session, refSpace, { lazy, rootMargin });
|
|
211
|
+
return new Inline3D(session, refSpace, { lazy, rootMargin, autoChrome });
|
|
93
212
|
}
|
|
94
213
|
|
|
95
214
|
/**
|
|
@@ -110,8 +229,38 @@ export async function startInline3D(
|
|
|
110
229
|
return { supported: true, wall, session: wall.session, close: () => wall.close() };
|
|
111
230
|
}
|
|
112
231
|
|
|
232
|
+
// Mutual rect-overlap fraction at which an overlay becomes indistinguishable from the canvas
|
|
233
|
+
// it sits on — the same >=70% the browser's own layer matcher uses. See _isFullTileOverlay.
|
|
234
|
+
const FULL_TILE_OVERLAP = 0.7;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Elements inside `root` worth their own overlay plate: anything with a direct
|
|
238
|
+
* non-whitespace text node, plus replaced/painted elements (img, svg, video,
|
|
239
|
+
* canvas, form controls). See _scanChrome for why chrome text is plated
|
|
240
|
+
* per-element instead of relying on the bar's own raster (browser#83).
|
|
241
|
+
*/
|
|
242
|
+
const CHROME_REPLACED = new Set(['IMG', 'SVG', 'VIDEO', 'CANVAS', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA']);
|
|
243
|
+
function chromeTextPlates(root) {
|
|
244
|
+
const plates = [];
|
|
245
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
246
|
+
for (let el = walker.nextNode(); el; el = walker.nextNode()) {
|
|
247
|
+
if (el.closest('[data-inline3d-no-overlay]')) continue;
|
|
248
|
+
if (CHROME_REPLACED.has(el.tagName.toUpperCase())) {
|
|
249
|
+
plates.push(el);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
for (const child of el.childNodes) {
|
|
253
|
+
if (child.nodeType === Node.TEXT_NODE && child.nodeValue.trim()) {
|
|
254
|
+
plates.push(el);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return plates;
|
|
260
|
+
}
|
|
261
|
+
|
|
113
262
|
class Inline3D {
|
|
114
|
-
constructor(session, refSpace, { lazy, rootMargin }) {
|
|
263
|
+
constructor(session, refSpace, { lazy, rootMargin, autoChrome = true }) {
|
|
115
264
|
this.supported = true;
|
|
116
265
|
this.session = session;
|
|
117
266
|
this.refSpace = refSpace;
|
|
@@ -125,14 +274,46 @@ class Inline3D {
|
|
|
125
274
|
// input and gets woven). Page-global overlays span many windows, so they are
|
|
126
275
|
// exactly the case that breaks.
|
|
127
276
|
this._isolatedBy = new WeakMap();
|
|
277
|
+
// Auto-chrome (sticky/fixed page furniture found by _scanChrome). Tracked apart
|
|
278
|
+
// from _globalOverlays so pruning disconnected chrome never touches overlays the
|
|
279
|
+
// app registered itself.
|
|
280
|
+
this._autoChrome = autoChrome;
|
|
281
|
+
this._autoChromeEls = new Set();
|
|
282
|
+
this._lastChromeScan = 0;
|
|
283
|
+
// Elements already reported as full-tile overlays, so the refusal is logged once per
|
|
284
|
+
// element instead of on every re-activate / overlay-scan sync.
|
|
285
|
+
this._fullTileWarned = new WeakSet();
|
|
286
|
+
// Set once the legacy occlusion machinery has been retired (draw-order browser whose
|
|
287
|
+
// capability flag could only be read from a live layer). See _standDownLegacyOcclusion.
|
|
288
|
+
this._stoodDown = false;
|
|
128
289
|
this._running = true;
|
|
129
290
|
this._lazy = lazy;
|
|
130
291
|
this._observer =
|
|
131
292
|
lazy && typeof IntersectionObserver === 'function'
|
|
132
293
|
? new IntersectionObserver((entries) => this._onIntersect(entries), { rootMargin })
|
|
133
294
|
: null;
|
|
295
|
+
// Frame-loop bookkeeping: one loop, identified, so a restart can retire a stalled
|
|
296
|
+
// predecessor instead of running two (see _requestFrame / _watchForStalledFrames).
|
|
297
|
+
this._loopId = 0;
|
|
298
|
+
this._framePending = false;
|
|
299
|
+
this._frameCount = 0;
|
|
300
|
+
this._frameWatchdog = null;
|
|
301
|
+
this._suspended = null;
|
|
302
|
+
if (liveManager && liveManager._running && liveManager !== this) {
|
|
303
|
+
console.warn(
|
|
304
|
+
'[inline3d] A second inline-3D session is live in this document. The browser\'s ' +
|
|
305
|
+
'element-rect channel is a whole-widget setter, so both managers clobber each ' +
|
|
306
|
+
"other's rect list every frame — tiles may flicker, ghost, or weave at a stale " +
|
|
307
|
+
'rect. Use ONE createInline3D() per document and add every window to it; if you ' +
|
|
308
|
+
'are switching views, close() the previous manager first.'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
liveManager = this;
|
|
134
312
|
session.addEventListener('end', () => this._teardown());
|
|
135
|
-
|
|
313
|
+
this._scanChrome(); // page chrome usually exists before the session does
|
|
314
|
+
this._bindLifecycle();
|
|
315
|
+
this._armDprWatch();
|
|
316
|
+
this._requestFrame();
|
|
136
317
|
}
|
|
137
318
|
|
|
138
319
|
/** Number of windows whose weave layer is currently live (on-screen in lazy mode). */
|
|
@@ -153,9 +334,18 @@ class Inline3D {
|
|
|
153
334
|
* but the per-tile present can still seam page-global chrome that spans tile gaps during
|
|
154
335
|
* scroll — the systematic fix is the DP-composited whole-window present (browser#22).
|
|
155
336
|
* No-op on browsers without excludeElement (progressive enhancement).
|
|
337
|
+
*
|
|
338
|
+
* @deprecated on a browser with draw-order occlusion (inline3dOcclusionByDrawOrder()):
|
|
339
|
+
* page chrome occludes every tile there with nothing registered. The call is accepted and
|
|
340
|
+
* stored, does nothing, and stays required on older browsers — so keep it unless your page
|
|
341
|
+
* targets Phase-2 browsers only.
|
|
156
342
|
*/
|
|
157
343
|
addGlobalOverlay(el) {
|
|
158
344
|
if (!el || this._globalOverlays.has(el)) return;
|
|
345
|
+
// Store it even where occlusion is automatic: the registration is API, a page may read
|
|
346
|
+
// nothing back but must be able to run unchanged on both browser generations. The
|
|
347
|
+
// exclusion below is a no-op there (see _applyExclusion).
|
|
348
|
+
if (hasDrawOrderOcclusion()) noteAutomaticOcclusion();
|
|
159
349
|
this._globalOverlays.add(el);
|
|
160
350
|
for (const win of this._windows.values()) if (win.layer) this._applyExclusion(win, el);
|
|
161
351
|
}
|
|
@@ -166,6 +356,85 @@ class Inline3D {
|
|
|
166
356
|
for (const win of this._windows.values()) if (win.layer) this._dropExclusion(win, el);
|
|
167
357
|
}
|
|
168
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Auto-chrome scan: find sticky/fixed page furniture and register it as page-global
|
|
361
|
+
* overlays, no app wiring required. Runs at session start and again on every layer
|
|
362
|
+
* activation (throttled) so late-mounted chrome is picked up as tiles churn.
|
|
363
|
+
*
|
|
364
|
+
* Two deliberate choices:
|
|
365
|
+
* - SHALLOW scan (top 3 DOM levels under <body>): page chrome lives there; a deep
|
|
366
|
+
* sticky element (a table header inside a scroller) is content, not chrome.
|
|
367
|
+
* - Besides the chrome element itself, its TEXT / replaced descendants are registered
|
|
368
|
+
* individually (browser#83): the browser re-composites an excluded element by
|
|
369
|
+
* geometrically matching its rect to a composited-layer quad (>=70% area overlap),
|
|
370
|
+
* and a full-width bar can raster as several cc tile quads — each a fraction of the
|
|
371
|
+
* bar's rect, so none match and the bar never stages. A near-solid bar hides that
|
|
372
|
+
* failure everywhere except its text (a uniform color weaves to itself). The small
|
|
373
|
+
* per-text plates each promote to their own layer and match ~1:1, closing the
|
|
374
|
+
* visible failure regardless of how the bar rasters.
|
|
375
|
+
*
|
|
376
|
+
* Opt-out: `data-inline3d-no-overlay` on an element skips it and its whole subtree.
|
|
377
|
+
* Elements containing a woven window are never plated (that would hand the weave
|
|
378
|
+
* input back to the compositor as crisp 2D).
|
|
379
|
+
*/
|
|
380
|
+
_scanChrome() {
|
|
381
|
+
// Draw-order occlusion makes this whole scan pointless work: the chrome already occludes
|
|
382
|
+
// every tile per-pixel. Bail BEFORE the DOM walk, so a Phase-2 page pays neither the
|
|
383
|
+
// querySelectorAll + getComputedStyle sweep (once a second, at every layer activation)
|
|
384
|
+
// nor the `will-change` promotions it would hand out across the page's furniture.
|
|
385
|
+
if (hasDrawOrderOcclusion()) return;
|
|
386
|
+
if (!this._autoChrome || !hasExclusion() || typeof document === 'undefined') return;
|
|
387
|
+
const now = Date.now();
|
|
388
|
+
if (now - this._lastChromeScan < 1000) return; // activations burst during scroll
|
|
389
|
+
this._lastChromeScan = now;
|
|
390
|
+
const body = document.body;
|
|
391
|
+
if (!body) return;
|
|
392
|
+
const found = new Set();
|
|
393
|
+
const candidates = body.querySelectorAll(':scope > *, :scope > * > *, :scope > * > * > *');
|
|
394
|
+
for (const el of candidates) {
|
|
395
|
+
if (el.closest('[data-inline3d-no-overlay]')) continue;
|
|
396
|
+
const pos = getComputedStyle(el).position;
|
|
397
|
+
if (pos !== 'fixed' && pos !== 'sticky') continue;
|
|
398
|
+
let containsWindow = false;
|
|
399
|
+
for (const canvas of this._windows.keys()) {
|
|
400
|
+
if (el === canvas || el.contains(canvas)) {
|
|
401
|
+
containsWindow = true;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (containsWindow) continue;
|
|
406
|
+
found.add(el);
|
|
407
|
+
for (const plate of chromeTextPlates(el)) found.add(plate);
|
|
408
|
+
}
|
|
409
|
+
// Prune auto-registrations that (a) left the document, or (b) NOW contain a woven
|
|
410
|
+
// window (windows register after the constructor's first scan — leaving such a
|
|
411
|
+
// wrapper plated would hand the tile back to the compositor as crisp 2D). Connected,
|
|
412
|
+
// window-free elements are left alone even when no longer detected: a still-connected
|
|
413
|
+
// element may also have been registered by the app, and _globalOverlays is one set —
|
|
414
|
+
// never yank something the app might be counting on.
|
|
415
|
+
for (const el of this._autoChromeEls) {
|
|
416
|
+
let containsWindow = false;
|
|
417
|
+
if (el.isConnected) {
|
|
418
|
+
for (const canvas of this._windows.keys()) {
|
|
419
|
+
if (el === canvas || el.contains(canvas)) {
|
|
420
|
+
containsWindow = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (!el.isConnected || containsWindow) {
|
|
426
|
+
this._autoChromeEls.delete(el);
|
|
427
|
+
this.removeGlobalOverlay(el);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const el of found) {
|
|
431
|
+
if (!this._globalOverlays.has(el)) {
|
|
432
|
+
this._autoChromeEls.add(el);
|
|
433
|
+
this.addGlobalOverlay(el);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
169
438
|
/**
|
|
170
439
|
* Weave a still side-by-side 3D image into `canvas`.
|
|
171
440
|
* @param {HTMLCanvasElement} canvas a 2D canvas; the SDK owns its backing buffer.
|
|
@@ -240,12 +509,18 @@ class Inline3D {
|
|
|
240
509
|
* like before — progressive enhancement, like the rest of this SDK). Prefer the
|
|
241
510
|
* declarative `data-inline3d-overlay` attribute (see _startOverlayScan) unless you need
|
|
242
511
|
* to exclude an element outside the window's container.
|
|
512
|
+
*
|
|
513
|
+
* On a browser with draw-order occlusion the overlay is already composited over the woven
|
|
514
|
+
* 3D per-pixel, so exclude()/unexclude() are stored-and-ignored (see _applyExclusion).
|
|
243
515
|
*/
|
|
244
516
|
_handle(canvas, win) {
|
|
245
517
|
return {
|
|
246
518
|
remove: () => this._remove(canvas),
|
|
247
519
|
exclude: (el) => {
|
|
248
520
|
if (!el) return;
|
|
521
|
+
// Stored, not honoured, where occlusion is automatic — same reason as
|
|
522
|
+
// addGlobalOverlay: one page, both browser generations.
|
|
523
|
+
if (hasDrawOrderOcclusion()) noteAutomaticOcclusion();
|
|
249
524
|
win.excluded.add(el);
|
|
250
525
|
this._applyExclusion(win, el);
|
|
251
526
|
},
|
|
@@ -296,6 +571,9 @@ class Inline3D {
|
|
|
296
571
|
excluded: new Set(),
|
|
297
572
|
autoExcluded: new Set(),
|
|
298
573
|
overlayObserver: null,
|
|
574
|
+
// Box/dpr watch, live only while the window is (see _startSizeWatch).
|
|
575
|
+
sizeObserver: null,
|
|
576
|
+
resizePending: false,
|
|
299
577
|
};
|
|
300
578
|
this._windows.set(canvas, win);
|
|
301
579
|
if (this._lazy && this._observer) {
|
|
@@ -332,6 +610,9 @@ class Inline3D {
|
|
|
332
610
|
|
|
333
611
|
_activate(win) {
|
|
334
612
|
if (win.layer) return;
|
|
613
|
+
// Pick up page chrome (incl. late-mounted) before the exclusion loop below —
|
|
614
|
+
// layers churn with scroll, so activations double as cheap rescan points.
|
|
615
|
+
this._scanChrome();
|
|
335
616
|
try {
|
|
336
617
|
// virtualDisplayHeight (display-rig m2v) tells the runtime what scale this
|
|
337
618
|
// window's scene is authored at, so it returns render-ready scaled views.
|
|
@@ -342,6 +623,10 @@ class Inline3D {
|
|
|
342
623
|
win.layer = null;
|
|
343
624
|
return;
|
|
344
625
|
}
|
|
626
|
+
// First real layer: if the occlusion capability is per-instance, this is the earliest point
|
|
627
|
+
// it can be read (see sampleDrawOrderOcclusion) — and if it says the browser occludes by
|
|
628
|
+
// draw order, retire whatever legacy machinery already started before we could know.
|
|
629
|
+
if (sampleDrawOrderOcclusion(win.layer) === true) this._standDownLegacyOcclusion();
|
|
345
630
|
// Re-apply overlay exclusions (browser#18): the browser's layer-side set died with
|
|
346
631
|
// the previous layer (lazy close), so a re-activated window must re-declare its own
|
|
347
632
|
// explicit exclusions, the page-global overlays, and the attribute-scanned overlays,
|
|
@@ -353,10 +638,12 @@ class Inline3D {
|
|
|
353
638
|
this._sizeBuffer(win, /*sbs*/ true);
|
|
354
639
|
this._paint(win, null); // first SBS paint (video will refresh each frame)
|
|
355
640
|
}
|
|
641
|
+
this._startSizeWatch(win);
|
|
356
642
|
}
|
|
357
643
|
|
|
358
644
|
_deactivate(win) {
|
|
359
645
|
this._stopOverlayScan(win);
|
|
646
|
+
this._stopSizeWatch(win);
|
|
360
647
|
if (win.layer) {
|
|
361
648
|
try {
|
|
362
649
|
win.layer.close();
|
|
@@ -374,8 +661,60 @@ class Inline3D {
|
|
|
374
661
|
|
|
375
662
|
// ── overlay exclusion (browser#18) ─────────────────────────────────────────────────
|
|
376
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Refuse a FULL-TILE overlay — an element whose rect is (near-)congruent with its own
|
|
666
|
+
* window's canvas.
|
|
667
|
+
*
|
|
668
|
+
* The browser re-composites an excluded element by geometrically matching its rect to a
|
|
669
|
+
* composited-layer quad (>=70% area overlap, see _scanChrome). A plate that covers the whole
|
|
670
|
+
* tile matches the tile's OWN canvas quad, so the CANVAS gets staged as the overlay: it
|
|
671
|
+
* leaves the weave input entirely and the tile presents its raw side-by-side buffer —
|
|
672
|
+
* squished halves, no 3D. That is a destroyed tile, not a degraded one, so skip the
|
|
673
|
+
* exclusion and say why instead of honouring it.
|
|
674
|
+
*
|
|
675
|
+
* The test is MUTUAL (>=70% of both rects) so page-global chrome stays legal: a sticky
|
|
676
|
+
* header may cover a small tile completely, but the tile is a small fraction of the header,
|
|
677
|
+
* so the header never looks congruent with any one canvas.
|
|
678
|
+
*
|
|
679
|
+
* Limit: it judges the rect it can measure now. A plate that is display:none at
|
|
680
|
+
* registration measures empty (and excluding it is harmless while hidden), so a plate that
|
|
681
|
+
* only becomes full-tile once shown slips through — the authoring rule stands on its own
|
|
682
|
+
* (docs/authoring-inline-3d.md § 2D overlays ON a 3D window).
|
|
683
|
+
*/
|
|
684
|
+
_isFullTileOverlay(win, el) {
|
|
685
|
+
if (!el || typeof el.getBoundingClientRect !== 'function') return false;
|
|
686
|
+
const e = el.getBoundingClientRect();
|
|
687
|
+
const c = win.canvas.getBoundingClientRect();
|
|
688
|
+
const eArea = e.width * e.height;
|
|
689
|
+
const cArea = c.width * c.height;
|
|
690
|
+
if (eArea <= 0 || cArea <= 0) return false; // hidden / detached — nothing to judge
|
|
691
|
+
const iw = Math.min(e.right, c.right) - Math.max(e.left, c.left);
|
|
692
|
+
const ih = Math.min(e.bottom, c.bottom) - Math.max(e.top, c.top);
|
|
693
|
+
if (iw <= 0 || ih <= 0) return false;
|
|
694
|
+
const inter = iw * ih;
|
|
695
|
+
if (inter / eArea < FULL_TILE_OVERLAP || inter / cArea < FULL_TILE_OVERLAP) return false;
|
|
696
|
+
if (!this._fullTileWarned.has(el)) {
|
|
697
|
+
this._fullTileWarned.add(el);
|
|
698
|
+
console.warn(
|
|
699
|
+
'[inline3d] Refusing a full-tile overlay: this element covers its own woven canvas, ' +
|
|
700
|
+
"and the browser's geometric matcher cannot tell the two apart — it would stage " +
|
|
701
|
+
'the CANVAS as the overlay and the tile would show its raw side-by-side buffer ' +
|
|
702
|
+
'instead of 3D. Make the overlay a PARTIAL region of the tile (a caption band, a ' +
|
|
703
|
+
'badge, a corner plate), or move it outside the tile and register it with ' +
|
|
704
|
+
'addGlobalOverlay().',
|
|
705
|
+
el
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
|
|
377
711
|
_applyExclusion(win, el) {
|
|
712
|
+
// Automatic occlusion: nothing to declare, and nothing to promote. Returning here (before
|
|
713
|
+
// the full-tile guard) is also why a full-tile plate is legal on such a browser — there is
|
|
714
|
+
// no geometric matcher to confuse, so there is no refusal and no warning.
|
|
715
|
+
if (hasDrawOrderOcclusion()) return;
|
|
378
716
|
if (!win.layer || !hasExclusion()) return;
|
|
717
|
+
if (this._isFullTileOverlay(win, el)) return;
|
|
379
718
|
// Force the overlay onto its OWN composited layer so the browser can grab it
|
|
380
719
|
// as an isolated resource (the element rastered on transparency) and
|
|
381
720
|
// composite it OVER the woven 3D — final = plate + (1−plate.a)·woven, true
|
|
@@ -405,14 +744,13 @@ class Inline3D {
|
|
|
405
744
|
}
|
|
406
745
|
|
|
407
746
|
_dropExclusion(win, el) {
|
|
747
|
+
// Nothing was ever excluded or promoted, so there is nothing to undo — and in particular
|
|
748
|
+
// this must not touch the element's `will-change`, which is the page's own here.
|
|
749
|
+
if (hasDrawOrderOcclusion()) return;
|
|
408
750
|
const refs = this._isolatedBy.get(el);
|
|
409
751
|
if (refs) refs.delete(win);
|
|
410
752
|
// Only un-promote once NO window needs this element isolated any more.
|
|
411
|
-
if (
|
|
412
|
-
el.style.willChange = el.dataset.inline3dPriorWillChange || '';
|
|
413
|
-
delete el.dataset.inline3dPriorWillChange;
|
|
414
|
-
delete el.dataset.inline3dIsolated;
|
|
415
|
-
}
|
|
753
|
+
if (!refs || refs.size === 0) this._unpromote(el);
|
|
416
754
|
if (!win.layer || !hasExclusion()) return;
|
|
417
755
|
try {
|
|
418
756
|
win.layer.unexcludeElement(el);
|
|
@@ -421,6 +759,53 @@ class Inline3D {
|
|
|
421
759
|
}
|
|
422
760
|
}
|
|
423
761
|
|
|
762
|
+
/**
|
|
763
|
+
* Undo the SDK's own compositing promotion on `el`, restoring the `will-change` the page had
|
|
764
|
+
* (which may be none). Only ever touches an element the SDK promoted — the marker dataset
|
|
765
|
+
* flag is what says so. Returns whether there was anything to undo.
|
|
766
|
+
*/
|
|
767
|
+
_unpromote(el) {
|
|
768
|
+
if (!el || !el.dataset || !el.dataset.inline3dIsolated) return false;
|
|
769
|
+
el.style.willChange = el.dataset.inline3dPriorWillChange || '';
|
|
770
|
+
delete el.dataset.inline3dPriorWillChange;
|
|
771
|
+
delete el.dataset.inline3dIsolated;
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Retire the legacy occlusion machinery, once, on learning the browser occludes by draw order.
|
|
777
|
+
*
|
|
778
|
+
* Only reachable when the capability could not be read until the first layer existed (the
|
|
779
|
+
* per-instance flag shape): by then one auto-chrome scan may have run and promoted page
|
|
780
|
+
* furniture. Where the flag is readable up front — the shape this SDK asks for — nothing has
|
|
781
|
+
* started and this finds nothing to do.
|
|
782
|
+
*
|
|
783
|
+
* Registrations the APP made are kept as API state (its `removeGlobalOverlay(el)` must still
|
|
784
|
+
* find `el` registered); only the SDK's own side effects on the page's DOM are reversed. The
|
|
785
|
+
* auto-chrome set is dropped entirely: it was never the app's, and nothing will re-add it.
|
|
786
|
+
*/
|
|
787
|
+
_standDownLegacyOcclusion() {
|
|
788
|
+
if (this._stoodDown) return;
|
|
789
|
+
this._stoodDown = true;
|
|
790
|
+
let retired = false;
|
|
791
|
+
for (const win of this._windows.values()) {
|
|
792
|
+
if (win.overlayObserver) {
|
|
793
|
+
this._stopOverlayScan(win);
|
|
794
|
+
retired = true;
|
|
795
|
+
}
|
|
796
|
+
for (const el of win.excluded) if (this._unpromote(el)) retired = true;
|
|
797
|
+
}
|
|
798
|
+
for (const el of this._autoChromeEls) {
|
|
799
|
+
this._globalOverlays.delete(el);
|
|
800
|
+
this._unpromote(el);
|
|
801
|
+
retired = true;
|
|
802
|
+
}
|
|
803
|
+
this._autoChromeEls.clear();
|
|
804
|
+
for (const el of this._globalOverlays) if (this._unpromote(el)) retired = true;
|
|
805
|
+
this._isolatedBy = new WeakMap(); // every promotion is gone; the refcounts with them
|
|
806
|
+
if (retired) noteAutomaticOcclusion(); // only worth saying if work was actually thrown away
|
|
807
|
+
}
|
|
808
|
+
|
|
424
809
|
// Declarative overlays: any element marked `data-inline3d-overlay` inside the window's
|
|
425
810
|
// container (the canvas's parent — where an over-the-window plate must live to be
|
|
426
811
|
// positioned over it) is auto-excluded while the window is live, and tracked through
|
|
@@ -430,6 +815,10 @@ class Inline3D {
|
|
|
430
815
|
// with display, not opacity/visibility: those still report a full rect, so the weave
|
|
431
816
|
// hole would stay punched under an invisible plate.)
|
|
432
817
|
_startOverlayScan(win) {
|
|
818
|
+
// No observer at all where occlusion is automatic: `data-inline3d-overlay` needs no
|
|
819
|
+
// honouring, so a page keeps its attributes (harmless, portable) and pays no
|
|
820
|
+
// MutationObserver per live tile.
|
|
821
|
+
if (hasDrawOrderOcclusion()) return;
|
|
433
822
|
if (!hasExclusion() || typeof MutationObserver !== 'function') return;
|
|
434
823
|
const container = win.canvas.parentElement;
|
|
435
824
|
if (!container) return;
|
|
@@ -468,10 +857,17 @@ class Inline3D {
|
|
|
468
857
|
win.autoExcluded.clear();
|
|
469
858
|
}
|
|
470
859
|
|
|
471
|
-
|
|
860
|
+
/** The per-eye buffer size this window should have right now (explicit, or box × dpr). */
|
|
861
|
+
_eyeSize(win) {
|
|
472
862
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
473
|
-
|
|
474
|
-
|
|
863
|
+
return {
|
|
864
|
+
w: win.reqW || Math.round((win.canvas.clientWidth || 256) * dpr),
|
|
865
|
+
h: win.reqH || Math.round((win.canvas.clientHeight || 256) * dpr),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
_sizeBuffer(win, sbs) {
|
|
870
|
+
const { w: boxW, h: boxH } = this._eyeSize(win);
|
|
475
871
|
win.eyeW = boxW;
|
|
476
872
|
win.eyeH = boxH;
|
|
477
873
|
win.canvas.width = sbs ? boxW * 2 : boxW; // SBS = two eye tiles wide
|
|
@@ -479,6 +875,92 @@ class Inline3D {
|
|
|
479
875
|
win.sbs = sbs;
|
|
480
876
|
}
|
|
481
877
|
|
|
878
|
+
// ── box / devicePixelRatio changes ──────────────────────────────────────────────────
|
|
879
|
+
//
|
|
880
|
+
// _sizeBuffer runs at activate and deactivate ONLY, so a live window whose CSS box or
|
|
881
|
+
// devicePixelRatio changes underneath it keeps its old backing store: the same SBS pixels
|
|
882
|
+
// are stretched onto a differently-shaped box and the two eyes come out mis-squeezed, with
|
|
883
|
+
// no error, until the tile happens to re-activate. A responsive reflow, a flex sibling
|
|
884
|
+
// appearing, a browser zoom or a drag to a different-scale monitor all do it. So: watch the
|
|
885
|
+
// box while the window is live, and re-derive the buffer when it actually moves.
|
|
886
|
+
|
|
887
|
+
_startSizeWatch(win) {
|
|
888
|
+
if (typeof ResizeObserver !== 'function') return;
|
|
889
|
+
if (win.sizeObserver) return;
|
|
890
|
+
// Scene canvases are the app's (ownsBuffer false) — never touch their width/height.
|
|
891
|
+
// An explicit {width, height} is box-independent by definition, so nothing to watch.
|
|
892
|
+
if (!win.ownsBuffer || (win.reqW && win.reqH)) return;
|
|
893
|
+
win.sizeObserver = new ResizeObserver(() => this._onBoxChange(win));
|
|
894
|
+
win.sizeObserver.observe(win.canvas);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
_stopSizeWatch(win) {
|
|
898
|
+
if (win.sizeObserver) {
|
|
899
|
+
win.sizeObserver.disconnect();
|
|
900
|
+
win.sizeObserver = null;
|
|
901
|
+
}
|
|
902
|
+
win.resizePending = false;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Re-derive one live window's SBS buffer and repaint it. Debounced to one animation frame:
|
|
907
|
+
* ResizeObserver and a dpr flip both fire in bursts during a drag-resize or a zoom, and
|
|
908
|
+
* every resize reallocates the backing store and clears it.
|
|
909
|
+
*/
|
|
910
|
+
_onBoxChange(win) {
|
|
911
|
+
if (!win.layer || !win.ownsBuffer || win.resizePending) return;
|
|
912
|
+
win.resizePending = true;
|
|
913
|
+
const run = () => {
|
|
914
|
+
if (!win.resizePending) return;
|
|
915
|
+
win.resizePending = false;
|
|
916
|
+
if (!win.layer || !win.ownsBuffer) return;
|
|
917
|
+
const { w, h } = this._eyeSize(win);
|
|
918
|
+
if (w === win.eyeW && h === win.eyeH) return; // observer fired, geometry didn't move
|
|
919
|
+
this._sizeBuffer(win, /*sbs*/ true);
|
|
920
|
+
this._paint(win, null); // repaint NOW: setting canvas.width cleared the buffer
|
|
921
|
+
};
|
|
922
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
|
|
923
|
+
else run();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* devicePixelRatio is invisible to ResizeObserver — a browser zoom or a move to a
|
|
928
|
+
* different-scale monitor leaves the CSS box the same number of CSS px while the buffer
|
|
929
|
+
* that box deserves changes. A `(resolution: Ndppx)` query flips exactly when dpr leaves
|
|
930
|
+
* its current value, so arm one, and re-arm it on the new value each time.
|
|
931
|
+
*/
|
|
932
|
+
_armDprWatch() {
|
|
933
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
|
934
|
+
this._disarmDprWatch();
|
|
935
|
+
let q;
|
|
936
|
+
try {
|
|
937
|
+
q = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
|
|
938
|
+
} catch {
|
|
939
|
+
return; // no resolution-query support: box changes are still covered
|
|
940
|
+
}
|
|
941
|
+
const onChange = () => {
|
|
942
|
+
if (!this._running) return;
|
|
943
|
+
this._armDprWatch(); // this query is stale the moment it fires
|
|
944
|
+
for (const win of this._windows.values()) if (win.layer) this._onBoxChange(win);
|
|
945
|
+
};
|
|
946
|
+
try {
|
|
947
|
+
q.addEventListener('change', onChange);
|
|
948
|
+
} catch {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
this._dprWatch = { q, onChange };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
_disarmDprWatch() {
|
|
955
|
+
if (!this._dprWatch) return;
|
|
956
|
+
try {
|
|
957
|
+
this._dprWatch.q.removeEventListener('change', this._dprWatch.onChange);
|
|
958
|
+
} catch {
|
|
959
|
+
/* ignore */
|
|
960
|
+
}
|
|
961
|
+
this._dprWatch = null;
|
|
962
|
+
}
|
|
963
|
+
|
|
482
964
|
_paint(win, _views) {
|
|
483
965
|
if (win.kind === 'scene' || !win.ctx) return;
|
|
484
966
|
const src = win.kind === 'video' ? win.video : win.img;
|
|
@@ -507,9 +989,32 @@ class Inline3D {
|
|
|
507
989
|
}
|
|
508
990
|
}
|
|
509
991
|
|
|
992
|
+
/**
|
|
993
|
+
* Arm the next session frame. `force` starts a NEW loop even though one is nominally
|
|
994
|
+
* pending: each loop carries an id and only the current id re-arms, so a stalled
|
|
995
|
+
* predecessor (a bfcache restore whose callback never fired) is retired rather than
|
|
996
|
+
* doubled if it ever does fire.
|
|
997
|
+
*/
|
|
998
|
+
_requestFrame(force) {
|
|
999
|
+
if (!this._running) return;
|
|
1000
|
+
if (this._framePending && !force) return;
|
|
1001
|
+
const id = force ? ++this._loopId : this._loopId;
|
|
1002
|
+
this._framePending = true;
|
|
1003
|
+
try {
|
|
1004
|
+
this.session.requestAnimationFrame((t, f) => {
|
|
1005
|
+
if (id !== this._loopId) return; // superseded loop — let it die here
|
|
1006
|
+
this._framePending = false;
|
|
1007
|
+
this._frameCount++;
|
|
1008
|
+
this._frame(t, f);
|
|
1009
|
+
});
|
|
1010
|
+
} catch {
|
|
1011
|
+
this._framePending = false; // session going away; 'end' → _teardown handles it
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
510
1015
|
_frame(t, f) {
|
|
511
1016
|
if (!this._running) return;
|
|
512
|
-
this.
|
|
1017
|
+
this._requestFrame();
|
|
513
1018
|
const pose = this.refSpace ? f.getViewerPose(this.refSpace) : null;
|
|
514
1019
|
const views = pose ? pose.views : null;
|
|
515
1020
|
for (const win of this._windows.values()) {
|
|
@@ -527,12 +1032,116 @@ class Inline3D {
|
|
|
527
1032
|
}
|
|
528
1033
|
}
|
|
529
1034
|
|
|
1035
|
+
// ── page lifecycle: bfcache, freeze, restore (browser#87) ───────────────────────────
|
|
1036
|
+
//
|
|
1037
|
+
// A weaved window's rect reaches the compositor from the session's own rAF: every frame the
|
|
1038
|
+
// live session pushes the full list of rects to weave, and the ONLY way to clear a rect is
|
|
1039
|
+
// to push a list without it. So a page that simply stops running frames leaves its last
|
|
1040
|
+
// list standing — the rects keep weaving over whatever is on screen now. Back/forward
|
|
1041
|
+
// navigation does exactly that: bfcache freezes the page mid-loop, the woven tiles stay
|
|
1042
|
+
// pinned where they were, and the next page inherits ghost 3D windows (browser#87).
|
|
1043
|
+
//
|
|
1044
|
+
// The fix is to make the LAST frames before suspension report an empty list: deactivate
|
|
1045
|
+
// every live window while frames still run, remember which ones were live, and restore them
|
|
1046
|
+
// on the way back. pagehide covers bfcache entry and unload; freeze covers a discarded
|
|
1047
|
+
// background tab where pagehide does not fire.
|
|
1048
|
+
|
|
1049
|
+
_bindLifecycle() {
|
|
1050
|
+
if (typeof window === 'undefined') return;
|
|
1051
|
+
this._onPageHide = () => this._suspend();
|
|
1052
|
+
this._onPageShow = (e) => this._resume(!!(e && e.persisted));
|
|
1053
|
+
window.addEventListener('pagehide', this._onPageHide);
|
|
1054
|
+
window.addEventListener('pageshow', this._onPageShow);
|
|
1055
|
+
// Page Lifecycle API (Blink): a frozen tab never gets pagehide/pageshow.
|
|
1056
|
+
if (typeof document !== 'undefined' && 'onfreeze' in document) {
|
|
1057
|
+
this._onFreeze = () => this._suspend();
|
|
1058
|
+
this._onResume = () => this._resume(true);
|
|
1059
|
+
document.addEventListener('freeze', this._onFreeze);
|
|
1060
|
+
document.addEventListener('resume', this._onResume);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
_unbindLifecycle() {
|
|
1065
|
+
if (typeof window === 'undefined') return;
|
|
1066
|
+
if (this._onPageHide) window.removeEventListener('pagehide', this._onPageHide);
|
|
1067
|
+
if (this._onPageShow) window.removeEventListener('pageshow', this._onPageShow);
|
|
1068
|
+
if (this._onFreeze && typeof document !== 'undefined') {
|
|
1069
|
+
document.removeEventListener('freeze', this._onFreeze);
|
|
1070
|
+
document.removeEventListener('resume', this._onResume);
|
|
1071
|
+
}
|
|
1072
|
+
this._onPageHide = this._onPageShow = this._onFreeze = this._onResume = null;
|
|
1073
|
+
if (this._frameWatchdog) {
|
|
1074
|
+
clearTimeout(this._frameWatchdog);
|
|
1075
|
+
this._frameWatchdog = null;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/** Close every live layer so the outgoing frames report an empty rect list. */
|
|
1080
|
+
_suspend() {
|
|
1081
|
+
if (!this._running || this._suspended) return;
|
|
1082
|
+
const was = [];
|
|
1083
|
+
for (const win of this._windows.values()) {
|
|
1084
|
+
if (win.layer) {
|
|
1085
|
+
was.push(win);
|
|
1086
|
+
this._deactivate(win);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
this._suspended = was;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Coming back: re-arm the windows that were live. In lazy mode the IntersectionObserver
|
|
1094
|
+
* owns that decision, and re-observing re-delivers the CURRENT intersection state — so a
|
|
1095
|
+
* tile the user scrolled away from before leaving stays dark, and only what is actually on
|
|
1096
|
+
* screen re-weaves. Chrome is rescanned because a restored page may have remounted it.
|
|
1097
|
+
*/
|
|
1098
|
+
_resume(persisted) {
|
|
1099
|
+
if (!this._running) return;
|
|
1100
|
+
const was = this._suspended;
|
|
1101
|
+
this._suspended = null;
|
|
1102
|
+
if (was) {
|
|
1103
|
+
for (const win of was) {
|
|
1104
|
+
if (!this._windows.has(win.canvas)) continue; // removed while we were away
|
|
1105
|
+
if (this._lazy && this._observer) {
|
|
1106
|
+
this._observer.unobserve(win.observeEl);
|
|
1107
|
+
this._observer.observe(win.observeEl);
|
|
1108
|
+
} else {
|
|
1109
|
+
this._activate(win);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
this._lastChromeScan = 0; // the 1 s throttle must not swallow the restore rescan
|
|
1114
|
+
this._scanChrome();
|
|
1115
|
+
this._armDprWatch(); // the restore may be on a different-scale display
|
|
1116
|
+
if (persisted) this._watchForStalledFrames();
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* A bfcache restore can hand back a session whose pending animation frame never arrives —
|
|
1121
|
+
* the loop was suspended between request and callback, and nothing re-issues it. The
|
|
1122
|
+
* manager then looks alive (`_running`) while no window ever paints again. Give it a second
|
|
1123
|
+
* to prove otherwise, then start a fresh loop (which retires the stalled one by id).
|
|
1124
|
+
*/
|
|
1125
|
+
_watchForStalledFrames() {
|
|
1126
|
+
if (this._frameWatchdog || typeof setTimeout !== 'function') return;
|
|
1127
|
+
const before = this._frameCount;
|
|
1128
|
+
this._frameWatchdog = setTimeout(() => {
|
|
1129
|
+
this._frameWatchdog = null;
|
|
1130
|
+
if (!this._running || this._frameCount !== before) return; // frames arrived
|
|
1131
|
+
this._requestFrame(/*force*/ true);
|
|
1132
|
+
}, 1000);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
530
1135
|
_teardown() {
|
|
531
1136
|
if (!this._running) return;
|
|
532
1137
|
this._running = false;
|
|
1138
|
+
if (liveManager === this) liveManager = null;
|
|
1139
|
+
this._unbindLifecycle();
|
|
1140
|
+
this._disarmDprWatch();
|
|
533
1141
|
if (this._observer) this._observer.disconnect();
|
|
534
1142
|
for (const win of this._windows.values()) {
|
|
535
1143
|
this._stopOverlayScan(win);
|
|
1144
|
+
this._stopSizeWatch(win);
|
|
536
1145
|
if (win.layer) {
|
|
537
1146
|
try {
|
|
538
1147
|
win.layer.close();
|