mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
Docking a popped-out panel brought it home DEAD. It rendered perfectly —
right markup, right size, right place — and every control in it was inert:
the close button, the sliders, the presets, even the pop-out chip. A
photograph of a panel.
Closing a pane window tears down its document, and the panel was still
inside it. The node itself survives (the manager holds a reference), but
every event listener in its subtree goes with the document that hosted
them. Two paths did this:
1. closePane() called the host's unplace() — which closes the window —
BEFORE adopting the element back. Order is now reversed, and the
comment says why so nobody helpfully "tidies" it back.
2. The user closing the pane window themselves was only noticed by the
`closed` poll, which by definition runs AFTER the document is gone.
The window now gets a `beforeunload` listener that brings the element
home while its document is still alive.
That listener has to be attached AFTER /pane loads: window.open() hands
back a throwaway about:blank document, and anything registered on it is
discarded when the real page replaces it. This is the same trap that made
the pane window blank in the first place — adopt into about:blank and the
panel is destroyed a moment later — and it is now handled in both places.
The `closed` poll stays, but only as a last-resort net for a CRASHED pane
window, where nothing can be saved.
Also fixed while chasing this:
- The chip stamped `.fb-pane-detached` (display:none !important) onto the
element to hide it in the main window — and that element is the one we
move, so the class travelled with it and blanked the pane window. The
chip now only hides an element the pane did NOT take, and marks the hole
with its stub otherwise. "Did not take" is an ownerDocument test, not
isConnected: a panel sitting in a pane window IS connected, just not
here, and a plugin that rebuilds its panel (Camera Director does, on
every mode change) re-runs attachChip while popped out.
- The stub was inserted "before the element", which is nowhere — the
element has left the document. The manager now hands over the element's
recorded home, and the stub goes there.
- GET /pane sent no cache headers. A stale copy is especially nasty here:
the opener waits for an element inside that page before adopting, so an
old cached version means the pane window just sits there blank.
Verified in the desktop app: pop out, use the controls in the pane window,
dock back, use them again. Panel comes home alive.
Signed-off-by: topkoa <topkoa@gmail.com>
176 lines
7.8 KiB
JavaScript
176 lines
7.8 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) {
|
|
// "Moved" means the element is not in THIS document — either because this
|
|
// open took it, or because it is already sitting in a pane window from an
|
|
// earlier one. The ownerDocument test is what makes re-attaching a chip
|
|
// safe: a plugin that rebuilds its panel (Camera Director does, on every
|
|
// mode change) re-runs attachChip while the pane is still popped out, and
|
|
// an isConnected test would say "still here" — it IS connected, to the pane
|
|
// window — and we would stamp display:none onto the live pane.
|
|
const moved = (detail && detail.el === rec.el) || rec.el.ownerDocument !== document;
|
|
|
|
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');
|
|
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;
|
|
})();
|