// Perspective's viewer captures the observer constructors at module-evaluation // time: // // var it = window.ResizeObserver; var st = window.IntersectionObserver; // // so replacing them on `window` before that module is imported puts us in front // of every callback it receives. This file must therefore be the FIRST import in // main.jsx -- ES modules evaluate in declaration order, and an import that lands // after the chain reaching perspective-viewer is too late to matter. // // Why bother: the WASM engine has no notion of focus or tab visibility. All it // ever sees is an observer callback saying "you are visible again, at this // size", and it re-renders in response -- which discards the view, and with it // set_depth() and per-node expansion, neither of which lives in ViewConfig. // Listening for window 'focus' is only a guess at when that happens. This is the // actual event. // // Everything is passed through to the native observer untouched. We add one // CustomEvent, and only for targets that are (or contain) a perspective-viewer, // so nothing else on the page pays for this. export const PF_OBSERVER_EVENT = 'pf-viewer-observed' function dbg(msg, extra) { let on = false try { on = !!localStorage.getItem('pf_debug') } catch { /* private mode */ } if (!on) return const t = new Date().toISOString().slice(11, 23) if (extra !== undefined) console.log(`[pf-obs ${t}] ${msg}`, extra) else console.log(`[pf-obs ${t}] ${msg}`) } // Perspective observes elements inside its own shadow root, and closest() stops // at a shadow boundary -- it will not climb from a shadow child out to the host. // So walk the tree explicitly, hopping host to host, or the match never fires. function touchesViewer(target) { if (!(target instanceof Element)) return false let node = target for (let hops = 0; node && hops < 20; hops++) { if (node.tagName === 'PERSPECTIVE-VIEWER') return true if (node.closest?.('perspective-viewer')) return true const root = node.getRootNode?.() node = root && root.host ? root.host : node.parentElement } return !!target.querySelector?.('perspective-viewer') } function describe(target) { if (!(target instanceof Element)) return String(target) const root = target.getRootNode?.() return `${target.tagName.toLowerCase()}${target.className ? '.' + String(target.className).split(' ')[0] : ''}` + (root && root.host ? ` (in shadow of ${root.host.tagName.toLowerCase()})` : '') } function wrap(Native, kind) { if (typeof Native !== 'function') return Native return class PfObserver extends Native { constructor(callback, options) { super((entries, observer) => { // Perspective's own handler runs first and unchanged. If it throws, // that is its business -- we still report, so a failure upstream is // visible rather than silently swallowing our notification too. try { callback(entries, observer) } finally { const hit = entries.some(e => touchesViewer(e.target)) dbg(`${kind} fired on ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}` + ` -> ${hit ? 'MATCHED viewer' : 'no viewer match'}`, entries.map(e => describe(e.target))) if (hit) { window.dispatchEvent(new CustomEvent(PF_OBSERVER_EVENT, { detail: { kind, at: Date.now(), // IntersectionObserver entries carry visibility; Resize // ones carry geometry. Report whichever exists so the // listener can tell a re-show from a relayout. entries: entries.map(e => ({ isIntersecting: e.isIntersecting, intersectionRatio: e.intersectionRatio, width: e.contentRect?.width, height: e.contentRect?.height, })), }, })) } } }, options) } } } let installed = false export function installObserverShim() { if (installed) return installed = true try { if (window.IntersectionObserver) { window.IntersectionObserver = wrap(window.IntersectionObserver, 'intersection') } if (window.ResizeObserver) { window.ResizeObserver = wrap(window.ResizeObserver, 'resize') } } catch { // A browser that refuses the assignment just means we fall back to the // focus/visibilitychange listeners, which still work -- they are only // less precise about when the rebuild happened. } } installObserverShim()