mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-10 18:59:56 +00:00
feat(panes)!: move the real element, instead of rebuilding it
The first cut of this got the model wrong. A pane was a SECOND
implementation of the plugin's panel — its own sliders, its own styling,
driven over a cross-realm bridge (ctx, a state store, capability RPC,
mirrorGlobal, a stream sampler). Popping out gave you something that
resembled the panel you popped, and every feature it did not reimplement
(presets, tabs, EQ, language) was simply gone.
What a user wants from "pop this out" is the thing they popped out.
So: MOVE THE REAL ELEMENT. Same-origin windows can adopt each other's
nodes, and an adopted node keeps its event listeners and its closures.
The panel goes on running the plugin's own code, against the plugin's own
state, in the plugin's own realm — it is merely being DISPLAYED in another
window. Copy the app's stylesheets into that window and it looks identical
too, because it is identical.
The plugin's side collapses to two lines:
feedBack.panes.register({ id, title, element: () => panelEl });
feedBack.panes.attachChip(panelEl, id);
and everything comes along: the CSS, the listeners, the presets, the
state. Nothing to keep in step, because there is no second copy.
Deleted, all of it now pointless: pane-bridge (ctx + transports), pane-hub
(the cross-realm server), pane-runtime (the pane realm's boot), pane-streams
(the rAF sampler that existed because an AnalyserNode can't cross a window),
pane-mirror (mirrorGlobal), pane-plugins + the manifest `panes[]` key and its
server-side validation, panes.state(), and both built-in demo panes. ~1200
lines. None of it was wrong — it was all correct machinery for the wrong
problem.
Consequences worth knowing:
- The window MUST be opened by the renderer with window.open(), not by the
desktop's main process: a window we did not open gives this realm no handle
to its document, and without the handle there is nothing to adopt into.
Electron turns the same-origin window.open() into a real BrowserWindow
anyway (setWindowOpenHandler → 'allow'), so we get the OS window AND the
live DOM link. The desktop side finds it by frame name.
- `.fb-paned` neutralises PLACEMENT only (position/inset/width/z-index/shadow).
A plugin panel is nearly always a fixed overlay pinned to a corner of the
app; alone in a 380px window that positioning is nonsense. Colours, borders,
padding, fonts and the panel's own internal layout are untouched — the whole
promise is that what you popped out is what you get.
- The element is returned to its EXACT home on dock: same parent, same position
among its siblings.
- The plugin's code still runs in the main window. So a document.body
.appendChild() inside a panel (a tooltip, a popover) lands in the main
window, not the pane — anchor to the panel instead. And a continuously
animating panel may run slowly while the main window is backgrounded, since
its rAF lives there. Both documented.
Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
@@ -1,20 +1,27 @@
|
||||
/*
|
||||
* fee[dB]ack — the pop-out window host (browser path).
|
||||
* fee[dB]ack — the pop-out window host.
|
||||
*
|
||||
* Opens a real OS window per pane and hands it off to pane-hub.js, which serves
|
||||
* it over BroadcastChannel. Registers as the `window` host at priority 10, so it
|
||||
* outranks the dock and `panes.detach()` prefers it.
|
||||
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
|
||||
*
|
||||
* This is the BROWSER implementation: a plain same-origin `window.open()`, which
|
||||
* is what the splitscreen follower has done for years. Electron already permits
|
||||
* it — main.ts's setWindowOpenHandler returns `action: 'allow'` for same-origin
|
||||
* URLs, and that is load-bearing: `deny` would push the URL to the system
|
||||
* browser, a different Chromium instance, where BroadcastChannel cannot reach it
|
||||
* and the pane would silently never sync.
|
||||
* 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.
|
||||
*
|
||||
* The desktop app will register its own host at a higher priority (a real
|
||||
* BrowserWindow, with a system tray, always-on-top, and remembered bounds).
|
||||
* Nothing else changes when it does — that is the point of the host registry.
|
||||
* 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 (see
|
||||
* main.ts's setWindowOpenHandler → `action: 'allow'`), and the main process
|
||||
* recognises it by its frame name and gives it remembered bounds, always-on-top
|
||||
* and the system tray. We get the OS window AND the DOM link.
|
||||
*
|
||||
* 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';
|
||||
@@ -25,38 +32,72 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// The desktop's main process finds a pane window by this name and attaches
|
||||
// bounds, tray and always-on-top to it. Keep it in sync with pane-hosts.ts.
|
||||
const FRAME_PREFIX = 'fbpane-';
|
||||
|
||||
const wins = new Map(); // paneId -> Window
|
||||
let reaper = null;
|
||||
|
||||
// A pane window the user closed with the OS X button never gets to say `bye`
|
||||
// reliably (a crashed renderer certainly doesn't). Poll `closed` and reap —
|
||||
// otherwise the pane stays "open" forever, its chip stays stubbed out, and
|
||||
// the user has no way back to their dialog. Same trick splitscreen uses.
|
||||
// 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); // → unmount() below clears the entry
|
||||
});
|
||||
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
|
||||
if (!wins.size) { clearInterval(reaper); reaper = null; }
|
||||
}, 500);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function mount(spec) {
|
||||
const url = new URL(window.location.origin + '/pane');
|
||||
url.searchParams.set('pane', spec.id);
|
||||
// The pane realm loads its mount() from this URL. Everything else it
|
||||
// needs (title, state, the song) arrives in the snapshot — URL params
|
||||
// are open-time only and must never be a state channel.
|
||||
url.searchParams.set('script', spec.script);
|
||||
// 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) {
|
||||
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
|
||||
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
|
||||
// off these for its colour tokens and interface scale, and a panel that
|
||||
// lands without them renders in the wrong palette at the wrong size.
|
||||
try {
|
||||
doc.documentElement.className = document.documentElement.className;
|
||||
doc.documentElement.setAttribute('style', document.documentElement.getAttribute('style') || '');
|
||||
doc.body.className = document.body.className;
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
const w = window.open(url.toString(), 'fbpane-' + spec.id,
|
||||
'popup,width=' + spec.width + ',height=' + spec.height);
|
||||
function _adopt(w, spec, el) {
|
||||
const doc = w.document;
|
||||
_copyStyles(doc);
|
||||
|
||||
const root = doc.getElementById('fb-pane-root') || doc.body;
|
||||
// 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');
|
||||
// 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';
|
||||
}
|
||||
|
||||
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. Bail BEFORE the manager records anything, so the
|
||||
// caller's dialog stays exactly where it was — and say so out loud
|
||||
// rather than appearing to do nothing.
|
||||
// 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',
|
||||
@@ -64,51 +105,78 @@
|
||||
icon: '⚠️', accent: '#f59e0b',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
throw new Error('pop-up blocked');
|
||||
}
|
||||
|
||||
wins.set(spec.id, w);
|
||||
_startReaper();
|
||||
return w;
|
||||
|
||||
// The document may or may not have parsed yet. Both paths must work, and
|
||||
// must not run twice — a double adopt would move the element into the
|
||||
// window and then move it in again, firing the plugin's own observers for
|
||||
// no reason.
|
||||
let done = false;
|
||||
const go = () => {
|
||||
if (done || w.closed) return;
|
||||
done = true;
|
||||
try { _adopt(w, spec, el); }
|
||||
catch (e) {
|
||||
console.error('[panes] failed to move', spec.id, 'into its window', e);
|
||||
panes.close(spec.id); // returns the element home
|
||||
}
|
||||
};
|
||||
if (w.document && w.document.readyState === 'complete') go();
|
||||
else w.addEventListener('load', go, { once: true });
|
||||
|
||||
// The window is ours and must not outlive the document that owns the
|
||||
// element inside it.
|
||||
w.addEventListener('beforeunload', () => {
|
||||
// Only react to the user closing the window — not to us closing it
|
||||
// during a dock, which has already taken the element back.
|
||||
if (wins.get(spec.id) === w && panes.isOpen(spec.id)) setTimeout(() => panes.close(spec.id), 0);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount(id) {
|
||||
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 w = wins.get(id);
|
||||
wins.delete(id);
|
||||
// Closing an already-closed window is a no-op, and closing one we opened
|
||||
// is always permitted (same-origin, script-opened).
|
||||
// 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) { /* OS may refuse */ } }
|
||||
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,
|
||||
remote: true,
|
||||
// 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 "pop-up blocked" toast. The manager brings it back in the dock
|
||||
// and the chip pops it out again on the user's next click. The desktop
|
||||
// host overrides this: it opens real BrowserWindows and needs no gesture.
|
||||
autoRestore: false,
|
||||
// No BroadcastChannel means no way to feed the pane once it's open. Better
|
||||
// to keep it docked than to open a window that renders forever-stale data.
|
||||
available: () => typeof BroadcastChannel === 'function',
|
||||
// A pane with no `script` exists only as a closure in this realm. There is
|
||||
// no honest way to move a closure across a window boundary, so decline it
|
||||
// and let the router fall back to the dock.
|
||||
canHost: (spec) => !!spec.script,
|
||||
mount, unmount, focus,
|
||||
autoRestore: isDesktop,
|
||||
place, unplace, focus,
|
||||
});
|
||||
|
||||
// The pane windows are ours; they must not outlive us. A pane window whose
|
||||
// main window is gone can never be fed again — leaving it on screen showing a
|
||||
// frozen playhead is worse than closing it.
|
||||
// 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 */ } } });
|
||||
});
|
||||
|
||||
// Exposed for pane-desktop.js, which upgrades this host in place rather than
|
||||
// registering a competing one — the window still has to be opened HERE, by
|
||||
// window.open(), or there would be no document to adopt into.
|
||||
window.__fbPaneWindows = { FRAME_PREFIX, get: (id) => wins.get(id) || null };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user