mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 04:15:06 +08:00
🏅 Sync 2026-08-13 09:29:33
This commit is contained in:
@@ -22,6 +22,10 @@
|
||||
const _fitters = [];
|
||||
let _rafPending = false;
|
||||
let _ro = null, _mo = null;
|
||||
/* one canvas for the whole document: wordFloor() measures text with it, and creating one per call
|
||||
* is the expensive half of that measurement */
|
||||
let _cx = null;
|
||||
const SPACE_RE = /\s+/;
|
||||
|
||||
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
|
||||
* relevant mutation. */
|
||||
@@ -119,6 +123,74 @@ return baseclass.extend({
|
||||
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
|
||||
},
|
||||
|
||||
/* THE NARROWEST THIS TABLE CAN BE WITHOUT BREAKING A WORD THROUGH — its own breakpoint, in px.
|
||||
*
|
||||
* Every other "does it fit" question here is asked of the browser. This one cannot be: it is
|
||||
* exactly the table's min-content width, and `overflow-wrap: anywhere` (base/40-tables.css) makes
|
||||
* the browser answer ONE CHARACTER per column. That is deliberate — an unbreakable ICCID must not
|
||||
* push a table nobody measures out of its card — but it means a measured table can be starved to
|
||||
* a ribbon of fragments and still report, truthfully, that it fits. Asking the engine the honest
|
||||
* question by flipping `overflow-wrap` for one layout does not work either: Blink returns the same
|
||||
* min-content for `normal`, `break-word` and `anywhere` on a table (measured — 645px in all three,
|
||||
* with the widest word alone needing 367), so the number simply is not available from layout.
|
||||
*
|
||||
* So compute it: per COLUMN, the width of the widest WORD any of its cells has to show, plus that
|
||||
* cell's own side padding; summed across columns. A `nowrap`/`pre` column takes its whole text
|
||||
* instead, because that is what it has committed to showing. Nothing here is a threshold — the
|
||||
* result is the table's content measured in the table's own fonts, so every table gets its own
|
||||
* number and none was picked by anyone.
|
||||
*
|
||||
* Two approximations, both stated rather than hidden:
|
||||
* - the FONT is sampled once per column, from the first data row. A column that changes face
|
||||
* row by row would be measured in the first row's; no LuCI table does that, and the
|
||||
* alternative is a `getComputedStyle` per cell, which is the expensive call here.
|
||||
* - the widest word is picked by CHARACTER COUNT and only that one is measured, so a column of
|
||||
* one font is one `measureText` per new maximum instead of one per cell. Within a single font
|
||||
* that ranks `iiii` above `WWW`; it costs a few px on the floor and takes the walk over
|
||||
* Processes (114 rows) from 6ms to about 1ms.
|
||||
*
|
||||
* A `colSpan` cell contributes its whole floor to one column, which over-states that column and
|
||||
* under-states its neighbours by the same amount — the sum, which is what the caller compares, is
|
||||
* unaffected. */
|
||||
wordFloor(t) {
|
||||
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
|
||||
if (!rows.length) return 0;
|
||||
if (!_cx) _cx = document.createElement('canvas').getContext('2d');
|
||||
const floors = [], longest = [];
|
||||
let cols = null;
|
||||
for (const row of rows) {
|
||||
const cells = row.children;
|
||||
if (!cols) {
|
||||
cols = [];
|
||||
for (const c of cells) {
|
||||
const s = getComputedStyle(c);
|
||||
cols.push({
|
||||
font: `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`,
|
||||
tracking: parseFloat(s.letterSpacing) || 0,
|
||||
pad: parseFloat(s.paddingLeft) + parseFloat(s.paddingRight),
|
||||
whole: (s.whiteSpace === 'nowrap' || s.whiteSpace === 'pre')
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const text = (cells[i].textContent || '').trim();
|
||||
if (!text) continue;
|
||||
const col = cols[i] || cols[cols.length - 1];
|
||||
let word = '';
|
||||
if (col.whole) word = text;
|
||||
else for (const w of text.split(SPACE_RE)) if (w.length > word.length) word = w;
|
||||
if (!(word.length > (longest[i] || 0))) continue;
|
||||
longest[i] = word.length;
|
||||
_cx.font = col.font;
|
||||
const need = _cx.measureText(word).width + (col.tracking * word.length) + col.pad;
|
||||
if (!(floors[i] >= need)) floors[i] = need;
|
||||
}
|
||||
}
|
||||
let sum = 0;
|
||||
for (const f of floors) sum += (f || 0);
|
||||
return sum;
|
||||
},
|
||||
|
||||
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
|
||||
* squeezed into a tower" — a cell that has to break its own words is a column that has run
|
||||
* out of width, which is a thing no viewport query can ask.
|
||||
|
||||
@@ -76,6 +76,63 @@ function clearViewIntervals() {
|
||||
}
|
||||
let _pollTimerWarned = false;
|
||||
|
||||
/* --- uci cache teardown for SPA nav ---
|
||||
* `uci.load()` does not answer "is this config present?" — it answers "which of these packages did
|
||||
* THIS call fetch", skipping every package already in its document-scoped cache:
|
||||
*
|
||||
* for (…) if (!self.state.values[packages[i]]) { pkgs.push(…); tasks.push(…) }
|
||||
* return Promise.all(tasks).then(() => pkgs); // uci.js, luci-base
|
||||
*
|
||||
* Four shipped views read that return value as an existence check and abort on an empty array —
|
||||
* luci-app-banip and luci-app-adblock's overview, luci-app-travelmate's overview and stations:
|
||||
*
|
||||
* if (!result[3] || result[3].length === 0) { ui.addNotification(…, _('No banIP config found!')); return; }
|
||||
*
|
||||
* On a full load the cache is empty, so the first visit always gets its package name back and the
|
||||
* check passes. Under SPA the document survives, so the SECOND visit gets `[]` and the page renders
|
||||
* as "no config found" — reported against banip, where switching between its own tabs and coming
|
||||
* back to Overview is the ordinary gesture, and unstickable by anything short of a reload.
|
||||
*
|
||||
* The apps' reading of `load()` is wrong, but the DIVERGENCE is ours: a cache that outlives the page
|
||||
* that filled it is state a fresh load does not have, exactly like the poll queue and the view's
|
||||
* intervals above. So drop it on navigation and let the incoming view fetch what it needs.
|
||||
*
|
||||
* `unload()` is upstream's own idiom for this, not a lever we found: `uci.save()` ends with
|
||||
* `self.unload(pkgs); return self.load(pkgs)`. Pending local edits (creates/changes/deletes) go with
|
||||
* it — as they do on a full load, which throws away the whole document. Saved changes are already
|
||||
* on the server and the Unsaved-changes banner reads them from there, so it is unaffected.
|
||||
*
|
||||
* Read through `window.L.uci` rather than a `'require uci'` pragma, deliberately: the class attaches
|
||||
* itself to L's prototype when the FIRST requirer compiles it, so this sees the instance the pages
|
||||
* actually use, and a router that required it would both bind it to its own prototypal L (the two-L
|
||||
* trap, docs/spa-router.md) and pull uci.js onto pages that never touch uci. No instance means no
|
||||
* cache to flush. */
|
||||
function flushUciCache() {
|
||||
const uci = window.L ? window.L.uci : null;
|
||||
if (!uci || typeof uci.unload !== 'function') return;
|
||||
/* `state.values` and `loaded` are private, so the same rule as L.Poll.timer applies: a shape we
|
||||
* do not recognise is a reason to do nothing, once, loudly — never to guess. The two hold
|
||||
* different halves of the cache (a package whose load is still in flight is in `loaded` alone),
|
||||
* and unload() clears both, so the names are taken from both. */
|
||||
if (!uci.state || typeof uci.state.values !== 'object' || typeof uci.loaded !== 'object') {
|
||||
if (!_uciCacheWarned) {
|
||||
_uciCacheWarned = true;
|
||||
console.error('footstrap: LuCI.uci keeps its cache somewhere this router does not know, so '
|
||||
+ 'it is left alone. An app that reads uci.load()\'s return value as an existence check '
|
||||
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
|
||||
+ 'this luci-base.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
|
||||
if (names.length) uci.unload(names);
|
||||
/* `state.reorder` is the one half unload() does not clear, and it is deliberately left alone
|
||||
* rather than reset by hand: with `values` gone, reorderSections() finds no sections to order,
|
||||
* emits no call and clears the map itself on the next save. Writing to that field would be us
|
||||
* editing another module's private state, for a difference nothing can observe. */
|
||||
}
|
||||
let _uciCacheWarned = false;
|
||||
|
||||
let _wired = false;
|
||||
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
|
||||
* navigation from a mere fragment change (see there). Seeded from the served page. */
|
||||
@@ -599,6 +656,9 @@ function navigate(pathname, push, kbd) {
|
||||
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
|
||||
* would have. L.Poll's own tick survives. */
|
||||
clearViewIntervals();
|
||||
/* and drop uci's document-scoped config cache, which a full load would not have carried into the
|
||||
* incoming page either (see flushUciCache) */
|
||||
flushUciCache();
|
||||
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
|
||||
_lastHovered = null;
|
||||
/* run every registered navigation callback — today the search palette's recent-pages record and
|
||||
|
||||
@@ -172,10 +172,40 @@ function enhance(sel) {
|
||||
*
|
||||
* `.table`, not `table.table` — the SAME selector relevant() and STACKABLE use. Stock LuCI
|
||||
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
|
||||
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card. */
|
||||
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card.
|
||||
*
|
||||
* `.table` is LuCI's own class, and everything the theme knows how to do with a table hangs off it.
|
||||
* A third-party app that emits a BARE `<table>` — no LuCI classes at all — therefore matched none of
|
||||
* this: nothing tagged it, nothing measured it, nothing carded it, and the only thing that reached it
|
||||
* was a phone-tier scrollbar (theme/90-responsive.css). Reported from a phone against a wifi-clients
|
||||
* dashboard whose last column was simply cut off.
|
||||
*
|
||||
* So the second half of this selector claims those too. It is deliberately UNMEASURABLE on the dev
|
||||
* stand — a census of `#view table:not(.table):not(.cbi-section-table)` over all 196 menu pages,
|
||||
* openclash / justclash / ssclash / dashboard / statistics included, found ZERO. That is the point:
|
||||
* every table anyone here emits already carries the class, so this changes nothing that can be
|
||||
* measured and covers the one shape that cannot be (docs/conventions.md: coverage is a contract). */
|
||||
const FOREIGN_TABLE = '#view .table:not(.cbi-section-table):not(.fs-dt), ' +
|
||||
'#view table:not(.table):not(.cbi-section-table):not(.fs-dt)';
|
||||
|
||||
/* The FOURTH header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
|
||||
* `<thead>` for the parser to imply and none of LuCI's class names. It is the exact shape the phone
|
||||
* tier's scroll fallback was written against, so it has to be recognisable here or that table can
|
||||
* still only ever scroll.
|
||||
*
|
||||
* "Every cell in the first row is a `<th>`" is the whole test, and it has to be EVERY: a data row
|
||||
* whose first cell is a row header (`<th scope=row>`) would otherwise be read as the header row and
|
||||
* every value below it captioned with a value. A table with no `<th>` at all — a layout table, a
|
||||
* matrix — returns null and keeps today's behaviour, which is what the scroll fallback is for. */
|
||||
function headerRow(t) {
|
||||
const row = t.rows && t.rows[0];
|
||||
if (!row || !row.cells.length) return null;
|
||||
return [ ...row.cells ].every((c) => c.tagName === 'TH') ? row : null;
|
||||
}
|
||||
|
||||
function tagDataTables() {
|
||||
document.querySelectorAll('#view .table:not(.cbi-section-table):not(.fs-dt)').forEach((t) => {
|
||||
/* THREE header markups, and each missing one cost a page. L.ui.Table emits
|
||||
document.querySelectorAll(FOREIGN_TABLE).forEach((t) => {
|
||||
/* FOUR header markups, and each missing one cost a page. L.ui.Table emits
|
||||
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
|
||||
* it is why the package list once needed a stacking block of its own); and a third-party
|
||||
* table may simply use a real `<thead>` — luci-mod-dashboard's device lists are
|
||||
@@ -188,16 +218,66 @@ function tagDataTables() {
|
||||
* to the `<thead>` — the parser's implied row never happens, so a `tr` in the selector finds
|
||||
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
|
||||
*
|
||||
* ANY of the three = a data table; NONE = a key/value include (System, Memory), which must
|
||||
* ANY of the four = a data table; NONE = a key/value include (System, Memory), which must
|
||||
* never card. `thead` is the structural form of the same statement the two classes make, so
|
||||
* it belongs in the same list rather than in a rule of its own. */
|
||||
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead');
|
||||
* it belongs in the same list rather than in a rule of its own; headerRow() is the fourth and
|
||||
* cannot be, because "the first row is all `<th>`" is not a selector. */
|
||||
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
|
||||
if (!head) return;
|
||||
t.classList.add('fs-dt');
|
||||
/* `.table` as well as `.fs-dt`, and only ever ADDED: the theme's whole table vocabulary —
|
||||
* the frame, the cell padding, the card stack — is written against `.table`, so a foreign
|
||||
* table that has just been recognised as a data table has to join it or the tag buys nothing.
|
||||
* A no-op on everything LuCI renders, which carries the class already. */
|
||||
t.classList.add('table', 'fs-dt');
|
||||
adoptMarkup(t, head);
|
||||
labelCells(t, head);
|
||||
});
|
||||
}
|
||||
|
||||
/* ...AND THE ROWS AND CELLS INSIDE IT, or the claim is a trap.
|
||||
*
|
||||
* `.table` alone gets the frame and the padding, because those rules end at the table. Everything
|
||||
* that makes the CARD is written one level down — `.table.fs-stacked .tr { display: flex }`, the
|
||||
* `.td[data-title]::before` label, the hidden header row — and a bare foreign `<table>` carries none
|
||||
* of those class names. So the fitter would measure it, decide it no longer fits, set `.fs-stacked`
|
||||
* and change NOTHING: measured at 390px on a bare four-column table, the rows stayed `table-row`,
|
||||
* the cells `table-cell` at 80px each, no label was generated — and `#view .table.fs-dt.fs-stacked`
|
||||
* sets `overflow: hidden`, so the columns were CLIPPED with no scrollbar to reach them. That is
|
||||
* worse than the phone-tier scroll it replaced, which is the whole reason this exists.
|
||||
*
|
||||
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md: the shared
|
||||
* zone), and the theme is already writing `.table` onto the same element — this is that one act
|
||||
* carried down to the rows, not a new liberty. Additive only, and cheap enough to re-run every fit
|
||||
* pass: `classList.add` on an element that already has the class is the same `contains` check we
|
||||
* would write to skip it, and these tables are POLLED, so fresh rows arrive bare.
|
||||
*
|
||||
* The HEADER also has to be recognisable as one, or the card shows it as a first row of column
|
||||
* names: a `<thead>` becomes `.thead` and a plain first row of `<th>` becomes `.tr.table-titles` —
|
||||
* the two names theme/30-tables.css hides when stacked. */
|
||||
function adoptMarkup(t, head) {
|
||||
/* DECIDED ONCE, AT CLAIM TIME, and only for a table that speaks none of this vocabulary. Asking
|
||||
* the question every pass instead would answer "already adopted" the moment we adopted it — and
|
||||
* these tables are polled, so the fresh rows that arrive bare afterwards would never be taken.
|
||||
* Asking it at all is what keeps the theme's hands off LuCI's own markup: the apk Software list
|
||||
* heads its table with `.tr.cbi-section-table-titles`, and blindly adding `table-titles` to that
|
||||
* would be the theme rewriting a class LuCI chose. */
|
||||
if (t._fsAdopt === undefined) t._fsAdopt = !t.querySelector('.tr, .thead');
|
||||
if (!t._fsAdopt) return;
|
||||
if (head.tagName === 'THEAD') head.classList.add('thead');
|
||||
else head.classList.add('tr', 'table-titles');
|
||||
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
||||
for (const c of titleRow.children) c.classList.add('th');
|
||||
/* `t.rows` covers a real <table> whether or not it has a <tbody> — a table built with
|
||||
* createElement has its <tr> directly under the <table> and `tbody tr` finds nothing. A
|
||||
* `<div class="table">` has no `.rows` and is LuCI's own markup, which carries the classes. */
|
||||
if (!t.rows) return;
|
||||
for (const row of t.rows) {
|
||||
if (head.contains(row) || row === head) continue;
|
||||
row.classList.add('tr');
|
||||
for (const cell of row.children) cell.classList.add(cell.tagName === 'TH' ? 'th' : 'td');
|
||||
}
|
||||
}
|
||||
|
||||
/* Give every cell the column heading it will show once the table cards.
|
||||
*
|
||||
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
|
||||
@@ -212,7 +292,14 @@ function tagDataTables() {
|
||||
* matters because these tables are POLLED: the rows are replaced wholesale every few seconds, and
|
||||
* the fresh ones arrive without it. */
|
||||
function labelCells(t, head) {
|
||||
const titles = [ ...head.children ].map((c) => (c.textContent || '').trim());
|
||||
/* A `<thead>` that was WRITTEN as markup nests a real `<tr>` — the parser inserts one even where
|
||||
* the author left it out — while one built by E() holds the `<th>`s directly (see above). Reading
|
||||
* `head.children` blind therefore captioned every cell of a parsed table with the header row's
|
||||
* ENTIRE text: "HostAddressSignal" over the hostname, over the address and over the signal.
|
||||
* Measured against a bare `<table><thead><tr><th>` on the stand, which is the shape a
|
||||
* server-rendered or innerHTML-built foreign table has. */
|
||||
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
||||
const titles = [ ...titleRow.children ].map((c) => (c.textContent || '').trim());
|
||||
if (!titles.some(Boolean)) return;
|
||||
for (const row of t.querySelectorAll('.tr, tbody tr')) {
|
||||
if (row === head) continue;
|
||||
@@ -299,6 +386,30 @@ function idTower(t) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---- AND THE SAME RIBBON IN A COLUMN THAT IS NOT THE FIRST ----
|
||||
*
|
||||
* There is no second test for it, and that is the fix rather than an omission.
|
||||
*
|
||||
* `idTower` above is deliberately first-column-only, on the grounds that "a value column wrapping to
|
||||
* a few lines is a value being shown, not a table falling apart". That held right up until the value
|
||||
* was one unbreakable token: `overflow-wrap: anywhere` gave such a cell a min-content of ONE
|
||||
* CHARACTER, so auto table layout was free to starve its column to one character, and `overflows()`
|
||||
* then reported — truthfully, uselessly — that the table fit. Reported from a hardware router at
|
||||
* 700-790px of window: the v4 lease table cards there (its `nowrap` columns give it a floor, so it
|
||||
* really does overflow) while the v6 table beside it shredded the DUID, measured at 5 lines with
|
||||
* 674px of room and 7 at 654px, against 1 line at 1160px.
|
||||
*
|
||||
* A first pass answered it with a line count, which is a number somebody picks. This asks the table
|
||||
* instead: `fit.wordFloor()` returns the narrowest the table can be without breaking a word through —
|
||||
* per column, the widest WORD it must show, in that column's own font, summed. Past that width the
|
||||
* browser has to cut through a value, and the card view is what shows values whole. So every table
|
||||
* carries its own breakpoint, derived from its own content, and no threshold was chosen anywhere.
|
||||
*
|
||||
* On the reporting router, at 1190px of room: leases6 asks for 935, the associated-stations table
|
||||
* for 966, the v4 leases for 645, Processes for 794, Connections for 550 and Startup for 381 — so
|
||||
* the two that were unreadable card at roughly a 1000px window, and the four that were fine keep
|
||||
* being tables until the room they actually need runs out. */
|
||||
|
||||
function fitTables() {
|
||||
document.querySelectorAll(STACKABLE).forEach((t) => {
|
||||
const was = t.classList.contains('fs-stacked');
|
||||
@@ -309,8 +420,8 @@ function fitTables() {
|
||||
const room = fit.roomFor(t);
|
||||
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
|
||||
|
||||
/* idTower last: it is the only one that walks the rows */
|
||||
const stack = room < CRAMPED || fit.overflows(t) || idTower(t);
|
||||
/* the two row walks last, cheapest first: idTower reads one column, wordFloor every cell */
|
||||
const stack = room < CRAMPED || fit.overflows(t) || idTower(t) || fit.wordFloor(t) > room;
|
||||
/* 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 */
|
||||
|
||||
Reference in New Issue
Block a user