feedBack/static/panes/pane-chip.js
topkoa 3a50e593bf fix(panes): fail fast when the pane window is unreachable; validate opts.header
Two from review.

1. _whenReady's own comment said a SecurityError means the pop-out is not
   reachable from this realm and "no amount of waiting will fix it" — and then
   it waited the full 10s deadline anyway. Ten seconds of a detached panel and a
   half-popped-out UI, for a condition we had already diagnosed as fatal.

   It now gives up after a 1s grace instead. Not instantly, deliberately: 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 a user should spend staring at a
   detached panel.

2. attachChip() took opts.header on trust. It's a public plugin API, and a
   truthy non-Element header (a selector string, a wrapper object, a ref) is an
   easy mistake — one that surfaced as a confusing DOM exception from deep
   inside core instead of a TypeError naming the offending pane.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-12 20:36:10 -04:00

191 lines
8.6 KiB
JavaScript

/*
* 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;
})();