mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.
What a user wants from "pop this out" is the thing they popped out.
So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.
The plugin's side collapses to two lines:
feedBack.panes.register({ id, title, element: () => panelEl });
feedBack.panes.attachChip(panelEl, id);
and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.
Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.
Consequences worth knowing:
- The window MUST be opened by the renderer with window.open(), not by the
desktop's main process: a window we did not open gives this realm no handle
to its document, and without the handle there is nothing to adopt into.
Electron turns the same-origin window.open() into a real BrowserWindow
anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
A plugin panel is nearly always a fixed overlay pinned to a corner of the
app; alone in a 380px window that positioning is nonsense. Colours, borders,
padding, fonts and the panel's own internal layout are untouched — the whole
promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
among its siblings.
- The plugin's code still runs in the main window. So a document.body
.appendChild() inside a panel (a tooltip, a popover) lands in the main
window, not the pane — anchor to the panel instead. And a continuously
animating panel may run slowly while the main window is backgrounded, since
its rAF lives there. Both documented.
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
+144
-240
@@ -1,43 +1,49 @@
|
||||
/*
|
||||
* fee[dB]ack — pane manager.
|
||||
*
|
||||
* The registry and host router behind `window.feedBack.panes`. Main realm only.
|
||||
* The registry and host router behind `window.feedBack.panes`.
|
||||
*
|
||||
* A "pane" is a piece of live UI — a mixer, a camera rig, a readout — authored
|
||||
* once as `mount(root, ctx)` and mountable into any *host*: the in-window dock
|
||||
* today, a pop-out OS window later. The manager owns which pane is open and
|
||||
* where; hosts own the chrome; the pane owns nothing but its own DOM.
|
||||
* 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 problem this exists to solve: the player's rail popovers are exclusive
|
||||
* (opening one closes the last), so you cannot watch the mixer while riding the
|
||||
* camera — and both vanish the moment you want to look at the highway. Panes
|
||||
* are non-exclusive by construction and survive song switches, because nothing
|
||||
* about them is tied to the per-song teardown.
|
||||
* The whole design is one sentence: WE MOVE THE REAL ELEMENT.
|
||||
*
|
||||
* Hosts register themselves; the manager never imports one. That is what lets
|
||||
* the pop-out window host drop in later without this file changing.
|
||||
* 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 B = window.__fbPaneBridge;
|
||||
if (!B) { console.error('[panes] pane-bridge.js must load before pane-manager.js'); return; }
|
||||
|
||||
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
|
||||
const STATE_KEY = (id) => 'fbPane:' + id;
|
||||
|
||||
// id -> normalized spec
|
||||
const specs = new Map();
|
||||
// id -> { spec, hostId, root, ctx, state }
|
||||
// id -> { spec, hostId, el, home: { parent, next } }
|
||||
const open = new Map();
|
||||
// hostId -> host provider
|
||||
const hosts = new Map();
|
||||
|
||||
// ── Persistence ──────────────────────────────────────────────────────────
|
||||
// localStorage is shared with any pop-out realm (same origin), so a
|
||||
// concurrent writer there would silently clobber us. The rule, enforced by
|
||||
// this file being main-realm-only: THE MAIN REALM IS THE ONLY WRITER. A pane
|
||||
// asks; the manager writes.
|
||||
// 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 {
|
||||
@@ -48,71 +54,48 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// Pane state is saved on a trailing debounce — a fader drag writes on every
|
||||
// input event, and localStorage is synchronous.
|
||||
const _saveTimers = new Map();
|
||||
function _scheduleSave(id, state) {
|
||||
clearTimeout(_saveTimers.get(id));
|
||||
_saveTimers.set(id, setTimeout(() => {
|
||||
_saveTimers.delete(id);
|
||||
_writeJSON(STATE_KEY(id), state.all());
|
||||
}, 250));
|
||||
}
|
||||
|
||||
// ── 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.mount !== 'function') throw new TypeError('panes.register(' + spec.id + '): spec.mount 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 || '▣',
|
||||
mount: spec.mount,
|
||||
unmount: typeof spec.unmount === 'function' ? spec.unmount : null,
|
||||
// Bus events mirrored into a pop-out realm for this pane. Docked, ctx.on()
|
||||
// reaches the real bus regardless — this list only matters once the
|
||||
// pane is in another realm, and it is declared here so it is the same
|
||||
// list in both.
|
||||
events: Array.isArray(spec.events) ? B.DEFAULT_EVENTS.concat(spec.events) : B.DEFAULT_EVENTS,
|
||||
persist: spec.persist !== false, // default on; opt out with `persist: false`
|
||||
initialState: spec.initialState || {},
|
||||
defaultHost: spec.defaultHost || 'window',
|
||||
// URL of the module the pane REALM loads to obtain this pane's
|
||||
// mount(). A pane with no script can only ever be docked — it exists
|
||||
// solely as a closure in this realm, and there is no honest way to
|
||||
// move a closure across a window boundary. The window host declines
|
||||
// such panes and the router falls back to the dock.
|
||||
script: spec.script || null,
|
||||
mirrorGlobal: spec.mirrorGlobal || null, // honoured by pane-mirror.js
|
||||
// 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 ─────────────────────────────────────────────────────────
|
||||
// A host provider is `{ id, priority, available(), mount(spec) -> Element,
|
||||
// unmount(id), focus(id) }`. Higher priority wins when a pane asks for a
|
||||
// host it can't have.
|
||||
|
||||
function _resolveHost(preferred, spec) {
|
||||
function _resolveHost(preferred) {
|
||||
const wanted = hosts.get(preferred);
|
||||
if (wanted && wanted.available() && wanted.canHost(spec)) return wanted;
|
||||
// Fall back to the best host that IS available and WILL take this pane,
|
||||
// preferring the highest priority. The dock registers at priority 0 and
|
||||
// accepts everything, so it is always the floor — a pane can never fail
|
||||
// to open just because the window host is unavailable or declines it.
|
||||
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() || !h.canHost(spec)) return;
|
||||
if (!h.available()) return;
|
||||
if (!best || h.priority > best.priority) best = h;
|
||||
});
|
||||
return best;
|
||||
@@ -123,14 +106,101 @@
|
||||
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
// ── Open / close ─────────────────────────────────────────────────────────
|
||||
|
||||
function openPane(id, opts) {
|
||||
opts = opts || {};
|
||||
const spec = specs.get(id);
|
||||
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
|
||||
if (open.has(id)) { focusPane(id); return true; }
|
||||
|
||||
let el;
|
||||
try { el = spec.element(); } catch (e) { el = null; }
|
||||
if (!(el instanceof Element)) {
|
||||
console.warn('[panes] open: pane has no element yet:', id);
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = _resolveHost(opts.host || spec.defaultHost);
|
||||
if (!host) { console.error('[panes] open: no host available for', id); return false; }
|
||||
|
||||
// Where the element lives right now, so docking can put it back EXACTLY
|
||||
// there — same parent, same position among its siblings. Anything less and
|
||||
// a docked panel reappears at the bottom of its container, or not at all.
|
||||
const home = { parent: el.parentNode, next: el.nextSibling };
|
||||
|
||||
try {
|
||||
host.place(spec, el);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to take', id, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
open.set(id, { spec, hostId: host.id, el, home });
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
|
||||
_emit('panes:opened', { id: id, host: host.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePane(id, opts) {
|
||||
opts = opts || {};
|
||||
const entry = open.get(id);
|
||||
if (!entry) return false;
|
||||
open.delete(id);
|
||||
|
||||
const host = hosts.get(entry.hostId);
|
||||
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
|
||||
|
||||
// Put the element back where it came from. Re-adopting it into THIS
|
||||
// document is what undoes the pop-out: a node adopted by another window
|
||||
// has that window's document as its owner, and appending it here without
|
||||
// adopting first would throw in some engines and leave it in a half-moved
|
||||
// state in others.
|
||||
const home = entry.home;
|
||||
if (home && home.parent && home.parent.isConnected) {
|
||||
try {
|
||||
const node = document.adoptNode(entry.el);
|
||||
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
|
||||
else home.parent.appendChild(node);
|
||||
} catch (e) {
|
||||
console.error('[panes] could not return', id, 'to its home', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.remember !== false) _rememberHost(id, null);
|
||||
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
|
||||
_emit('panes:closed', { id: id, host: entry.hostId });
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusPane(id) {
|
||||
const entry = open.get(id);
|
||||
if (!entry) return false;
|
||||
const host = hosts.get(entry.hostId);
|
||||
if (host && typeof host.focus === 'function') host.focus(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// What the pop-out chip calls: put this pane wherever a pane most wants to
|
||||
// live. That is a window if one can be had, and the dock otherwise.
|
||||
function detach(id) {
|
||||
const spec = specs.get(id);
|
||||
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
|
||||
}
|
||||
|
||||
function dock(id) {
|
||||
if (open.has(id)) closePane(id, { remember: false });
|
||||
return openPane(id, { host: 'dock' });
|
||||
}
|
||||
|
||||
// ── Registry ─────────────────────────────────────────────────────────────
|
||||
|
||||
function register(spec) {
|
||||
const s = _normalize(spec);
|
||||
if (specs.has(s.id)) {
|
||||
// First registration wins, matching libraryCardActions.register. A
|
||||
// silent overwrite would let a re-injected plugin script swap the
|
||||
// mount function out from under an already-open pane.
|
||||
// silent overwrite would swap the element out from under an open pane.
|
||||
console.warn('[panes] pane already registered, ignoring:', s.id);
|
||||
return () => {};
|
||||
}
|
||||
@@ -138,14 +208,13 @@
|
||||
_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.
|
||||
// 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.
|
||||
// (The desktop host has no such restriction and restores in place.)
|
||||
let remembered = _readJSON(HOSTS_KEY, {})[s.id];
|
||||
if (remembered) {
|
||||
const h = hosts.get(remembered);
|
||||
@@ -162,201 +231,36 @@
|
||||
_emit('panes:unregistered', { id: id });
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
const host = _resolveHost(opts.host || spec.defaultHost, spec);
|
||||
if (!host) { console.error('[panes] open: no host available for', id); return false; }
|
||||
|
||||
const state = B.createStateStore(spec.persist ? _readJSON(STATE_KEY(id), spec.initialState) : spec.initialState);
|
||||
if (spec.persist) state.subscribe(() => _scheduleSave(id, state));
|
||||
|
||||
// A REMOTE host (a pop-out window) runs the pane's mount() in its own
|
||||
// realm — this realm never sees the pane's DOM and must not call mount()
|
||||
// itself. All we own here is the authoritative state store; pane-hub.js
|
||||
// serves the other realm from it.
|
||||
if (host.remote) {
|
||||
let handle;
|
||||
try {
|
||||
handle = host.mount(spec);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to open a window for', id, e);
|
||||
return false;
|
||||
}
|
||||
if (!handle) return false; // host already explained itself (popup blocked, etc.)
|
||||
open.set(id, { spec, hostId: host.id, state, remote: true, handle });
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
_emit('panes:opened', { id: id, host: host.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
let root;
|
||||
try {
|
||||
root = host.mount(spec);
|
||||
} catch (e) {
|
||||
console.error('[panes] host', host.id, 'failed to mount', id, e);
|
||||
return false;
|
||||
}
|
||||
|
||||
const ctx = B.createCtx({
|
||||
paneId: id,
|
||||
host: host.id,
|
||||
transport: B.createLocalTransport(id),
|
||||
state: state,
|
||||
onClose: () => closePane(id),
|
||||
});
|
||||
|
||||
const entry = { spec, hostId: host.id, root, ctx, state };
|
||||
open.set(id, entry);
|
||||
|
||||
try {
|
||||
spec.mount(root, ctx);
|
||||
} catch (e) {
|
||||
// A pane that throws in mount() must not leave a half-open shell
|
||||
// behind — tear the whole thing back down and tell the user, rather
|
||||
// than leaving an empty card they can't explain.
|
||||
console.error('[panes] pane threw in mount():', id, e);
|
||||
closePane(id, { remember: false });
|
||||
if (window.fbNotify) window.fbNotify.show({ title: spec.title, message: 'Failed to open.', icon: '⚠️', accent: '#f59e0b' });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (opts.remember !== false) _rememberHost(id, host.id);
|
||||
_emit('panes:opened', { id: id, host: host.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePane(id, opts) {
|
||||
opts = opts || {};
|
||||
const entry = open.get(id);
|
||||
if (!entry) return false;
|
||||
open.delete(id);
|
||||
|
||||
// Order matters for a local pane: the pane tears down its own DOM and
|
||||
// listeners first, then ctx drops everything it handed out, then the host
|
||||
// removes the shell. Reversing any of these hands the pane a root that has
|
||||
// already been detached, or leaks the subscriptions its unmount() assumed
|
||||
// it kept. A remote pane's mount/unmount ran in the other realm and
|
||||
// teardown goes with the window, so there is nothing to do but close it.
|
||||
if (!entry.remote) {
|
||||
try { if (entry.spec.unmount) entry.spec.unmount(entry.root, entry.ctx); }
|
||||
catch (e) { console.error('[panes] pane threw in unmount():', id, e); }
|
||||
try { entry.ctx._dispose(); } catch (e) { console.error('[panes] ctx dispose threw:', id, e); }
|
||||
}
|
||||
|
||||
const host = hosts.get(entry.hostId);
|
||||
try { if (host) host.unmount(id); } catch (e) { console.error('[panes] host', entry.hostId, 'threw in unmount:', id, e); }
|
||||
|
||||
// Flush any pending debounced state write — closing must not lose the
|
||||
// last fader nudge.
|
||||
if (entry.spec.persist) {
|
||||
clearTimeout(_saveTimers.get(id));
|
||||
_saveTimers.delete(id);
|
||||
_writeJSON(STATE_KEY(id), entry.state.all());
|
||||
}
|
||||
|
||||
if (opts.remember !== false) _rememberHost(id, null);
|
||||
_emit('panes:closed', { id: id, host: entry.hostId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move an open pane to a different host without losing its state: the pane's
|
||||
// DOM is rebuilt (mount runs again against the new root) but its state store
|
||||
// is the persisted one, so a fader sits where the user left it.
|
||||
function movePane(id, hostId) {
|
||||
const wasOpen = open.has(id);
|
||||
if (wasOpen) closePane(id, { remember: false });
|
||||
return openPane(id, { host: hostId });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// `detach` is what the pop-out chip calls: put this pane wherever a pane
|
||||
// most wants to live. Today the dock is usually the only host; once the
|
||||
// window host registers, it outranks the dock and the same call opens an OS
|
||||
// window instead. The chip never changes.
|
||||
function detach(id) {
|
||||
const spec = specs.get(id);
|
||||
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
|
||||
}
|
||||
|
||||
function dock(id) { return movePane(id, 'dock'); }
|
||||
|
||||
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,
|
||||
// `remote: true` means the pane's mount() runs in ANOTHER JS realm.
|
||||
// The manager then owns only the state store, and pane-hub.js serves
|
||||
// the pane over the channel.
|
||||
remote: !!host.remote,
|
||||
// `autoRestore: false` — this host cannot be opened without a user
|
||||
// gesture, so a pane remembered here comes back in the dock instead.
|
||||
autoRestore: host.autoRestore !== false,
|
||||
available: typeof host.available === 'function' ? host.available : () => true,
|
||||
canHost: typeof host.canHost === 'function' ? host.canHost : () => true,
|
||||
mount: host.mount,
|
||||
unmount: host.unmount,
|
||||
place: host.place,
|
||||
unplace: host.unplace,
|
||||
focus: host.focus,
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
version: 1,
|
||||
version: 2,
|
||||
register,
|
||||
unregister,
|
||||
open: openPane,
|
||||
close: closePane,
|
||||
move: movePane,
|
||||
focus: focusPane,
|
||||
detach,
|
||||
dock,
|
||||
focus: focusPane,
|
||||
isOpen: (id) => open.has(id),
|
||||
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
|
||||
get: (id) => specs.get(id) || null,
|
||||
list: () => Array.from(specs.values()).map((s) => ({ id: s.id, title: s.title, icon: s.icon, open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null })),
|
||||
// Host registration is host-internal, but it lives on the same object so
|
||||
// a future out-of-tree host (a plugin shipping its own window shell) can
|
||||
// participate without a private import.
|
||||
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,
|
||||
|
||||
// The main realm's handle on an open pane's state.
|
||||
//
|
||||
// `mirrorGlobal` covers the case where a pane drives a plain global that
|
||||
// some renderer reads. It does NOT cover the far more common one: a
|
||||
// plugin whose main-realm code is the authority — it clamps, it persists,
|
||||
// it emits events, it owns the audio graph or the camera rig — and which
|
||||
// therefore needs to APPLY the pane's values itself rather than have core
|
||||
// splat them somewhere.
|
||||
//
|
||||
// Such a plugin subscribes here on `panes:opened`, seeds the store with
|
||||
// its current values, and applies whatever comes back. The pane stays
|
||||
// realm-agnostic (it only ever touches ctx.state) and the plugin keeps
|
||||
// being the single source of truth. Returns null when the pane is closed.
|
||||
state: (id) => {
|
||||
const entry = open.get(id);
|
||||
return entry ? {
|
||||
get: (path) => entry.state.get(path),
|
||||
set: (path, value) => entry.state.set(path, value),
|
||||
all: () => entry.state.all(),
|
||||
subscribe: (fn) => entry.state.subscribe(fn),
|
||||
} : null;
|
||||
},
|
||||
|
||||
// Host-internal. pane-hub.js serves a pop-out realm from the
|
||||
// authoritative state store, which only lives here. Not part of the pane
|
||||
// API — panes must never reach for this.
|
||||
_entry: (id) => open.get(id) || null,
|
||||
};
|
||||
|
||||
window.feedBack = window.feedBack || {};
|
||||
|
||||
Reference in New Issue
Block a user