mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 04:41:23 +00:00
`spec.width` / `spec.height` are plugin-controlled, and the window host builds
window.open()'s feature string by concatenation:
'popup,width=' + spec.width + ',height=' + spec.height
`spec.width || 380` passed anything truthy straight through. So a width of
'300,menubar=1' would not merely be an invalid size — it would inject window
features. Less dramatically, any non-numeric value produced a malformed feature
string and a pane that failed to open for no visible reason.
They now go through _size(): Number, round, reject anything not finite and
positive, clamp to 120..4000. A hostile or careless value falls back to the
default instead of reaching window.open() at all.
Verified against the obvious inputs: '300,menubar=1' -> 380 (default), '300' ->
300, 0/-50/NaN/{}/'abc' -> 380, 1e9 -> 4000, 5 -> 120.
Signed-off-by: topkoa <topkoa@gmail.com>
382 lines
19 KiB
JavaScript
382 lines
19 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.
|
|
|
|
// A pane id is plugin-controlled and is used as a key in the persisted
|
|
// host map. `__proto__` and friends are not ids, they are booby traps: writing
|
|
// `map['__proto__'] = 'window'` on a plain object corrupts the map (and can
|
|
// reach Object.prototype), and reading `map[id]` can pick a value straight off
|
|
// the prototype chain for a pane that was never remembered at all.
|
|
//
|
|
// Rejected at registration, so the id never reaches storage — and the reads
|
|
// below are own-property checks anyway, because defence in depth is cheap here.
|
|
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
|
|
function _isUnsafeId(id) { return UNSAFE_KEYS.indexOf(id) >= 0; }
|
|
|
|
function _readJSON(key, fallback) {
|
|
try {
|
|
const raw = localStorage.getItem(key);
|
|
if (!raw) return fallback;
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return fallback;
|
|
// Re-key onto a null-prototype object: whatever was in storage (hand
|
|
// edited, corrupt, polluted) can no longer smuggle in a prototype.
|
|
const safe = Object.create(null);
|
|
Object.keys(parsed).forEach((k) => { if (!_isUnsafeId(k)) safe[k] = parsed[k]; });
|
|
return safe;
|
|
} 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) {
|
|
if (_isUnsafeId(id)) return;
|
|
const map = _readJSON(HOSTS_KEY, Object.create(null));
|
|
if (hostId) map[id] = hostId; else delete map[id];
|
|
_writeJSON(HOSTS_KEY, map);
|
|
}
|
|
function _rememberedHost(id) {
|
|
const map = _readJSON(HOSTS_KEY, Object.create(null));
|
|
return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : undefined;
|
|
}
|
|
|
|
// ── Spec ─────────────────────────────────────────────────────────────────
|
|
|
|
// A pane window's initial size. Plugin-controlled, and the window host builds
|
|
// window.open()'s feature string by concatenation — so this has to come out the
|
|
// other side as a number, not merely as something number-ish.
|
|
const MIN_PANE_PX = 120;
|
|
const MAX_PANE_PX = 4000; // wider than any real display; a guard, not a policy
|
|
function _size(v, fallback) {
|
|
const n = Math.round(Number(v));
|
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
return Math.min(MAX_PANE_PX, Math.max(MIN_PANE_PX, n));
|
|
}
|
|
|
|
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');
|
|
// See UNSAFE_KEYS: a pane id becomes a key in the persisted host map.
|
|
if (_isUnsafeId(spec.id)) throw new TypeError('panes.register: unsafe pane id: ' + spec.id);
|
|
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,
|
|
// Coerced to real numbers, because these are plugin-controlled and the
|
|
// window host concatenates them into window.open()'s feature string. A
|
|
// `width` of '300,menubar=1' would not merely be an invalid size — it
|
|
// would inject window features. Anything that isn't a finite positive
|
|
// number falls back to the default, and absurd sizes are clamped rather
|
|
// than honoured.
|
|
width: _size(spec.width, 380),
|
|
height: _size(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');
|
|
|
|
// Make it visible, and remember exactly how it wasn't.
|
|
//
|
|
// A plugin's panel is usually hidden until its launcher is clicked, and a
|
|
// pane can be opened from the tray or the rail without that ever happening.
|
|
// So we un-hide it — but only in the two ways a panel is actually hidden
|
|
// (`hidden`, or an inline `display:none`), and we put both back on dock.
|
|
//
|
|
// Note what we do NOT do: force a `display`. A panel that is `display:flex`
|
|
// must stay flex. Neutralising placement is one thing; silently re-laying
|
|
// out someone's panel is another.
|
|
const vis = { hidden: el.hidden, display: el.style.display };
|
|
el.hidden = false;
|
|
if (el.style.display === 'none') el.style.display = '';
|
|
|
|
try {
|
|
host.place(spec, el);
|
|
} catch (e) {
|
|
console.error('[panes] host', host.id, 'failed to take', id, e);
|
|
el.hidden = vis.hidden;
|
|
el.style.display = vis.display;
|
|
return false;
|
|
}
|
|
|
|
open.set(id, { spec, hostId: host.id, el, home, vis });
|
|
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); }
|
|
|
|
// Put its visibility back exactly as we found it. A panel that was closed
|
|
// when the pane was opened from the tray goes back to being closed; one that
|
|
// was open stays open. We forced it visible; we un-force it.
|
|
if (entry.vis) {
|
|
entry.el.hidden = entry.vis.hidden;
|
|
entry.el.style.display = entry.vis.display;
|
|
}
|
|
|
|
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 = _rememberedHost(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; },
|
|
// The element a host actually took. The chip needs this to tell "the pane
|
|
// took MY element" from "the pane took something else" — and it cannot ask
|
|
// the element, which may now be in a dock card or another window entirely.
|
|
elementOf: (id) => { const e = open.get(id); return e ? e.el : 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);
|
|
})();
|