mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
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.
503 lines
20 KiB
TypeScript
503 lines
20 KiB
TypeScript
import { _get, _post, addLoader, removeLoader, throttle } from "./common";
|
|
import { Search, SearchConfiguration } from "./search";
|
|
import "@af-utils/scrollend-polyfill";
|
|
|
|
declare var window: GlobalWindow;
|
|
|
|
export class RecordCounter {
|
|
private _container: HTMLElement;
|
|
private _totalRecords: HTMLElement;
|
|
private _loadedRecords: HTMLElement;
|
|
private _shownRecords: HTMLElement;
|
|
private _selectedRecords: HTMLElement;
|
|
private _total: number;
|
|
private _loaded: number;
|
|
private _shown: number;
|
|
private _selected: number;
|
|
constructor(container: HTMLElement) {
|
|
this._container = container;
|
|
this._container.innerHTML = `
|
|
<span class="records-total"></span>
|
|
<span class="records-loaded"></span>
|
|
<span class="records-shown"></span>
|
|
<span class="records-selected"></span>
|
|
`;
|
|
this._totalRecords = document.getElementsByClassName("records-total")[0] as HTMLElement;
|
|
this._loadedRecords = document.getElementsByClassName("records-loaded")[0] as HTMLElement;
|
|
this._shownRecords = document.getElementsByClassName("records-shown")[0] as HTMLElement;
|
|
this._selectedRecords = document.getElementsByClassName("records-selected")[0] as HTMLElement;
|
|
this.total = 0;
|
|
this.loaded = 0;
|
|
this.shown = 0;
|
|
}
|
|
|
|
reset() {
|
|
this.total = 0;
|
|
this.loaded = 0;
|
|
this.shown = 0;
|
|
this.selected = 0;
|
|
}
|
|
|
|
// Sets the total using a PageCountDTO-returning API endpoint.
|
|
getTotal(endpoint: string) {
|
|
_get(endpoint, null, (req: XMLHttpRequest) => {
|
|
if (req.readyState != 4 || req.status != 200) return;
|
|
this.total = req.response["count"] as number;
|
|
});
|
|
}
|
|
|
|
get total(): number { return this._total; }
|
|
set total(v: number) {
|
|
this._total = v;
|
|
this._totalRecords.textContent = window.lang.var("strings", "totalRecords", `${v}`);
|
|
}
|
|
|
|
get loaded(): number { return this._loaded; }
|
|
set loaded(v: number) {
|
|
this._loaded = v;
|
|
this._loadedRecords.textContent = window.lang.var("strings", "loadedRecords", `${v}`);
|
|
}
|
|
|
|
get shown(): number { return this._shown; }
|
|
set shown(v: number) {
|
|
this._shown = v;
|
|
this._shownRecords.textContent = window.lang.var("strings", "shownRecords", `${v}`);
|
|
}
|
|
|
|
get selected(): number { return this._selected; }
|
|
set selected(v: number) {
|
|
this._selected = v;
|
|
if (v == 0) this._selectedRecords.textContent = ``;
|
|
else this._selectedRecords.textContent = window.lang.var("strings", "selectedRecords", `${v}`);
|
|
}
|
|
}
|
|
|
|
export interface PaginatedListConfig {
|
|
loader: HTMLElement;
|
|
loadMoreButton: HTMLButtonElement;
|
|
loadAllButton: HTMLButtonElement;
|
|
refreshButton: HTMLButtonElement;
|
|
filterArea: HTMLElement;
|
|
searchOptionsHeader: HTMLElement;
|
|
searchBox: HTMLInputElement;
|
|
recordCounter: HTMLElement;
|
|
totalEndpoint: string;
|
|
getPageEndpoint: string;
|
|
itemsPerPage: number;
|
|
maxItemsLoadedForSearch: number;
|
|
newElementsFromPage: (resp: paginatedDTO) => void;
|
|
updateExistingElementsFromPage: (resp: paginatedDTO) => void;
|
|
defaultSortField: string;
|
|
defaultSortAscending: boolean;
|
|
pageLoadCallback?: (req: XMLHttpRequest) => void;
|
|
}
|
|
|
|
export abstract class PaginatedList {
|
|
protected _c: PaginatedListConfig;
|
|
|
|
// Container to append items to.
|
|
protected _container: HTMLElement;
|
|
// List of visible IDs (i.e. those set with setVisibility).
|
|
protected _visible: string[];
|
|
// Infinite-scroll related data.
|
|
// Implementation partially based on this blog post, thank you Miina Lervik:
|
|
// https://www.bekk.christmas/post/2021/02/how-to-lazy-render-large-data-tables-to-up-performance
|
|
protected _scroll = {
|
|
rowHeight: 0,
|
|
screenHeight: 0,
|
|
// Render this many screen's worth of content below the viewport.
|
|
renderNExtraScreensWorth: 3,
|
|
rendered: 0,
|
|
initialRenderCount: 0,
|
|
scrollLoading: false,
|
|
// Used to calculate scroll speed, so more pages are loaded when scrolling fast.
|
|
lastScrollY: 0,
|
|
};
|
|
|
|
protected _search: Search;
|
|
|
|
protected _counter: RecordCounter;
|
|
|
|
protected _hasLoaded: boolean;
|
|
protected _lastLoad: number;
|
|
protected _page: number = 0;
|
|
protected _lastPage: boolean;
|
|
get lastPage(): boolean { return this._lastPage };
|
|
set lastPage(v: boolean) {
|
|
this._lastPage = v;
|
|
if (v) {
|
|
this._c.loadAllButton.classList.add("unfocused");
|
|
this._c.loadMoreButton.textContent = window.lang.strings("noMoreResults");
|
|
this._search.setServerSearchButtonsDisabled(this._search.inServerSearch);
|
|
this._c.loadMoreButton.disabled = true;
|
|
} else {
|
|
this._c.loadMoreButton.textContent = window.lang.strings("loadMore");
|
|
this._c.loadMoreButton.disabled = false;
|
|
this._search.setServerSearchButtonsDisabled(false);
|
|
this._c.loadAllButton.classList.remove("unfocused");
|
|
}
|
|
}
|
|
|
|
protected _previousPageSize = 0;
|
|
|
|
// Stores a PaginatedReqDTO-implementing thing.
|
|
// A standard PaginatedReqDTO will be overridden entirely,
|
|
// but a ServerSearchDTO will keep it's fields.
|
|
protected _searchParams: PaginatedReqDTO;
|
|
defaultParams = (): PaginatedReqDTO => {
|
|
return {
|
|
limit: 0,
|
|
page: 0,
|
|
sortByField: "",
|
|
ascending: false
|
|
};
|
|
}
|
|
|
|
constructor(c: PaginatedListConfig) {
|
|
this._c = c;
|
|
this._counter = new RecordCounter(this._c.recordCounter);
|
|
this._hasLoaded = false;
|
|
|
|
this._c.loadMoreButton.onclick = () => this.loadMore(null, false);
|
|
this._c.loadAllButton.onclick = () => {
|
|
addLoader(this._c.loadAllButton, true);
|
|
this.loadMore(null, true);
|
|
};
|
|
/* this._keepSearchingButton.onclick = () => {
|
|
addLoader(this._keepSearchingButton, true);
|
|
this.loadMore(() => removeLoader(this._keepSearchingButton, true));
|
|
}; */
|
|
// Since this.reload doesn't exist, we need an arrow function to wrap it.
|
|
this._c.refreshButton.onclick = () => this.reload();
|
|
}
|
|
|
|
initSearch = (searchConfig: SearchConfiguration) => {
|
|
const previousCallback = searchConfig.onSearchCallback;
|
|
searchConfig.onSearchCallback = (visibleCount: number, newItems: boolean, loadAll: boolean) => {
|
|
this._counter.shown = visibleCount;
|
|
|
|
// if (this._search.inSearch && !this.lastPage) this._c.loadAllButton.classList.remove("unfocused");
|
|
// else this._c.loadAllButton.classList.add("unfocused");
|
|
|
|
if (this._search.sortField == this._c.defaultSortField && this._search.ascending == this._c.defaultSortAscending) {
|
|
this._search.setServerSearchButtonsDisabled(!this._search.inSearch)
|
|
} else {
|
|
this._search.setServerSearchButtonsDisabled(false)
|
|
}
|
|
|
|
// FIXME: Figure out why this makes sense and make it clearer.
|
|
if ((visibleCount < this._c.itemsPerPage && this._counter.loaded < this._c.maxItemsLoadedForSearch && !this.lastPage) || loadAll) {
|
|
if (!newItems ||
|
|
this._previousPageSize != visibleCount ||
|
|
(visibleCount == 0 && !this.lastPage) ||
|
|
loadAll
|
|
) {
|
|
this.loadMore(() => {}, loadAll);
|
|
}
|
|
}
|
|
this._previousPageSize = visibleCount;
|
|
if (previousCallback) previousCallback(visibleCount, newItems, loadAll);
|
|
};
|
|
const previousServerSearch = searchConfig.searchServer;
|
|
searchConfig.searchServer = (params: PaginatedReqDTO, newSearch: boolean) => {
|
|
this._searchParams = params;
|
|
if (newSearch) this.reload();
|
|
else this.loadMore(null, false);
|
|
|
|
if (previousServerSearch) previousServerSearch(params, newSearch);
|
|
};
|
|
searchConfig.clearServerSearch = () => {
|
|
console.log("Clearing server search");
|
|
this._page = 0;
|
|
this.reload();
|
|
}
|
|
this._search = new Search(searchConfig);
|
|
this._search.generateFilterList();
|
|
this.lastPage = false;
|
|
};
|
|
|
|
// Sets the elements with "name"s in "elements" as visible or not.
|
|
setVisibilityNaive = (elements: string[], visible: boolean) => {
|
|
let timer = this._search.timeSearches ? performance.now() : null;
|
|
if (visible) this._visible = elements;
|
|
else this._visible = this._search.ordering.filter(v => !elements.includes(v));
|
|
const frag = document.createDocumentFragment()
|
|
for (let i = 0; i < this._visible.length; i++) {
|
|
frag.appendChild(this._search.items[this._visible[i]].asElement())
|
|
}
|
|
this._container.replaceChildren(frag);
|
|
if (this._search.timeSearches) {
|
|
const totalTime = performance.now() - timer;
|
|
console.log(`setVisibility took ${totalTime}ms`);
|
|
}
|
|
}
|
|
|
|
// FIXME: On reload, load enough pages to fill required space.
|
|
// FIXME: Might have broken _counter.shown!
|
|
// Sets the elements with "name"s in "elements" as visible or not.
|
|
// appendedItems==true implies "elements" is the previously rendered elements plus some new ones on the end. Knowing this means the page's infinite scroll doesn't have to be reset.
|
|
setVisibility = (elements: string[], visible: boolean, appendedItems: boolean = false) => {
|
|
let timer = this._search.timeSearches ? performance.now() : null;
|
|
if (visible) this._visible = elements;
|
|
else this._visible = this._search.ordering.filter(v => !elements.includes(v));
|
|
if (this._visible.length == 0) return;
|
|
|
|
if (!appendedItems) {
|
|
// 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._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;
|
|
if (appendedItems) {
|
|
baseIndex = this._scroll.rendered;
|
|
}
|
|
const frag = document.createDocumentFragment()
|
|
for (let i = baseIndex; i < this._scroll.initialRenderCount; i++) {
|
|
frag.appendChild(this._search.items[this._visible[i]].asElement())
|
|
}
|
|
this._scroll.rendered = Math.max(baseIndex, this._scroll.initialRenderCount);
|
|
// appendChild over replaceChildren because there's already elements on the DOM
|
|
this._container.appendChild(frag);
|
|
|
|
if (this._search.timeSearches) {
|
|
const totalTime = performance.now() - timer;
|
|
console.log(`setVisibility took ${totalTime}ms`);
|
|
}
|
|
}
|
|
|
|
// Computes required scroll info, requiring one on-DOM item. Should be computed on page resize and this._visible change.
|
|
_computeScrollInfo = () => {
|
|
this._scroll.screenHeight = Math.max(
|
|
document.documentElement.clientHeight,
|
|
window.innerHeight || 0
|
|
);
|
|
|
|
this._scroll.rowHeight = this._search.items[this._visible[0]].asElement().offsetHeight;
|
|
}
|
|
|
|
// 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.
|
|
maximumItemsToRender = (scrollY: number): number => {
|
|
const bottomScroll = scrollY + ((this._scroll.renderNExtraScreensWorth+1)*this._scroll.screenHeight);
|
|
const bottomIdx = Math.floor(bottomScroll / this._scroll.rowHeight);
|
|
return bottomIdx;
|
|
}
|
|
|
|
// Removes all elements, and reloads the first page.
|
|
// FIXME: Share more code between reload and loadMore, and go over the logic, it's messy.
|
|
public abstract reload: () => void;
|
|
protected _reload = (
|
|
callback?: (req: XMLHttpRequest) => void
|
|
) => {
|
|
this._lastLoad = Date.now();
|
|
this.lastPage = false;
|
|
|
|
this._counter.reset();
|
|
this._counter.getTotal(this._c.totalEndpoint);
|
|
|
|
// Reload all currently visible elements, i.e. Load a new page of size (limit*(page+1)).
|
|
let limit = this._c.itemsPerPage;
|
|
if (this._page != 0) {
|
|
limit *= this._page+1;
|
|
}
|
|
|
|
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
|
params.limit = limit;
|
|
params.page = 0;
|
|
if (params.sortByField == "") {
|
|
params.sortByField = this._c.defaultSortField;
|
|
params.ascending = this._c.defaultSortAscending;
|
|
}
|
|
|
|
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
|
|
if (req.readyState != 4) return;
|
|
if (req.status != 200) {
|
|
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
|
if (callback) callback(req);
|
|
return;
|
|
}
|
|
|
|
this._hasLoaded = true;
|
|
// Allow refreshes every 15s
|
|
this._c.refreshButton.disabled = true;
|
|
setTimeout(() => this._c.refreshButton.disabled = false, 15000);
|
|
|
|
let resp = req.response as paginatedDTO;
|
|
|
|
this.lastPage = resp.last_page;
|
|
|
|
this._c.updateExistingElementsFromPage(resp);
|
|
|
|
this._counter.loaded = this._search.ordering.length;
|
|
|
|
this._search.onSearchBoxChange(true);
|
|
if (this._search.inSearch) {
|
|
// this._c.loadAllButton.classList.remove("unfocused");
|
|
} else {
|
|
this._counter.shown = this._counter.loaded;
|
|
this.setVisibility(this._search.ordering, true);
|
|
// this._search.showHideNotFoundPanel(false);
|
|
}
|
|
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
|
if (callback) callback(req);
|
|
}, true);
|
|
}
|
|
|
|
// Loads the next page. If "loadAll", all pages will be loaded until the last is reached.
|
|
public abstract loadMore: (callback: () => void, loadAll: boolean) => void;
|
|
protected _loadMore = (
|
|
loadAll: boolean = false,
|
|
callback?: (req: XMLHttpRequest) => void
|
|
) => {
|
|
this._lastLoad = Date.now();
|
|
this._c.loadMoreButton.disabled = true;
|
|
const timeout = setTimeout(() => {
|
|
this._c.loadMoreButton.disabled = false;
|
|
}, 1000);
|
|
this._page += 1;
|
|
|
|
let params = this._search.inServerSearch ? this._searchParams : this.defaultParams();
|
|
params.limit = this._c.itemsPerPage;
|
|
params.page = this._page;
|
|
if (params.sortByField == "") {
|
|
params.sortByField = this._c.defaultSortField;
|
|
params.ascending = this._c.defaultSortAscending;
|
|
}
|
|
|
|
_post(this._c.getPageEndpoint, params, (req: XMLHttpRequest) => {
|
|
if (req.readyState != 4) return;
|
|
if (req.status != 200) {
|
|
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
|
if (callback) callback(req);
|
|
return;
|
|
}
|
|
|
|
let resp = req.response as paginatedDTO;
|
|
|
|
// Check before setting this.lastPage so we have a chance to cancel the timeout.
|
|
if (resp.last_page) {
|
|
clearTimeout(timeout);
|
|
removeLoader(this._c.loadAllButton);
|
|
}
|
|
|
|
this.lastPage = resp.last_page;
|
|
|
|
this._c.newElementsFromPage(resp);
|
|
|
|
this._counter.loaded = this._search.ordering.length;
|
|
|
|
if (this._search.inSearch || loadAll) {
|
|
if (this.lastPage) {
|
|
loadAll = false;
|
|
}
|
|
this._search.onSearchBoxChange(true, loadAll);
|
|
} else {
|
|
// Since results come to us ordered already, we can assume "ordering"
|
|
// will be identical to pre-page-load but with extra elements at the end,
|
|
// allowing infinite scroll to continue
|
|
this.setVisibility(this._search.ordering, true, true);
|
|
this._search.setNotFoundPanelVisibility(false);
|
|
}
|
|
if (this._c.pageLoadCallback) this._c.pageLoadCallback(req);
|
|
if (callback) callback(req);
|
|
}, true)
|
|
}
|
|
|
|
loadNItems = (n: number) => {
|
|
const cb = () => {
|
|
if (this._counter.loaded > n) return;
|
|
this.loadMore(cb, false);
|
|
}
|
|
cb();
|
|
}
|
|
|
|
// As reloading can disrupt long-scrolling, this function will only do it if you're at the top of the page, essentially.
|
|
public reloadIfNotInScroll = () => {
|
|
if (this.maximumItemsToRender(window.scrollY) < this._scroll.initialRenderCount) {
|
|
return this.reload();
|
|
}
|
|
}
|
|
|
|
|
|
_detectScroll = () => {
|
|
if (!this._hasLoaded || this._scroll.scrollLoading) return;
|
|
if (this._visible.length == 0) return;
|
|
const scrollY = window.scrollY;
|
|
const scrollSpeed = scrollY - this._scroll.lastScrollY;
|
|
this._scroll.lastScrollY = scrollY;
|
|
// If you've scrolled back up, do nothing
|
|
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 frag = document.createDocumentFragment();
|
|
for (let i = this._scroll.rendered; i < realEndIdx; i++) {
|
|
frag.appendChild(this._search.items[this._visible[i]].asElement());
|
|
}
|
|
this._scroll.rendered = realEndIdx;
|
|
this._container.appendChild(frag);
|
|
|
|
if (endIdx >= this._visible.length) {
|
|
if (this.lastPage || this._lastLoad + 500 > Date.now()) return;
|
|
this._scroll.scrollLoading = true;
|
|
const cb = () => {
|
|
if (this._visible.length < endIdx && !this.lastPage) {
|
|
this.loadMore(cb, false)
|
|
return;
|
|
}
|
|
|
|
this._scroll.scrollLoading = false;
|
|
this._detectScroll();
|
|
};
|
|
cb();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Should be assigned to window.onscroll whenever the list is in view.
|
|
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);
|
|
}
|
|
}
|
|
|
|
|