mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
fix(panes): don't hide a docked pane; don't force display; restore visibility
Six more findings from CodeRabbit on #928. Three are real bugs. 1. THE CHIP HID DOCKED PANES. `_onOpened` decided "did the pane take my element?" from `ownerDocument !== document`. That is true for a pane in a pop-out window — and false for a pane moved into the DOCK, which lives in this very document. So docking a pane stamped `.fb-pane-detached` (display:none !important) onto the panel the user was looking at, and put the stub next to it instead of at its home. The element cannot answer this question — `isConnected` is true in a pane window, `ownerDocument` is this one in the dock. Both were live bugs. Ask the manager, which knows exactly what it handed to the host: `panes.elementOf(id)`. That holds for every host, and for reconciling after the fact (detail == null), which is what a plugin rebuilding its panel mid-pop-out triggers. 2. `.fb-paned` FORCED `display: block !important`. A panel that is `display:flex` or `grid` would be silently re-laid-out while detached — the exact opposite of "placement only", and precisely the kind of surprise this feature exists to avoid. Removed. Making a hidden panel visible is a separate job, and it now belongs to the manager, which does it without touching the panel's display MODE: clear `hidden`, and clear an inline `display:none` if that is how the panel hides. 3. VISIBILITY IS NOW RESTORED. The hosts used to set `el.hidden = false` and never put it back, so the docs' "core only changes placement" was a lie and a panel's hidden state was quietly lost. The manager stashes both `hidden` and the inline `display` on open and restores them on dock: a panel that was closed when you opened its pane from the tray goes back to being closed; one that was open stays open. Plus: - The launcher rebuilt its whole list on every panes:opened/closed — including the one fired by clicking a button in that list — destroying the button under the user's finger and dropping focus to <body>. It now restores focus to the toggled pane's button. - `_copyStyles` cloned every stylesheet link, including the panes.css that pane.html already loads. Skip sheets the pane document already has. - Docs: the chip may route to the DOCK, not always a window (it goes through detach() → the host router). `header` precedence was documented backwards — an explicit `header` wins. And the visibility contract above is now written down rather than being a surprise. Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -88,14 +88,22 @@
|
||||
// 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;
|
||||
// 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');
|
||||
|
||||
+113
-114
@@ -1,114 +1,113 @@
|
||||
/*
|
||||
* 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';
|
||||
dockEl.className = 'fb-pane-dock';
|
||||
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');
|
||||
el.hidden = false;
|
||||
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;
|
||||
card.scrollIntoView({ block: 'nearest', behavior: '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 });
|
||||
})();
|
||||
/*
|
||||
* 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';
|
||||
dockEl.className = 'fb-pane-dock';
|
||||
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;
|
||||
card.scrollIntoView({ block: 'nearest', behavior: '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 });
|
||||
})();
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
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) {
|
||||
@@ -39,6 +47,7 @@
|
||||
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) => {
|
||||
@@ -46,6 +55,7 @@
|
||||
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
|
||||
});
|
||||
listEl.appendChild(b);
|
||||
if (p.id === focusedId) b.focus();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -135,14 +135,30 @@
|
||||
// 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 });
|
||||
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
|
||||
@@ -207,6 +223,14 @@
|
||||
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 });
|
||||
@@ -298,6 +322,10 @@
|
||||
// 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,
|
||||
|
||||
@@ -55,7 +55,13 @@
|
||||
// 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 have = new Set(
|
||||
Array.from(doc.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href));
|
||||
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
|
||||
if (node.tagName === 'LINK' && have.has(node.href)) return;
|
||||
try { doc.head.appendChild(node.cloneNode(true)); } catch (e) { /* skip a node we can't clone */ }
|
||||
});
|
||||
// Carry the theme/scale hooks the app hangs on <html> and <body>. v3 keys
|
||||
@@ -150,10 +156,6 @@
|
||||
// casting a drop shadow over nothing. Neutralise the *placement* while
|
||||
// touching nothing else about how it looks.
|
||||
el.classList.add('fb-paned');
|
||||
// Some panels are hidden until opened (Camera Director's is `hidden` until
|
||||
// you click its launcher). It is being shown on purpose now.
|
||||
el.hidden = false;
|
||||
|
||||
root.appendChild(doc.adoptNode(el));
|
||||
doc.title = spec.title + ' — fee[dB]ack';
|
||||
|
||||
|
||||
@@ -34,8 +34,12 @@
|
||||
max-height: none !important;
|
||||
z-index: auto !important;
|
||||
box-shadow: none !important;
|
||||
/* A panel hidden until its launcher is clicked is being shown on purpose now. */
|
||||
display: block !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 ────────────────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user