| 1 | import { useEffect } from "react"; |
| 2 | |
| 3 | import type { BrowserLayoutRect, DesktopBrowserHost } from "./browserHost"; |
| 4 | |
| 5 | export type BrowserSurfaceHost = Pick<DesktopBrowserHost, "setLayout" | "setOverlay">; |
| 6 | type View = Window & typeof globalThis; |
| 7 | |
| 8 | /** Marks a portaled application overlay root; while one exists every native website view is hidden. */ |
| 9 | export const APP_OVERLAY_SELECTOR = "[data-app-overlay]"; |
| 10 | |
| 11 | const sameRect = (a: BrowserLayoutRect, b: BrowserLayoutRect) => |
| 12 | a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; |
| 13 | |
| 14 | /** Reports the surface's viewport rectangle to the shell until detached, then reports null. */ |
| 15 | export function attachBrowserSurfaceLayout(host: Pick<DesktopBrowserHost, "setLayout">, surface: HTMLElement, view: View = window): () => void { |
| 16 | let frame = 0; |
| 17 | let last: BrowserLayoutRect | null = null; |
| 18 | const measure = () => { |
| 19 | frame = 0; |
| 20 | const box = surface.getBoundingClientRect(); |
| 21 | const rect = { x: Math.round(box.left), y: Math.round(box.top), width: Math.round(box.width), height: Math.round(box.height) }; |
| 22 | if (last && sameRect(last, rect)) return; |
| 23 | last = rect; |
| 24 | host.setLayout(rect); |
| 25 | }; |
| 26 | const schedule = () => { |
| 27 | if (!frame) frame = view.requestAnimationFrame(measure); |
| 28 | }; |
| 29 | const observer = typeof view.ResizeObserver === "function" ? new view.ResizeObserver(schedule) : null; |
| 30 | observer?.observe(surface); |
| 31 | const scrollers: EventTarget[] = [view]; |
| 32 | for (let node = surface.parentElement; node; node = node.parentElement) scrollers.push(node); |
| 33 | for (const scroller of scrollers) scroller.addEventListener("scroll", schedule, { passive: true }); |
| 34 | view.addEventListener("resize", schedule); |
| 35 | view.visualViewport?.addEventListener("resize", schedule); |
| 36 | view.visualViewport?.addEventListener("scroll", schedule); |
| 37 | measure(); |
| 38 | return () => { |
| 39 | if (frame) view.cancelAnimationFrame(frame); |
| 40 | observer?.disconnect(); |
| 41 | for (const scroller of scrollers) scroller.removeEventListener("scroll", schedule); |
| 42 | view.removeEventListener("resize", schedule); |
| 43 | view.visualViewport?.removeEventListener("resize", schedule); |
| 44 | view.visualViewport?.removeEventListener("scroll", schedule); |
| 45 | host.setLayout(null); |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | const carriesOverlay = (node: Node) => |
| 50 | node.nodeType === 1 && ((node as Element).matches(APP_OVERLAY_SELECTOR) || (node as Element).querySelector(APP_OVERLAY_SELECTOR) !== null); |
| 51 | |
| 52 | /** Mirrors the presence of any `[data-app-overlay]` element into the shell's overlay state. */ |
| 53 | export function attachAppOverlayGate(host: Pick<DesktopBrowserHost, "setOverlay">, view: View = window): () => void { |
| 54 | const document = view.document; |
| 55 | let frame = 0; |
| 56 | let last: boolean | null = null; |
| 57 | const check = () => { |
| 58 | frame = 0; |
| 59 | const active = document.querySelector(APP_OVERLAY_SELECTOR) !== null; |
| 60 | if (active === last) return; |
| 61 | last = active; |
| 62 | host.setOverlay(active); |
| 63 | }; |
| 64 | const schedule = () => { |
| 65 | if (!frame) frame = view.requestAnimationFrame(check); |
| 66 | }; |
| 67 | // Transcript streaming mutates the DOM constantly; only records that can |
| 68 | // change overlay presence are allowed to schedule the document query. |
| 69 | const observer = new view.MutationObserver((records) => { |
| 70 | if (records.some((record) => record.type === "attributes" |
| 71 | || [...record.addedNodes, ...record.removedNodes].some(carriesOverlay))) schedule(); |
| 72 | }); |
| 73 | observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-app-overlay"] }); |
| 74 | check(); |
| 75 | return () => { |
| 76 | if (frame) view.cancelAnimationFrame(frame); |
| 77 | observer.disconnect(); |
| 78 | if (last) host.setOverlay(false); |
| 79 | }; |
| 80 | } |
| 81 | |
| 82 | export function useBrowserSurfaceLayout(host: BrowserSurfaceHost | undefined, surface: HTMLElement | null): void { |
| 83 | useEffect(() => (host && surface ? attachBrowserSurfaceLayout(host, surface) : undefined), [host, surface]); |
| 84 | useEffect(() => (host ? attachAppOverlayGate(host) : undefined), [host]); |
| 85 | } |
| 86 |