import { mount, unmount } from 'svelte'; // Every window is mounted once, into a host element this registry owns, and // moved into whichever slot shows it: a tile, a tab, a window on a canvas. // Where the browser has moveBefore the move keeps everything (an artifact's // page, scroll, focus); elsewhere it is a plain move, which reloads an iframe // but remounts nothing. // // A slot that goes away parks its window out of sight, and windows still // parked on the next frame are unmounted. A window moved from one slot to // another in the same frame stays alive; one nothing shows is gone a frame // later. Svelte takes a slot out of the document before it lets its window // go, so a layout change shelters every window first (`shelter`): otherwise // whatever the slot is rebuilt around would lose its page and scroll. type Mounted = ReturnType; type Entry = { host: HTMLElement; instance: Mounted | null; slot: HTMLElement | null }; /** The key a window of `board` is hosted under, stamped on its host as `data-window`. */ export function hostKey(board: string, window: string) { return `${board}:${window}`; } /** The window of `board` whose host holds `element`, if any. */ export function windowOfHost(board: string, element: Element | null | undefined): string | null { const key = element?.closest('.window-host')?.dataset.window; return key?.startsWith(`${board}:`) ? key.slice(board.length + 1) : null; } const movable = typeof Element !== 'undefined' && 'moveBefore' in Element.prototype; function moveInto(parent: HTMLElement, node: HTMLElement) { if (movable && node.isConnected && parent.isConnected) { try { (parent as HTMLElement & { moveBefore(node: Node, child: Node | null): void }).moveBefore(node, null); return; } catch {} } parent.appendChild(node); } class Hosts { #entries = new Map(); #parking: HTMLElement | null = null; #sweep = 0; #held = new Set(); /** The slot showing each window, by window id: only one board is on screen at a time. */ #slots = new Map(); /** Shows `window` of `board` in `slot`, mounting it with `make` the first time. */ claim(board: string, window: string, slot: HTMLElement, make: (target: HTMLElement) => Mounted) { const key = hostKey(board, window); let entry = this.#entries.get(key); if (!entry) { const host = document.createElement('div'); host.className = 'window-host'; host.dataset.window = key; host.style.cssText = 'width: 100%; height: 100%'; const fresh: Entry = (entry = { host, instance: null, slot: null }); this.#entries.set(key, fresh); // Mounted outside the claiming slot's effect: svelte would make the // window's root a child of it, tying the window to the first slot that // showed it (and leaving it inert if that slot was). queueMicrotask(() => { if (this.#entries.get(key) === fresh) fresh.instance = make(host); }); } entry.slot = slot; this.#slots.set(window, slot); entry.host.style.width = entry.host.style.height = '100%'; moveInto(slot, entry.host); } /** * Parks the windows of `board` before a layout change rebuilds their slots, * and puts each one whose slot stayed back once it is done. Only where moves * keep a window's state; elsewhere a park would cost what it saves. */ shelter(board: string) { if (!movable) return; const parked = [...this.#entries].filter(([key, entry]) => key.startsWith(`${board}:`) && entry.slot && entry.host.isConnected).map(([, entry]) => entry); // Every size is read before anything moves: a read after a move would lay the board out again. const sizes = parked.map((entry) => [entry.host.offsetWidth, entry.host.offsetHeight]); parked.forEach((entry, at) => { entry.host.style.width = `${sizes[at][0]}px`; entry.host.style.height = `${sizes[at][1]}px`; moveInto(this.#park(), entry.host); }); queueMicrotask(() => { for (const entry of parked) { if (!entry.slot?.isConnected || entry.host.parentElement === entry.slot) continue; entry.host.style.width = entry.host.style.height = '100%'; moveInto(entry.slot, entry.host); } }); } /** The slot is going; its window is parked, and unmounted next frame unless another slot claims it first. */ release(board: string, window: string, slot: HTMLElement) { if (this.#slots.get(window) === slot) this.#slots.delete(window); const entry = this.#entries.get(hostKey(board, window)); if (!entry || entry.slot !== slot) return; // The parked window keeps the size it had, so nothing inside it reflows on the way to its next slot. // A slot Svelte has already taken out measures nothing; the window was sized as it left (`shelter`). const { width, height } = slot.getBoundingClientRect(); if (width && height) { entry.host.style.width = `${width}px`; entry.host.style.height = `${height}px`; } entry.slot = null; moveInto(this.#park(), entry.host); this.#sweep ||= requestAnimationFrame(() => this.#collect()); } /** The element `window` is shown in, while it is on screen. */ slot(window: string): HTMLElement | null { const slot = this.#slots.get(window); return slot?.isConnected ? slot : null; } /** Keeps `window` of `board` mounted while it is parked, for a window carried out of the layout. */ hold(board: string, window: string) { this.#held.add(hostKey(board, window)); } /** Lets a held window go: unmounted next frame unless a slot has claimed it. */ free(board: string, window: string) { this.#held.delete(hostKey(board, window)); this.#sweep ||= requestAnimationFrame(() => this.#collect()); } #park() { if (!this.#parking?.isConnected) { this.#parking = document.createElement('div'); this.#parking.className = 'window-parking'; this.#parking.setAttribute('aria-hidden', 'true'); this.#parking.inert = true; Object.assign(this.#parking.style, { position: 'fixed', left: '-100000px', top: '0', visibility: 'hidden', contain: 'strict', width: '0', height: '0', overflow: 'hidden' }); document.body.appendChild(this.#parking); } return this.#parking; } #collect() { this.#sweep = 0; for (const [key, entry] of this.#entries) { if (entry.slot || this.#held.has(key)) continue; this.#entries.delete(key); if (entry.instance) void unmount(entry.instance); entry.host.remove(); } } } export const hosts = new Hosts(); /** * An attachment for a slot element: `
`. * Keyed by window, so a window moved between slots keeps its one mount. */ export function hosted(board: string, window: string, make: (target: HTMLElement) => Mounted) { return (slot: HTMLElement) => { hosts.claim(board, window, slot, make); return () => hosts.release(board, window, slot); }; }