import { DROP_CSS, TAB_DRAG_CSS } from "./drop.ts"; /** Where a floating window goes, in screen pixels. `width` and `height` size its page, not its frame. */ export interface Bounds { readonly left: number; readonly top: number; readonly width: number; readonly height: number; } export interface FloatingOptions { /** * A blank same-origin page to open, so the window's address bar shows a URL of this site rather than * `about:blank` (no browser lets a page hide a popup's address bar). Empty opens `about:blank`. */ readonly page: string; readonly bounds: Bounds; /** What the window shows: a stack element and its drop indicator, built by the main window and moved in. */ readonly content: readonly Node[]; /** The person closed the window, which puts its panels back rather than closing them. */ readonly closed: () => void; /** Drag events in the window, so the main window's shell handles tabs dragged in, out, and across. */ readonly drag: (event: DragEvent) => void; } const DRAG_EVENTS = ["dragstart", "dragend", "dragover", "dragleave", "drop"] as const; /** Page CSS the popup adds to the main window's stylesheets: the stack fills the window, bodies fill its content area. */ const PAGE_CSS = ` html, body { margin: 0; height: 100%; } body { display: flex; background: var(--nib-color-canvas); color: var(--nib-color-text); font: var(--nib-font-ui); } nib-stack > div { position: relative; } nib-tabpanel { position: absolute; inset: 0; display: flex; overflow: hidden; } nib-tabpanel[hidden] { display: none; } nib-tabpanel > * { flex: 1; min-width: 0; min-height: 0; } /* While a tab drags, bodies (iframes most of all) must not swallow the events the stack takes drops from. */ body.dragging nib-tabpanel { pointer-events: none; } ${DROP_CSS} ${TAB_DRAG_CSS} `; /** * A floating stack's own window: a same-origin popup that panels' live views move into, so their * state (an editor's undo history, a terminal's scrollback) survives tearing off. Popups rather than * Document Picture-in-Picture: a page gets one PiP window at a time, always on top, and only in Chromium. * * Shadow styles follow the moved elements (see `adopt` in `@nibling/web/internals`); page styles are * mirrored from the main window's ``. An iframe reloads on the move, which a preview's `srcdoc` * survives. */ export class FloatingWindow { readonly #window: Window; readonly #options: FloatingOptions; readonly #mirror = new MutationObserver(() => this.#mirrorStyles()); #built = false; #title = ""; #url: string | undefined; #dragging = false; #mirrored: Node[] = []; #closing = false; /** Opens the window, or returns undefined when the browser blocked the popup. */ static open(name: string, options: FloatingOptions): FloatingWindow | undefined { const popup = window.open(options.page, name, `popup,${features(options.bounds)}`); return popup ? new FloatingWindow(popup, options) : undefined; } private constructor(popup: Window, options: FloatingOptions) { this.#window = popup; this.#options = options; // `about:blank` is ready at once. A page replaces the initial blank document when it loads, and // the window object carries over, so the listener survives that. if (!options.page) this.#build(); else popup.addEventListener("load", () => this.#build(), { once: true }); } get window(): Window { return this.#window; } set title(value: string) { this.#title = value; if (this.#built) this.#window.document.title = value; } /** * What the address bar shows, set with `replaceState` so the window doesn't navigate. It must be on * this origin. A page hides no popup's address bar, so it might as well say something useful. */ set url(value: string) { this.#url = value; if (this.#built && this.#window.location.href !== new URL(value, location.href).href) this.#window.history.replaceState(null, "", value); } set dragging(value: boolean) { this.#dragging = value; if (this.#built) this.#window.document.body.classList.toggle("dragging", value); } moveTo(bounds: Bounds): void { this.#window.moveTo(Math.round(bounds.left), Math.round(bounds.top)); } focus(): void { this.#window.focus(); } /** Closes the window. Move panels out first: whatever is still inside goes with it. */ close(): void { this.#closing = true; this.#mirror.disconnect(); this.#window.close(); } #build() { if (this.#closing) return; const doc = this.#window.document; for (const { name, value } of document.documentElement.attributes) doc.documentElement.setAttribute(name, value); this.#mirror.observe(document.head, { childList: true, subtree: true, characterData: true }); this.#mirrorStyles(); doc.body.replaceChildren(...this.#options.content); this.#built = true; this.title = this.#title; if (this.#url) this.url = this.#url; this.dragging = this.#dragging; for (const type of DRAG_EVENTS) doc.addEventListener(type, (event) => this.#options.drag(event)); // Closing the window is how most people will put panels back, so it docks rather than closes them. this.#window.addEventListener("pagehide", () => { if (!this.#closing) this.#options.closed(); }); } /** Copies the main window's stylesheets in, replacing the last copy, so hot-reloaded CSS reaches the popup too. */ #mirrorStyles() { const doc = this.#window.document; const styles = [...document.head.querySelectorAll('style, link[rel~="stylesheet"]')].map((node) => doc.importNode(node, true)); const page = doc.createElement("style"); page.textContent = PAGE_CSS; for (const node of this.#mirrored) node.parentNode?.removeChild(node); this.#mirrored = [...styles, page]; doc.head.append(...this.#mirrored); } } function features({ left, top, width, height }: Bounds): string { return `left=${Math.round(left)},top=${Math.round(top)},width=${Math.round(width)},height=${Math.round(height)}`; }