mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
Three real findings from CodeRabbit on #928, all in current code. 1. closePane() adopted the element out of the pane window ONLY when its original home was still connected. If the panel never had a parent (a plugin that builds it lazily and hands it straight over) or its container was torn down while the pane was out (a screen change), the whole block was skipped — leaving the element inside a window we then close, which strips every listener in its subtree. That is exactly the "comes home dead" failure this ordering exists to prevent; the guard just moved it from the common path to the rare one, where it is far harder to spot. Adopting and re-homing are two different jobs and only one of them is allowed to fail. Adopt UNCONDITIONALLY — that is what rescues the element — and insert only when there is somewhere to insert it. With no home the element ends up owned by this document but not in it: detached, intact, listeners alive, ready for the plugin to re-insert. 2. The pane window's `beforeunload` handler was registered TWICE, comment block and all — a bad scripted edit on my part. Harmless (the handler is idempotent via panes.isOpen) but dead duplicate code. Also fixed the stale comment further down that still claimed there was no beforeunload listener at all. 3. _copyStyles ASSIGNED className on the pane document's <html> and <body> instead of merging. pane.html sets `class="fb-pane-window"` on <html>, and panes.css hangs the pane window's own chrome off exactly that — so copying the app's classes over it silently took the pane window's own layout with them. Merge both class lists, and append the interface-scale inline style rather than replacing the attribute. Also guarded the docs' integration example behind a feedBack.panes check: the doc says the API is optional, and then showed an example that would throw on a host without it. Not applicable (reviewed againste5cbea2, the branch's first commit, before the rebuild): the prototype-pollution findings in pane-bridge.js and pane-mirror.js, and the `panes[]` manifest validation in plugins/__init__.py. All three files are gone —188bdaadeleted the entire cross-realm bridge, mirrorGlobal, and manifest layer when panes switched to moving the real DOM node. Signed-off-by: topkoa <topkoa@gmail.com>
312 lines
15 KiB
JavaScript
312 lines
15 KiB
JavaScript
/*
|
|
* 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.
|
|
// ADOPT UNCONDITIONALLY, INSERT CONDITIONALLY. The rescue and the
|
|
// re-homing are two different jobs, and only one of them is allowed to
|
|
// fail.
|
|
//
|
|
// Adopting is what saves the element: it transfers ownership away from the
|
|
// pane window's document, so that document can be destroyed without taking
|
|
// the listeners with it. Do that FIRST, and always — even when there is
|
|
// nowhere to put the element afterwards.
|
|
//
|
|
// Re-homing can legitimately be impossible: the panel may never have had a
|
|
// parent (a plugin that builds it lazily and hands it straight to us), or
|
|
// its container may have been torn down while the pane was out (a screen
|
|
// change). Gating the adopt on a reachable home would mean that in exactly
|
|
// those cases we leave the element inside a window we are about to close —
|
|
// which is the "comes home dead" failure this whole ordering exists to
|
|
// prevent. It just moves it from the common path to the rare one, where it
|
|
// is far harder to spot.
|
|
//
|
|
// With no home, the element ends up owned by this document but not in it:
|
|
// detached, intact, listeners alive, and ready for the plugin to re-insert
|
|
// whenever it rebuilds its UI.
|
|
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);
|
|
const home = entry.home;
|
|
if (home && home.parent && home.parent.isConnected) {
|
|
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
|
|
else home.parent.appendChild(node);
|
|
} else {
|
|
console.warn('[panes]', id, 'has no home to return to — the element is detached but intact');
|
|
}
|
|
} catch (e) {
|
|
console.error('[panes] could not bring', id, 'back out of its pane window', 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);
|
|
})();
|