mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 19:55:13 +08:00
🛸 Sync 2026-08-18 08:57:58
This commit is contained in:
1 parent
a9264dc426
commit
0b567b4be0
56 files changed
+8850
-2138
No files matched your search
@@ -210,6 +210,14 @@ function fitShell() {
|
||||
}
|
||||
|
||||
function fitChrome() {
|
||||
/* Nothing this function asks can change while the reader scrolls — the bar's width, the menu's
|
||||
* width, the room beside the brand — and every one of those questions is a layout read landing
|
||||
* in the middle of a flick. Put off until the scrolling stops; see fs-fit.js. */
|
||||
if (fit.scrolling()) {
|
||||
fit.deferMeasurement();
|
||||
return;
|
||||
}
|
||||
|
||||
fitShell();
|
||||
|
||||
const bar = document.querySelector('.fs-sidebar');
|
||||
@@ -463,5 +471,20 @@ return baseclass.extend({
|
||||
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
|
||||
* measurement rides the same engine as the data tables' */
|
||||
fitChrome,
|
||||
|
||||
/* The width a page's content column has, WITHOUT reading layout. The sidebar (or the rail) eats
|
||||
* a known amount of the window and the shell adds a known padding, and all three are already
|
||||
* memoised against the density attribute for `fitShell()`. It is exported because a pass that
|
||||
* must answer mid-scroll — fs-select's, for a table the poll has just brought in — otherwise has
|
||||
* only `window.innerWidth`, which in the sidebar layout is the wrong number by exactly the
|
||||
* sidebar: at an 800px window the column is 520px, so the cheap judgement said "plenty of room"
|
||||
* for a table that then overflowed and was clipped. */
|
||||
contentWidth() {
|
||||
const g = shellGeometry();
|
||||
const narrow = document.documentElement.hasAttribute('data-narrow');
|
||||
/* in the bar layout the chrome is above the content, not beside it */
|
||||
const cut = narrow ? 0 : (prefs.currentRail() ? g.railW : g.sidebarW);
|
||||
return Math.max(0, window.innerWidth - cut - (g.contentPad * 2));
|
||||
},
|
||||
wireRail
|
||||
});
|
||||
@@ -20,21 +20,184 @@
|
||||
* ResizeObserver, not onresize: a rail collapse and a layout toggle change the content width
|
||||
* without resizing the window. */
|
||||
|
||||
/* THE STYLESHEET MAY ONLY HIDE WHAT THIS FILE WILL SHOW, AND THE ARM BELONGS TO THE DISARM.
|
||||
* `theme/30-tables.css` keeps a data table out of the layout until something marks it `.fs-fitted`.
|
||||
* This attribute is what arms that rule — and it used to be written here, at module eval, while the
|
||||
* only code that ever writes `.fs-fitted` lives in fs-select.js, which the footer requires
|
||||
* SEPARATELY (`partials/footer.ut`: one require for menu-footstrap, another for fs-select, with no
|
||||
* dependency edge between them). A document that loaded this file and not that one — a failed
|
||||
* fetch, a parse error, a throw in fs-select's own init — armed a rule nobody could clear, and every
|
||||
* data table on Status/Leases/Processes/Wireless rendered as nothing at all, silently.
|
||||
*
|
||||
* So the arming is exported instead, and the module that clears the rule is the one that raises it.
|
||||
* A document where fs-select never ran carries no attribute and shows every table exactly as it did
|
||||
* before the rule existed. */
|
||||
function armGate() {
|
||||
if (!fittersEnabled()) return;
|
||||
try { document.documentElement.dataset.fsFit = '1'; } catch (e) { /* no document, no gate */ }
|
||||
}
|
||||
|
||||
const _fitters = [];
|
||||
let _rafPending = false;
|
||||
let _ro = null, _mo = null, _moFlag = null;
|
||||
|
||||
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
|
||||
* relevant mutation. */
|
||||
function run() {
|
||||
for (const fit of _fitters) {
|
||||
/* ---- A PASS THAT READS LAYOUT MAY NOT RUN WHILE THE READER SCROLLS ----
|
||||
*
|
||||
* Reading layout — `getBoundingClientRect()`, `clientWidth`, `scrollWidth` — forces the engine to
|
||||
* lay the page out synchronously. Once a second, from a poll tick, in the middle of a flick on a
|
||||
* phone, that is what iOS holds the main thread back to prevent, and it was the largest part of the
|
||||
* shaking reported from an iPhone; a stock theme has no JS in that path at all.
|
||||
*
|
||||
* The rule is stated by each pass rather than by this file, and that is not an oversight — the
|
||||
* central version was written, measured and reverted, because moving the decision here also moved
|
||||
* WHEN the deferred work lands and the device started shaking again. A pass that reads layout asks
|
||||
* `scrolling()` and calls `deferMeasurement()`; a pass that only writes does neither and runs
|
||||
* always. The one that must run always is the marking of a freshly polled table: the stylesheet
|
||||
* keeps an unmarked data table out of the layout, so waiting would leave it invisible for as long
|
||||
* as the reader keeps scrolling. */
|
||||
function runAll(list, what) {
|
||||
for (const fit of list) {
|
||||
try { fit(); }
|
||||
/* one broken fitter must take neither the others nor the poll's MutationObserver
|
||||
* callback with it — that would silently stop ALL re-fitting */
|
||||
catch (e) { console.error('fs-fit: a fitter threw', e); }
|
||||
catch (e) { console.error('fs-fit: a ' + what + ' threw', e); }
|
||||
}
|
||||
}
|
||||
|
||||
/* Everything that may run right now, with the reader kept where they were.
|
||||
*
|
||||
* ONE PATH: the mutation observer, the coalesced re-fit and the pass that was put off during a
|
||||
* scroll all come through here, so the order — reference, work, correction — is stated once.
|
||||
* `anchorRef()` answers null while the reader scrolls and the correction below does nothing with a
|
||||
* pass that happens mid-scroll costs neither of the two layout reads.
|
||||
*
|
||||
* WHAT MAY RUN MID-SCROLL IS EACH PASS'S OWN ANSWER, not this function's. A pass that only writes —
|
||||
* marking a freshly polled table, which cannot wait because the stylesheet holds an unmarked table
|
||||
* out of the layout — runs always; a pass that reads layout asks `scrolling()` first and calls
|
||||
* `deferMeasurement()`. Deciding it here instead was tried and reverted: it changed WHEN the
|
||||
* deferred work lands, and the device that had the problem started shaking again. */
|
||||
/* DEV SWITCH: `localStorage.fsFit = 'off'` stops every fitter. It exists because the only way to
|
||||
* tell "the theme's measuring is what shakes this phone" from "something else is" is to turn the
|
||||
* measuring off ON THAT PHONE, and a laptop cannot answer it. */
|
||||
function fittersEnabled() {
|
||||
try { return localStorage.getItem('fsFit') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
function run() {
|
||||
if (!fittersEnabled()) return;
|
||||
runAll(_fitters, 'fitter');
|
||||
}
|
||||
|
||||
/* ---- IS THE PAGE MOVING RIGHT NOW? — ASKED OF THE POSITION, NEVER OF THE EVENTS ----
|
||||
*
|
||||
* A pass that reads layout must not run while the page moves: on a phone every such read forces a
|
||||
* synchronous layout in the middle of a flick, which is the work iOS holds the main thread back to
|
||||
* prevent. So the passes ask this before measuring, and what they skipped is run once the movement
|
||||
* stops.
|
||||
*
|
||||
* THE FIRST VERSION ASKED THE EVENTS — `scroll`, `wheel`, `touchmove`, plus 200 ms of quiet — and it
|
||||
* was wrong in the one place it mattered. On iOS the momentum keeps carrying the page long after the
|
||||
* finger has gone, and events do not reliably arrive through it; the timer then declared the reader
|
||||
* still and dropped the whole deferred pass — every table stripped and measured, the chrome
|
||||
* re-fitted — into the middle of the glide. That is a stall and a relayout while the page is
|
||||
* visibly moving, and it is the shaking that came BACK when a refactor made less work happen during
|
||||
* the scroll and more of it land in that burst. The measurement that named it: the same device
|
||||
* shook more with strictly less work in the scroll path.
|
||||
*
|
||||
* So movement is read from the SCROLL POSITION. A frame in which the offset differs from the last is
|
||||
* movement, whatever the event stream is doing; the sampler runs only while there is reason to
|
||||
* think the page moves, and stops itself when the offset has held for SCROLL_IDLE. Momentum,
|
||||
* rubber-banding and a programmatic `scrollTo` all look the same to it, which is the point.
|
||||
*
|
||||
* The offset read is one per frame, and it is the cheapest question there is — no geometry, no
|
||||
* element, no forced layout beyond what the frame already needs. */
|
||||
/* HOW LONG THE PAGE MUST HOLD STILL BEFORE THE PUT-OFF WORK MAY RUN, and the number is the fix for
|
||||
* the shaking, not a tuning knob.
|
||||
*
|
||||
* 200ms was shorter than the pauses a slow reader leaves. Rocking a page gently at the tables — the
|
||||
* reader's own description of when it happens — the offset stops for a moment between one movement
|
||||
* and the next, this timer called that a stop, and the whole deferred pass landed in the middle of
|
||||
* the gesture: every table stripped of its marks, measured, marked again. That is a full relayout
|
||||
* while the page is visibly moving, and it is what "jerks back and forth" was.
|
||||
*
|
||||
* Measured on a stand with the reader's motion imitated (a slow rock, 60 frames, the error between
|
||||
* what the wheel asked for and what the page did, summed): 137-256px of roughness at 200ms with
|
||||
* every twentieth frame off by 40px, and 59px — the floor, one pixel of rounding per frame, the same
|
||||
* as switching the fitters off entirely — at 250ms and above. 400 is that floor with room to spare,
|
||||
* and still well inside the time a reader takes to look at what they scrolled to. */
|
||||
const SCROLL_IDLE = 400;
|
||||
/* set by a pass that skipped its measurement because the page was moving; consumed by the sampler
|
||||
* below the moment it stops */
|
||||
let _deferred = false;
|
||||
function deferMeasurement() { _deferred = true; }
|
||||
let _movingUntil = 0;
|
||||
let _lastOffset = null;
|
||||
let _sampling = false;
|
||||
|
||||
/* WHICH ELEMENT SCROLLS, ASKED ONCE PER WIDTH RATHER THAN ONCE PER FRAME.
|
||||
*
|
||||
* This function is the one every other pass consults before it dares to measure, and it ran in the
|
||||
* frame loop below for as long as the page moved — so the `scrollHeight`/`clientHeight` probe it
|
||||
* used to make WAS a forced synchronous layout, once per frame, in the middle of the flick this file
|
||||
* exists to keep clear. Worse, a poll tick lands as a microtask that writes classes, so the very
|
||||
* next frame's probe paid for a layout that had just been dirtied; and `touchstart` starts the
|
||||
* sampler, so a plain tap on a button bought ~24 of them.
|
||||
*
|
||||
* The answer cannot change without the room changing: the two layouts scroll different elements and
|
||||
* the switch between them is a width change, which `onResize` already watches and stamps. So the
|
||||
* verdict is cached against that stamp and the offset is then read from the scroller directly —
|
||||
* `scrollTop` on an element and `scrollY` on the window are both free. */
|
||||
let _scroller = null, _scrollerAt = -1;
|
||||
function scroller() {
|
||||
if (_scrollerAt === _resizeSeq && (_scroller === null || _scroller.isConnected)) return _scroller;
|
||||
const sc = document.getElementById('maincontent');
|
||||
_scroller = (sc && sc.scrollHeight - sc.clientHeight > 4) ? sc : null;
|
||||
_scrollerAt = _resizeSeq;
|
||||
return _scroller;
|
||||
}
|
||||
function scrollTop() {
|
||||
const sc = scroller();
|
||||
return sc ? sc.scrollTop : window.scrollY;
|
||||
}
|
||||
|
||||
function scrolling() { return Date.now() < _movingUntil; }
|
||||
|
||||
function sampleMotion() {
|
||||
const y = scrollTop();
|
||||
if (_lastOffset === null || y !== _lastOffset) {
|
||||
_lastOffset = y;
|
||||
_movingUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
|
||||
_sampling = false;
|
||||
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */
|
||||
if (_deferred) {
|
||||
_deferred = false;
|
||||
const ref = anchorRef();
|
||||
run();
|
||||
scheduleAnchor(ref);
|
||||
}
|
||||
}
|
||||
|
||||
function noteMotion() {
|
||||
_movingUntil = Date.now() + SCROLL_IDLE;
|
||||
if (_sampling) return;
|
||||
_sampling = true;
|
||||
requestAnimationFrame(sampleMotion);
|
||||
}
|
||||
|
||||
/* `passive: true` and `capture: true`: this must never sit in front of the scroll it is watching,
|
||||
* and `scroll` does not bubble from an element — it only travels down the capture phase, which is
|
||||
* how the sidebar layout's inner scroller is seen as well as the document. The events only START
|
||||
* the sampler; whether the page is still moving is the sampler's answer, not theirs. */
|
||||
(function watchMotion() {
|
||||
const opts = { passive: true, capture: true };
|
||||
window.addEventListener('scroll', noteMotion, opts);
|
||||
window.addEventListener('wheel', noteMotion, opts);
|
||||
window.addEventListener('touchstart', noteMotion, opts);
|
||||
window.addEventListener('touchmove', noteMotion, opts);
|
||||
})();
|
||||
|
||||
/* Next frame, at most once per frame (rule 3). */
|
||||
function schedule() {
|
||||
if (_rafPending) return;
|
||||
@@ -59,6 +222,9 @@ function schedule() {
|
||||
*
|
||||
* Per element, because the roots are observed separately and a dialog can resize while #view does
|
||||
* not. The first entry for an element always counts as a change, so nothing is lost at start-up. */
|
||||
/* bumped whenever an observed root changes WIDTH — the only thing that can change which element
|
||||
* scrolls, and therefore what `scroller()` above may cache */
|
||||
let _resizeSeq = 0;
|
||||
const _lastWidth = new WeakMap();
|
||||
function onResize(entries) {
|
||||
let widthMoved = false;
|
||||
@@ -71,7 +237,7 @@ function onResize(entries) {
|
||||
widthMoved = true;
|
||||
}
|
||||
}
|
||||
if (widthMoved) schedule();
|
||||
if (widthMoved) { _resizeSeq++; schedule(); }
|
||||
}
|
||||
|
||||
/* Watch an element's size. A change in WIDTH re-fits everything — the fitters are cheap and few. */
|
||||
@@ -85,6 +251,111 @@ function watch(el) {
|
||||
_ro.observe(el);
|
||||
}
|
||||
|
||||
/* ---- SCROLL ANCHORING, WHERE THE ENGINE HAS NONE ----
|
||||
*
|
||||
* A poll tick changes the height of what is ABOVE the reader: a lease expires, a station joins, a
|
||||
* section renders one row fewer. An engine with scroll anchoring absorbs that — it moves the scroll
|
||||
* offset by the same amount, so the page under the reader does not move. WebKit has none, and it is
|
||||
* every browser on iOS: measured on the reporter's own router, `content +133px, +134px, +123px,
|
||||
* +108px…` one after another, each next to a `child +1/-1` in a polled section. That is the shaking,
|
||||
* and it is not the tall-intermediate problem the rest of this file solves — the height change here
|
||||
* is REAL, and simply nobody compensates for it.
|
||||
*
|
||||
* So this compensates for it, and only when nobody else did. A reference is taken from the elements
|
||||
* that survive a poll — the section frames — choosing the one that crosses the top of the viewport,
|
||||
* because that is the boundary a reader perceives as "where I am". The fitters run, the reference is
|
||||
* read again, and the offset is moved by however far it drifted.
|
||||
*
|
||||
* WHY THIS IS SAFE ON AN ENGINE THAT DOES ANCHOR, which an earlier attempt got wrong by measuring
|
||||
* the wrong thing: the correction is computed from the REFERENCE, not from the scroll offset. An
|
||||
* anchoring engine has already put the reference back where it was by the time this reads it — the
|
||||
* read forces layout, and the adjustment happens during layout — so the drift is zero and this does
|
||||
* nothing at all. Measuring the offset instead reads an anchoring adjustment as a fault and corrects
|
||||
* a correction, which is exactly how a previous version made Chromium worse (16 movements, 1827px).
|
||||
*
|
||||
* It never fights the user: a page at the very top has no offset to give back and is left alone, and
|
||||
* a drift under a pixel is rounding rather than movement. */
|
||||
function anchorRef() {
|
||||
/* NOT WHILE THE READER SCROLLS. Every rect read here is a forced layout, and this runs on every
|
||||
* content mutation — i.e. once a second, in the middle of a flick, which is the work iOS holds
|
||||
* the main thread back to avoid. The compensation exists for a page the reader is looking at,
|
||||
* not for one they are already moving; while they scroll there is nothing to keep still. */
|
||||
if (scrolling()) return null;
|
||||
const frames = document.querySelectorAll('#view .cbi-section, #view > .cbi-section-node, #view > div');
|
||||
for (const el of frames) {
|
||||
const r = el.getBoundingClientRect();
|
||||
/* the frame the viewport's top edge cuts through */
|
||||
if (r.top <= 0 && r.bottom > 0) return { el, top: r.top };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* THE CORRECTION IS UNCONDITIONAL, AND IT IS A NO-OP WHERE THE ENGINE DOES THE JOB.
|
||||
*
|
||||
* There used to be a watching phase here: the first three mutations were only measured, and a
|
||||
* reference that had held still meant "this engine anchors, never touch the offset again". It was
|
||||
* wrong in the way that mattered. WebKit does not anchor, but the drift it leaves is often small —
|
||||
* a value grew a line, a row appeared — so three quiet samples were easy to come by, the
|
||||
* compensation switched itself off for the life of the document, and the page walked downwards a
|
||||
* few pixels at a time, once per poll tick. That is exactly how it was reported: "jerks a little
|
||||
* downwards, all the time".
|
||||
*
|
||||
* Correcting always is safe because the correction is computed from the REFERENCE, not from the
|
||||
* scroll offset: an engine that anchors has already put the reference back by the time this reads
|
||||
* it — the read forces layout, and the adjustment happens during layout — so the drift is zero and
|
||||
* nothing is written. Measuring the OFFSET instead reads an anchoring adjustment as a fault and
|
||||
* corrects a correction, which is how an earlier version made Chromium worse (10 movements up to
|
||||
* 1616px against 0 without it); that mistake is not in this shape.
|
||||
*
|
||||
* Two guards remain, and both are about not fighting the reader: a page at the very top has no
|
||||
* offset to give back, and a drift under a pixel is rounding rather than movement. */
|
||||
/* ONCE PER FRAME, AFTER EVERYTHING HAS LANDED — not once per mutation batch.
|
||||
*
|
||||
* A poll tick does not arrive as one mutation: luci-mod-status fills its sections one after another,
|
||||
* and each fill wakes this observer. Correcting on each of them measures a page that is still being
|
||||
* written: the reference is read between two batches, the drift is computed against a layout that
|
||||
* the next batch immediately invalidates, and the correction is wrong by whatever the rest of the
|
||||
* tick brought. The next tick corrects the other way. That is a page that jerks while nobody
|
||||
* touches it, and it is what was reported — with a telling detail: it went away whenever a
|
||||
* diagnostic probe that forced layout on EVERY frame was loaded, because then every reading this
|
||||
* code took happened to be against a settled layout.
|
||||
*
|
||||
* So the reference is taken at the first mutation of a batch and the correction is applied in a
|
||||
* requestAnimationFrame — after the batch, after style and layout have settled, once. A newer
|
||||
* reference is not taken while one is pending: the first one is where the reader actually was. */
|
||||
let _anchorPending = null;
|
||||
let _anchorFrame = 0;
|
||||
/* DEV SWITCH, for the device that has the problem: `localStorage.fsAnchor = 'off'` stops the theme
|
||||
* from ever writing the scroll offset, which is the one thing here that can move a page nobody is
|
||||
* touching. It exists to answer a question a laptop cannot — whether the correction is the cure or
|
||||
* the disease — and comes out with the answer. */
|
||||
function anchorEnabled() {
|
||||
try { return localStorage.getItem('fsAnchor') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
function scheduleAnchor(ref) {
|
||||
if (!ref || !anchorEnabled()) return;
|
||||
if (_anchorPending) return;
|
||||
_anchorPending = ref;
|
||||
if (_anchorFrame) return;
|
||||
_anchorFrame = requestAnimationFrame(() => {
|
||||
_anchorFrame = 0;
|
||||
const pending = _anchorPending;
|
||||
_anchorPending = null;
|
||||
applyAnchor(pending);
|
||||
});
|
||||
}
|
||||
function applyAnchor(ref) {
|
||||
if (!ref || !ref.el.isConnected) return;
|
||||
const sc = document.getElementById('maincontent');
|
||||
const inner = sc && sc.scrollHeight - sc.clientHeight > 4;
|
||||
const at = inner ? sc.scrollTop : window.scrollY;
|
||||
if (at <= 0) return;
|
||||
const drift = ref.el.getBoundingClientRect().top - ref.top;
|
||||
if (Math.abs(drift) < 1) return;
|
||||
if (inner) sc.scrollTop = at + drift;
|
||||
else window.scrollTo(0, at + drift);
|
||||
}
|
||||
|
||||
/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
|
||||
* get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
|
||||
* as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
|
||||
@@ -100,7 +371,12 @@ function watch(el) {
|
||||
* constructor, and luci-base instantiates a class exactly once, at the first require. */
|
||||
function observeContent() {
|
||||
if (_mo) return;
|
||||
_mo = new MutationObserver(run);
|
||||
_mo = new MutationObserver(() => {
|
||||
/* what the reader is looking at, before the poll's mutation and the fitters move anything */
|
||||
const ref = anchorRef();
|
||||
run();
|
||||
scheduleAnchor(ref);
|
||||
});
|
||||
for (const host of [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]) {
|
||||
if (!host) continue;
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
@@ -136,11 +412,21 @@ return baseclass.extend({
|
||||
fit();
|
||||
},
|
||||
|
||||
/* Is the reader scrolling, and "I could not measure, wake me when they stop". A pass that has to
|
||||
* read layout asks the first and calls the second; a pass that only writes does neither. */
|
||||
scrolling,
|
||||
deferMeasurement,
|
||||
|
||||
/* Raise the stylesheet's "an unanswered table takes no room" rule. Called by the module that
|
||||
* answers — see armGate above; nothing else may call it. */
|
||||
armGate,
|
||||
|
||||
/* Re-fit on the next frame, coalesced. (There is no exported `run`: everything that changes
|
||||
* the available room — the layout toggle, the rail collapse — schedules. Only the mutation
|
||||
* observer re-fits synchronously, and that is rule 2's whole point.) */
|
||||
schedule,
|
||||
|
||||
|
||||
/* Coalesce ANY callback into one call per frame (rule 3, for non-fitters): schedule() runs
|
||||
* EVERY fitter, so a caller wanting only its own work batched cannot use it — three had
|
||||
* hand-rolled the identical five lines. NOT for the per-element case: menu-footstrap.js's
|
||||
|
||||
@@ -32,20 +32,45 @@
|
||||
* Hooked at module eval — before any view render can set a timer, and LuCI resolves a module's
|
||||
* dependencies BEFORE running the dependent's factory, so this runs no later than it used to when
|
||||
* it sat in menu-footstrap-common.js itself. */
|
||||
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Set()));
|
||||
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Map()));
|
||||
(function hookIntervals() {
|
||||
if (window.__fsIntervalsHooked) return;
|
||||
window.__fsIntervalsHooked = true;
|
||||
const _si = window.setInterval, _ci = window.clearInterval;
|
||||
window.setInterval = function () {
|
||||
window.setInterval = function (fn, ms) {
|
||||
const id = _si.apply(window, arguments);
|
||||
_viewIntervals.add(id);
|
||||
/* the arguments are kept, not just the id: a paused timer has to be re-armed with what it
|
||||
* was armed with, and a `setInterval` id carries none of that back */
|
||||
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2) });
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = function (id) {
|
||||
_viewIntervals.delete(id);
|
||||
return _ci.apply(window, arguments);
|
||||
};
|
||||
/* A HIDDEN TAB MUST NOT KEEP CALLING THE ROUTER. `wireVisibility()` below stops LuCI's own poll
|
||||
* when the tab goes away, which is most of the traffic — but a view is free to run a plain
|
||||
* `setInterval` of its own (luci-app-podkop's log tailer does), and those kept hammering ubus in
|
||||
* a background tab for as long as it stayed open. The registry that navigation already uses to
|
||||
* clear them is enough to pause them too: cleared on hide, re-armed on show with the same
|
||||
* callback and period, so a view that was polling every 3 s is polling every 3 s again and one
|
||||
* that was cleared meanwhile stays cleared. */
|
||||
let _paused = [];
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
_paused = [];
|
||||
for (const [ id, spec ] of _viewIntervals) {
|
||||
_paused.push(spec);
|
||||
_ci.call(window, id);
|
||||
_viewIntervals.delete(id);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const back = _paused;
|
||||
_paused = [];
|
||||
for (const spec of back) window.setInterval(spec.fn, spec.ms, ...spec.rest);
|
||||
}
|
||||
});
|
||||
})();
|
||||
function clearViewIntervals() {
|
||||
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/
|
||||
@@ -84,7 +109,8 @@ function clearViewIntervals() {
|
||||
return;
|
||||
}
|
||||
const keep = running ? L.Poll.timer : null;
|
||||
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
|
||||
/* Map, not Set: the key is the timer id and the value is what it would take to re-arm it */
|
||||
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
|
||||
}
|
||||
/* ONE line per document whatever went wrong: this runs on every navigation, and a router that
|
||||
* cannot read L.Poll cannot read it on the next click either — a message per click would bury the
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
'require ui';
|
||||
'require dom';
|
||||
'require fs-fit as fit';
|
||||
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
|
||||
'require fs-chrome as chrome';
|
||||
|
||||
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
|
||||
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
|
||||
@@ -503,10 +505,180 @@ function breakWidestColumn(t) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---- A TABLE THAT REPLACES A TABLE INHERITS ITS ANSWER ----
|
||||
*
|
||||
* The measurement below has to strip a table's marks first (fs-fit rule 1: a stacked table is a pile
|
||||
* of flex rows and always "fits"), and that lays the page out with a full-width table for the length
|
||||
* of the pass — at 390px, several screens taller than the card stack it is about to be. Standing
|
||||
* still nobody sees it. The poll REPLACES these tables rather than updating them, so every tick used
|
||||
* to hand the fitter a fresh element with no marks and that intermediate happened again; on a remote
|
||||
* router at iPhone width it threw the reader 612px out and back, twice per tick.
|
||||
*
|
||||
* Compensating for it was tried four ways and each was wrong somewhere; the changelog entry for this
|
||||
* release records all four, because each looked right. This attacks the cause instead: the
|
||||
* intermediate exists only because the answer was thrown away with the element, so the answer is
|
||||
* kept on the SLOT. The slot is the section frame — `slotHome()` below says why it cannot be the
|
||||
* table's own parent — and inside it a table is named by its id, or by its position among the
|
||||
* tables of that frame when it has none.
|
||||
*
|
||||
* WHEN THE INHERITED ANSWER IS TRUSTED, and it is not "always". The tier depends on how many columns
|
||||
* share the room and on how wide the room is, so both are remembered with it. Room is `roomFor()`,
|
||||
* which reads the container rather than the table, so it is unaffected by the marks. The shape is
|
||||
* the column count, read from the DOM — `shapeOf()` says why rows are deliberately not in it.
|
||||
*
|
||||
* A change in either one re-measures. So does the arrival of a table in a slot nobody has measured
|
||||
* yet, and so does every full pass, which happens whenever the reader is not scrolling — that last
|
||||
* one is what catches the case a column count cannot see, a value that grew long enough to change
|
||||
* the tier, within a second of it happening and never during a scroll.
|
||||
*
|
||||
* WHAT IT COSTS. A tick on a table whose columns and room did not change writes its class names and
|
||||
* reads one width, against stripping every mark, forcing a layout, reading `scrollWidth` and
|
||||
* possibly walking the columns. Mid-scroll it does not even read the width: only tables with no
|
||||
* answer at all are visited, and each is given one without measuring anything. */
|
||||
const _slots = new WeakMap();
|
||||
|
||||
/* THE SLOT IS THE FRAME, NOT THE PARENT, and the difference is the whole cache. A poll tick hands
|
||||
* the container a freshly rendered subtree, so the table's immediate parent is usually new as well —
|
||||
* keyed on that, every lookup missed and every tick measured from scratch, which is exactly the
|
||||
* behaviour this was written to end (measured: the ±612px kept coming). What survives a tick is the
|
||||
* frame the theme and luci-mod-status build once, `.cbi-section`; inside it a table is named by its
|
||||
* id where it has one, and by its position among the tables of that frame where it does not. */
|
||||
function slotHome(t) {
|
||||
return (t.closest && t.closest('.cbi-section, .cbi-section-node, #view')) || t.parentElement;
|
||||
}
|
||||
|
||||
function slotKey(t, home) {
|
||||
if (t.id) return '#' + t.id;
|
||||
const kin = home ? Array.from(home.querySelectorAll('.table')) : [];
|
||||
const at = kin.indexOf(t);
|
||||
return at >= 0 ? 'at' + at : 'lone';
|
||||
}
|
||||
|
||||
/* WHAT CAN CHANGE THE ANSWER, AND WHAT CANNOT. The tier depends on how many columns have to share
|
||||
* the room and on how wide the room is — not on how many rows there are. Rows were in this
|
||||
* signature first and that was the bug behind a whole afternoon: a lease list gains and loses a row
|
||||
* on almost every poll tick, so the signature changed every tick, the cache never hit, and every
|
||||
* tick measured from scratch — which is the tall intermediate this exists to avoid.
|
||||
*
|
||||
* Read from the tree, never from layout: `children.length` is a DOM question, so asking it cannot
|
||||
* force the layout the whole design is arranging not to force. */
|
||||
function shapeOf(t) {
|
||||
const head = t.querySelector('.tr.table-titles, .cbi-section-table-titles') || t.firstElementChild;
|
||||
return String(head ? head.children.length : 0);
|
||||
}
|
||||
|
||||
function applyDecision(t, d) {
|
||||
/* the mark that lets the table into the layout at all — see theme/30-tables.css */
|
||||
t.classList.add('fs-fitted');
|
||||
t.classList.toggle('fs-stacked', !!d.stack);
|
||||
t.classList.toggle('fs-drop-xs', !!d.drop);
|
||||
if (d.breakCol !== undefined && d.breakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
|
||||
}
|
||||
|
||||
function fitTables() {
|
||||
document.querySelectorAll(stackables()).forEach((t) => {
|
||||
/* MID-SCROLL, ONLY THE TABLES THAT HAVE NO ANSWER YET. A table that already carries its answer
|
||||
* needs nothing; one that does not is being held out of the layout by the stylesheet and cannot
|
||||
* wait. Neither branch of this function reads layout while `scrolling()` is true. */
|
||||
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
|
||||
document.querySelectorAll(sel).forEach((t) => {
|
||||
/* ---- WHILE THE READER SCROLLS, THIS PASS READS NOTHING ----
|
||||
*
|
||||
* Every layout read here — `roomFor()` is one per table — forces the engine to lay the page
|
||||
* out synchronously, and a poll tick lands once a second, so on a phone that is ten forced
|
||||
* layouts in the middle of a flick. iOS runs the main thread sparingly during momentum
|
||||
* scrolling exactly to keep the frame rate; work like this is what it is protecting against,
|
||||
* and the reporter's word for the result was "shakes". A stock theme has no JS in this path
|
||||
* at all, which is why the same page under luci-theme-bootstrap scrolls smoothly.
|
||||
*
|
||||
* So a pass that happens mid-scroll only WRITES: the answer this slot already has goes on the
|
||||
* fresh table, and nothing is measured until the scrolling stops. A slot nobody has measured
|
||||
* yet is judged from the window width instead of the container's — the one number available
|
||||
* without touching layout, and at phone width it gives the same answer. */
|
||||
const home = slotHome(t);
|
||||
const slots = home ? (_slots.get(home) || new Map()) : null;
|
||||
const key = slots ? slotKey(t, home) : null;
|
||||
const known = slots ? slots.get(key) : null;
|
||||
|
||||
/* the answer without a measurement: the slot's, or — for a slot nobody has measured yet —
|
||||
* the window's width, which is the one number available without touching layout. At the
|
||||
* width where this matters the cheap judgement and the measured one agree. */
|
||||
/* MID-SCROLL THE ANSWER STILL GOES ON, and it must. Letting a freshly polled table wait for
|
||||
* the scrolling to stop was tried on the device that has the fault: the reader then WATCHES
|
||||
* the tables fold into cards under their thumb, and that fold is the hardest jerk of all. The
|
||||
* answer is a write, it forces nothing, and it keeps the fresh table the same shape as the one
|
||||
* it replaced. */
|
||||
if (fit.scrolling()) {
|
||||
/* the column's width, not the window's: in the sidebar layout they differ by the sidebar,
|
||||
* and an 800px window whose column is 520px used to clear CRAMPED and leave the table
|
||||
* unstacked at full width, clipped by `.fs-main { overflow-x: clip }` until the reader
|
||||
* held still. `chrome.contentWidth()` answers from memoised tokens and attributes — no
|
||||
* layout is read here either. */
|
||||
applyDecision(t, known || { stack: chrome.contentWidth() < CRAMPED, drop: false, breakCol: -1 });
|
||||
fit.deferMeasurement();
|
||||
return;
|
||||
}
|
||||
|
||||
/* the column count, read from the tree: no layout is forced by asking */
|
||||
const shape = shapeOf(t);
|
||||
|
||||
/* ---- THE ANSWER GOES ON BEFORE ANYTHING IS MEASURED, AND THAT ORDER IS THE FIX ----
|
||||
*
|
||||
* `roomFor()` reads a clientWidth, which forces layout — and a fresh table from a poll tick
|
||||
* is UNMARKED when it lands, so a pass that measured first laid the page out with a
|
||||
* full-width table before it had said a word. That is the tall intermediate the engine
|
||||
* re-anchors on: measured on a remote router at iPhone width, ±612px twice per tick, and it
|
||||
* survived every attempt to compensate for it because the compensation ran after the layout
|
||||
* that caused it.
|
||||
*
|
||||
* With the slot's answer applied first, the only layout this pass forces is one where the
|
||||
* table already wears the answer it had a second ago. */
|
||||
if (known) applyDecision(t, known);
|
||||
|
||||
const was = t.classList.contains('fs-stacked');
|
||||
const wasDrop = t.classList.contains('fs-drop-xs');
|
||||
/* rounded, because `roomFor()` subtracts parsed padding and answers in fractions: comparing
|
||||
* those exactly made the cache miss on a width that had not moved at all */
|
||||
const room = Math.round(fit.roomFor(t));
|
||||
if (!(room > 0)) {
|
||||
/* Detached, hidden, or a dialog that is closed: keep what it had rather than decide
|
||||
* against a width it does not have — but LET IT INTO THE LAYOUT anyway. The stylesheet
|
||||
* keeps an unanswered table out of the flow (theme/30-tables.css), and a table whose
|
||||
* section is collapsed when it arrives would otherwise stay hidden after the section is
|
||||
* opened: nothing measures it again, because opening a section changes no width this
|
||||
* file watches. A table with no room cannot move anything either way, so admitting it
|
||||
* costs nothing and the next pass with room decides properly. */
|
||||
t.classList.add('fs-fitted');
|
||||
return;
|
||||
}
|
||||
|
||||
/* the answer this slot already has, for a table of the same shape in the same room — UNLESS the
|
||||
* table has since outgrown it. Room and column count are not the whole input: a poll tick can
|
||||
* put longer values into the same columns, and then a table that fitted a second ago does not.
|
||||
* Measured on a live router at 900px, where the cache held: status_leases6 stood 1000px wide in
|
||||
* an 810px column and wifi_assoclist_table 896px, neither carded, both quietly clipped by
|
||||
* `.fs-main { overflow-x: clip }` — columns cut off with nothing to say so. The extra question
|
||||
* is one the pass already has the layout for. */
|
||||
/* AND THE OTHER DIRECTION, WHICH THAT QUESTION CANNOT ASK. `overflows()` is put to a table
|
||||
* that is already wearing its remedy, and every remedy makes a table fit by construction: a
|
||||
* card is a pile of flex rows, `.fs-drop-xs` has hidden the expendable columns, a broken
|
||||
* column shreds. So the answer is always "it fits", and a remedy applied once could never be
|
||||
* lifted while the room and the column count held still — a one-way ratchet. The case is
|
||||
* real: one station with a long hostname makes Associated Stations drop its `hide-xs`
|
||||
* columns, and those columns stayed hidden after that station left, on a screen with room to
|
||||
* spare and nothing to say a column existed.
|
||||
*
|
||||
* The only honest measurement is of a table with no remedy on it, and taking that on every
|
||||
* tick would mean stripping the marks on every tick — the strip-measure-restore that lands as
|
||||
* a visible relayout, which is what the deferral above exists to keep out of a scroll. So the
|
||||
* re-decision is asked for by the CONTENT instead: a remedied table whose row count has gone
|
||||
* DOWN has lost something, and losing something is the only way it can stop needing the
|
||||
* remedy. Rows are counted from the tree, not the layout. A table that keeps its rows keeps
|
||||
* its answer, which is what makes the poll cheap. */
|
||||
const rows = t.querySelectorAll('.tr').length;
|
||||
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
|
||||
const shrank = remedied && known.rows !== undefined && rows < known.rows;
|
||||
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
|
||||
return;
|
||||
|
||||
/* fs-fit rule 1: a stacked table is a pile of flex rows and always "fits", so reading
|
||||
* it as it stands un-stacks it and the next frame stacks it again — oscillation. Every
|
||||
@@ -515,15 +687,6 @@ function fitTables() {
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
|
||||
const room = fit.roomFor(t);
|
||||
if (!(room > 0)) {
|
||||
/* detached, hidden, or a dialog that is closed: keep what it had rather than decide
|
||||
* against a width it does not have */
|
||||
if (was) t.classList.add('fs-stacked');
|
||||
if (wasDrop) t.classList.add('fs-drop-xs');
|
||||
return;
|
||||
}
|
||||
|
||||
/* the one judgement a measurement cannot make, and the only number in this file */
|
||||
let stack = room < CRAMPED;
|
||||
if (!stack && fit.overflows(t)) {
|
||||
@@ -533,6 +696,7 @@ function fitTables() {
|
||||
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
|
||||
}
|
||||
|
||||
t.classList.add('fs-fitted');
|
||||
/* write only on a real change: the poll re-renders these tables once a second, and
|
||||
* toggling the class off and on each tick would invalidate style for every row of
|
||||
* Processes/Leases for nothing */
|
||||
@@ -544,6 +708,20 @@ function fitTables() {
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
}
|
||||
else if (was) t.classList.remove('fs-stacked');
|
||||
|
||||
if (slots) {
|
||||
slots.set(key, {
|
||||
stack,
|
||||
drop: t.classList.contains('fs-drop-xs'),
|
||||
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
|
||||
room,
|
||||
shape,
|
||||
/* what the answer was decided over — a drop below this is the one signal that a
|
||||
* remedy may no longer be needed (see the fast path above) */
|
||||
rows,
|
||||
});
|
||||
_slots.set(home, slots);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -616,6 +794,11 @@ function unreach(t) {
|
||||
}
|
||||
|
||||
function fitScrollables() {
|
||||
/* A layout read here lands in the middle of a flick once per poll tick, and that is the work iOS
|
||||
* holds the main thread back to prevent — the same argument fitTables() makes above, and the
|
||||
* reason the theme shook on a phone while a stock one did not. Put off until the page is still;
|
||||
* fs-fit's sampler runs what was deferred the moment the scroll offset stops changing. */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
document.querySelectorAll(scrollables()).forEach((t) => {
|
||||
const was = t.classList.contains('fs-xscroll');
|
||||
const wasStack = t.classList.contains('fs-rowstack');
|
||||
@@ -687,6 +870,11 @@ function fitScrollables() {
|
||||
* into — so wiping the pin cannot change the key and set this oscillating. It fires once per CHANGE,
|
||||
* never per tick, so it does not fight upstream for the pin on a polled page. */
|
||||
function unpinActionColumn() {
|
||||
/* A layout read here lands in the middle of a flick once per poll tick, and that is the work iOS
|
||||
* holds the main thread back to prevent — the same argument fitTables() makes above, and the
|
||||
* reason the theme shook on a phone while a stock one did not. Put off until the page is still;
|
||||
* fs-fit's sampler runs what was deferred the moment the scroll offset stops changing. */
|
||||
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
||||
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
|
||||
if (!t.querySelector('.cbi-section-actions')) continue;
|
||||
/* ---- and CLAIM upstream's resize hook, because under SPA navigation it is a leak ----
|
||||
@@ -851,10 +1039,26 @@ return baseclass.extend({
|
||||
};
|
||||
scan();
|
||||
|
||||
/* ARM THE STYLESHEET'S GATE FROM HERE, because this is the file that clears it. The rule in
|
||||
* theme/30-tables.css holds an unanswered data table out of the layout, and `.fs-fitted` —
|
||||
* the answer — is written nowhere but in this module. Arming it in fs-fit.js meant a document
|
||||
* that loaded fs-fit and not this file (the footer requires them separately) hid every table
|
||||
* forever. Raised here, one line before the pass that clears it is registered. */
|
||||
fit.armGate();
|
||||
|
||||
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
|
||||
* brings a fresh one back — so the two travel as one fitter, which fs-fit runs now, on
|
||||
* every content mutation (synchronously, pre-paint) and on every resize of #view. */
|
||||
fit.add(() => { tagDataTables(); fitTables(); fitScrollables(); unpinActionColumn(); resyncValues(); });
|
||||
* brings a fresh one back — so tagging leads, and the passes that depend on it follow.
|
||||
*
|
||||
* FIVE REGISTRATIONS, NOT ONE CALLBACK. fs-fit catches per registered fitter, so a throw in
|
||||
* one of these used to take the other four with it — and the first of them walks third-party
|
||||
* markup, which is the shape most likely to surprise it. Bundled, a single throw in
|
||||
* `tagDataTables()` left no table tagged, no table answered, and — with the gate above raised
|
||||
* — a page with no tables on it at all. Registered separately, each pass fails alone. */
|
||||
fit.add(tagDataTables);
|
||||
fit.add(fitTables);
|
||||
fit.add(fitScrollables);
|
||||
fit.add(unpinActionColumn);
|
||||
fit.add(resyncValues);
|
||||
|
||||
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
|
||||
* coalescer) */
|
||||
|
||||
@@ -838,6 +838,24 @@ function rehostInvasiveSheets() {
|
||||
if (!universe) return;
|
||||
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
|
||||
if (el.dataset.fsLayered) return;
|
||||
/* A SHEET WITH NO RULES YET IS NOT AN INNOCENT SHEET. `insertRule()` produces no mutation
|
||||
* record of any kind, so an app that appends an empty <style> and fills it later — lazily, on
|
||||
* first hover, from a `.then()` — is judged here as empty, found not invasive, and never
|
||||
* looked at again unless some other sheet happens to be added afterwards. Measured: a
|
||||
* `* { padding: 0 !important }` inserted that way flattened the chrome (the sidebar's 20px
|
||||
* padding went to 0) and stayed unfenced for the life of the document. One deferred look per
|
||||
* such element is enough, and it costs nothing in the ordinary case where a <style> arrives
|
||||
* with its rules already in it. */
|
||||
let rules = null;
|
||||
try { rules = el.sheet && el.sheet.cssRules; } catch (e) { /* cross-origin: judged below */ }
|
||||
if (rules && !rules.length && !el.__fsRecheck) {
|
||||
el.__fsRecheck = true;
|
||||
window.setTimeout(() => {
|
||||
let now;
|
||||
try { now = el.isConnected && el.sheet && el.sheet.cssRules; } catch (e) { return; }
|
||||
if (now && now.length) rehostInvasiveSheets();
|
||||
}, 2000);
|
||||
}
|
||||
if (invasiveSheet(el, universe)) rehostIntoThemeLayer(el, universe);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,13 +106,23 @@ function probeColor(expr) {
|
||||
/* off-screen rather than display:none — a display:none element still computes `color`, but
|
||||
* keeping it laid out avoids depending on that being true of every engine. It carries no
|
||||
* text and no size, so it paints nothing. */
|
||||
_probe = E('span', { 'style': 'position:fixed;left:-9999px;top:0;width:0;height:0;overflow:hidden;pointer-events:none', 'aria-hidden': 'true' });
|
||||
/* EVERY DECLARATION IMPORTANT, and the reason is the same one fs-chrome.js's length probe
|
||||
* carries (issue #19): this is an unmarked plain element in a document shared with
|
||||
* `luci-app-*`, the sheet fence deliberately does not spare it, and an app's unlayered
|
||||
* `span { color: … !important }` or `* { color: … !important }` outranks a layer and a plain
|
||||
* inline style alike. A probe that loses its own colour reports the app's instead — and this
|
||||
* probe's answer is written into the hex field, painted into the swatch and graded for
|
||||
* contrast, so a stolen reading becomes the admin's saved axis on the next confirm. */
|
||||
_probe = E('span', { 'aria-hidden': 'true' });
|
||||
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
|
||||
+ 'width:0!important;height:0!important;overflow:hidden!important;'
|
||||
+ 'pointer-events:none!important;';
|
||||
document.body.appendChild(_probe);
|
||||
}
|
||||
/* cleared first: an expression the engine rejects leaves the PREVIOUS colour standing, which
|
||||
* would report a stale answer as a fresh one. */
|
||||
_probe.style.color = '';
|
||||
_probe.style.color = expr;
|
||||
_probe.style.setProperty('color', '');
|
||||
_probe.style.setProperty('color', expr, 'important');
|
||||
return getComputedStyle(_probe).color;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user