diff --git a/server.py b/server.py index 2b22882..85f616d 100644 --- a/server.py +++ b/server.py @@ -1719,9 +1719,14 @@ def index_v3(): @app.get("/pane") def pane_host(): - # The document a popped-out pane runs in. Deliberately NOT the app shell with - # a query flag (the splitscreen follower's approach): that loads the library, - # the highway and the whole v3 shell only to hide them again, and pays for it - # with anti-flash hacks in three files. This page loads the pane runtime and - # nothing else. See docs/plugin-panes.md. - return FileResponse(str(STATIC_DIR / "panes" / "pane.html")) + # The document a popped-out pane is displayed in. It builds nothing: the opener + # MOVES the real panel element into it (document.adoptNode) and copies the app's + # stylesheets across. See docs/plugin-panes.md. + # + # no-cache, matching the /static mount's contract (_RevalidatedStaticFiles). A + # stale copy of this page is especially nasty: the opener waits for an element + # inside it before adopting, so an old cached version means the pane window + # simply sits there blank. + resp = FileResponse(str(STATIC_DIR / "panes" / "pane.html")) + resp.headers["Cache-Control"] = "no-cache" + return resp diff --git a/static/panes/pane-chip.js b/static/panes/pane-chip.js index 2e6024e..2d3e942 100644 --- a/static/panes/pane-chip.js +++ b/static/panes/pane-chip.js @@ -1,135 +1,175 @@ -/* - * fee[dB]ack — the pop-out chip. - * - * One affordance, core-owned, identical everywhere: the small ⇱ button a plugin - * drops into a dialog it already has. - * - * feedBack.panes.register({ id: 'camera_director', title: 'Camera', mount, unmount }); - * feedBack.panes.attachChip(myDialogEl, 'camera_director'); - * - * That is the entire adoption cost. Clicking the chip opens the pane in its host - * and hides `myDialogEl`; a stub takes its place so the user can find it again; - * closing the pane un-hides the dialog and restores the chip. The plugin writes - * no show/hide logic — if it did, every plugin would invent a slightly different - * one, which is exactly the inconsistency this exists to prevent. - * - * Hiding uses `.fb-pane-detached`, NOT the `hidden` class or `[hidden]`, because - * the dialogs being hidden here already toggle those themselves (the core mixer - * popover, every rail popover). Two owners of one class is a bug waiting for a - * bad day; a dedicated class composes cleanly with whatever the dialog does. - */ -(function () { - 'use strict'; - - const panes = window.feedBack && window.feedBack.panes; - if (!panes || typeof panes.register !== 'function') { - console.error('[panes] pane-manager.js must load before pane-chip.js'); - return; - } - - // paneId -> { el, chip, stub, spec } - const attached = new Map(); - - function _makeChip(spec) { - const b = document.createElement('button'); - b.type = 'button'; - b.className = 'fb-pane-chip'; - b.title = 'Pop out'; - b.setAttribute('aria-label', 'Pop out ' + spec.title); - b.textContent = '⇱'; - b.addEventListener('click', (e) => { - // Rail popovers close on any document click that lands outside them - // (player-chrome.js). Without this the popover would close under the - // chip mid-click, which reads as the button not working. - e.stopPropagation(); - e.preventDefault(); - panes.detach(spec.id); - }); - return b; - } - - function _makeStub(spec) { - const s = document.createElement('button'); - s.type = 'button'; - s.className = 'fb-pane-stub'; - s.setAttribute('aria-label', 'Bring ' + spec.title + ' back'); - s.title = 'Bring it back'; - const glyph = document.createElement('span'); - glyph.className = 'fb-pane-stub-glyph'; - glyph.textContent = '⇲'; - const label = document.createElement('span'); - label.textContent = spec.title + ' is popped out'; - s.appendChild(glyph); - s.appendChild(label); - s.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - panes.close(spec.id); - }); - return s; - } - - function _onOpened(rec) { - rec.el.classList.add('fb-pane-detached'); - if (!rec.stub.isConnected) rec.el.parentNode.insertBefore(rec.stub, rec.el); - } - - function _onClosed(rec) { - rec.el.classList.remove('fb-pane-detached'); - rec.stub.remove(); - } - - /** - * attachChip(el, paneId, opts) - * - * `el` — the dialog to hide when the pane pops out. The chip is injected - * into `el.querySelector('[data-pane-header]')` when present, else - * prepended to `el` itself. - * `opts` — { header: Element } to place the chip somewhere specific. - * - * Returns a detach function that removes the chip and stub and restores the - * dialog — call it if your plugin tears its dialog down. - */ - function attachChip(el, paneId, opts) { - opts = opts || {}; - if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element'); - const spec = panes.get(paneId); - if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; } - if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; } - - const chip = _makeChip(spec); - const stub = _makeStub(spec); - const host = opts.header || el.querySelector('[data-pane-header]') || el; - if (host === el) host.insertBefore(chip, host.firstChild); - else host.appendChild(chip); - - const rec = { el, chip, stub, spec }; - attached.set(paneId, rec); - - // Reconcile immediately: register() reopens a pane the user left open at - // last unload, and that can land before (or after) attachChip runs. - if (panes.isOpen(paneId)) _onOpened(rec); - - return () => { - if (attached.get(paneId) !== rec) return; - attached.delete(paneId); - chip.remove(); - _onClosed(rec); - }; - } - - // One pair of bus listeners for every chip, rather than one pair per chip. - const bus = window.feedBack; - if (bus && typeof bus.on === 'function') { - bus.on('panes:opened', (e) => { - const rec = attached.get(e.detail && e.detail.id); - if (rec) _onOpened(rec); - }); - bus.on('panes:closed', (e) => { - const rec = attached.get(e.detail && e.detail.id); - if (rec) _onClosed(rec); - }); - } - - window.feedBack.panes.attachChip = attachChip; -})(); +/* + * fee[dB]ack — the pop-out chip. + * + * One affordance, core-owned, identical everywhere: the small ⇱ button a plugin + * drops into the panel it already has. + * + * feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl }); + * feedBack.panes.attachChip(panelEl, 'camera_director'); + * + * That is the entire adoption cost. Clicking the chip pops the panel out; a stub + * takes its place so the user can find it again; closing the pane brings the panel + * home and restores the chip. The plugin writes no show/hide logic — if it did, + * every plugin would invent a slightly different one, which is exactly the + * inconsistency this exists to prevent. + * + * The panel a chip is attached to is USUALLY the very element the pane moves into + * the pop-out window — so most of the time there is nothing here left to hide, and + * the job is simply to mark the hole it left. Hiding it would in fact be actively + * harmful: `.fb-pane-detached` is `display:none !important`, and it would travel + * with the node straight into the pane window and blank it. + * + * When the chip IS attached to something the pane didn't take (a wrapper, a + * launcher row), that element stays put and is hidden with `.fb-pane-detached` — + * a dedicated class, not `.hidden`/[hidden], because the panels we attach to + * already toggle those themselves. + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + if (!panes || typeof panes.register !== 'function') { + console.error('[panes] pane-manager.js must load before pane-chip.js'); + return; + } + + // paneId -> { el, chip, stub, spec } + const attached = new Map(); + + function _makeChip(spec) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'fb-pane-chip'; + b.title = 'Pop out'; + b.setAttribute('aria-label', 'Pop out ' + spec.title); + b.textContent = '⇱'; + b.addEventListener('click', (e) => { + // Rail popovers close on any document click that lands outside them + // (player-chrome.js). Without this the popover would close under the + // chip mid-click, which reads as the button not working. + e.stopPropagation(); + e.preventDefault(); + panes.detach(spec.id); + }); + return b; + } + + function _makeStub(spec) { + const s = document.createElement('button'); + s.type = 'button'; + s.className = 'fb-pane-stub'; + s.setAttribute('aria-label', 'Bring ' + spec.title + ' back'); + s.title = 'Bring it back'; + const glyph = document.createElement('span'); + glyph.className = 'fb-pane-stub-glyph'; + glyph.textContent = '⇲'; + const label = document.createElement('span'); + label.textContent = spec.title + ' is popped out'; + s.appendChild(glyph); + s.appendChild(label); + s.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + panes.close(spec.id); + }); + return s; + } + + // The pane is out. Leave a stub where its panel used to be. + // + // The subtlety: the panel a chip is attached to is USUALLY the very element the + // pane moved into the pop-out window. It is no longer in this document at all — + // so hiding it would be worse than pointless (the `display:none` travels with + // the node and blanks the pane window, which is exactly the bug this fixes), and + // the stub cannot be inserted "before it", because it is not here to be before. + // + // Hence `home`: the manager tells us where the element used to live, and the + // stub goes there. If the chip is attached to something the pane did NOT take — + // a wrapper, a launcher row — that element is still here, and we hide it as + // before. + function _onOpened(rec, detail) { + // "Moved" means the element is not in THIS document — either because this + // open took it, or because it is already sitting in a pane window from an + // earlier one. The ownerDocument test is what makes re-attaching a chip + // safe: a plugin that rebuilds its panel (Camera Director does, on every + // mode change) re-runs attachChip while the pane is still popped out, and + // an isConnected test would say "still here" — it IS connected, to the pane + // window — and we would stamp display:none onto the live pane. + const moved = (detail && detail.el === rec.el) || rec.el.ownerDocument !== document; + + if (!moved && rec.el.isConnected) { + rec.el.classList.add('fb-pane-detached'); + if (!rec.stub.isConnected && rec.el.parentNode) rec.el.parentNode.insertBefore(rec.stub, rec.el); + return; + } + + // Mark the hole the element left. `home` comes with the event, or from the + // manager when we are reconciling after the fact. + const home = (detail && detail.home) || panes.homeOf(rec.spec.id); + if (!rec.stub.isConnected && home && home.parent && home.parent.isConnected) { + const next = (home.next && home.next.parentNode === home.parent) ? home.next : null; + home.parent.insertBefore(rec.stub, next); + } + } + + function _onClosed(rec) { + // The element is back. Whatever we did to hide it, undo — including a class + // it might have carried out of the document and back. + rec.el.classList.remove('fb-pane-detached'); + rec.stub.remove(); + } + + /** + * attachChip(el, paneId, opts) + * + * `el` — the dialog to hide when the pane pops out. The chip is injected + * into `el.querySelector('[data-pane-header]')` when present, else + * prepended to `el` itself. + * `opts` — { header: Element } to place the chip somewhere specific. + * + * Returns a detach function that removes the chip and stub and restores the + * dialog — call it if your plugin tears its dialog down. + */ + function attachChip(el, paneId, opts) { + opts = opts || {}; + if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element'); + const spec = panes.get(paneId); + if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; } + if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; } + + const chip = _makeChip(spec); + const stub = _makeStub(spec); + const host = opts.header || el.querySelector('[data-pane-header]') || el; + if (host === el) host.insertBefore(chip, host.firstChild); + else host.appendChild(chip); + + const rec = { el, chip, stub, spec }; + attached.set(paneId, rec); + + // Reconcile immediately: register() reopens a pane the user left open at + // last unload, and that can land before (or after) attachChip runs. + if (panes.isOpen(paneId)) _onOpened(rec, null); + + return () => { + if (attached.get(paneId) !== rec) return; + attached.delete(paneId); + chip.remove(); + _onClosed(rec); + }; + } + + // One pair of bus listeners for every chip, rather than one pair per chip. + const bus = window.feedBack; + if (bus && typeof bus.on === 'function') { + bus.on('panes:opened', (e) => { + const rec = attached.get(e.detail && e.detail.id); + if (rec) _onOpened(rec, e.detail); + }); + bus.on('panes:closed', (e) => { + const rec = attached.get(e.detail && e.detail.id); + if (rec) _onClosed(rec); + }); + } + + window.feedBack.panes.attachChip = attachChip; +})(); diff --git a/static/panes/pane-manager.js b/static/panes/pane-manager.js index 849c8a4..5d7c40e 100644 --- a/static/panes/pane-manager.js +++ b/static/panes/pane-manager.js @@ -1,268 +1,288 @@ -/* - * fee[dB]ack — pane manager. - * - * The registry and host router behind `window.feedBack.panes`. - * - * A "pane" is a piece of UI a plugin already has — a mixer panel, a camera rig, - * a settings board — that the user can pop out into its own OS window and leave - * open: while they play, across song switches, on a second monitor, minimized to - * the tray. - * - * The whole design is one sentence: WE MOVE THE REAL ELEMENT. - * - * Not a copy of it, not a re-implementation of it in the pop-out window — the - * actual DOM node. Same-origin windows can adopt each other's nodes, and an - * adopted node keeps its event listeners and its closures. So the panel goes on - * running the plugin's own code, against the plugin's own state, in the plugin's - * own realm. It looks and behaves exactly like the thing that was popped out, - * because it IS the thing that was popped out. - * - * That is what makes the plugin's side of this two lines: - * - * feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl }); - * feedBack.panes.attachChip(panelEl, 'camera_director'); - * - * No state mirroring, no cross-window RPC, no second copy of the UI to keep in - * step with the first. Those were all workarounds for a problem we simply do not - * have once the node itself moves. - * - * The manager owns which pane is open and where, and — crucially — where each - * pane's element CAME FROM, so docking it puts it back exactly where it was. - */ -(function () { - 'use strict'; - - const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload - - // id -> normalized spec - const specs = new Map(); - // id -> { spec, hostId, el, home: { parent, next } } - const open = new Map(); - // hostId -> host provider - const hosts = new Map(); - - // ── Persistence ────────────────────────────────────────────────────────── - // Only which pane was open, and where. A pane's CONTENTS are the plugin's own - // DOM and the plugin's own state — none of our business. - - function _readJSON(key, fallback) { - try { - const raw = localStorage.getItem(key); - return raw ? JSON.parse(raw) : fallback; - } catch (e) { return fallback; } // private mode / corrupt value - } - function _writeJSON(key, value) { - try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ } - } - function _rememberHost(id, hostId) { - const map = _readJSON(HOSTS_KEY, {}); - if (hostId) map[id] = hostId; else delete map[id]; - _writeJSON(HOSTS_KEY, map); - } - - // ── Spec ───────────────────────────────────────────────────────────────── - - function _normalize(spec) { - if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object'); - if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required'); - if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) { - throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one'); - } - return { - id: spec.id, - title: spec.title || spec.id, - icon: spec.icon || '▣', - // Resolved lazily: a plugin often builds its panel on first use, so the - // element may not exist at registration time — and it may be rebuilt - // later (Camera Director rebuilds its panel on every mode change). - // Asking for it at open time means we always move the live one. - element: typeof spec.element === 'function' ? spec.element : () => spec.element, - width: spec.width || 380, - height: spec.height || 560, - defaultHost: spec.defaultHost || 'window', - // Called after the element lands in (or returns from) a pane window, - // for a plugin that needs to re-measure or re-anchor something. - onHost: typeof spec.onHost === 'function' ? spec.onHost : null, - }; - } - - // ── Host routing ───────────────────────────────────────────────────────── - - function _resolveHost(preferred) { - const wanted = hosts.get(preferred); - if (wanted && wanted.available()) return wanted; - // Fall back to the best available host. The dock registers at priority 0 - // and is always available, so a pane can never fail to open. - let best = null; - hosts.forEach((h) => { - if (!h.available()) return; - if (!best || h.priority > best.priority) best = h; - }); - return best; - } - - function _emit(name, detail) { - const bus = window.feedBack; - if (bus && typeof bus.emit === 'function') bus.emit(name, detail); - } - - // ── Open / close ───────────────────────────────────────────────────────── - - function openPane(id, opts) { - opts = opts || {}; - const spec = specs.get(id); - if (!spec) { console.warn('[panes] open: no such pane:', id); return false; } - if (open.has(id)) { focusPane(id); return true; } - - let el; - try { el = spec.element(); } catch (e) { el = null; } - if (!(el instanceof Element)) { - console.warn('[panes] open: pane has no element yet:', id); - return false; - } - - const host = _resolveHost(opts.host || spec.defaultHost); - if (!host) { console.error('[panes] open: no host available for', id); return false; } - - // Where the element lives right now, so docking can put it back EXACTLY - // there — same parent, same position among its siblings. Anything less and - // a docked panel reappears at the bottom of its container, or not at all. - const home = { parent: el.parentNode, next: el.nextSibling }; - - try { - host.place(spec, el); - } catch (e) { - console.error('[panes] host', host.id, 'failed to take', id, e); - return false; - } - - open.set(id, { spec, hostId: host.id, el, home }); - if (opts.remember !== false) _rememberHost(id, host.id); - if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } } - _emit('panes:opened', { id: id, host: host.id }); - return true; - } - - function closePane(id, opts) { - opts = opts || {}; - const entry = open.get(id); - if (!entry) return false; - open.delete(id); - - const host = hosts.get(entry.hostId); - try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); } - - // Put the element back where it came from. Re-adopting it into THIS - // document is what undoes the pop-out: a node adopted by another window - // has that window's document as its owner, and appending it here without - // adopting first would throw in some engines and leave it in a half-moved - // state in others. - const home = entry.home; - if (home && home.parent && home.parent.isConnected) { - try { - const node = document.adoptNode(entry.el); - if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next); - else home.parent.appendChild(node); - } catch (e) { - console.error('[panes] could not return', id, 'to its home', e); - } - } - - if (opts.remember !== false) _rememberHost(id, null); - if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } } - _emit('panes:closed', { id: id, host: entry.hostId }); - return true; - } - - function focusPane(id) { - const entry = open.get(id); - if (!entry) return false; - const host = hosts.get(entry.hostId); - if (host && typeof host.focus === 'function') host.focus(id); - return true; - } - - // What the pop-out chip calls: put this pane wherever a pane most wants to - // live. That is a window if one can be had, and the dock otherwise. - function detach(id) { - const spec = specs.get(id); - return openPane(id, { host: (spec && spec.defaultHost) || 'window' }); - } - - function dock(id) { - if (open.has(id)) closePane(id, { remember: false }); - return openPane(id, { host: 'dock' }); - } - - // ── Registry ───────────────────────────────────────────────────────────── - - function register(spec) { - const s = _normalize(spec); - if (specs.has(s.id)) { - // First registration wins, matching libraryCardActions.register. A - // silent overwrite would swap the element out from under an open pane. - console.warn('[panes] pane already registered, ignoring:', s.id); - return () => {}; - } - specs.set(s.id, s); - _emit('panes:registered', { id: s.id, title: s.title }); - - // Reopen where the user left it. Deferred a tick so a plugin can call - // register() and attachChip() back to back — the chip must exist before - // the pane opens, or it has nothing to hide. - // - // A host may refuse to be auto-restored: a browser blocks window.open() - // without a user gesture, so restoring a popped-out pane on page load - // would only ever produce a "pop-up blocked" toast. Such a pane comes back - // in the dock, and the chip pops it out again on the user's next click. - let remembered = _readJSON(HOSTS_KEY, {})[s.id]; - if (remembered) { - const h = hosts.get(remembered); - if (h && h.autoRestore === false) remembered = 'dock'; - setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0); - } - - return () => unregister(s.id); - } - - function unregister(id) { - if (open.has(id)) closePane(id, { remember: false }); - specs.delete(id); - _emit('panes:unregistered', { id: id }); - } - - function registerHost(host) { - if (!host || !host.id) throw new TypeError('panes: host needs an id'); - hosts.set(host.id, { - id: host.id, - priority: host.priority || 0, - autoRestore: host.autoRestore !== false, - available: typeof host.available === 'function' ? host.available : () => true, - place: host.place, - unplace: host.unplace, - focus: host.focus, - }); - } - - const api = { - version: 2, - register, - unregister, - open: openPane, - close: closePane, - detach, - dock, - focus: focusPane, - isOpen: (id) => open.has(id), - hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; }, - get: (id) => specs.get(id) || null, - list: () => Array.from(specs.values()).map((s) => ({ - id: s.id, title: s.title, icon: s.icon, - open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null, - })), - registerHost, - }; - - window.feedBack = window.feedBack || {}; - window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api); -})(); +/* + * fee[dB]ack — pane manager. + * + * The registry and host router behind `window.feedBack.panes`. + * + * A "pane" is a piece of UI a plugin already has — a mixer panel, a camera rig, + * a settings board — that the user can pop out into its own OS window and leave + * open: while they play, across song switches, on a second monitor, minimized to + * the tray. + * + * The whole design is one sentence: WE MOVE THE REAL ELEMENT. + * + * Not a copy of it, not a re-implementation of it in the pop-out window — the + * actual DOM node. Same-origin windows can adopt each other's nodes, and an + * adopted node keeps its event listeners and its closures. So the panel goes on + * running the plugin's own code, against the plugin's own state, in the plugin's + * own realm. It looks and behaves exactly like the thing that was popped out, + * because it IS the thing that was popped out. + * + * That is what makes the plugin's side of this two lines: + * + * feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl }); + * feedBack.panes.attachChip(panelEl, 'camera_director'); + * + * No state mirroring, no cross-window RPC, no second copy of the UI to keep in + * step with the first. Those were all workarounds for a problem we simply do not + * have once the node itself moves. + * + * The manager owns which pane is open and where, and — crucially — where each + * pane's element CAME FROM, so docking it puts it back exactly where it was. + */ +(function () { + 'use strict'; + + const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload + + // id -> normalized spec + const specs = new Map(); + // id -> { spec, hostId, el, home: { parent, next } } + const open = new Map(); + // hostId -> host provider + const hosts = new Map(); + + // ── Persistence ────────────────────────────────────────────────────────── + // Only which pane was open, and where. A pane's CONTENTS are the plugin's own + // DOM and the plugin's own state — none of our business. + + function _readJSON(key, fallback) { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch (e) { return fallback; } // private mode / corrupt value + } + function _writeJSON(key, value) { + try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ } + } + function _rememberHost(id, hostId) { + const map = _readJSON(HOSTS_KEY, {}); + if (hostId) map[id] = hostId; else delete map[id]; + _writeJSON(HOSTS_KEY, map); + } + + // ── Spec ───────────────────────────────────────────────────────────────── + + function _normalize(spec) { + if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object'); + if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required'); + if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) { + throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one'); + } + return { + id: spec.id, + title: spec.title || spec.id, + icon: spec.icon || '▣', + // Resolved lazily: a plugin often builds its panel on first use, so the + // element may not exist at registration time — and it may be rebuilt + // later (Camera Director rebuilds its panel on every mode change). + // Asking for it at open time means we always move the live one. + element: typeof spec.element === 'function' ? spec.element : () => spec.element, + width: spec.width || 380, + height: spec.height || 560, + defaultHost: spec.defaultHost || 'window', + // Called after the element lands in (or returns from) a pane window, + // for a plugin that needs to re-measure or re-anchor something. + onHost: typeof spec.onHost === 'function' ? spec.onHost : null, + }; + } + + // ── Host routing ───────────────────────────────────────────────────────── + + function _resolveHost(preferred) { + const wanted = hosts.get(preferred); + if (wanted && wanted.available()) return wanted; + // Fall back to the best available host. The dock registers at priority 0 + // and is always available, so a pane can never fail to open. + let best = null; + hosts.forEach((h) => { + if (!h.available()) return; + if (!best || h.priority > best.priority) best = h; + }); + return best; + } + + function _emit(name, detail) { + const bus = window.feedBack; + if (bus && typeof bus.emit === 'function') bus.emit(name, detail); + } + + // ── Open / close ───────────────────────────────────────────────────────── + + function openPane(id, opts) { + opts = opts || {}; + const spec = specs.get(id); + if (!spec) { console.warn('[panes] open: no such pane:', id); return false; } + if (open.has(id)) { focusPane(id); return true; } + + let el; + try { el = spec.element(); } catch (e) { el = null; } + if (!(el instanceof Element)) { + console.warn('[panes] open: pane has no element yet:', id); + return false; + } + + const host = _resolveHost(opts.host || spec.defaultHost); + if (!host) { console.error('[panes] open: no host available for', id); return false; } + + // Where the element lives right now, so docking can put it back EXACTLY + // there — same parent, same position among its siblings. Anything less and + // a docked panel reappears at the bottom of its container, or not at all. + const home = { parent: el.parentNode, next: el.nextSibling }; + + // An element on its way OUT of this document must not carry a class whose + // whole job is to hide it IN this document. `.fb-pane-detached` is + // `display:none !important`, and it travels with the node — straight into + // the pane window, which then renders nothing at all. + el.classList.remove('fb-pane-detached'); + + try { + host.place(spec, el); + } catch (e) { + console.error('[panes] host', host.id, 'failed to take', id, e); + return false; + } + + open.set(id, { spec, hostId: host.id, el, home }); + if (opts.remember !== false) _rememberHost(id, host.id); + if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } } + // `home` rides along because the element has LEFT this document — anything + // that wants to mark the hole it left (the chip's stub) needs to know where + // the hole is, and can no longer ask the element itself. + _emit('panes:opened', { id: id, host: host.id, el: el, home: home }); + return true; + } + + function closePane(id, opts) { + opts = opts || {}; + const entry = open.get(id); + if (!entry) return false; + open.delete(id); + + // ORDER IS LOAD-BEARING: bring the element home BEFORE the host lets go of + // it. The host's unplace() closes the pane window, and closing a window + // tears down its document — with the element still inside it. The node + // survives (we hold a reference) but comes back stripped of its event + // listeners, so the panel returns looking perfect and completely dead: no + // buttons, no sliders, nothing. + // + // Adopt first, while the pane window is still alive, and the node moves out + // of a living document into a living document, which is the only case the + // DOM actually guarantees. + const home = entry.home; + if (home && home.parent && home.parent.isConnected) { + try { + // adoptNode, not appendChild: the node's owner is currently the pane + // window's document, and adopting is what transfers ownership back. + const node = document.adoptNode(entry.el); + if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next); + else home.parent.appendChild(node); + } catch (e) { + console.error('[panes] could not return', id, 'to its home', e); + } + } + + const host = hosts.get(entry.hostId); + try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); } + + if (opts.remember !== false) _rememberHost(id, null); + if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } } + _emit('panes:closed', { id: id, host: entry.hostId }); + + return true; + } + + function focusPane(id) { + const entry = open.get(id); + if (!entry) return false; + const host = hosts.get(entry.hostId); + if (host && typeof host.focus === 'function') host.focus(id); + return true; + } + + // What the pop-out chip calls: put this pane wherever a pane most wants to + // live. That is a window if one can be had, and the dock otherwise. + function detach(id) { + const spec = specs.get(id); + return openPane(id, { host: (spec && spec.defaultHost) || 'window' }); + } + + function dock(id) { + if (open.has(id)) closePane(id, { remember: false }); + return openPane(id, { host: 'dock' }); + } + + // ── Registry ───────────────────────────────────────────────────────────── + + function register(spec) { + const s = _normalize(spec); + if (specs.has(s.id)) { + // First registration wins, matching libraryCardActions.register. A + // silent overwrite would swap the element out from under an open pane. + console.warn('[panes] pane already registered, ignoring:', s.id); + return () => {}; + } + specs.set(s.id, s); + _emit('panes:registered', { id: s.id, title: s.title }); + + // Reopen where the user left it. Deferred a tick so a plugin can call + // register() and attachChip() back to back — the chip must exist before + // the pane opens, or it has nothing to hide. + // + // A host may refuse to be auto-restored: a browser blocks window.open() + // without a user gesture, so restoring a popped-out pane on page load + // would only ever produce a "pop-up blocked" toast. Such a pane comes back + // in the dock, and the chip pops it out again on the user's next click. + let remembered = _readJSON(HOSTS_KEY, {})[s.id]; + if (remembered) { + const h = hosts.get(remembered); + if (h && h.autoRestore === false) remembered = 'dock'; + setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0); + } + + return () => unregister(s.id); + } + + function unregister(id) { + if (open.has(id)) closePane(id, { remember: false }); + specs.delete(id); + _emit('panes:unregistered', { id: id }); + } + + function registerHost(host) { + if (!host || !host.id) throw new TypeError('panes: host needs an id'); + hosts.set(host.id, { + id: host.id, + priority: host.priority || 0, + autoRestore: host.autoRestore !== false, + available: typeof host.available === 'function' ? host.available : () => true, + place: host.place, + unplace: host.unplace, + focus: host.focus, + }); + } + + const api = { + version: 2, + register, + unregister, + open: openPane, + close: closePane, + detach, + dock, + focus: focusPane, + isOpen: (id) => open.has(id), + hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; }, + // Where an open pane's element came from. The chip needs this to mark the + // hole the element left, since it can no longer ask the element itself. + homeOf: (id) => { const e = open.get(id); return e ? e.home : null; }, + get: (id) => specs.get(id) || null, + list: () => Array.from(specs.values()).map((s) => ({ + id: s.id, title: s.title, icon: s.icon, + open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null, + })), + registerHost, + }; + + window.feedBack = window.feedBack || {}; + window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api); +})(); diff --git a/static/panes/pane-window-host.js b/static/panes/pane-window-host.js index e602a66..5243c84 100644 --- a/static/panes/pane-window-host.js +++ b/static/panes/pane-window-host.js @@ -1,196 +1,297 @@ -/* - * fee[dB]ack — the pop-out window host. - * - * Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT. - * - * The move is the whole trick, and it works because the pane window is same-origin - * and opener-linked: `document.adoptNode()` re-parents a live node into another - * window's document, and an adopted node keeps its event listeners, its closures, - * and every reference anything else holds to it. So the plugin's panel goes on - * running the plugin's own code in the plugin's own realm — it is just being - * *displayed* somewhere else. It looks and behaves exactly like what was popped - * out, because it is exactly what was popped out. - * - * That is why this file must use `window.open()` and not ask the desktop's main - * process to make a BrowserWindow: a window we didn't open gives us no handle to - * its document, and without the handle there is nothing to adopt into. Electron - * turns this same-origin `window.open()` into a real BrowserWindow anyway (see - * main.ts's setWindowOpenHandler → `action: 'allow'`), and the main process - * recognises it by its frame name and gives it remembered bounds, always-on-top - * and the system tray. We get the OS window AND the DOM link. - * - * Styles come across too — the pane document starts empty, so we copy the app's - * stylesheets into it. Without that the panel would land unstyled, which is the - * one thing a "pop out exactly this" feature cannot do. - */ -(function () { - 'use strict'; - - const panes = window.feedBack && window.feedBack.panes; - if (!panes || typeof panes.registerHost !== 'function') { - console.error('[panes] pane-manager.js must load before pane-window-host.js'); - return; - } - - // The desktop's main process finds a pane window by this name and attaches - // bounds, tray and always-on-top to it. Keep it in sync with pane-hosts.ts. - const FRAME_PREFIX = 'fbpane-'; - - const wins = new Map(); // paneId -> Window - let reaper = null; - - // A pane window the user closed with the OS X button gets no reliable - // beforeunload (a crashed renderer certainly gets none). Poll `closed` and - // reap — otherwise the pane stays "open" forever, its chip stays stubbed out, - // and the element it holds is stranded in a dead document with no way back. - function _startReaper() { - if (reaper != null) return; - reaper = setInterval(() => { - wins.forEach((w, id) => { if (w.closed) panes.close(id); }); - if (!wins.size) { clearInterval(reaper); reaper = null; } - }, 400); - } - - // Give the pane document the app's styles, so the panel looks identical. - // Cloned rather than shared: a node can only live in one document, and - // we are not about to steal the app's own stylesheet out of its head. - function _copyStyles(doc) { - document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => { - try { doc.head.appendChild(node.cloneNode(true)); } catch (e) { /* skip a node we can't clone */ } - }); - // Carry the theme/scale hooks the app hangs on and . v3 keys - // off these for its colour tokens and interface scale, and a panel that - // lands without them renders in the wrong palette at the wrong size. - try { - doc.documentElement.className = document.documentElement.className; - doc.documentElement.setAttribute('style', document.documentElement.getAttribute('style') || ''); - doc.body.className = document.body.className; - } catch (e) { /* non-fatal */ } - } - - // Wait for the REAL pane document. - // - // window.open() returns immediately, with an `about:blank` document that is - // already readyState 'complete'. Adopt into that and it works for a few - // milliseconds — and then /pane finishes loading, replaces the document, and - // takes the panel with it. The window is left blank and the element is gone. - // - // So we do not trust readyState, and we do not trust 'load' (which may have - // fired for about:blank before we could listen). We wait for the one thing that - // only exists in the document we actually want: pane.html's #fb-pane-root. - function _whenReady(w, onReady, onFail) { - const deadline = performance.now() + 10000; - const tick = () => { - if (w.closed) return; - let root = null; - try { root = w.document && w.document.getElementById('fb-pane-root'); } - catch (e) { root = null; } // mid-navigation: the document is being swapped - if (root) { onReady(root); return; } - if (performance.now() > deadline) { onFail(new Error('the pane window never loaded')); return; } - setTimeout(tick, 25); - }; - tick(); - } - - function _adopt(w, root, spec, el) { - const doc = w.document; - _copyStyles(doc); - // The panel was almost certainly a fixed/absolute overlay pinned to a - // corner of the app. In a window of its own that positioning is nonsense — - // it would sit 72px from the top of a 380px window, still 288px wide, still - // casting a drop shadow over nothing. Neutralise the *placement* while - // touching nothing else about how it looks. - el.classList.add('fb-paned'); - // Some panels are hidden until opened (Camera Director's is `hidden` until - // you click its launcher). It is being shown on purpose now. - el.hidden = false; - - root.appendChild(doc.adoptNode(el)); - doc.title = spec.title + ' — fee[dB]ack'; - } - - function place(spec, el) { - const w = window.open( - window.location.origin + '/pane', - FRAME_PREFIX + spec.id, - 'popup,width=' + spec.width + ',height=' + spec.height, - ); - - if (!w) { - // Popup blocked. Throw BEFORE the manager records anything, so the - // caller's panel stays exactly where it is — and say so out loud rather - // than appearing to do nothing. - if (window.fbNotify) { - window.fbNotify.show({ - title: 'Pop-out blocked', - message: 'Allow pop-ups for this site to detach ' + spec.title + '.', - icon: '⚠️', accent: '#f59e0b', - }); - } - throw new Error('pop-up blocked'); - } - - wins.set(spec.id, w); - _startReaper(); - - _whenReady(w, (root) => { - try { _adopt(w, root, spec, el); } - catch (e) { - console.error('[panes] failed to move', spec.id, 'into its window', e); - panes.close(spec.id); // brings the element home - } - }, (err) => { - console.error('[panes]', spec.id, err); - panes.close(spec.id); // never strand the element in a dead window - }); - - // Note there is no 'beforeunload' listener on the popup. A listener added - // now would be attached to its throwaway about:blank window and thrown away - // with it when /pane loads. The `closed` poll above is what notices the user - // shutting a pane window — and it has to be, since a crashed renderer never - // gets to say goodbye either. - } - - function unplace(id, el) { - // Hand the element back unmarked. The manager returns it to its home right - // after this, and it must arrive as the plugin left it — a panel that - // stayed .fb-paned would come back with its own positioning stripped. - if (el) el.classList.remove('fb-paned'); - const w = wins.get(id); - wins.delete(id); - // The manager adopts the element back into this document immediately after - // this returns, so the window is empty by the time it closes. - if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } } - } - - function focus(id) { - const w = wins.get(id); - if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } } - } - - // A BROWSER blocks window.open() outside a user gesture, so a pane remembered - // here cannot be restored on page load — it would only ever produce a "blocked" - // toast. Such a pane comes back in the dock, and the chip pops it out again on - // the user's next click. The DESKTOP app has no such restriction, so there a - // pane left popped out comes back popped out, where you left it. - const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes); - - panes.registerHost({ - id: 'window', - priority: 10, - autoRestore: isDesktop, - place, unplace, focus, - }); - - // Our windows; they must not outlive us. A pane window whose opener is gone - // holds an element belonging to a dead document — there is nothing left to - // dock it back into. - window.addEventListener('beforeunload', () => { - wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } }); - }); - - // Exposed for pane-desktop.js, which upgrades this host in place rather than - // registering a competing one — the window still has to be opened HERE, by - // window.open(), or there would be no document to adopt into. - window.__fbPaneWindows = { FRAME_PREFIX, get: (id) => wins.get(id) || null }; -})(); +/* + * fee[dB]ack — the pop-out window host. + * + * Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT. + * + * The move is the whole trick, and it works because the pane window is same-origin + * and opener-linked: `document.adoptNode()` re-parents a live node into another + * window's document, and an adopted node keeps its event listeners, its closures, + * and every reference anything else holds to it. So the plugin's panel goes on + * running the plugin's own code in the plugin's own realm — it is just being + * *displayed* somewhere else. It looks and behaves exactly like what was popped + * out, because it is exactly what was popped out. + * + * That is why this file must use `window.open()` and not ask the desktop's main + * process to make a BrowserWindow: a window we didn't open gives us no handle to + * its document, and without the handle there is nothing to adopt into. Electron + * turns this same-origin `window.open()` into a real BrowserWindow anyway (see + * main.ts's setWindowOpenHandler → `action: 'allow'`), and the main process + * recognises it by its frame name and gives it remembered bounds, always-on-top + * and the system tray. We get the OS window AND the DOM link. + * + * Styles come across too — the pane document starts empty, so we copy the app's + * stylesheets into it. Without that the panel would land unstyled, which is the + * one thing a "pop out exactly this" feature cannot do. + */ +(function () { + 'use strict'; + + const panes = window.feedBack && window.feedBack.panes; + if (!panes || typeof panes.registerHost !== 'function') { + console.error('[panes] pane-manager.js must load before pane-window-host.js'); + return; + } + + // The desktop's main process finds a pane window by this name and attaches + // bounds, tray and always-on-top to it. Keep it in sync with pane-hosts.ts. + const FRAME_PREFIX = 'fbpane-'; + + const wins = new Map(); // paneId -> Window + let reaper = null; + + // A pane window the user closed with the OS X button gets no reliable + // beforeunload (a crashed renderer certainly gets none). Poll `closed` and + // reap — otherwise the pane stays "open" forever, its chip stays stubbed out, + // and the element it holds is stranded in a dead document with no way back. + function _startReaper() { + if (reaper != null) return; + reaper = setInterval(() => { + wins.forEach((w, id) => { if (w.closed) panes.close(id); }); + if (!wins.size) { clearInterval(reaper); reaper = null; } + }, 400); + } + + // Give the pane document the app's styles, so the panel looks identical. + // Cloned rather than shared: a node can only live in one document, and + // we are not about to steal the app's own stylesheet out of its head. + function _copyStyles(doc) { + document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => { + try { doc.head.appendChild(node.cloneNode(true)); } catch (e) { /* skip a node we can't clone */ } + }); + // Carry the theme/scale hooks the app hangs on and . v3 keys + // off these for its colour tokens and interface scale, and a panel that + // lands without them renders in the wrong palette at the wrong size. + try { + doc.documentElement.className = document.documentElement.className; + doc.documentElement.setAttribute('style', document.documentElement.getAttribute('style') || ''); + doc.body.className = document.body.className; + } catch (e) { /* non-fatal */ } + } + + // Wait for the REAL pane document. + // + // window.open() returns immediately, with an `about:blank` document that is + // already readyState 'complete'. Adopt into that and it works for a few + // milliseconds — and then /pane finishes loading, replaces the document, and + // takes the panel with it. The window is left blank and the element is gone. + // + // So we do not trust readyState, and we do not trust 'load' (which may have + // fired for about:blank before we could listen). We wait for the one thing that + // only exists in the document we actually want: pane.html's #fb-pane-root. + function _whenReady(w, onReady, onFail) { + const deadline = performance.now() + 10000; + let reachFailure = null; // why we could never see the pop-out's document + const tick = () => { + if (w.closed) return; + + let doc = null; + try { doc = w.document; } + catch (e) { + // A SecurityError here is the one that matters: it means the pop-out + // is not reachable from this realm at all (a separate process / + // browsing-context group), and no amount of waiting will fix it — + // adoptNode can never work. + doc = null; + reachFailure = e; + } + + if (doc && doc.readyState !== 'loading') { + // Only ever adopt into the document we actually navigated TO. + // about:blank reports readyState 'complete' from the moment + // window.open() returns, and adopting into it means the panel is + // destroyed when /pane replaces it a moment later. + const href = (doc.location && doc.location.href) || ''; + const isPaneDoc = href.indexOf('/pane') >= 0; + if (isPaneDoc) { + // Prefer pane.html's own root, but never fail for want of it — + // a stale cached copy of the page (or a future rename) must not + // leave the user with a blank window and no panel. + const root = doc.getElementById('fb-pane-root') || doc.body; + if (root) { onReady(root); return; } + } + } + + if (performance.now() > deadline) { + let why; + if (reachFailure) { + why = 'the pane window\'s document is NOT reachable from this window (' + + reachFailure.name + ': ' + reachFailure.message + + ') — it is in a separate process, so the element cannot be moved into it'; + } else if (!doc) { + why = 'the pane window exposed no document at all'; + } else { + why = 'the pane window never loaded /pane (it is showing ' + + ((doc.location && doc.location.href) || 'an unknown URL') + + ', readyState ' + doc.readyState + ')'; + } + onFail(new Error(why)); + return; + } + setTimeout(tick, 25); + }; + tick(); + } + + function _adopt(w, root, spec, el) { + const doc = w.document; + _copyStyles(doc); + // The panel was almost certainly a fixed/absolute overlay pinned to a + // corner of the app. In a window of its own that positioning is nonsense — + // it would sit 72px from the top of a 380px window, still 288px wide, still + // casting a drop shadow over nothing. Neutralise the *placement* while + // touching nothing else about how it looks. + el.classList.add('fb-paned'); + // Some panels are hidden until opened (Camera Director's is `hidden` until + // you click its launcher). It is being shown on purpose now. + el.hidden = false; + + root.appendChild(doc.adoptNode(el)); + doc.title = spec.title + ' — fee[dB]ack'; + + // THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES. + // + // When the user closes a pane window, its document is torn down — and the + // panel is inside it. The node itself survives (we hold a reference) and + // comes home looking perfect: right markup, right classes, right size. But + // it comes home DEAD: every event listener in the subtree is gone with the + // document that hosted them. A panel that renders and does nothing. + // + // The `closed` poll cannot save us: by the time `w.closed` is true, the + // document is already gone. `beforeunload` fires while it is still alive, so + // this is the last moment we can get the element out — and panes.close() + // adopts it back into the main document synchronously. + // + // We attach it HERE, not when the window was opened: back then the window + // still held its throwaway about:blank document, and a listener registered + // on that is discarded when /pane replaces it. + w.addEventListener('beforeunload', () => { + if (panes.isOpen(spec.id)) panes.close(spec.id); + }); + + // THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES. + // + // When the user closes a pane window, its document is torn down — and the + // panel is inside it. The node itself survives (we hold a reference) and + // comes home looking perfect: right markup, right classes, right size. But + // it comes home DEAD: every event listener in the subtree is gone with the + // document that hosted them. A panel that renders and does nothing. + // + // The `closed` poll cannot save us: by the time `w.closed` is true, the + // document is already gone. `beforeunload` fires while it is still alive, so + // this is the last moment we can get the element out — and panes.close() + // adopts it back into the main document synchronously. + // + // We attach it HERE, not when the window was opened: back then the window + // still held its throwaway about:blank document, and a listener registered + // on that is discarded when /pane replaces it. + w.addEventListener('beforeunload', () => { + if (panes.isOpen(spec.id)) panes.close(spec.id); + }); + + // Measure LATE. The pane window has not laid out yet at this point (it is + // still being created and shown), so anything read now reports 0x0 whether + // or not there is a real problem. + setTimeout(() => { + if (w.closed || !el.isConnected) return; + const view = doc.defaultView; + const cs = view.getComputedStyle(el); + const rootCs = view.getComputedStyle(root); + console.info('[panes] adopted', spec.id, + '| el:', el.id || el.className, + '| size:', el.offsetWidth + 'x' + el.offsetHeight, + '| display:', cs.display, '| visibility:', cs.visibility, '| opacity:', cs.opacity, + '| position:', cs.position, '| w/h:', cs.width + '/' + cs.height, + '| children:', el.childElementCount, + '| hidden attr:', el.hasAttribute('hidden'), + '| inline style:', el.getAttribute('style') || '(none)', + '| root size:', root.offsetWidth + 'x' + root.offsetHeight, '/', rootCs.display, + '| window inner:', view.innerWidth + 'x' + view.innerHeight, + '| styles:', doc.querySelectorAll('link[rel="stylesheet"], style').length); + }, 400); + } + + function place(spec, el) { + const w = window.open( + window.location.origin + '/pane', + FRAME_PREFIX + spec.id, + 'popup,width=' + spec.width + ',height=' + spec.height, + ); + + if (!w) { + // Popup blocked. Throw BEFORE the manager records anything, so the + // caller's panel stays exactly where it is — and say so out loud rather + // than appearing to do nothing. + if (window.fbNotify) { + window.fbNotify.show({ + title: 'Pop-out blocked', + message: 'Allow pop-ups for this site to detach ' + spec.title + '.', + icon: '⚠️', accent: '#f59e0b', + }); + } + throw new Error('pop-up blocked'); + } + + wins.set(spec.id, w); + _startReaper(); + + _whenReady(w, (root) => { + try { _adopt(w, root, spec, el); } + catch (e) { + console.error('[panes] failed to move', spec.id, 'into its window', e); + panes.close(spec.id); // brings the element home + } + }, (err) => { + console.error('[panes]', spec.id, err); + panes.close(spec.id); // never strand the element in a dead window + }); + + // Note there is no 'beforeunload' listener on the popup. A listener added + // now would be attached to its throwaway about:blank window and thrown away + // with it when /pane loads. The `closed` poll above is what notices the user + // shutting a pane window — and it has to be, since a crashed renderer never + // gets to say goodbye either. + } + + function unplace(id, el) { + // Hand the element back unmarked. The manager returns it to its home right + // after this, and it must arrive as the plugin left it — a panel that + // stayed .fb-paned would come back with its own positioning stripped. + if (el) el.classList.remove('fb-paned'); + const w = wins.get(id); + wins.delete(id); + // The manager adopts the element back into this document immediately after + // this returns, so the window is empty by the time it closes. + if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } } + } + + function focus(id) { + const w = wins.get(id); + if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } } + } + + // A BROWSER blocks window.open() outside a user gesture, so a pane remembered + // here cannot be restored on page load — it would only ever produce a "blocked" + // toast. Such a pane comes back in the dock, and the chip pops it out again on + // the user's next click. The DESKTOP app has no such restriction, so there a + // pane left popped out comes back popped out, where you left it. + const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes); + + panes.registerHost({ + id: 'window', + priority: 10, + autoRestore: isDesktop, + place, unplace, focus, + }); + + // Our windows; they must not outlive us. A pane window whose opener is gone + // holds an element belonging to a dead document — there is nothing left to + // dock it back into. + window.addEventListener('beforeunload', () => { + wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } }); + }); + + // Exposed for pane-desktop.js, which upgrades this host in place rather than + // registering a competing one — the window still has to be opened HERE, by + // window.open(), or there would be no document to adopt into. + window.__fbPaneWindows = { FRAME_PREFIX, get: (id) => wins.get(id) || null }; +})();