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