diff --git a/public/assets/pages/filespage/ctrl_filesystem.js b/public/assets/pages/filespage/ctrl_filesystem.js index dc2e3e84..ebdebaf6 100644 --- a/public/assets/pages/filespage/ctrl_filesystem.js +++ b/public/assets/pages/filespage/ctrl_filesystem.js @@ -7,7 +7,7 @@ import { ApplicationError } from "../../lib/error.js"; import { toggle as toggleLoader } from "../../components/loader.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 { ls } from "./model_files.js"; @@ -37,12 +37,17 @@ export default async function(render) { files: new Array(400).fill(1), }), 1000))), toggleLoader($page, false), - rxjs.mergeMap(({ files }) => { - const BLOCK_SIZE = 8; - const COLUMN_PER_ROW = 2; + rxjs.mergeMap(({ files }) => { // STEP1: setup the list of files const FILE_HEIGHT = 160; - const size = Math.min(files.length, BLOCK_SIZE * COLUMN_PER_ROW); - allocateMemory(BLOCK_SIZE * COLUMN_PER_ROW); + const BLOCK_SIZE = Math.ceil(document.body.clientHeight / FILE_HEIGHT) + 1; + // 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(); for (let i = 0; i < size; i++) { $fs.appendChild(createThing({ @@ -51,43 +56,56 @@ export default async function(render) { 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 $listAfter = qs($page, ".ifscroll-after"); - - const height = (Math.floor(files.length / COLUMN_PER_ROW) - BLOCK_SIZE) * FILE_HEIGHT; + const height = (Math.ceil(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) => { + if (size < 0 || size > height) throw new ApplicationError( + "INTERNAL ERROR", + `assertion on size failed: size[${size}] height[${height}]` + ); $listBefore.style.height = `${size}px`; $listAfter.style.height = `${height - size}px`; }; setHeight(0); - animate($list, { time: 200, keyframes: slideYIn(5) }); - $list.appendChild($fs); - if (files.length === size) return rxjs.EMPTY; + const top = ($node) => $node.getBoundingClientRect().top; return rxjs.of({ files, currentState: 0, - BLOCK_SIZE, COLUMN_PER_ROW, FILE_HEIGHT, - setHeight, $list, + setHeight, + FILE_HEIGHT, BLOCK_SIZE, COLUMN_PER_ROW, + MARGIN: 35, // TODO: top($list) - top($list.closest(".scroll-y")); }); }), rxjs.mergeMap(({ files, BLOCK_SIZE, COLUMN_PER_ROW, FILE_HEIGHT, + MARGIN, currentState, height, setHeight, $list, - }) => rxjs.fromEvent( - $page.parentElement.parentElement.parentElement, - "scroll", { passive: true }, - ).pipe( - rxjs.map((e) => Math.min( - Math.ceil(Math.max(0, e.target.scrollTop) / FILE_HEIGHT), - // 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.fromEvent($page.closest(".scroll-y"), "scroll", { passive: true }).pipe( + rxjs.map((e) => { + // 0-------------0-----------1-----------2-----------3 .... + // [padding] $block1 $block2 $block3 .... + const nextState = Math.floor((e.target.scrollTop - MARGIN) / FILE_HEIGHT); + return Math.max(nextState, 0); + }), rxjs.distinctUntilChanged(), rxjs.debounce(() => new rxjs.Observable((observer) => { const id = requestAnimationFrame(() => observer.next()); @@ -97,31 +115,32 @@ export default async function(render) { // STEP1: calculate the virtual scroll paramameters let diff = nextState - currentState; 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; 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=6-min(5,1)=5] fileStart -= Math.min(diff, BLOCK_SIZE) * COLUMN_PER_ROW; } let fileEnd = fileStart + diffSgn * diff * COLUMN_PER_ROW; - - if (fileStart >= files.length) throw new ApplicationError( - "INTERNAL_ERROR", - `assert failed in virtual scroll range[${fileStart}:${fileEnd}] length[${files.length}]`, - ); else if (fileEnd > files.length) { + if (fileStart >= files.length) { // occur when BLOCK_SIZE is larger than its absolute minimum + return; + } + else if (fileEnd > files.length) { // occur when files.length isn't a multiple of COLUMN_PER_ROW and - // we've scrolled to the bottom of the list - nextState = Math.ceil(files.length / COLUMN_PER_ROW) - BLOCK_SIZE - 1; - fileEnd = files.length; - do { + // we've scrolled to the bottom of the list already + nextState = Math.ceil(files.length / COLUMN_PER_ROW) - BLOCK_SIZE; + fileEnd = files.length - 1; + for (let i=0; i ${nextState}] files[${fileStart}:${fileEnd}]`) - if (n === 0) return; // STEP3: update the DOM if (diffSgn > 0) { // scroll down @@ -156,12 +173,4 @@ export default async function(render) { )), rxjs.catchError(ctrlError()), )); - - // feature2: fs in "search" mode - // TODO -} - -function isInViewport(element) { - const rect = element.getBoundingClientRect(); - return rect.bottom > 0; } diff --git a/public/assets/pages/filespage/thing.js b/public/assets/pages/filespage/thing.js index 30ab6cbc..4cfee4a1 100644 --- a/public/assets/pages/filespage/thing.js +++ b/public/assets/pages/filespage/thing.js @@ -37,28 +37,17 @@ export function createThing({ link = "", // permissions = {} }) { - const $thing = $get(); - if ($thing instanceof HTMLElement) { - const $label = $thing.querySelector(".component_filename .file-details > span"); - if ($label instanceof HTMLElement) $label.textContent = name; - $thing?.querySelector("a")?.setAttribute("href", link); + const $thing = $tmpl.cloneNode(true); + if (!$thing instanceof window.HTMLElement) throw new Error("assertion failed: $thing must be an HTMLELement"); + const $label = $thing.querySelector(".component_filename .file-details > span"); + if (!$label instanceof window.HTMLElement) throw new Error("assertion failed: $label must be an HTMLELement"); + + $label.textContent = name; + $thing.querySelector(".component_checkbox").onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log("CLICK"); } + $thing.querySelector("a").setAttribute("href", link); 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)) -}