mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 23:35:13 +08:00
🐤 Sync 2026-08-24 23:23:52
This commit is contained in:
1 parent
406281adb5
commit
cf5ae27e91
82 files changed
+5971
-5537
No files matched your search
@@ -226,15 +226,24 @@ function scrollTop() {
|
||||
}
|
||||
|
||||
function scrolling() { return Date.now() < _movingUntil; }
|
||||
/* how many times the offset has moved in this stretch: >1 is a scroll, 1 is a compensation */
|
||||
let _steps = 0;
|
||||
|
||||
function sampleMotion() {
|
||||
const y = scrollTop();
|
||||
if (_lastOffset === null || y !== _lastOffset) {
|
||||
/* HOW MANY TIMES the offset has moved inside this stretch of movement, not just that it did.
|
||||
* One step is what a compensation looks like — the engine puts the offset somewhere and it
|
||||
* stays there. A scroll is a SERIES. `lateDrift()` is the only caller that needs to tell the
|
||||
* two apart, and this is the cheapest thing that can: it is counted in a loop that already
|
||||
* runs, from a value it already reads. */
|
||||
if (_lastOffset !== null) _steps++;
|
||||
_lastOffset = y;
|
||||
_movingUntil = Date.now() + SCROLL_IDLE;
|
||||
}
|
||||
if (scrolling()) { requestAnimationFrame(sampleMotion); return; }
|
||||
_sampling = false;
|
||||
_steps = 0;
|
||||
/* the reader has stopped: this is a still moment, so the floor and the reference both belong to
|
||||
* where the page now stands */
|
||||
holdFloor();
|
||||
@@ -242,9 +251,18 @@ function sampleMotion() {
|
||||
/* the page has held still for SCROLL_IDLE: whatever was put off may run now */
|
||||
if (_deferred) {
|
||||
_deferred = false;
|
||||
const ref = anchorRef();
|
||||
/* NO CORRECTION FOR THIS BATCH, and that is deliberate. What runs here is work the fitters
|
||||
* put off because the reader was moving; the page they are about to change is a page the
|
||||
* reader has just scrolled through, and the two references available are both wrong for it.
|
||||
* A FRESH one describes the page after the scroll — against an offset WebKit may not have
|
||||
* laid out yet, which made the theme undo the reader's own move (measured at 1440, top,
|
||||
* Compact: parked at 591, put back to 0). The one from the last STILL page describes where
|
||||
* the reader was before the flick, and correcting to it drags them back there — the gate
|
||||
* caught exactly that as a 231px jump landing inside a scroll, on all three engines. Nothing
|
||||
* here is a poll tick: the fitters do not grow the page under anybody, they re-measure what
|
||||
* the scroll already showed. The next mutation corrects against a reference taken while the
|
||||
* page was still, which is the one that is true. */
|
||||
run();
|
||||
scheduleAnchor(ref);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,12 +277,26 @@ function noteMotion() {
|
||||
* 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. */
|
||||
/* IS THE READER DRIVING, as opposed to the page moving?
|
||||
*
|
||||
* `scrolling()` above cannot tell those apart, and must not: every pass that reads layout has to
|
||||
* stay out of a moving page whoever is moving it. `lateDrift()` asks the other question — a scroll
|
||||
* offset that changed because the ENGINE compensated a mutation is precisely what it exists to
|
||||
* inspect, and gating it on `scrolling()` made it fire never (measured: `late:busy` on every tick,
|
||||
* because the engine's own correction starts the motion sampler). A gesture is what says the reader
|
||||
* is driving, so the gesture is what it asks about. `mousedown` is in the list for the scrollbar
|
||||
* thumb and `keydown` for Page Down, neither of which produces a wheel or a touch. */
|
||||
let _userUntil = 0;
|
||||
function noteUser() {
|
||||
_userUntil = Date.now() + SCROLL_IDLE;
|
||||
noteMotion();
|
||||
}
|
||||
|
||||
(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);
|
||||
for (const name of [ 'wheel', 'touchstart', 'touchmove', 'mousedown', 'keydown' ])
|
||||
window.addEventListener(name, noteUser, opts);
|
||||
})();
|
||||
|
||||
/* Next frame, at most once per frame (rule 3). */
|
||||
@@ -346,7 +378,9 @@ function watch(el) {
|
||||
* a drift under a pixel is rounding rather than movement. */
|
||||
/* DOES THE ENGINE ANCHOR AT ALL? Chromium and Firefox compensate a height change above the viewport
|
||||
* by themselves — the reader stays where they were and the scroll offset moves under them. WebKit
|
||||
* has never implemented it, so on Safari and on every iPhone the same poll tick moves the page:
|
||||
* did not implement it until recently, so on an older Safari and on every iPhone of that vintage the
|
||||
* same poll tick moves the page (a current WebKit anchors, and gets the COLLAPSE case wrong instead —
|
||||
* lateDrift() below):
|
||||
* measured with the engine's anchoring suppressed, a 120px growth above the fold moves the reader
|
||||
* 120px, and 0px with it on.
|
||||
*
|
||||
@@ -389,13 +423,24 @@ function forgetRest() {
|
||||
_restPage = null;
|
||||
}
|
||||
function rememberRest() {
|
||||
if (ENGINE_ANCHORS || scrolling()) return;
|
||||
if (scrolling()) return;
|
||||
/* A PAGE AT THE TOP HAS NOTHING TO BE PUT BACK TO, so it does not pay for a reference. Every
|
||||
* correction here gives back offset the reader lost; at offset 0 there is none to lose, and the
|
||||
* hit test plus rect that anchorRef() costs (0.2ms typical, 6ms worst on a poll-dirtied WebKit
|
||||
* layout, measured on the stands) buys nothing. The offset is still remembered — it is one read,
|
||||
* and `anchorFor()`'s clamp test is written in terms of it. */
|
||||
if (ENGINE_ANCHORS && scrollTop() <= 0) {
|
||||
_rest = null;
|
||||
_restAt = 0;
|
||||
_restPage = pageStamp();
|
||||
return;
|
||||
}
|
||||
const ref = anchorRef();
|
||||
/* the offset it was taken at travels with it: a reference is only about the page, and the page
|
||||
* moving under the reader is a different fact from the reader moving through it */
|
||||
_restAt = scrollTop();
|
||||
_restPage = pageStamp();
|
||||
_rest = ref ? { el: ref.el, top: ref.top, at: _restAt } : null;
|
||||
_rest = ref ? { el: ref.el, top: ref.top, at: _restAt, sec: ref.sec, secTop: ref.secTop } : null;
|
||||
}
|
||||
|
||||
/* -> the reference to correct against: the pre-mutation one where the engine leaves that to us, the
|
||||
@@ -428,8 +473,14 @@ function anchorFor() {
|
||||
* is shorter now, the browser clamps the write straight back and the reader keeps the offset they
|
||||
* already had. The element path below stays preferred where it survives, because it compensates
|
||||
* the height change the tick brought with it as well. */
|
||||
if (!_rest || !_rest.el.isConnected)
|
||||
return clamped ? { by: _restAt - at } : anchorRef();
|
||||
if (!_rest || !_rest.el.isConnected) {
|
||||
if (clamped) return { by: _restAt - at };
|
||||
/* the element is gone but its section is not — see anchorRef() for why that is the common
|
||||
* case rather than an edge one */
|
||||
if (_rest && _rest.sec && _rest.sec.isConnected && at === _restAt)
|
||||
return { el: _rest.sec, top: _rest.secTop, slack: 0 };
|
||||
return anchorRef();
|
||||
}
|
||||
/* THE READER MOVED, NOT THE PAGE. A reference taken at one offset says nothing about a document
|
||||
* seen from another: correcting against it would drag the page back to where the reader had
|
||||
* scrolled FROM. So an offset that is not the one the reference was captured at normally means
|
||||
@@ -455,7 +506,15 @@ function anchorFor() {
|
||||
*
|
||||
* Measured on the same harness with the reader flicking while the swap lands: the theme writes
|
||||
* no offset at all, before this change and after it. */
|
||||
if (at !== _rest.at && !clamped) return anchorRef();
|
||||
/* THE READER MOVED, SO THERE IS NOTHING TO PUT BACK — and taking a fresh reference here is worse
|
||||
* than taking none. `anchorRef()` reads a rect, and a scroll that has only just landed leaves
|
||||
* WebKit reporting the NEW `scrollTop` against the OLD layout: the reference then describes the
|
||||
* page from before the scroll, the correction a frame later measures the scroll itself, and the
|
||||
* reader is dragged back to where they started. Measured on the 24.10 stand at 1440, top layout,
|
||||
* Compact density: parked at 591, put back to 0, every run, on both the engine-anchoring path and
|
||||
* the fallback. A tick that lands right after a scroll compensates nothing; the next still moment
|
||||
* takes a reference that is true. */
|
||||
if (at !== _rest.at && !clamped) return null;
|
||||
/* HOW MUCH OF THE DRIFT IS ALREADY ACCOUNTED FOR. applyAnchor() refuses a correction bigger than
|
||||
* a viewport because a drift that size normally means the view replaced its whole subtree and
|
||||
* the reference is describing a page that no longer exists. A clamp is the one drift that big
|
||||
@@ -496,15 +555,65 @@ function anchorRef() {
|
||||
let y = 1;
|
||||
let el = document.elementFromPoint(x, y);
|
||||
const chrome = el && el.closest ? el.closest('[data-fs-chrome]') : null;
|
||||
if (chrome) {
|
||||
y = Math.max(1, Math.round(chrome.getBoundingClientRect().bottom) + 1);
|
||||
el = document.elementFromPoint(x, y);
|
||||
}
|
||||
if (!el || !host.contains(el)) return null;
|
||||
if (chrome) y = Math.max(1, Math.round(chrome.getBoundingClientRect().bottom) + 1);
|
||||
|
||||
/* THE HIT IS A SEARCH, NOT A SINGLE PROBE, and neither the host nor anything outside it counts.
|
||||
*
|
||||
* `elementFromPoint` answers with whatever is topmost, and two of those answers are useless here.
|
||||
* `#view` itself comes back wherever the point lands in a GAP — the margin between two sections,
|
||||
* the gutter of the Overview's two-column grid — and the host's own top does not move when a poll
|
||||
* changes something inside it, so a drift measured against it is zero on every tick, for ever.
|
||||
* And a point above the first section answers with `.fs-content`, which is not inside the host at
|
||||
* all: the first version returned null there without trying anywhere else, so on 25.12's Overview
|
||||
* at 1440 the theme had no reference AT ALL and a 120px growth moved the page 120px.
|
||||
*
|
||||
* So: take the whole STACK at the point (what a gap belongs to is directly underneath it), and if
|
||||
* that yields nothing inside the host, step down the viewport and ask again. */
|
||||
const floor = Math.max(1, Math.round(window.innerHeight || 800));
|
||||
const pick = (yy) => {
|
||||
if (typeof document.elementsFromPoint === 'function') {
|
||||
for (const cand of document.elementsFromPoint(x, yy))
|
||||
if (cand !== host && host.contains(cand)) return cand;
|
||||
return null;
|
||||
}
|
||||
const one = document.elementFromPoint(x, yy);
|
||||
return (one && one !== host && host.contains(one)) ? one : null;
|
||||
};
|
||||
el = null;
|
||||
for (let step = 0; step < 5 && !el; step++)
|
||||
el = pick(Math.min(floor - 1, y + (Math.round(floor * 0.12) * step)));
|
||||
if (!el) return null;
|
||||
const table = el.closest('.table.fs-dt');
|
||||
if (table) el = table.parentElement;
|
||||
if (!el || !host.contains(el)) return null;
|
||||
return { el, top: el.getBoundingClientRect().top };
|
||||
if (table) {
|
||||
const up = table.parentElement;
|
||||
el = (up && up !== host && host.contains(up)) ? up : table;
|
||||
}
|
||||
if (!el || el === host || !host.contains(el)) return null;
|
||||
/* `getClientRects()`, not `offsetParent` plus a `getComputedStyle` fallback: the question is only
|
||||
* "is this box in the layout at all", an element the table gate is holding out of it has no rects,
|
||||
* and resolving style on every settled pass to learn that would cost more than the rect already
|
||||
* read above. A box with no rects reports a top of 0 — a reference to nowhere. */
|
||||
if (!el.getClientRects().length) return null;
|
||||
/* AND A SECOND REFERENCE THAT SURVIVES THE TICK. `dom.content()` replaces a section's CHILDREN,
|
||||
* so the element the hit landed on is usually gone by the time the correction runs — and where
|
||||
* the tick also grew the page, nothing was clamped either, so the "give back what the engine
|
||||
* took" path has no number and the theme takes a fresh reference and measures a drift of zero.
|
||||
* That is a section swap nobody compensates: measured on 25.12's Overview at 1440 with the
|
||||
* engine's anchoring suppressed, the page moved 136px under the reader.
|
||||
*
|
||||
* The wrapper is what survives — the stock poll refreshes a `.cbi-section` in place and never
|
||||
* rebuilds it — so it is kept alongside, and used only when the precise reference is gone. */
|
||||
/* the nearest ANCESTOR that a tick does not replace — `closest()` on the element itself is not it:
|
||||
* where the hit already climbed to the `.cbi-section` (which is what happens on any page whose
|
||||
* section is one table), `closest('.cbi-section')` answers with that same element, and a fallback
|
||||
* that is the reference is no fallback at all. */
|
||||
let keep = el.parentElement;
|
||||
while (keep && keep !== host && !keep.classList.contains('cbi-section')
|
||||
&& !keep.classList.contains('cbi-map') && !keep.classList.contains('fs-ovl'))
|
||||
keep = keep.parentElement;
|
||||
if (!keep || keep === host || !host.contains(keep)) keep = null;
|
||||
return { el, top: el.getBoundingClientRect().top,
|
||||
sec: keep, secTop: keep ? keep.getBoundingClientRect().top : 0 };
|
||||
}
|
||||
|
||||
let _anchorPending = null;
|
||||
@@ -517,6 +626,58 @@ function anchorEnabled() {
|
||||
try { return localStorage.getItem('fsAnchor') !== 'off'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
/* ---- WHAT THE ENGINE'S OWN ANCHORING LEAVES BEHIND ----
|
||||
*
|
||||
* Scroll anchoring keeps a reference element still while things above it change size. It is not the
|
||||
* same promise as "a section can vanish and come back": `dom.content()` — every LuCI poll — empties
|
||||
* a container before it refills it, the offset is clamped into a document that is briefly shorter,
|
||||
* and what the engine does on the way back is its own business. Chromium lands exactly where it
|
||||
* started. WebKit OVERSHOOTS — measured on the 24.10 stand's Overview at 390 and at 1440, in both
|
||||
* layouts: a swap that grew a section by 120px moved the offset by 180, so the reader ended 60px
|
||||
* up the page on every tick. That is what a Safari user sees as the page creeping while they read.
|
||||
*
|
||||
* WHY THE OFFSET CANNOT ANSWER THIS, and why this is not a browser test. The first version compared
|
||||
* the offset with what it had been and gave back the difference; it never fired, because the offset
|
||||
* came back LARGER, not smaller. A `CSS.supports('overflow-anchor')` test cannot separate the two
|
||||
* behaviours either — WebKit shipped the property, so every engine now claims it — and a synthetic
|
||||
* probe that performs the collapse itself calls Firefox broken as well, because a real page puts
|
||||
* layout and a frame between the collapse and the refill and a probe does not. Both were measured,
|
||||
* both were wrong, and the second one cost Chromium and Firefox 15px of drift they did not have.
|
||||
*
|
||||
* So nothing is assumed: the element the reader was looking at is asked where it is now, TWO FRAMES
|
||||
* after the mutation — long enough for the engine to have finished its own correction. An engine
|
||||
* that got it right reports a drift of zero and this does nothing at all. What is left over is what
|
||||
* nobody put back, and it is given back here. The same guards as the main correction: not while the
|
||||
* reader scrolls, not across a navigation, and never more than a viewport — a drift that size means
|
||||
* the reference is describing a page that no longer exists. */
|
||||
let _lateFrame = 0;
|
||||
function lateDrift(ref) {
|
||||
/* the reference from BEFORE this tick, captured by the caller: `run()` re-remembers it on its way
|
||||
* through, and a reference taken after the mutation describes the page as the mutation left it —
|
||||
* the drift it would report is zero by construction */
|
||||
if (_lateFrame || !ref) return;
|
||||
_lateFrame = requestAnimationFrame(() => {
|
||||
_lateFrame = requestAnimationFrame(() => {
|
||||
_lateFrame = 0;
|
||||
/* NOT `scrolling()`: the engine's own compensation moves the offset and therefore starts
|
||||
* the motion sampler, so gating on that skipped every tick this exists for (measured —
|
||||
* the check reported "busy" on all of them). Two narrower questions instead: is the
|
||||
* reader driving (a gesture), and is the offset STREAMING (moving repeatedly, which a
|
||||
* one-shot compensation never does but a scroll always does). Either one means hands off. */
|
||||
if (!anchorEnabled() || Date.now() < _userUntil || (scrolling() && _steps > 1)) return;
|
||||
if (!ref.el.isConnected || _restPage !== pageStamp()) return;
|
||||
const drift = ref.el.getBoundingClientRect().top - ref.top;
|
||||
if (Math.abs(drift) < 1) return; /* the engine put it back */
|
||||
if (Math.abs(drift) > (window.innerHeight || 800)) return;
|
||||
const sc = scroller();
|
||||
const at = sc ? sc.scrollTop : window.scrollY;
|
||||
if (sc) sc.scrollTop = at + drift; else window.scrollTo(0, at + drift);
|
||||
/* the write may have been clamped short; the reader is where they are now */
|
||||
_restAt = scrollTop();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleAnchor(ref) {
|
||||
if (!ref || !anchorEnabled()) return;
|
||||
if (_anchorPending) return;
|
||||
@@ -535,6 +696,13 @@ function scheduleAnchor(ref) {
|
||||
}
|
||||
function applyAnchor(ref) {
|
||||
if (!ref) return;
|
||||
/* NOT INTO A MOVING PAGE. The correction is scheduled from the mutation and applied a frame
|
||||
* later, and the reader can start scrolling in between — the reference is then describing a page
|
||||
* they have already left, and putting them back on it is the jump this whole file exists to
|
||||
* prevent. `anchorRef()` refuses to TAKE a reference during a flick for the same reason; nothing
|
||||
* refused to USE one, and the gate caught it as a 231px correction landing inside a scroll on
|
||||
* all three engines once the reference stopped being re-read at apply time. */
|
||||
if (scrolling()) return;
|
||||
/* through scroller(), not a second probe of its own: the two asked the same question in the
|
||||
* same two lines and could already answer differently within one frame */
|
||||
const sc = scroller();
|
||||
@@ -588,9 +756,28 @@ function observeContent() {
|
||||
* see anchorFor(): where the engine does no anchoring of its own that reference comes from
|
||||
* the last still moment, because by the time this callback runs the poll has already moved
|
||||
* the page */
|
||||
const ref = anchorFor();
|
||||
/* ONE CORRECTION PER ENGINE, and which one depends on whether the engine is doing the job.
|
||||
*
|
||||
* Where it anchors, the immediate correction is not just redundant, it is harmful: the
|
||||
* reference it uses is read HERE, in the same instant the poll mutated the page, and after a
|
||||
* scroll WebKit hands back the new `scrollTop` before the layout that goes with it. The drift
|
||||
* then measures the reader's own move and the correction undoes it — measured on the 24.10
|
||||
* stand at 1440 in the top layout at Compact: the page went back to 0 from 591, every run.
|
||||
* What that engine needs is the RESIDUE, two frames later, once its own anchoring has run
|
||||
* and the layout is settled — which is lateDrift().
|
||||
*
|
||||
* Where it does not anchor, nobody else will put the reader back within the frame, so the
|
||||
* immediate correction stays, measured against the reference from the last still page. */
|
||||
const settled = _rest;
|
||||
const ref = ENGINE_ANCHORS ? null : anchorFor();
|
||||
run();
|
||||
scheduleAnchor(ref);
|
||||
/* one or the other, never a fallthrough from one to the other: where the engine does not
|
||||
* anchor, `anchorFor()` returning nothing means it has DECIDED not to correct this tick (the
|
||||
* reader moved, or there is no reference worth using), and handing that case to the deferred
|
||||
* path corrects it two frames later instead — measured as a 320px correction landing inside
|
||||
* a flick, on all three engines. */
|
||||
if (ENGINE_ANCHORS) lateDrift(settled);
|
||||
else scheduleAnchor(ref);
|
||||
});
|
||||
for (const host of [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]) {
|
||||
if (!host) continue;
|
||||
@@ -638,6 +825,17 @@ return baseclass.extend({
|
||||
scrolling,
|
||||
deferMeasurement,
|
||||
|
||||
/* -> the offset this file last took a reference at, or null before it has taken one.
|
||||
*
|
||||
* FOR THE GATES, and it is not a convenience: every correction here is measured against a
|
||||
* reference captured while the page was still, so a probe that grows the page before that
|
||||
* reference exists measures the guard rather than the anchor. There is no way to ask that from
|
||||
* outside — "is it scrolling" answers a different question, and answers "no" both before the
|
||||
* motion sampler starts and after it finishes, which in WebKit are 1.5 seconds apart. Waiting a
|
||||
* flat interval instead made tools/scroll-anchor.mjs report a jump on every WebKit run and none
|
||||
* on the other two engines, with the theme identical on all three. */
|
||||
restAt: () => _restAt,
|
||||
|
||||
/* "THE OFFSET IS MINE NOW, FORGET WHAT YOU REMEMBERED." Called by fs-router where it resets both
|
||||
* scrollers for an incoming page, and it is not a courtesy — without it the correction below
|
||||
* fights that reset. The router resets synchronously and stamps `body[data-page]` an await later,
|
||||
|
||||
Reference in new issue
Block a user