Files
losadev-tools/app/public/legacy/js/vtable.js
T

284 lines
9.7 KiB
JavaScript

"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;