fix (scroll): virtual scroll debugging

This commit is contained in:
MickaelK
2023-12-05 22:13:57 +11:00
parent 6acffc65b1
commit caad0cbccd
2 changed files with 69 additions and 71 deletions

View File

@ -7,7 +7,7 @@ import { ApplicationError } from "../../lib/error.js";
import { toggle as toggleLoader } from "../../components/loader.js"; import { toggle as toggleLoader } from "../../components/loader.js";
import ctrlError from "../ctrl_error.js"; import ctrlError from "../ctrl_error.js";
import { createThing, allocateMemory, css } from "./thing.js"; import { createThing, css } from "./thing.js";
import { handleError, getFiles } from "./ctrl_filesystem_state.js"; import { handleError, getFiles } from "./ctrl_filesystem_state.js";
import { ls } from "./model_files.js"; import { ls } from "./model_files.js";
@ -37,12 +37,17 @@ export default async function(render) {
files: new Array(400).fill(1), files: new Array(400).fill(1),
}), 1000))), }), 1000))),
toggleLoader($page, false), toggleLoader($page, false),
rxjs.mergeMap(({ files }) => { rxjs.mergeMap(({ files }) => { // STEP1: setup the list of files
const BLOCK_SIZE = 8;
const COLUMN_PER_ROW = 2;
const FILE_HEIGHT = 160; const FILE_HEIGHT = 160;
const size = Math.min(files.length, BLOCK_SIZE * COLUMN_PER_ROW); const BLOCK_SIZE = Math.ceil(document.body.clientHeight / FILE_HEIGHT) + 1;
allocateMemory(BLOCK_SIZE * COLUMN_PER_ROW); // const BLOCK_SIZE = 7;
const COLUMN_PER_ROW = 2;
const VIRTUAL_SCROLL_MINIMUM_TRIGGER = 10;
let size = files.length;
if (size > VIRTUAL_SCROLL_MINIMUM_TRIGGER) {
size = BLOCK_SIZE * COLUMN_PER_ROW;
}
const $list = qs($page, ".list");
const $fs = document.createDocumentFragment(); const $fs = document.createDocumentFragment();
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
$fs.appendChild(createThing({ $fs.appendChild(createThing({
@ -51,43 +56,56 @@ export default async function(render) {
link: "/view/test.txt", link: "/view/test.txt",
})); }));
} }
const $list = qs($page, ".list"); animate($list, { time: 200, keyframes: slideYIn(5) });
$list.appendChild($fs);
/////////////////////////////////////////
// CASE 1: virtual scroll isn't enabled
if (files.length <= VIRTUAL_SCROLL_MINIMUM_TRIGGER) {
return rxjs.EMPTY;
}
/////////////////////////////////////////
// CASE 2: with virtual scroll
const $listBefore = qs($page, ".ifscroll-before"); const $listBefore = qs($page, ".ifscroll-before");
const $listAfter = qs($page, ".ifscroll-after"); const $listAfter = qs($page, ".ifscroll-after");
const height = (Math.ceil(files.length / COLUMN_PER_ROW) - BLOCK_SIZE) * FILE_HEIGHT;
const height = (Math.floor(files.length / COLUMN_PER_ROW) - BLOCK_SIZE) * FILE_HEIGHT; if (height > 33554400) {
console.log(`maximum CSS height reached, requested height ${height} is too large`);
}
const setHeight = (size) => { const setHeight = (size) => {
if (size < 0 || size > height) throw new ApplicationError(
"INTERNAL ERROR",
`assertion on size failed: size[${size}] height[${height}]`
);
$listBefore.style.height = `${size}px`; $listBefore.style.height = `${size}px`;
$listAfter.style.height = `${height - size}px`; $listAfter.style.height = `${height - size}px`;
}; };
setHeight(0); setHeight(0);
animate($list, { time: 200, keyframes: slideYIn(5) }); const top = ($node) => $node.getBoundingClientRect().top;
$list.appendChild($fs);
if (files.length === size) return rxjs.EMPTY;
return rxjs.of({ return rxjs.of({
files, files,
currentState: 0, currentState: 0,
BLOCK_SIZE, COLUMN_PER_ROW, FILE_HEIGHT,
setHeight,
$list, $list,
setHeight,
FILE_HEIGHT, BLOCK_SIZE, COLUMN_PER_ROW,
MARGIN: 35, // TODO: top($list) - top($list.closest(".scroll-y"));
}); });
}), }),
rxjs.mergeMap(({ rxjs.mergeMap(({
files, files,
BLOCK_SIZE, COLUMN_PER_ROW, FILE_HEIGHT, BLOCK_SIZE, COLUMN_PER_ROW, FILE_HEIGHT,
MARGIN,
currentState, currentState,
height, setHeight, height, setHeight,
$list, $list,
}) => rxjs.fromEvent( }) => rxjs.fromEvent($page.closest(".scroll-y"), "scroll", { passive: true }).pipe(
$page.parentElement.parentElement.parentElement, rxjs.map((e) => {
"scroll", { passive: true }, // 0-------------0-----------1-----------2-----------3 ....
).pipe( // [padding] $block1 $block2 $block3 ....
rxjs.map((e) => Math.min( const nextState = Math.floor((e.target.scrollTop - MARGIN) / FILE_HEIGHT);
Math.ceil(Math.max(0, e.target.scrollTop) / FILE_HEIGHT), return Math.max(nextState, 0);
// cap state value when BLOCK_SIZE is larger than minimum value. This is to }),
// prevent issues when scrolling fast to the bottom (aka diff > 1)
Math.ceil(files.length / COLUMN_PER_ROW) - BLOCK_SIZE,
)),
rxjs.distinctUntilChanged(), rxjs.distinctUntilChanged(),
rxjs.debounce(() => new rxjs.Observable((observer) => { rxjs.debounce(() => new rxjs.Observable((observer) => {
const id = requestAnimationFrame(() => observer.next()); const id = requestAnimationFrame(() => observer.next());
@ -97,31 +115,32 @@ export default async function(render) {
// STEP1: calculate the virtual scroll paramameters // STEP1: calculate the virtual scroll paramameters
let diff = nextState - currentState; let diff = nextState - currentState;
const diffSgn = Math.sign(diff); const diffSgn = Math.sign(diff);
if (Math.abs(diff) > BLOCK_SIZE) diff = diffSgn * BLOCK_SIZE; // fast scroll if (Math.abs(diff) > BLOCK_SIZE) { // diff is bound by BLOCK_SIZE
// we can't be moving more than what is on the screen
diff = diffSgn * BLOCK_SIZE;
}
let fileStart = nextState * COLUMN_PER_ROW; let fileStart = nextState * COLUMN_PER_ROW;
if (diffSgn > 0) { // => scroll down if (diffSgn > 0) { // => scroll down
// eg: files[15] BLOCK_SIZE=1 COLUMN_PER_ROW=1
// -----------[currentState:0]--------------------------[nextState:5]---------
// -----------[fileStart=5+1=6]
fileStart += BLOCK_SIZE * COLUMN_PER_ROW; fileStart += BLOCK_SIZE * COLUMN_PER_ROW;
// -----------[fileStart=6-min(5,1)=5]
fileStart -= Math.min(diff, BLOCK_SIZE) * COLUMN_PER_ROW; fileStart -= Math.min(diff, BLOCK_SIZE) * COLUMN_PER_ROW;
} }
let fileEnd = fileStart + diffSgn * diff * COLUMN_PER_ROW; let fileEnd = fileStart + diffSgn * diff * COLUMN_PER_ROW;
if (fileStart >= files.length) { // occur when BLOCK_SIZE is larger than its absolute minimum
if (fileStart >= files.length) throw new ApplicationError( return;
"INTERNAL_ERROR", }
`assert failed in virtual scroll range[${fileStart}:${fileEnd}] length[${files.length}]`, else if (fileEnd > files.length) {
); else if (fileEnd > files.length) {
// occur when files.length isn't a multiple of COLUMN_PER_ROW and // occur when files.length isn't a multiple of COLUMN_PER_ROW and
// we've scrolled to the bottom of the list // we've scrolled to the bottom of the list already
nextState = Math.ceil(files.length / COLUMN_PER_ROW) - BLOCK_SIZE - 1; nextState = Math.ceil(files.length / COLUMN_PER_ROW) - BLOCK_SIZE;
fileEnd = files.length; fileEnd = files.length - 1;
do { for (let i=0; i<COLUMN_PER_ROW; i++) {
// add some padding to fileEnd to balance the list to the // add some padding to fileEnd to balance the list to the
// nearest COLUMN_PER_ROW // nearest COLUMN_PER_ROW
fileEnd += 1; fileEnd += 1;
} while (fileEnd % COLUMN_PER_ROW !== 0); if (fileEnd % COLUMN_PER_ROW === 0) {
break
}
}
} }
// STEP2: create the new elements // STEP2: create the new elements
@ -139,8 +158,6 @@ export default async function(render) {
})); }));
n += 1; n += 1;
} }
// console.log(`n[${n}] state[${currentState} -> ${nextState}] files[${fileStart}:${fileEnd}]`)
if (n === 0) return;
// STEP3: update the DOM // STEP3: update the DOM
if (diffSgn > 0) { // scroll down if (diffSgn > 0) { // scroll down
@ -156,12 +173,4 @@ export default async function(render) {
)), )),
rxjs.catchError(ctrlError()), rxjs.catchError(ctrlError()),
)); ));
// feature2: fs in "search" mode
// TODO
}
function isInViewport(element) {
const rect = element.getBoundingClientRect();
return rect.bottom > 0;
} }

