accounts: fix infinite scroll over-loading, use scrollend+polyfill

calculation for number of rows to be drawn was wrong, fixed now. To
compensate for overshooting with fast scrolling, speed is calculated
using previous scrollY in rows/scroll, and used to render more rows.
Also, the "scrollend" event is used to load more at the end of a scroll
always. Since this isn't available on safari/webkit(2gtk), a polyfill
has been added.
This commit is contained in:
Harvey Tindall
2025-05-26 21:52:31 +01:00
parent ac56ad1400
commit 4dcec4b9c7
6 changed files with 82 additions and 26 deletions
+12
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@ts-stack/markdown": "^1.4.0", "@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"a17t": "^0.10.1", "a17t": "^0.10.1",
@@ -36,6 +37,12 @@
"esbuild": "^0.18.20" "esbuild": "^0.18.20"
} }
}, },
"node_modules/@af-utils/scrollend-polyfill": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz",
"integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ==",
"license": "MIT"
},
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -7293,6 +7300,11 @@
} }
}, },
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": {
"version": "0.0.14",
"resolved": "https://registry.npmjs.org/@af-utils/scrollend-polyfill/-/scrollend-polyfill-0.0.14.tgz",
"integrity": "sha512-pThXK3XqbWeJHJJAEzhNqCEgOiZ7Flk/Wj/uM6+TGJuA/3n/NeKP3C+5o4jt79i46Cc18iA0kJaMd056GQTfYQ=="
},
"@alloc/quick-lru": { "@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+1
View File
@@ -17,6 +17,7 @@
}, },
"homepage": "https://github.com/hrfee/jfa-go#readme", "homepage": "https://github.com/hrfee/jfa-go#readme",
"dependencies": { "dependencies": {
"@af-utils/scrollend-polyfill": "^0.0.14",
"@ts-stack/markdown": "^1.4.0", "@ts-stack/markdown": "^1.4.0",
"@types/node": "^20.3.0", "@types/node": "^20.3.0",
"a17t": "^0.10.1", "a17t": "^0.10.1",
+7 -4
View File
@@ -127,7 +127,7 @@ let isInviteURL = window.invites.isInviteURL();
let isAccountURL = accounts.isAccountURL(); let isAccountURL = accounts.isAccountURL();
// load tabs // load tabs
const tabs: { id: string, url: string, reloader: () => void }[] = [ const tabs: { id: string, url: string, reloader: () => void, unloader?: () => void }[] = [
{ {
id: "invites", id: "invites",
url: "", url: "",
@@ -148,16 +148,19 @@ const tabs: { id: string, url: string, reloader: () => void }[] = [
// Don't keep loading the same item on every tab refresh // Don't keep loading the same item on every tab refresh
isAccountURL = false; isAccountURL = false;
} }
window.onscroll = accounts.detectScroll; accounts.bindPageEvents();
}), }),
unloader: accounts.unbindPageEvents
}, },
{ {
id: "activity", id: "activity",
url: "activity", url: "activity",
reloader: () => { reloader: () => {
activity.reload() activity.reload()
window.onscroll = activity.detectScroll; activity.bindPageEvents();
}, },
unloader: activity.unbindPageEvents
}, },
{ {
id: "settings", id: "settings",
@@ -171,7 +174,7 @@ const defaultTab = tabs[0];
window.tabs = new Tabs(); window.tabs = new Tabs();
for (let tab of tabs) { for (let tab of tabs) {
window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader); window.tabs.addTab(tab.id, window.pages.Admin + "/" + tab.url, null, tab.reloader, tab.unloader || null);
} }
let matchedTab = false let matchedTab = false
+58 -19
View File
@@ -1,5 +1,6 @@
import { _get, _post, addLoader, removeLoader, throttle } from "./common"; import { _get, _post, addLoader, removeLoader, throttle } from "./common";
import { Search, SearchConfiguration } from "./search"; import { Search, SearchConfiguration } from "./search";
import "@af-utils/scrollend-polyfill";
declare var window: GlobalWindow; declare var window: GlobalWindow;
@@ -106,10 +107,11 @@ export abstract class PaginatedList {
screenHeight: 0, screenHeight: 0,
// Render this many screen's worth of content below the viewport. // Render this many screen's worth of content below the viewport.
renderNExtraScreensWorth: 3, renderNExtraScreensWorth: 3,
rowsOnPage: 0,
rendered: 0, rendered: 0,
initialRenderCount: 0, initialRenderCount: 0,
scrollLoading: false scrollLoading: false,
// Used to calculate scroll speed, so more pages are loaded when scrolling fast.
lastScrollY: 0,
}; };
protected _search: Search; protected _search: Search;
@@ -230,7 +232,6 @@ export abstract class PaginatedList {
} }
} }
// FIXME: Call on window resize/zoom
// FIXME: On reload, load enough pages to fill required space. // FIXME: On reload, load enough pages to fill required space.
// FIXME: Might have broken _counter.shown! // FIXME: Might have broken _counter.shown!
// Sets the elements with "name"s in "elements" as visible or not. // Sets the elements with "name"s in "elements" as visible or not.
@@ -241,17 +242,20 @@ export abstract class PaginatedList {
else this._visible = this._search.ordering.filter(v => !elements.includes(v)); else this._visible = this._search.ordering.filter(v => !elements.includes(v));
if (this._visible.length == 0) return; if (this._visible.length == 0) return;
this._scroll.screenHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0
);
if (!appendedItems) { if (!appendedItems) {
// Wipe old elements and render 1 new one, so we can take the element height. // Wipe old elements and render 1 new one, so we can take the element height.
this._container.replaceChildren(this._search.items[this._visible[0]].asElement()) this._container.replaceChildren(this._search.items[this._visible[0]].asElement())
} }
this.computeScrollInfo(); this._computeScrollInfo();
// Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it.
this._scroll.initialRenderCount = Math.floor(Math.min(
this._visible.length,
Math.max(
((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight)/this._scroll.rowHeight,
this._c.itemsPerPage)
));
let baseIndex = 1; let baseIndex = 1;
if (appendedItems) { if (appendedItems) {
@@ -272,21 +276,20 @@ export abstract class PaginatedList {
} }
// Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change. // Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change.
computeScrollInfo = () => { _computeScrollInfo = () => {
this._scroll.screenHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight || 0
);
this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight; this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
// We want to have _scroll.renderNScreensWorth*_scroll.screenHeight or more elements rendered always.
this._scroll.rowsOnPage = Math.floor(this._scroll.screenHeight / this._scroll.rowHeight);
// Initial render of min(_visible.length, max(rowsOnPage*renderNExtraScreensWorth, itemsPerPage)), skipping 1 as we already did it.
this._scroll.initialRenderCount = Math.min(this._visible.length, Math.max((this._scroll.renderNExtraScreensWorth+1)*this._scroll.rowsOnPage, this._c.itemsPerPage));
} }
// returns the item index to render up to for the given scroll position. // returns the item index to render up to for the given scroll position.
// might return a value greater than this._visible.length, indicating a need for a page load. // might return a value greater than this._visible.length, indicating a need for a page load.
maximumItemsToRender = (scrollY: number): number => { maximumItemsToRender = (scrollY: number): number => {
const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight); const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight);
const bottomIdx = Math.floor(bottomScroll / this._scroll.rowsOnPage); const bottomIdx = Math.floor(bottomScroll / this._scroll.rowHeight);
return bottomIdx; return bottomIdx;
} }
@@ -425,12 +428,24 @@ export abstract class PaginatedList {
} }
} }
_detectScroll = () => { _detectScroll = () => {
if (!this._hasLoaded || this._scroll.scrollLoading) return; if (!this._hasLoaded || this._scroll.scrollLoading) return;
if (this._visible.length == 0) return; if (this._visible.length == 0) return;
const endIdx = this.maximumItemsToRender(window.scrollY); const scrollY = window.scrollY;
const scrollSpeed = scrollY - this._scroll.lastScrollY;
this._scroll.lastScrollY = scrollY;
// If you've scrolled back up, do nothing // If you've scrolled back up, do nothing
if (endIdx <= this._scroll.rendered) return; if (scrollSpeed < 0) return;
let endIdx = this.maximumItemsToRender(scrollY);
// Throttling this function means we might not catch up in time if the user scrolls fast,
// so we calculate the scroll speed (in rows/call) from the previous scrollY value.
// This still might not be enough, so hackily we'll just scale it up.
// With onscrollend, this is less necessary, but with both I wasn't able to hit the bottom of the page on my mouse.
const rowsPerScroll = Math.round((scrollSpeed / this._scroll.rowHeight));
// Render extra pages depending on scroll speed
endIdx += rowsPerScroll*2;
const realEndIdx = Math.min(endIdx, this._visible.length); const realEndIdx = Math.min(endIdx, this._visible.length);
const frag = document.createDocumentFragment(); const frag = document.createDocumentFragment();
@@ -448,6 +463,7 @@ export abstract class PaginatedList {
this.loadMore(cb, false) this.loadMore(cb, false)
return; return;
} }
this._scroll.scrollLoading = false; this._scroll.scrollLoading = false;
this._detectScroll(); this._detectScroll();
}; };
@@ -458,6 +474,29 @@ export abstract class PaginatedList {
// Should be assigned to window.onscroll whenever the list is in view. // Should be assigned to window.onscroll whenever the list is in view.
detectScroll = throttle(this._detectScroll, 200); detectScroll = throttle(this._detectScroll, 200);
computeScrollInfo = throttle(this._computeScrollInfo, 200);
// Should be called in window resize
redrawScroll = throttle(() => {
// FIXME: Make sure this is enough when rows resize, and that we don't need to re-setVisibility.
this._computeScrollInfo();
// this.setVisibility(this._visible, true, false);
}, 200);
// bindPageEvents binds window event handlers for when this list/tab containing it is visible.
bindPageEvents = () => {
window.addEventListener("scroll", this.detectScroll);
// Not available on safari, we include a polyfill though.
window.addEventListener("scrollend", this.detectScroll);
window.addEventListener("resize", this.redrawScroll);
};
unbindPageEvents = () => {
window.removeEventListener("scroll", this.detectScroll);
window.removeEventListener("scrollend", this.detectScroll);
window.removeEventListener("resize", this.redrawScroll);
}
} }
+2 -1
View File
@@ -24,7 +24,7 @@ export class Tabs implements Tabs {
}); });
} }
addTab = (tabID: string, url: string, preFunc = () => void {}, postFunc = () => void {},) => { addTab = (tabID: string, url: string, preFunc = () => void {}, postFunc = () => void {}, unloadFunc = () => void {}) => {
let tab: Tab = { let tab: Tab = {
page: null, page: null,
tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement, tabEl: document.getElementById("tab-" + tabID) as HTMLDivElement,
@@ -50,6 +50,7 @@ export class Tabs implements Tabs {
tab.buttonEl.classList.remove("active"); tab.buttonEl.classList.remove("active");
tab.buttonEl.classList.remove("~urge"); tab.buttonEl.classList.remove("~urge");
tab.tabEl.classList.add("unfocused"); tab.tabEl.classList.add("unfocused");
if (unloadFunc) unloadFunc();
return true; return true;
}, },
shouldSkip: () => false, shouldSkip: () => false,
+1 -1
View File
@@ -91,7 +91,7 @@ declare interface NotificationBox {
declare interface Tabs { declare interface Tabs {
current: string; current: string;
addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void) => void; addTab: (tabID: string, url: string, preFunc?: () => void, postFunc?: () => void, unloadFunc?: () => void) => void;
switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void; switch: (tabID: string, noRun?: boolean, keepURL?: boolean) => void;
} }