284 lines
9.7 KiB
JavaScript
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;
|