mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) (#771)
* feat(web): the Display tab is an ES-module page, with a page-visibility service (stage 4) - core/visibility.js: each page gets ctx.visibility (whileVisible, every, isVisible). Work registered there runs only while the page's tab is active and the browser tab visible, and ends when the page is swapped out. It reads the active tab from window.LEDVisibility, so it agrees with the classic partials. The registry gained a mountContext option for per-mount services. - pages/display.js replaces display.html's two inline scripts. The 5 s sync status poll runs through ctx.visibility.every; the status and scroll-speed hint requests go through ctx.api with ctx.signal, as does the Vegas order widget's plugin-list request. The Advanced toggle is a delegated data-action; window.updateSyncUI is a deprecated alias. - New DOM suites test_visibility_service.js and test_display_page.js; test_display_partial_ids.js imports the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): no computed keys in the Display page's readout and destroy Codacy's object-injection rule flagged v[id] and ctx.state[name]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,17 @@
|
||||
import { createApi } from './api.js';
|
||||
import { createFacade, installFacade } from './facade.js';
|
||||
import { createRegistry } from './registry.js';
|
||||
import { createVisibility } from './visibility.js';
|
||||
|
||||
const api = createApi();
|
||||
const visibility = createVisibility({ window: window });
|
||||
const registry = createRegistry({
|
||||
context: {
|
||||
api: api,
|
||||
notify: function(message, type) { return window.LEDMatrix.notify(message, type); },
|
||||
},
|
||||
// Bound to one mounted page: its timers stop when it is destroyed.
|
||||
mountContext: function(ctx) { return { visibility: visibility.forPage(ctx) }; },
|
||||
});
|
||||
const facade = installFacade(window, createFacade(window, api, registry));
|
||||
|
||||
@@ -45,6 +49,7 @@ const pages = {
|
||||
'backup-restore': page(function() { return import('../pages/backup-restore.js'); }),
|
||||
'schedule': page(function() { return import('../pages/schedule.js'); }),
|
||||
'general': page(function() { return import('../pages/general.js'); }),
|
||||
'display': page(function() { return import('../pages/display.js'); }),
|
||||
};
|
||||
Object.keys(pages).forEach(function(name) { registry.register(name, pages[name]); });
|
||||
|
||||
@@ -85,5 +90,6 @@ alias('cache', 'deleteCacheFile', "the Cache tab's Delete buttons");
|
||||
});
|
||||
aliasObject('general', 'webLogin', ['setPassword', 'disable', 'createToken', 'copyToken', 'revoke'],
|
||||
"the General tab's Security buttons");
|
||||
alias('display', 'updateSyncUI', "the Display tab's Role menu");
|
||||
|
||||
registry.start();
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* destroy(root, ctx) optional; undo anything `ctx.signal` does not.
|
||||
*
|
||||
* ctx is a per-mount object holding the shared services passed to
|
||||
* createRegistry({ context }) (boot.js passes `api` and `notify`) plus:
|
||||
* createRegistry({ context }) (boot.js passes `api` and `notify`), the fields
|
||||
* returned by createRegistry({ mountContext }) for this mount (boot.js adds
|
||||
* `visibility`, core/visibility.js), plus:
|
||||
* ctx.root the data-page element
|
||||
* ctx.name the page name
|
||||
* ctx.signal an AbortSignal aborted on destroy. Pass it to
|
||||
@@ -42,12 +44,16 @@ export const PAGE_ATTRIBUTE = 'data-page';
|
||||
* @param {object} [options]
|
||||
* @param {Document} [options.document] the document to wire (default: globalThis.document)
|
||||
* @param {object} [options.context] services copied onto every page's ctx
|
||||
* @param {Function} [options.mountContext] (ctx) => fields added to that mount's ctx;
|
||||
* for services bound to one page (they see
|
||||
* ctx.root, ctx.name and ctx.signal)
|
||||
* @param {{error: Function}} [options.logger]
|
||||
*/
|
||||
export function createRegistry(options = {}) {
|
||||
const doc = options.document || globalThis.document;
|
||||
const logger = options.logger || console;
|
||||
const services = options.context || {};
|
||||
const mountContext = options.mountContext || null;
|
||||
// The document's own AbortController: an element only accepts a signal
|
||||
// from its own realm (it matters for jsdom in the tests, not in a browser).
|
||||
const Controller = (doc && doc.defaultView && doc.defaultView.AbortController) || globalThis.AbortController;
|
||||
@@ -105,6 +111,13 @@ export function createRegistry(options = {}) {
|
||||
const controller = new Controller();
|
||||
const ctx = Object.assign({}, services,
|
||||
{ root: root, name: name, signal: controller.signal, state: {} });
|
||||
if (mountContext) {
|
||||
try {
|
||||
Object.assign(ctx, mountContext(ctx));
|
||||
} catch (error) {
|
||||
logger.error('[LEDMatrix.pages] ' + name + ': mountContext failed:', error);
|
||||
}
|
||||
}
|
||||
const entry = { name: name, root: root, ctx: ctx, controller: controller,
|
||||
module: null, initialised: false, destroyed: false, ready: null };
|
||||
mounted.set(root, entry);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* core/visibility.js -- run a page's timers and polling only while the page
|
||||
* is on screen: its tab is the active tab AND the browser tab is visible.
|
||||
*
|
||||
* Each mounted page gets its own handle as ctx.visibility (the registry asks
|
||||
* boot.js for it on every mount; see createRegistry's `mountContext`):
|
||||
*
|
||||
* ctx.visibility.whileVisible(start, stop)
|
||||
* start() runs when the page comes on screen, stop() when it leaves.
|
||||
* start() should refresh at once: it also runs when the page mounts
|
||||
* already on screen. Returns a function that ends the registration
|
||||
* (running stop() first if it is running).
|
||||
* ctx.visibility.every(ms, fn)
|
||||
* fn() at once and then every `ms` while on screen; the interval is
|
||||
* cleared while hidden and restarted (with an immediate fn()) when the
|
||||
* page is back. Returns the same kind of end function.
|
||||
* ctx.visibility.isVisible()
|
||||
* true while the page is on screen.
|
||||
*
|
||||
* Everything a page registered ends when the page is destroyed (its
|
||||
* ctx.signal aborts, after destroy()), so a page needs no teardown for it:
|
||||
* a swapped-out partial leaves no interval behind.
|
||||
*
|
||||
* Which tab: the page's name (ctx.name), or forPage(ctx, { tab }) when a
|
||||
* page's tab is named differently.
|
||||
*
|
||||
* Where the answer comes from: window.LEDVisibility (app-shell.js), read at
|
||||
* call time. It owns the one notion of "active tab" (Alpine's activeTab plus
|
||||
* the ledmatrix:tab-changed event) and already pauses the SSE streams, so
|
||||
* the classic partials that still call it and the page modules agree. Each
|
||||
* registration here takes its own LEDVisibility key, so two pages (or two
|
||||
* timers on one page) never replace each other. Without LEDVisibility (a
|
||||
* page outside base.html) the browser tab's visibility alone decides.
|
||||
*
|
||||
* No DOM globals are read at import time, so node tests can import this file
|
||||
* and hand createVisibility() a jsdom window.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {Window} [options.window] default: globalThis
|
||||
* @param {Function} [options.tracker] () => an LEDVisibility-shaped object or null
|
||||
* (default: window.LEDVisibility at call time)
|
||||
* @param {{error: Function}} [options.logger]
|
||||
*/
|
||||
export function createVisibility(options = {}) {
|
||||
const win = options.window || globalThis;
|
||||
const doc = win.document;
|
||||
const logger = options.logger || win.console || console;
|
||||
const tracker = options.tracker || function() { return win.LEDVisibility || null; };
|
||||
let sequence = 0;
|
||||
|
||||
function call(fn, label) {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
logger.error('[LEDMatrix.visibility] ' + label + ' failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a tracker: follow document.hidden only. Same contract as
|
||||
* LEDVisibility.onActive: start/stop on change, returns an unregister fn.
|
||||
*/
|
||||
function onDocumentVisible(start, stop) {
|
||||
let running = false;
|
||||
function evaluate() {
|
||||
const want = !doc.hidden;
|
||||
if (want === running) return;
|
||||
running = want;
|
||||
call(want ? start : stop, want ? 'start' : 'stop');
|
||||
}
|
||||
doc.addEventListener('visibilitychange', evaluate);
|
||||
evaluate();
|
||||
return function() {
|
||||
doc.removeEventListener('visibilitychange', evaluate);
|
||||
if (running) { running = false; call(stop, 'stop'); }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The handle for one mounted page. Everything it registers ends when
|
||||
* ctx.signal aborts.
|
||||
*/
|
||||
function forPage(ctx, pageOptions = {}) {
|
||||
const tab = pageOptions.tab || ctx.name;
|
||||
const signal = ctx.signal;
|
||||
const live = new Set();
|
||||
|
||||
function whileVisible(start, stop) {
|
||||
if (typeof start !== 'function' || typeof stop !== 'function') {
|
||||
throw new TypeError('visibility.whileVisible(start, stop) needs two functions');
|
||||
}
|
||||
if (signal && signal.aborted) return function() {};
|
||||
const t = tracker();
|
||||
const key = 'page:' + ctx.name + ':' + (++sequence);
|
||||
const unregister = t
|
||||
? t.onActive(tab, start, stop, key)
|
||||
: onDocumentVisible(start, stop);
|
||||
let ended = false;
|
||||
const end = function() {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
live.delete(end);
|
||||
if (typeof unregister === 'function') call(unregister, key + ' stop');
|
||||
};
|
||||
live.add(end);
|
||||
return end;
|
||||
}
|
||||
|
||||
function every(ms, fn) {
|
||||
if (!(ms > 0) || typeof fn !== 'function') {
|
||||
throw new TypeError('visibility.every(ms, fn) needs a positive interval and a function');
|
||||
}
|
||||
let timer = null;
|
||||
function stop() {
|
||||
if (timer !== null) {
|
||||
win.clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
return whileVisible(function() {
|
||||
stop();
|
||||
timer = win.setInterval(function() { call(fn, 'every(' + ms + ')'); }, ms);
|
||||
fn();
|
||||
}, stop);
|
||||
}
|
||||
|
||||
function isVisible() {
|
||||
const t = tracker();
|
||||
return t ? t.isActive(tab) : !doc.hidden;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', function() {
|
||||
Array.from(live).forEach(function(end) { end(); });
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
return Object.freeze({ tab: tab, whileVisible: whileVisible, every: every, isVisible: isVisible });
|
||||
}
|
||||
|
||||
return { forPage: forPage };
|
||||
}
|
||||
Reference in New Issue
Block a user