mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 13:43:19 +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,180 @@
|
|||||||
|
# Detachable panes (`window.feedBack.panes`)
|
||||||
|
|
||||||
|
Pop a panel out of the app into its own OS window, and leave it there: while you
|
||||||
|
play, across song switches, on a second monitor, minimized to the system tray.
|
||||||
|
|
||||||
|
Panes exist because the player's rail popovers are **exclusive** — opening one
|
||||||
|
closes the last. You cannot watch the mixer while riding the camera, and both
|
||||||
|
vanish the moment you want to look at the highway.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The whole idea, in one sentence
|
||||||
|
|
||||||
|
**We move the real element.**
|
||||||
|
|
||||||
|
Not a copy of your panel. 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 your panel goes on
|
||||||
|
running *your* code, against *your* state, in *your* realm. The app's stylesheets
|
||||||
|
are copied into the pane window, so it looks identical too.
|
||||||
|
|
||||||
|
What you popped out is what you get. That is the promise, and it is the reason
|
||||||
|
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
|
||||||
|
your UI to keep in step with the first. Those are all solutions to a problem we
|
||||||
|
simply do not have.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a pane to your plugin
|
||||||
|
|
||||||
|
Two lines.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Guard: the panes API is optional. On a host without it, skip both calls and
|
||||||
|
// your panel behaves exactly as it does today.
|
||||||
|
const panes = window.feedBack && window.feedBack.panes;
|
||||||
|
if (panes && typeof panes.register === 'function') {
|
||||||
|
panes.register({
|
||||||
|
id: 'camera_director',
|
||||||
|
title: 'Camera Director',
|
||||||
|
icon: '🎥',
|
||||||
|
element: () => panelEl, // your existing panel, as it is
|
||||||
|
});
|
||||||
|
panes.attachChip(panelEl, 'camera_director');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
|
||||||
|
place, same behaviour in every plugin. Clicking it moves your panel to whichever
|
||||||
|
**host** the router picks — usually a pop-out window, but the dock when a window
|
||||||
|
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
|
||||||
|
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
|
||||||
|
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
|
||||||
|
no show/hide logic.
|
||||||
|
|
||||||
|
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
|
||||||
|
your state — all of it comes along, because none of it moved anywhere except into
|
||||||
|
a different window's document.
|
||||||
|
|
||||||
|
### `element` is a function for a reason
|
||||||
|
|
||||||
|
It is resolved at open time, not at registration. Plugins commonly build their
|
||||||
|
panel lazily on first use, or rebuild it wholesale when something changes (Camera
|
||||||
|
Director rebuilds its panel on every mode change). Asking for it when we need it
|
||||||
|
means we always move the live one.
|
||||||
|
|
||||||
|
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
|
||||||
|
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
|
||||||
|
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (chipDetach) chipDetach();
|
||||||
|
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-attaching is safe while the pane is popped out: the chip reconciles against the
|
||||||
|
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
|
||||||
|
|
||||||
|
### The two things core changes about your element
|
||||||
|
|
||||||
|
**1. Placement.** `.fb-paned` is added while the pane is out:
|
||||||
|
|
||||||
|
```css
|
||||||
|
position: static; inset: auto; margin: 0; width: 100%;
|
||||||
|
max-width: none; max-height: none; z-index: auto; box-shadow: none;
|
||||||
|
```
|
||||||
|
|
||||||
|
Your panel was almost certainly a fixed overlay pinned to a corner of the app
|
||||||
|
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
|
||||||
|
every one of those is wrong — it would float 72px down from the top of a 380px
|
||||||
|
window, still 288px wide, still casting a shadow over nothing.
|
||||||
|
|
||||||
|
Note there is deliberately **no `display` override**: a panel that is
|
||||||
|
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
|
||||||
|
and your panel's own internal layout are untouched.
|
||||||
|
|
||||||
|
**2. Visibility.** A 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 core
|
||||||
|
un-hides it, in the two ways a panel is actually hidden:
|
||||||
|
|
||||||
|
```js
|
||||||
|
el.hidden = false;
|
||||||
|
if (el.style.display === 'none') el.style.display = '';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Both are restored exactly as they were when the pane docks**, along with the
|
||||||
|
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
|
||||||
|
goes back to being closed; one that was open stays open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
```js
|
||||||
|
feedBack.panes.register({
|
||||||
|
id, // required, unique
|
||||||
|
element, // required — an Element, or a function returning one
|
||||||
|
title, // shown in the pane window's title bar, the dock card, the tray
|
||||||
|
icon, // one glyph, for the dock/tray/launcher lists
|
||||||
|
width, height, // the pane window's initial size (it remembers yours after that)
|
||||||
|
defaultHost, // 'window' (default) or 'dock'
|
||||||
|
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
|
||||||
|
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
|
||||||
|
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
|
||||||
|
```
|
||||||
|
|
||||||
|
`attachChip` puts the chip in the `header` element you pass, else in
|
||||||
|
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
|
||||||
|
An explicit `header` always wins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
`detach(id)` puts a pane in the best host available:
|
||||||
|
|
||||||
|
| host | | |
|
||||||
|
|---|---|---|
|
||||||
|
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
|
||||||
|
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
|
||||||
|
|
||||||
|
You don't pick; you declare `defaultHost` and the router does the rest.
|
||||||
|
|
||||||
|
In the **desktop app** a pane you left popped out comes back popped out on next
|
||||||
|
launch. In a **browser** it comes back **docked** — a browser blocks
|
||||||
|
`window.open()` without a user gesture, so restoring it would only ever produce a
|
||||||
|
"pop-up blocked" toast. The chip pops it out again on your next click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things worth knowing
|
||||||
|
|
||||||
|
1. **Your code still runs in the main window.** The element is displayed in the
|
||||||
|
pane window, but its closures, its timers and its `document` references all
|
||||||
|
still belong to the main realm. That is exactly why everything keeps working —
|
||||||
|
but it means a `document.body.appendChild()` inside your panel (a tooltip, a
|
||||||
|
popover) lands in the **main** window, not the pane. Anchor such things to the
|
||||||
|
panel itself, not to `document.body`.
|
||||||
|
|
||||||
|
2. **Chromium throttles a backgrounded window's `requestAnimationFrame`.** While
|
||||||
|
the user is looking at your pane, the main window may be in the background —
|
||||||
|
and your rAF lives there. Event-driven panels (sliders, buttons, presets) are
|
||||||
|
unaffected. A panel that *animates* continuously may run slowly while it's the
|
||||||
|
only thing you're looking at.
|
||||||
|
|
||||||
|
3. **The element goes home exactly where it came from** — same parent, same
|
||||||
|
position among its siblings. Don't move it yourself while it's popped out.
|
||||||
|
|
||||||
|
4. **A pane the user closed with the window's X button is reaped** (a crashed
|
||||||
|
renderer never gets to say goodbye), and your element is docked back. Without
|
||||||
|
that, your panel would be stranded in a dead document with no way back.
|
||||||
|
|
||||||
|
5. **Nothing here is required.** On a host without the panes API, `feedBack.panes`
|
||||||
|
is undefined, you skip both calls, and your panel behaves exactly as it does
|
||||||
|
today.
|
||||||
@@ -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">
|
<link rel="stylesheet" href="/static/tour-engine.css">
|
||||||
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
<!-- v0.3.0 shell styles (radial-gradient bg, custom scrollbars). -->
|
||||||
<link rel="stylesheet" href="/static/v3/v3.css">
|
<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.
|
<!-- EVERY external script below is `defer`. Do not add a plain one.
|
||||||
`defer` and `type="module"` scripts share a single "execute after
|
`defer` and `type="module"` scripts share a single "execute after
|
||||||
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
parsing" list and run in DOCUMENT ORDER; a plain classic script runs
|
||||||
@@ -1048,6 +1052,10 @@
|
|||||||
<span class="v3-rail-border"></span>
|
<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>
|
<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>
|
||||||
|
<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">
|
<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-border"></span>
|
||||||
<span class="v3-rail-badge" id="v3-plugin-count" hidden></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
|
injects into #player-controls (the auto-hiding transport) into
|
||||||
this stable, always-reachable popover. See player-chrome.js
|
this stable, always-reachable popover. See player-chrome.js
|
||||||
(rehoming MutationObserver). -->
|
(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 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 class="v3-pop-label">Plugin controls</div>
|
||||||
<div id="v3-plugin-controls-slot" class="v3-plugin-slot"></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/interface-size-nudge.js"></script>
|
||||||
<script defer src="/static/v3/feedbarcade.js"></script>
|
<script defer src="/static/v3/feedbarcade.js"></script>
|
||||||
<script defer src="/static/v3/player-chrome.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>
|
<script>
|
||||||
// Navbar scroll effect
|
// Navbar scroll effect
|
||||||
window.addEventListener('scroll', () => {
|
window.addEventListener('scroll', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user