View File

@ -37,28 +37,17 @@ export function createThing({
link = "", link = "",
// permissions = {} // permissions = {}
}) { }) {
const $thing = $get(); const $thing = $tmpl.cloneNode(true);
if ($thing instanceof HTMLElement) { if (!$thing instanceof window.HTMLElement) throw new Error("assertion failed: $thing must be an HTMLELement");
const $label = $thing.querySelector(".component_filename .file-details > span"); const $label = $thing.querySelector(".component_filename .file-details > span");
if ($label instanceof HTMLElement) $label.textContent = name; if (!$label instanceof window.HTMLElement) throw new Error("assertion failed: $label must be an HTMLELement");
$thing?.querySelector("a")?.setAttribute("href", link);
$label.textContent = name;
$thing.querySelector(".component_checkbox").onclick = (e) => {
e.preventDefault();
e.stopPropagation();
console.log("CLICK");
} }
$thing.querySelector("a").setAttribute("href", link);
return $thing; return $thing;
} }
function $get() {
// the very first implementation was:
return $tmpl.cloneNode(true);
// the major issue was cloneNode is slow and would often make us miss an animationFrame. A much more
// efficient approach is to use a ring buffer of node we reuse as we scroll around
if (bufferIdx >= $tmplBuffer.length) bufferIdx = 0;
const $node = $tmplBuffer[bufferIdx];
bufferIdx += 1;
// console.log($node);
return $node;
}
let $tmplBuffer = [];
let bufferIdx = 0;
export function allocateMemory(size) {
$tmplBuffer = Array.apply(null, {length: size}).map(() => $tmpl.cloneNode(true))
}