"use strict"; // Virtual table: only the visible rows exist in the DOM, so 50 000+ pack entries scroll smoothly. // columns: [{ key, label, width (px), grow, cls, format(row) → text|Node, sort(row) → value }] // Column widths can be dragged at the header's right edge; the `grow` column takes the free width. // The table scrolls sideways when the columns are wider than the view. class VTable { constructor(host, columns, { rowHeight = 28, multi = true, id = null } = {}) { this.host = host; this.columns = columns; this.rh = rowHeight; this.multi = multi; this.id = id; // remembers column widths per table this.rows = []; this.view = []; this.sel = new Set(); this.anchor = -1; this.sortKey = null; this.sortDesc = false; this.sortFirst = null; // (row) → rank, applied before the column sort (folders first) this.onSelect = () => {}; this.onOpen = () => {}; this.keyOf = (r) => r.key ?? r[0]; this.loadWidths(); host.innerHTML = ""; host.classList.add("vtable"); this.head = document.createElement("div"); this.head.className = "vt-head"; this.body = document.createElement("div"); this.body.className = "vt-rows"; host.append(this.head, this.body); for (const c of columns) { const h = document.createElement("div"); h.className = "vt-h"; const label = document.createElement("span"); label.textContent = c.label; h.append(label); if (c.cls?.includes("num")) h.style.justifyContent = "flex-end"; h.onclick = (e) => !e.target.classList.contains("vt-resize") && this.sortBy(c.key); const grip = document.createElement("i"); grip.className = "vt-resize"; grip.onmousedown = (e) => this.startResize(e, c); grip.ondblclick = () => this.autoFit(c); h.append(grip); c.headEl = h; this.head.append(h); } host.addEventListener("scroll", () => this.draw()); // Double-click is caught here: rows are re-created while scrolling, so a per-row handler // could miss the second click host.addEventListener("dblclick", (e) => { const row = e.target.closest(".vt-row"); if (row) this.onOpen(this.view[Number(row.dataset.i)]); }); new ResizeObserver(() => this.layout()).observe(host); host.tabIndex = 0; host.addEventListener("keydown", (e) => this.key(e)); this.layout(); } // ---------- column widths ---------- loadWidths() { if (!this.id) return; try { const saved = JSON.parse(localStorage.getItem(`vt:${this.id}`) || "{}"); for (const c of this.columns) if (saved[c.key] > 20) c.width = saved[c.key]; } catch {} } saveWidths() { if (!this.id) return; try { localStorage.setItem(`vt:${this.id}`, JSON.stringify(Object.fromEntries(this.columns.map((c) => [c.key, c.width])))); } catch {} } /** Column widths in px, the grow column stretched to fill the view. */ widths() { const base = this.columns.map((c) => c.width); const total = base.reduce((a, b) => a + b, 0); const free = Math.max(0, this.host.clientWidth - total); const g = this.columns.findIndex((c) => c.grow); if (g >= 0) base[g] += free; return base; } layout() { const w = this.widths(); this.totalWidth = w.reduce((a, b) => a + b, 0); this.head.style.width = this.body.style.width = `${this.totalWidth}px`; this.columns.forEach((c, i) => (c.headEl.style.width = `${w[i]}px`)); this.draw(); } startResize(e, col) { e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const start = col.width; document.body.classList.add("col-resizing"); const move = (ev) => { col.width = Math.max(40, Math.round(start + ev.clientX - startX)); this.layout(); }; const up = () => { document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.classList.remove("col-resizing"); this.saveWidths(); }; document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); } /** Double-click on a column edge: fit the widest visible value. */ autoFit(col) { const probe = document.createElement("span"); probe.className = "vt-measure"; document.body.append(probe); let max = col.label.length * 8 + 30; for (const r of this.view.slice(0, 2000)) { const v = col.format ? col.format(r) : r[col.key]; probe.textContent = v instanceof Node ? v.textContent : String(v ?? ""); max = Math.max(max, probe.offsetWidth + 34); } probe.remove(); col.width = Math.min(1200, max); this.layout(); this.saveWidths(); } // ---------- data ---------- setRows(rows, { keepSelection = false, animate = null } = {}) { this.rows = rows; if (!keepSelection) { this.sel.clear(); this.anchor = -1; } else { const keys = new Set(rows.map(this.keyOf)); for (const k of [...this.sel]) if (!keys.has(k)) this.sel.delete(k); } if (animate) { this.host.scrollTop = 0; this.body.classList.remove("in-left", "in-right", "in-fade"); void this.body.offsetWidth; this.body.classList.add(`in-${animate}`); } this.applySort(); } setView(filterFn) { this.filter = filterFn; this.applySort(); } sortBy(key) { if (this.sortKey === key) this.sortDesc = !this.sortDesc; else { this.sortKey = key; this.sortDesc = false; } for (const c of this.columns) { c.headEl.classList.toggle("sorted", c.key === this.sortKey); c.headEl.classList.toggle("desc", c.key === this.sortKey && this.sortDesc); } this.applySort(); } applySort() { const v = this.filter ? this.rows.filter(this.filter) : this.rows.slice(); const col = this.columns.find((c) => c.key === this.sortKey); const first = this.sortFirst; if (col || first) { const get = col ? col.sort || ((r) => r[col.key]) : null; v.sort((a, b) => { if (first) { const f = first(a) - first(b); if (f) return f; } if (!get) return 0; const x = get(a); const y = get(b); const d = typeof x === "number" && typeof y === "number" ? x - y : String(x).localeCompare(String(y)); return this.sortDesc ? -d : d; }); } this.view = v; this.body.style.height = `${v.length * this.rh}px`; this.draw(); } draw() { if (!this.totalWidth) return; const w = this.widths(); const top = this.host.scrollTop; const height = this.host.clientHeight; const first = Math.max(0, Math.floor(top / this.rh) - 8); const last = Math.min(this.view.length, Math.ceil((top + height) / this.rh) + 8); const frag = document.createDocumentFragment(); for (let i = first; i < last; i++) { const r = this.view[i]; const row = document.createElement("div"); row.className = "vt-row" + (this.sel.has(this.keyOf(r)) ? " sel" : "") + (this.rowClass ? ` ${this.rowClass(r)}` : ""); row.style.top = `${i * this.rh}px`; row.style.width = `${this.totalWidth}px`; row.dataset.i = i; this.columns.forEach((c, ci) => { const cell = document.createElement("div"); cell.style.width = `${w[ci]}px`; if (c.cls) cell.className = c.cls; const v = c.format ? c.format(r) : r[c.key]; if (v instanceof Node) cell.append(v); else { cell.textContent = v ?? ""; if (c.title) cell.title = cell.textContent; } row.append(cell); }); row.onmousedown = (e) => { if (e.button !== 0) return; // e.g. a folder: one click opens it (Ctrl/Shift still select) if (!e.ctrlKey && !e.shiftKey && this.onActivate?.(r, e)) return; this.click(i, e); }; frag.append(row); } this.body.replaceChildren(frag); } // ---------- selection / keys ---------- click(i, e) { const key = this.keyOf(this.view[i]); if (this.multi && e.shiftKey && this.anchor >= 0) { if (!e.ctrlKey) this.sel.clear(); const [a, b] = [Math.min(this.anchor, i), Math.max(this.anchor, i)]; for (let k = a; k <= b; k++) this.sel.add(this.keyOf(this.view[k])); } else if (this.multi && e.ctrlKey) { if (this.sel.has(key)) this.sel.delete(key); else this.sel.add(key); this.anchor = i; } else { this.sel.clear(); this.sel.add(key); this.anchor = i; } this.markSelection(); this.onSelect(this.selected(), this.view[i]); } /** Updates the highlight on the rows already on screen (keeps the elements, so double-click works). */ markSelection() { for (const row of this.body.children) row.classList.toggle("sel", this.sel.has(this.keyOf(this.view[Number(row.dataset.i)]))); } key(e) { if (e.ctrlKey && e.key.toLowerCase() === "a" && this.multi) { e.preventDefault(); for (const r of this.view) this.sel.add(this.keyOf(r)); this.markSelection(); this.onSelect(this.selected()); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!this.view.length) return; const i = Math.max(0, Math.min(this.view.length - 1, this.anchor + (e.key === "ArrowDown" ? 1 : -1))); this.click(i, { shiftKey: e.shiftKey, ctrlKey: false }); const y = i * this.rh; if (y < this.host.scrollTop + 30) this.host.scrollTop = y - 30; else if (y > this.host.scrollTop + this.host.clientHeight - this.rh * 2) this.host.scrollTop = y - this.host.clientHeight + this.rh * 2; } else if (e.key === "Enter" && this.anchor >= 0) this.onOpen(this.view[this.anchor]); else if (e.key === "Backspace") this.onBack?.(); } selected() { return this.rows.filter((r) => this.sel.has(this.keyOf(r))); } clearSelection() { this.sel.clear(); this.draw(); } } window.VTable = VTable;