mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Merge pull request #928 from got-feedBack/feat/panes-core
feat(panes): detachable panes — pop a plugin's real panel out into its own window
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out chip.
|
||||
*
|
||||
* One affordance, core-owned, identical everywhere: the small ⇱ button a plugin
|
||||
* drops into the panel it already has.
|
||||
*
|
||||
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
|
||||
* feedBack.panes.attachChip(panelEl, 'camera_director');
|
||||
*
|
||||
* That is the entire adoption cost. Clicking the chip pops the panel out; a stub
|
||||
* takes its place so the user can find it again; closing the pane brings the panel
|
||||
* home and restores the chip. The plugin writes no show/hide logic — if it did,
|
||||
* every plugin would invent a slightly different one, which is exactly the
|
||||
* inconsistency this exists to prevent.
|
||||
*
|
||||
* The panel a chip is attached to is USUALLY the very element the pane moves into
|
||||
* the pop-out window — so most of the time there is nothing here left to hide, and
|
||||
* the job is simply to mark the hole it left. Hiding it would in fact be actively
|
||||
* harmful: `.fb-pane-detached` is `display:none !important`, and it would travel
|
||||
* with the node straight into the pane window and blank it.
|
||||
*
|
||||
* When the chip IS attached to something the pane didn't take (a wrapper, a
|
||||
* launcher row), that element stays put and is hidden with `.fb-pane-detached` —
|
||||
* a dedicated class, not `.hidden`/[hidden], because the panels we attach to
|
||||
* already toggle those themselves.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.register !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-chip.js');
|
||||
return;
|
||||
}
|
||||
|
||||
// paneId -> { el, chip, stub, spec }
|
||||
const attached = new Map();
|
||||
|
||||
function _makeChip(spec) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'fb-pane-chip';
|
||||
b.title = 'Pop out';
|
||||
b.setAttribute('aria-label', 'Pop out ' + spec.title);
|
||||
b.textContent = '⇱';
|
||||
b.addEventListener('click', (e) => {
|
||||
// Rail popovers close on any document click that lands outside them
|
||||
// (player-chrome.js). Without this the popover would close under the
|
||||
// chip mid-click, which reads as the button not working.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
panes.detach(spec.id);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
function _makeStub(spec) {
|
||||
const s = document.createElement('button');
|
||||
s.type = 'button';
|
||||
s.className = 'fb-pane-stub';
|
||||
s.setAttribute('aria-label', 'Bring ' + spec.title + ' back');
|
||||
s.title = 'Bring it back';
|
||||
const glyph = document.createElement('span');
|
||||
glyph.className = 'fb-pane-stub-glyph';
|
||||
glyph.textContent = '⇲';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = spec.title + ' is popped out';
|
||||
s.appendChild(glyph);
|
||||
s.appendChild(label);
|
||||
s.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
panes.close(spec.id);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
// The pane is out. Leave a stub where its panel used to be.
|
||||
//
|
||||
// The subtlety: the panel a chip is attached to is USUALLY the very element the
|
||||
// pane moved into the pop-out window. It is no longer in this document at all —
|
||||
// so hiding it would be worse than pointless (the `display:none` travels with
|
||||
// the node and blanks the pane window, which is exactly the bug this fixes), and
|
||||
// the stub cannot be inserted "before it", because it is not here to be before.
|
||||
//
|
||||
// Hence `home`: the manager tells us where the element used to live, and the
|
||||
// stub goes there. If the chip is attached to something the pane did NOT take —
|
||||
// a wrapper, a launcher row — that element is still here, and we hide it as
|
||||
// before.
|
||||
function _onOpened(rec, detail) {
|
||||
// Did the pane take MY element?
|
||||
//
|
||||
// Ask the manager, which knows exactly what it handed to the host. Do not
|
||||
// try to infer it from the element:
|
||||
//
|
||||
// - `isConnected` says "still here" for a panel sitting in a pane window.
|
||||
// It IS connected — to that window.
|
||||
// - `ownerDocument` says "still here" for a panel moved into the DOCK,
|
||||
// which is in this very document. Hiding it there would blank a pane the
|
||||
// user is looking at.
|
||||
//
|
||||
// Both were live bugs. The manager's answer is the only one that holds for
|
||||
// every host, and it works when reconciling after the fact (detail == null),
|
||||
// which is what a plugin rebuilding its panel mid-pop-out triggers.
|
||||
const takenEl = (detail && detail.el) || panes.elementOf(rec.spec.id);
|
||||
const moved = takenEl === rec.el;
|
||||
|
||||
if (!moved && rec.el.isConnected) {
|
||||
rec.el.classList.add('fb-pane-detached');
|
||||
if (!rec.stub.isConnected && rec.el.parentNode) rec.el.parentNode.insertBefore(rec.stub, rec.el);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the hole the element left. `home` comes with the event, or from the
|
||||
// manager when we are reconciling after the fact.
|
||||
const home = (detail && detail.home) || panes.homeOf(rec.spec.id);
|
||||
if (!rec.stub.isConnected && home && home.parent && home.parent.isConnected) {
|
||||
const next = (home.next && home.next.parentNode === home.parent) ? home.next : null;
|
||||
home.parent.insertBefore(rec.stub, next);
|
||||
}
|
||||
}
|
||||
|
||||
function _onClosed(rec) {
|
||||
// The element is back. Whatever we did to hide it, undo — including a class
|
||||
// it might have carried out of the document and back.
|
||||
rec.el.classList.remove('fb-pane-detached');
|
||||
rec.stub.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* attachChip(el, paneId, opts)
|
||||
*
|
||||
* `el` — the dialog to hide when the pane pops out. The chip is injected
|
||||
* into `el.querySelector('[data-pane-header]')` when present, else
|
||||
* prepended to `el` itself.
|
||||
* `opts` — { header: Element } to place the chip somewhere specific.
|
||||
*
|
||||
* Returns a detach function that removes the chip and stub and restores the
|
||||
* dialog — call it if your plugin tears its dialog down.
|
||||
*/
|
||||
function attachChip(el, paneId, opts) {
|
||||
opts = opts || {};
|
||||
if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element');
|
||||
// Validate here, not at the insertBefore below. This is a public plugin API,
|
||||
// and a truthy non-Element `header` (a selector string, a jQuery-ish wrapper,
|
||||
// a ref object) is an easy mistake to make — one that would otherwise surface
|
||||
// as a confusing DOM exception from deep inside core.
|
||||
if (opts.header != null && !(opts.header instanceof Element)) {
|
||||
throw new TypeError('panes.attachChip(' + paneId + '): opts.header 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;
|
||||
})();
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* fee[dB]ack — desktop upgrades for pane windows.
|
||||
*
|
||||
* In the desktop app a pane window is a real BrowserWindow: it remembers where you
|
||||
* put it, it stays off the taskbar, it minimizes to the system tray, and the tray
|
||||
* lists every pane you have.
|
||||
*
|
||||
* Note what this file does NOT do: it does not open the window, and it does not
|
||||
* close it. That stays in pane-window-host.js, and it stays `window.open()` —
|
||||
* because the pane's element is MOVED into that window's document, and a window
|
||||
* the main process created for us would give this realm no handle to adopt into.
|
||||
*
|
||||
* Electron turns our same-origin `window.open()` into a real BrowserWindow anyway,
|
||||
* and the main process recognises it by its frame name (`fbpane-<id>`) and takes
|
||||
* over the OS-level behaviour from there. So the only thing left to say across IPC
|
||||
* is "here are the panes that exist" — for the tray — and to listen for the tray
|
||||
* saying "open that one".
|
||||
*
|
||||
* In a browser, or on an older desktop build, this file does nothing and pop-out
|
||||
* works anyway. Everything here is an upgrade, not a dependency.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
|
||||
if (!panes || !bus || !desktop) return;
|
||||
|
||||
// The tray asked to toggle a pane. Only this realm knows what that means — the
|
||||
// pane might belong in the dock, and its element lives here.
|
||||
desktop.onToggle((paneId) => {
|
||||
if (panes.isOpen(paneId)) panes.close(paneId);
|
||||
else panes.detach(paneId);
|
||||
});
|
||||
|
||||
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
|
||||
// registered at load and toggled by hand, never on a playback path.
|
||||
function sync() {
|
||||
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
|
||||
}
|
||||
bus.on('panes:registered', sync);
|
||||
bus.on('panes:unregistered', sync);
|
||||
bus.on('panes:opened', sync);
|
||||
bus.on('panes:closed', sync);
|
||||
sync();
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* fee[dB]ack — pane dock (the in-window pane host).
|
||||
*
|
||||
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
|
||||
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
|
||||
* opening the next), which is exactly why you cannot watch the mixer while riding
|
||||
* the camera. Cards here coexist.
|
||||
*
|
||||
* As everywhere in this system, the card holds the plugin's REAL element — moved,
|
||||
* not copied. The dock is a frame; the panel inside it is the panel.
|
||||
*
|
||||
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
|
||||
* child outside every .screen, so the per-song teardown never sees it.
|
||||
*
|
||||
* Registers as the `dock` host at priority 0 — the floor. Whatever else exists
|
||||
* (an OS window), a pane can always land here, so opening one can never fail.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.registerHost !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-dock.js');
|
||||
return;
|
||||
}
|
||||
|
||||
let dockEl = null;
|
||||
const cards = new Map(); // paneId -> card element
|
||||
|
||||
function dock() {
|
||||
if (dockEl && dockEl.isConnected) return dockEl;
|
||||
dockEl = document.getElementById('fb-pane-dock');
|
||||
if (!dockEl) {
|
||||
dockEl = document.createElement('div');
|
||||
dockEl.id = 'fb-pane-dock';
|
||||
// `is-empty` from the start: panes.css hides an empty dock, and a dock
|
||||
// born without the class is a visible-to-CSS, announced-to-screen-readers
|
||||
// `role="region"` landmark with nothing in it until the first card
|
||||
// arrives. Born empty, because it is.
|
||||
dockEl.className = 'fb-pane-dock is-empty';
|
||||
dockEl.setAttribute('role', 'region');
|
||||
dockEl.setAttribute('aria-label', 'Panes');
|
||||
document.body.appendChild(dockEl);
|
||||
}
|
||||
return dockEl;
|
||||
}
|
||||
|
||||
function _syncEmpty() {
|
||||
dock().classList.toggle('is-empty', cards.size === 0);
|
||||
}
|
||||
|
||||
function place(spec, el) {
|
||||
const card = document.createElement('section');
|
||||
card.className = 'fb-pane-card';
|
||||
card.dataset.paneId = spec.id;
|
||||
card.setAttribute('aria-label', spec.title);
|
||||
|
||||
const head = document.createElement('header');
|
||||
head.className = 'fb-pane-card-head';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'fb-pane-card-title';
|
||||
// textContent, not innerHTML — a pane title comes from a plugin.
|
||||
title.textContent = spec.icon + ' ' + spec.title;
|
||||
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'fb-pane-card-btn';
|
||||
close.setAttribute('aria-label', 'Close ' + spec.title);
|
||||
close.title = 'Close';
|
||||
close.textContent = '✕';
|
||||
close.addEventListener('click', () => panes.close(spec.id));
|
||||
|
||||
head.appendChild(title);
|
||||
head.appendChild(close);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'fb-pane-card-body';
|
||||
|
||||
// Same neutralisation as the window host: the panel was a fixed overlay
|
||||
// pinned to a corner of the app, and inside a card that positioning is
|
||||
// nonsense. .fb-paned unpins it and nothing else.
|
||||
el.classList.add('fb-paned');
|
||||
body.appendChild(el);
|
||||
|
||||
card.appendChild(head);
|
||||
card.appendChild(body);
|
||||
dock().appendChild(card);
|
||||
cards.set(spec.id, card);
|
||||
_syncEmpty();
|
||||
}
|
||||
|
||||
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 card = cards.get(id);
|
||||
if (card) card.remove();
|
||||
cards.delete(id);
|
||||
_syncEmpty();
|
||||
}
|
||||
|
||||
function focus(id) {
|
||||
const card = cards.get(id);
|
||||
if (!card) return;
|
||||
// Honour prefers-reduced-motion, as the flash animation below already does
|
||||
// in panes.css. A smooth scroll is motion too, and a user who asked for less
|
||||
// of it meant this as well.
|
||||
const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
card.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
|
||||
// Re-trigger the flash even if the class is still there — repeat focus of
|
||||
// the same card would otherwise be a no-op animation.
|
||||
card.classList.remove('is-flash');
|
||||
void card.offsetWidth;
|
||||
card.classList.add('is-flash');
|
||||
setTimeout(() => card.classList.remove('is-flash'), 700);
|
||||
}
|
||||
|
||||
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* fee[dB]ack — pane launcher (the "Panes" rail popover).
|
||||
*
|
||||
* A chip only works for a pane that already has a dialog to hide. Panes with no
|
||||
* dialog — a readout, a plugin's optional extra — need somewhere to be opened
|
||||
* from, so every registered pane gets one: a checkbox list in the rail.
|
||||
*
|
||||
* The rail popover is the right home for this precisely because it IS exclusive
|
||||
* and transient. It's a menu, not a workspace; the panes it opens are the
|
||||
* workspace, and they persist.
|
||||
*
|
||||
* Populated from the registry, so a plugin that calls panes.register() appears
|
||||
* here with no further work. (The system tray will mirror this list.)
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
const bus = window.feedBack;
|
||||
if (!panes || !bus || typeof bus.on !== 'function') return;
|
||||
|
||||
let listEl = null;
|
||||
|
||||
function render() {
|
||||
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
|
||||
if (!listEl) return;
|
||||
const all = panes.list();
|
||||
|
||||
// Toggling a pane from this list fires panes:opened/closed, which re-renders
|
||||
// the list — destroying the very button the user just pressed and dropping
|
||||
// focus to <body>. Remember which one had it and give it back, so keyboard
|
||||
// and screen-reader users can toggle several panes without losing their place.
|
||||
const focusedId = (listEl.contains(document.activeElement) && document.activeElement.dataset)
|
||||
? document.activeElement.dataset.paneId : null;
|
||||
|
||||
listEl.replaceChildren();
|
||||
|
||||
if (!all.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'v3-pop-empty';
|
||||
empty.textContent = 'No panes available.';
|
||||
listEl.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
all.forEach((p) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'v3-pop-btn';
|
||||
b.dataset.paneId = p.id;
|
||||
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
|
||||
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
|
||||
b.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
|
||||
});
|
||||
listEl.appendChild(b);
|
||||
if (p.id === focusedId) b.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// The registry changes when plugins load and when panes open/close. Render is
|
||||
// cheap and rare (never on a playback path), so just re-run it.
|
||||
bus.on('panes:registered', render);
|
||||
bus.on('panes:unregistered', render);
|
||||
bus.on('panes:opened', render);
|
||||
bus.on('panes:closed', render);
|
||||
|
||||
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render);
|
||||
else render();
|
||||
})();
|
||||
@@ -0,0 +1,381 @@
|
||||
/*
|
||||
* 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);
|
||||
})();
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out window host.
|
||||
*
|
||||
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
|
||||
*
|
||||
* The move is the whole trick, and it works because the pane window is same-origin
|
||||
* and opener-linked: `document.adoptNode()` re-parents a live node into another
|
||||
* window's document, and an adopted node keeps its event listeners, its closures,
|
||||
* and every reference anything else holds to it. So the plugin's panel goes on
|
||||
* running the plugin's own code in the plugin's own realm — it is just being
|
||||
* *displayed* somewhere else. It looks and behaves exactly like what was popped
|
||||
* out, because it is exactly what was popped out.
|
||||
*
|
||||
* That is why this file must use `window.open()` and not ask the desktop's main
|
||||
* process to make a BrowserWindow: a window we didn't open gives us no handle to
|
||||
* its document, and without the handle there is nothing to adopt into.
|
||||
*
|
||||
* Electron turns this same-origin `window.open()` into a real BrowserWindow anyway
|
||||
* — its setWindowOpenHandler answers same-origin URLs with `action: 'allow'` — and
|
||||
* the main process then recognises the window by its frame name and gives it
|
||||
* remembered bounds, skip-taskbar and a system-tray entry. So we get the OS window
|
||||
* AND the DOM link. (That code lives in the separate desktop repo,
|
||||
* got-feedback/feedBack-desktop: src/main/main.ts and src/main/pane-hosts.ts. It is
|
||||
* not in this repo, and nothing here depends on it — in a plain browser this is
|
||||
* simply a pop-up.)
|
||||
*
|
||||
* Styles come across too — the pane document starts empty, so we copy the app's
|
||||
* stylesheets into it. Without that the panel would land unstyled, which is the
|
||||
* one thing a "pop out exactly this" feature cannot do.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const panes = window.feedBack && window.feedBack.panes;
|
||||
if (!panes || typeof panes.registerHost !== 'function') {
|
||||
console.error('[panes] pane-manager.js must load before pane-window-host.js');
|
||||
return;
|
||||
}
|
||||
|
||||
// The frame name every pane window is opened with. In the desktop app the main
|
||||
// process matches on this prefix to recognise a pane window and give it its
|
||||
// remembered bounds, skip-taskbar and tray entry — so changing it here without
|
||||
// changing it there silently downgrades every pane to a plain pop-up.
|
||||
//
|
||||
// The other half lives in a DIFFERENT REPO (got-feedback/feedBack-desktop,
|
||||
// src/main/pane-hosts.ts). There is no build-time link between them; this comment
|
||||
// is the link.
|
||||
const FRAME_PREFIX = 'fbpane-';
|
||||
|
||||
const wins = new Map(); // paneId -> Window
|
||||
let reaper = null;
|
||||
|
||||
// A pane window the user closed with the OS X button gets no reliable
|
||||
// beforeunload (a crashed renderer certainly gets none). Poll `closed` and
|
||||
// reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
|
||||
// and the element it holds is stranded in a dead document with no way back.
|
||||
function _startReaper() {
|
||||
if (reaper != null) return;
|
||||
reaper = setInterval(() => {
|
||||
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
|
||||
if (!wins.size) { clearInterval(reaper); reaper = null; }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Give the pane document the app's styles, so the panel looks identical.
|
||||
// Cloned rather than shared: a <link> node can only live in one document, and
|
||||
// we are not about to steal the app's own stylesheet out of its head.
|
||||
function _copyStyles(doc) {
|
||||
// pane.html already links panes.css, so don't clone a second copy of it —
|
||||
// duplicate sheets cost a redundant fetch and an extra style recalc for no
|
||||
// change in appearance.
|
||||
const own = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
|
||||
const have = new Set(own.map((l) => l.href));
|
||||
|
||||
// Insert the app's sheets BEFORE pane.html's own, not after.
|
||||
//
|
||||
// Cascade order is the whole game here. In the app document panes.css loads
|
||||
// LAST, after tailwind/style/v3 — so its rules win ties. Appending the app's
|
||||
// sheets into the pane document would put them after panes.css and silently
|
||||
// invert that, letting core styles override the pane chrome and the .fb-paned
|
||||
// placement rules. "Looks identical" has to include the order things are
|
||||
// said in.
|
||||
const anchor = own[0] || null;
|
||||
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
|
||||
if (node.tagName === 'LINK' && have.has(node.href)) return;
|
||||
try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ }
|
||||
});
|
||||
_syncChrome(doc);
|
||||
}
|
||||
|
||||
// The theme/scale hooks the app hangs on <html> and <body>. v3 keys off these
|
||||
// for its colour tokens and its interface scale, and a panel that lands without
|
||||
// them renders in the wrong palette at the wrong size.
|
||||
//
|
||||
// MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, and
|
||||
// panes.css hangs the pane window's own chrome off it. Overwriting the class
|
||||
// list would take that with it and the window would lose its own layout — the
|
||||
// app's classes and the pane document's are both wanted.
|
||||
//
|
||||
// Re-run on every theme/scale change for as long as the pane is open (see
|
||||
// _followChrome). A one-time snapshot would leave an already-open pane rendering
|
||||
// at the old scale the moment the user touched Interface size — "looks identical"
|
||||
// has to keep being true, not merely start out true.
|
||||
function _syncChrome(doc) {
|
||||
try {
|
||||
document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c));
|
||||
document.body.classList.forEach((c) => doc.body.classList.add(c));
|
||||
// The inline style on <html> carries the interface-scale custom property
|
||||
// (--fb-scale). Assign it wholesale: unlike the class lists, pane.html
|
||||
// sets no inline style of its own, so there is nothing here to preserve —
|
||||
// and merging by concatenation would grow the attribute without bound as
|
||||
// the user dragged the scale slider.
|
||||
doc.documentElement.style.cssText = document.documentElement.style.cssText;
|
||||
} catch (e) { /* the window may be closing under us */ }
|
||||
}
|
||||
|
||||
// paneId -> stop following the app's theme/scale
|
||||
const chromeFollowers = new Map();
|
||||
|
||||
function _followChrome(paneId, doc) {
|
||||
const bus = window.feedBack;
|
||||
if (!bus || typeof bus.on !== 'function') return;
|
||||
const sync = () => _syncChrome(doc);
|
||||
bus.on('scale:changed', sync);
|
||||
bus.on('theme:changed', sync);
|
||||
bus.on('v3:cosmetics-applied', sync);
|
||||
chromeFollowers.set(paneId, () => {
|
||||
bus.off('scale:changed', sync);
|
||||
bus.off('theme:changed', sync);
|
||||
bus.off('v3:cosmetics-applied', sync);
|
||||
});
|
||||
}
|
||||
|
||||
function _unfollowChrome(paneId) {
|
||||
const off = chromeFollowers.get(paneId);
|
||||
if (off) { off(); chromeFollowers.delete(paneId); }
|
||||
}
|
||||
|
||||
// How long a "we cannot even see the pop-out's document" condition has to persist
|
||||
// before we call it fatal. A SecurityError means the window is not reachable from
|
||||
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
|
||||
// rather than bailing on the first tick, because a throw *during* the navigation
|
||||
// from about:blank to /pane would otherwise take down a pop-out that was about to
|
||||
// work perfectly. A second is far more than that transition needs, and far less
|
||||
// than the 10s a user would otherwise stare at a detached panel for.
|
||||
const UNREACHABLE_GRACE_MS = 1000;
|
||||
|
||||
// Wait for the REAL pane document.
|
||||
//
|
||||
// window.open() returns immediately, with an `about:blank` document that is
|
||||
// already readyState 'complete'. Adopt into that and it works for a few
|
||||
// milliseconds — and then /pane finishes loading, replaces the document, and
|
||||
// takes the panel with it. The window is left blank and the element is gone.
|
||||
//
|
||||
// So we do not trust readyState, and we do not trust 'load' (which may have
|
||||
// fired for about:blank before we could listen). We wait for the one thing that
|
||||
// only exists in the document we actually want: pane.html's #fb-pane-root.
|
||||
|
||||
function _whenReady(w, onReady, onFail) {
|
||||
const deadline = performance.now() + 10000;
|
||||
let reachFailure = null; // why we could never see the pop-out's document
|
||||
let reachFailureAt = 0; // when we first couldn't
|
||||
const tick = () => {
|
||||
if (w.closed) return;
|
||||
|
||||
let doc = null;
|
||||
try { doc = w.document; }
|
||||
catch (e) {
|
||||
// A SecurityError here is the one that matters: it means the pop-out
|
||||
// is not reachable from this realm at all (a separate process /
|
||||
// browsing-context group), and no amount of waiting will fix it —
|
||||
// adoptNode can never work.
|
||||
doc = null;
|
||||
if (!reachFailure) reachFailureAt = performance.now();
|
||||
reachFailure = e;
|
||||
}
|
||||
|
||||
// Unreachable, and it has stayed that way. Fail now rather than leaving
|
||||
// the panel detached and the UI mid-pop-out for the full 10s deadline,
|
||||
// when we already know this can never succeed.
|
||||
if (reachFailure && !doc && performance.now() - reachFailureAt > UNREACHABLE_GRACE_MS) {
|
||||
onFail(new Error('the pane window\'s document is NOT reachable from this window ('
|
||||
+ reachFailure.name + ': ' + reachFailure.message
|
||||
+ ') — it is in a separate process, so the element cannot be moved into it'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (doc && doc.readyState !== 'loading') {
|
||||
// Only ever adopt into the document we actually navigated TO.
|
||||
// about:blank reports readyState 'complete' from the moment
|
||||
// window.open() returns, and adopting into it means the panel is
|
||||
// destroyed when /pane replaces it a moment later.
|
||||
const href = (doc.location && doc.location.href) || '';
|
||||
const isPaneDoc = href.indexOf('/pane') >= 0;
|
||||
if (isPaneDoc) {
|
||||
// Prefer pane.html's own root, but never fail for want of it —
|
||||
// a stale cached copy of the page (or a future rename) must not
|
||||
// leave the user with a blank window and no panel.
|
||||
const root = doc.getElementById('fb-pane-root') || doc.body;
|
||||
if (root) { onReady(root); return; }
|
||||
}
|
||||
}
|
||||
|
||||
if (performance.now() > deadline) {
|
||||
let why;
|
||||
if (reachFailure) {
|
||||
why = 'the pane window\'s document is NOT reachable from this window ('
|
||||
+ reachFailure.name + ': ' + reachFailure.message
|
||||
+ ') — it is in a separate process, so the element cannot be moved into it';
|
||||
} else if (!doc) {
|
||||
why = 'the pane window exposed no document at all';
|
||||
} else {
|
||||
why = 'the pane window never loaded /pane (it is showing '
|
||||
+ ((doc.location && doc.location.href) || 'an unknown URL')
|
||||
+ ', readyState ' + doc.readyState + ')';
|
||||
}
|
||||
onFail(new Error(why));
|
||||
return;
|
||||
}
|
||||
setTimeout(tick, 25);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function _adopt(w, root, spec, el) {
|
||||
const doc = w.document;
|
||||
_copyStyles(doc);
|
||||
// The panel was almost certainly a fixed/absolute overlay pinned to a
|
||||
// corner of the app. In a window of its own that positioning is nonsense —
|
||||
// it would sit 72px from the top of a 380px window, still 288px wide, still
|
||||
// casting a drop shadow over nothing. Neutralise the *placement* while
|
||||
// touching nothing else about how it looks.
|
||||
el.classList.add('fb-paned');
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
doc.title = spec.title + ' — fee[dB]ack';
|
||||
|
||||
// Keep the pane window's theme and interface scale in step with the app for
|
||||
// as long as it is open. Stopped in unplace().
|
||||
_followChrome(spec.id, doc);
|
||||
|
||||
// THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES.
|
||||
//
|
||||
// When the user closes a pane window, its document is torn down — and the
|
||||
// panel is inside it. The node itself survives (we hold a reference) and
|
||||
// comes home looking perfect: right markup, right classes, right size. But
|
||||
// it comes home DEAD: every event listener in the subtree is gone with the
|
||||
// document that hosted them. A panel that renders and does nothing.
|
||||
//
|
||||
// The `closed` poll cannot save us: by the time `w.closed` is true, the
|
||||
// document is already gone. `beforeunload` fires while it is still alive, so
|
||||
// this is the last moment we can get the element out — and panes.close()
|
||||
// adopts it back into the main document synchronously.
|
||||
//
|
||||
// We attach it HERE, not when the window was opened: back then the window
|
||||
// still held its throwaway about:blank document, and a listener registered
|
||||
// on that is discarded when /pane replaces it.
|
||||
w.addEventListener('beforeunload', () => {
|
||||
if (panes.isOpen(spec.id)) panes.close(spec.id);
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Take the element out of the document NOW, not when the window is ready.
|
||||
//
|
||||
// Everything below this line is async: the window has to load /pane before
|
||||
// there is anything to adopt into. But the manager emits `panes:opened` as
|
||||
// soon as we return, and the chip reacts by putting its "popped out" stub
|
||||
// where the element used to be — so for that whole gap the user would see
|
||||
// BOTH the real panel and a stub claiming it had left. On a window that
|
||||
// never loads, that lasts the full 10s timeout.
|
||||
//
|
||||
// Detaching is not destructive: the node keeps its owner document (this
|
||||
// one), its listeners and its closures. It is simply out of the tree,
|
||||
// waiting — and if the window never loads, closePane() puts it straight
|
||||
// back at its home.
|
||||
el.remove();
|
||||
|
||||
_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
|
||||
});
|
||||
|
||||
// The pane window's 'beforeunload' listener is registered in _adopt(), NOT
|
||||
// here: a listener added now would attach to the window's throwaway
|
||||
// about:blank document and be discarded when /pane replaces it.
|
||||
}
|
||||
|
||||
function unplace(id, el) {
|
||||
_unfollowChrome(id);
|
||||
// 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 */ } } });
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="fb-pane-window">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>fee[dB]ack</title>
|
||||
<link rel="icon" href="/static/assets/favicon.png">
|
||||
<!-- Deliberately almost empty.
|
||||
|
||||
This document does not build a pane; it RECEIVES one. The opener moves the
|
||||
real element in here with document.adoptNode() and copies the app's
|
||||
stylesheets across, so the panel arrives complete — its own markup, its own
|
||||
CSS, its own listeners, its own closures, still running the plugin's code
|
||||
back in the main window.
|
||||
|
||||
So there is nothing to load, nothing to boot, and nothing to keep in step
|
||||
with the app. Only panes.css, for the window chrome and the layout reset the
|
||||
adopted element needs. -->
|
||||
<link rel="stylesheet" href="/static/panes/panes.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="fb-pane-root"></main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,193 @@
|
||||
/* fee[dB]ack — detachable panes.
|
||||
*
|
||||
* Hand-authored (not Tailwind-scanned) so a runtime-installed plugin gets the chip
|
||||
* and the dock without shipping its own stylesheet — the same reason .fb-selectable
|
||||
* is hand-authored in core CSS.
|
||||
*
|
||||
* Z-index: the dock is a child of <body>, so it is NOT on the ladder from
|
||||
* docs/plugin-v3-ui.md (transport 20, rail 30, popovers 40) — those numbers live
|
||||
* *inside* #player's stacking context, and #player itself is `position:fixed;
|
||||
* z-index:100` covering the viewport. A dock below 100 is invisible on the one
|
||||
* screen panes exist for. Body-level ladder: #player 100 < dock 110 < toasts 120
|
||||
* < modals 200.
|
||||
*/
|
||||
|
||||
/* ── The popped-out element ──────────────────────────────────────────────────
|
||||
*
|
||||
* The single most important rule in this file.
|
||||
*
|
||||
* A plugin's panel is almost always a fixed overlay pinned to a corner of the app:
|
||||
* `position:fixed; top:72px; right:18px; width:288px; z-index:99999`, with a drop
|
||||
* shadow and a max-height sized against the viewport. Inside a dock card, or alone
|
||||
* in a 320px window, every one of those is wrong — it would float 72px down from
|
||||
* the top of its own window, still 288px wide, still casting a shadow over nothing.
|
||||
*
|
||||
* So we neutralise PLACEMENT and nothing else. Colours, borders, radius, padding,
|
||||
* fonts, the panel's own internal layout: all untouched, because the whole promise
|
||||
* of this feature is that what you popped out is what you get. */
|
||||
.fb-paned {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
margin: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
z-index: auto !important;
|
||||
box-shadow: none !important;
|
||||
/* Deliberately NO `display` override. Forcing `display:block` would silently
|
||||
re-lay-out a panel that is `display:flex` or `grid` — which is the opposite
|
||||
of "placement only", and exactly the kind of surprise this feature exists to
|
||||
avoid. Making a hidden panel visible is the manager's job (it clears the
|
||||
element's `hidden`/inline `display:none` on open and restores them on dock),
|
||||
and it does it without touching the panel's own display mode. */
|
||||
}
|
||||
|
||||
/* ── The pop-out chip ────────────────────────────────────────────────────── */
|
||||
|
||||
.fb-pane-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 1px solid rgba(51, 65, 85, .7);
|
||||
border-radius: .4rem;
|
||||
background: rgba(30, 41, 59, .8);
|
||||
color: #94a3b8;
|
||||
font-size: .8rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.fb-pane-chip:hover {
|
||||
color: #e2e8f0;
|
||||
border-color: #4080e0;
|
||||
background: rgba(64, 128, 224, .18);
|
||||
}
|
||||
.fb-pane-chip:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||
|
||||
/* The panel, while its pane is popped out. A dedicated class rather than
|
||||
.hidden/[hidden]: the panels we attach to toggle those themselves, and two owners
|
||||
of one class is a bug waiting for a bad day. */
|
||||
.fb-pane-detached { display: none !important; }
|
||||
|
||||
/* What the user sees in the panel's place. */
|
||||
.fb-pane-stub {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: .35rem .6rem;
|
||||
border: 1px dashed rgba(64, 128, 224, .55);
|
||||
border-radius: .5rem;
|
||||
background: rgba(64, 128, 224, .08);
|
||||
color: #93b4e8;
|
||||
font-size: .72rem;
|
||||
cursor: pointer;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.fb-pane-stub:hover { background: rgba(64, 128, 224, .18); border-color: #4080e0; }
|
||||
.fb-pane-stub:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||
.fb-pane-stub-glyph { font-size: .85rem; }
|
||||
|
||||
/* ── The dock ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.fb-pane-dock {
|
||||
position: fixed;
|
||||
top: 4.5rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 110; /* above #player (100), below toasts (120) */
|
||||
width: 22rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .6rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* A frame around cards, not a surface — the empty space below them must never
|
||||
eat a click meant for the highway. */
|
||||
pointer-events: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.fb-pane-dock.is-empty { display: none; }
|
||||
|
||||
.fb-pane-card {
|
||||
pointer-events: auto;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(15, 23, 42, .96);
|
||||
border: 1px solid rgba(51, 65, 85, .6);
|
||||
border-radius: .9rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, .5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fb-pane-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding: .5rem .7rem;
|
||||
border-bottom: 1px solid rgba(51, 65, 85, .5);
|
||||
background: rgba(30, 41, 59, .6);
|
||||
}
|
||||
.fb-pane-card-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.fb-pane-card-btn {
|
||||
flex: 0 0 auto;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: .35rem;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: .75rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fb-pane-card-btn:hover { background: rgba(51, 65, 85, .7); color: #e2e8f0; }
|
||||
.fb-pane-card-btn:focus-visible { outline: 2px solid #4080e0; outline-offset: 1px; }
|
||||
|
||||
.fb-pane-card-body { overflow: auto; }
|
||||
|
||||
/* focus(id) — a brief highlight, so re-opening an already-open pane says so
|
||||
instead of appearing to do nothing. */
|
||||
.fb-pane-card.is-flash { animation: fb-pane-flash .7s ease-out; }
|
||||
@keyframes fb-pane-flash {
|
||||
0% { border-color: #4080e0; box-shadow: 0 0 0 3px rgba(64, 128, 224, .35), 0 12px 40px rgba(0, 0, 0, .5); }
|
||||
100% { border-color: rgba(51, 65, 85, .6); box-shadow: 0 12px 40px rgba(0, 0, 0, .5); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fb-pane-card.is-flash { animation: none; }
|
||||
}
|
||||
|
||||
/* ── The pop-out window (static/panes/pane.html) ─────────────────────────────
|
||||
*
|
||||
* The window's own chrome — everything INSIDE it is the adopted element, styled by
|
||||
* the app's stylesheets, which the host copies into this document. */
|
||||
|
||||
html.fb-pane-window,
|
||||
html.fb-pane-window body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #0f172a;
|
||||
}
|
||||
html.fb-pane-window body { display: flex; flex-direction: column; overflow: hidden; }
|
||||
#fb-pane-root {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
padding: .6rem;
|
||||
}
|
||||
@@ -99,6 +99,10 @@
|
||||
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
||||
<link rel="stylesheet" href="/static/v3/v3.css">
|
||||
<!-- Detachable panes: the pop-out chip, the dock, and the widgets built-in
|
||||
panes render with. Hand-authored (not Tailwind-scanned) so a
|
||||
runtime-installed plugin can use the chip without shipping its own CSS. -->
|
||||
<link rel="stylesheet" href="/static/panes/panes.css">
|
||||
<!-- EVERY external script below is `defer`. Do not add a plain one.
|
||||
`defer` and `type="module"` scripts share a single "execute after
|
||||
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
||||
@@ -1048,6 +1052,10 @@
|
||||
<span class="v3-rail-border"></span>
|
||||
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z"/></svg>
|
||||
</button>
|
||||
<button class="v3-rail-icon" type="button" data-rail="panes" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-panes" title="Panes" aria-label="Panes">
|
||||
<span class="v3-rail-border"></span>
|
||||
<svg class="v3-rail-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M19,4H5A2,2 0 0,0 3,6V18A2,2 0 0,0 5,20H19A2,2 0 0,0 21,18V6A2,2 0 0,0 19,4M13,18H5V6H13V18M19,18H15V6H19V18Z"/></svg>
|
||||
</button>
|
||||
<button class="v3-rail-icon" type="button" data-rail="plugins" aria-haspopup="true" aria-expanded="false" aria-controls="v3-rail-pop-plugins" title="Plugin controls" aria-label="Plugin controls">
|
||||
<span class="v3-rail-border"></span>
|
||||
<span class="v3-rail-badge" id="v3-plugin-count" hidden></span>
|
||||
@@ -1064,6 +1072,15 @@
|
||||
injects into #player-controls (the auto-hiding transport) into
|
||||
this stable, always-reachable popover. See player-chrome.js
|
||||
(rehoming MutationObserver). -->
|
||||
<!-- Panes: open/close any registered detachable pane. Populated from
|
||||
the pane registry by static/panes/pane-launcher.js — a plugin
|
||||
that calls feedBack.panes.register() shows up here for free. -->
|
||||
<div id="v3-rail-pop-panes" class="v3-rail-pop hidden" role="group" aria-label="Panes">
|
||||
<div class="v3-pop-label">Panes</div>
|
||||
<div id="v3-rail-panes-list" class="flex flex-col gap-1"></div>
|
||||
<p class="text-xs text-gray-500 px-1 pb-1 leading-snug">Panes stay open while you play, and across song switches.</p>
|
||||
</div>
|
||||
|
||||
<div id="v3-rail-pop-plugins" class="v3-rail-pop hidden" role="group" aria-label="Plugin controls">
|
||||
<div class="v3-pop-label">Plugin controls</div>
|
||||
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></div>
|
||||
@@ -1300,6 +1317,15 @@
|
||||
<script defer src="/static/v3/interface-size-nudge.js"></script>
|
||||
<script defer src="/static/v3/feedbarcade.js"></script>
|
||||
<script defer src="/static/v3/player-chrome.js"></script>
|
||||
<!-- Detachable panes. The manager first; then the hosts, which register
|
||||
themselves with it; then the chip and the launcher, which drive it.
|
||||
pane-desktop only does anything inside the desktop app. -->
|
||||
<script defer src="/static/panes/pane-manager.js"></script>
|
||||
<script defer src="/static/panes/pane-dock.js"></script>
|
||||
<script defer src="/static/panes/pane-window-host.js"></script>
|
||||
<script defer src="/static/panes/pane-desktop.js"></script>
|
||||
<script defer src="/static/panes/pane-chip.js"></script>
|
||||
<script defer src="/static/panes/pane-launcher.js"></script>
|
||||
<script>
|
||||
// Navbar scroll effect
|
||||
window.addEventListener('scroll', () => {
|
||||
|
||||
Reference in New Issue
Block a user