546 lines
13 KiB
TypeScript
546 lines
13 KiB
TypeScript
"use stsrict";
|
|
// License: MIT
|
|
|
|
import { AbstractTable } from "./abstracttable";
|
|
import { APOOL } from "./animationpool";
|
|
import { ROW_CACHE_SIZE, ROW_REUSE_SIZE } from "./constants";
|
|
// eslint-disable-next-line no-unused-vars
|
|
import { Columns, Column } from "./column";
|
|
import { LRUMap } from "./lru";
|
|
import { Row } from "./row";
|
|
import { SelectionRange, TableSelection } from "./selection";
|
|
import {
|
|
COLS, FIRSTROW, FOCUSROW, ROWCACHE, ROWHEIGHT, ROWREUSE, VISIBLE
|
|
} from "./tablesymbols";
|
|
import { InvalidatedSet, UpdateRecord } from "./tableutil";
|
|
import { addClass, clampUInt, IS_MAC } from "./util";
|
|
|
|
const ROWS_SMALL_UPDATE = 5;
|
|
const PIXEL_PREC = 5;
|
|
|
|
interface KeyEvent extends UIEvent {
|
|
readonly altKey: boolean;
|
|
readonly ctrlKey: boolean;
|
|
readonly metaKey: boolean;
|
|
readonly shiftKey: boolean;
|
|
}
|
|
|
|
interface Invalidated {
|
|
colid: number;
|
|
row: Row;
|
|
}
|
|
|
|
export class BaseTable extends AbstractTable {
|
|
public readonly version: number;
|
|
|
|
elem: HTMLElement;
|
|
|
|
invalidated: InvalidatedSet<Invalidated>;
|
|
|
|
lastIdx: number;
|
|
|
|
firstIdx: number;
|
|
|
|
record: any;
|
|
|
|
selStartRow: number;
|
|
|
|
updateDOMId: Promise<void> | null;
|
|
|
|
updating: number;
|
|
|
|
singleSelect: boolean;
|
|
|
|
hover: boolean;
|
|
|
|
selection: TableSelection;
|
|
|
|
selectionGrippy: HTMLDivElement;
|
|
|
|
body: HTMLDivElement;
|
|
|
|
table: HTMLTableElement;
|
|
|
|
head: HTMLDivElement;
|
|
|
|
columns: HTMLTableElement;
|
|
|
|
[ROWCACHE]: LRUMap<number, Row>;
|
|
|
|
[ROWREUSE]: Row[];
|
|
|
|
[FIRSTROW]: Row | null;
|
|
|
|
[FOCUSROW]: number;
|
|
|
|
[ROWHEIGHT]: number;
|
|
|
|
[VISIBLE]: Map<number, Row>;
|
|
|
|
[COLS]: Columns;
|
|
|
|
constructor(elem: any, config: any, version?: number) {
|
|
config = (config && config.version === version && config) || {};
|
|
super();
|
|
|
|
this.version = version || 0;
|
|
if (typeof elem === "string") {
|
|
const sel = document.querySelector(elem) as HTMLElement;
|
|
if (!sel) {
|
|
throw new Error("Invalid selector");
|
|
}
|
|
this.elem = sel;
|
|
}
|
|
else {
|
|
this.elem = elem;
|
|
}
|
|
|
|
this[ROWCACHE] = new LRUMap(ROW_CACHE_SIZE);
|
|
const reuse: Row[] = this[ROWREUSE] = [];
|
|
this[ROWCACHE].onpurge = (_: any, v: Row) => {
|
|
if (v && reuse.length < ROW_REUSE_SIZE) {
|
|
reuse.push(v);
|
|
}
|
|
};
|
|
|
|
this.invalidated = new InvalidatedSet(this.processInvalidated.bind(this));
|
|
this.lastIdx = this.firstIdx = 0;
|
|
this.record = null;
|
|
this.selStartRow = 0;
|
|
this.updateDOMId = null;
|
|
this.updating = 0;
|
|
this[FIRSTROW] = null;
|
|
this[FOCUSROW] = -1;
|
|
this[ROWHEIGHT] = 0;
|
|
this[VISIBLE] = new Map();
|
|
this.update = APOOL.wrap(this.update);
|
|
|
|
this.singleSelect = this.elem.dataset.singleselect === "true";
|
|
this.hover = this.elem.dataset.hover === "true";
|
|
this.selection = new TableSelection();
|
|
this.makeDOM(config);
|
|
}
|
|
|
|
makeDOM(config: any) {
|
|
const configColumns = "columns" in config ? config.columns : null;
|
|
const cols = this[COLS] = new Columns(this, configColumns);
|
|
|
|
const container = document.createElement("div");
|
|
const thead = document.createElement("div");
|
|
const columns = document.createElement("table");
|
|
const colrow = document.createElement("tr");
|
|
for (const c of cols.cols) {
|
|
colrow.appendChild(c.elem);
|
|
}
|
|
addClass(colrow, "columnrow");
|
|
columns.appendChild(colrow);
|
|
addClass(columns, "columns");
|
|
thead.appendChild(columns);
|
|
addClass(thead, "head");
|
|
thead.appendChild(cols.scrollSpace);
|
|
const selectionGrippy = document.createElement("div");
|
|
addClass(selectionGrippy, "column-selection-grippy");
|
|
selectionGrippy.textContent = "☰";
|
|
this.selectionGrippy = selectionGrippy;
|
|
thead.appendChild(selectionGrippy);
|
|
container.appendChild(thead);
|
|
|
|
const tbody = this.body = document.createElement("div");
|
|
const table = document.createElement("table");
|
|
table.setAttribute("tabindex", "1");
|
|
addClass(table, "table"),
|
|
tbody.appendChild(table);
|
|
addClass(tbody, "body");
|
|
container.appendChild(tbody);
|
|
|
|
addClass(container, "container");
|
|
const {parentElement} = this.elem;
|
|
if (parentElement) {
|
|
parentElement.insertBefore(container, this.elem);
|
|
parentElement.removeChild(this.elem);
|
|
}
|
|
|
|
this.table = table;
|
|
this.head = thead;
|
|
this.columns = columns;
|
|
container.id = this.elem.id;
|
|
this.elem = container;
|
|
}
|
|
|
|
init() {
|
|
// ignored
|
|
}
|
|
|
|
/* do not override unless you know what you're doing */
|
|
|
|
getColumnByName(id: string) {
|
|
return this[COLS].named.get(id);
|
|
}
|
|
|
|
get focusRow() {
|
|
return this[FOCUSROW];
|
|
}
|
|
|
|
set focusRow(rowid) {
|
|
if (!isFinite(rowid)) {
|
|
throw new Error("Invalid focus row");
|
|
}
|
|
if (this[FOCUSROW] === rowid) {
|
|
return;
|
|
}
|
|
if (this[FOCUSROW] >= 0) {
|
|
const ofr = this.getRow(this.focusRow);
|
|
if (ofr) {
|
|
ofr.focused(false);
|
|
}
|
|
}
|
|
this[FOCUSROW] = rowid;
|
|
const row = this.getRow(rowid);
|
|
if (row) {
|
|
row.focused(true);
|
|
}
|
|
this.scrollIntoView(rowid);
|
|
}
|
|
|
|
get visibleHeight() {
|
|
return this.body.clientHeight;
|
|
}
|
|
|
|
get visibleWidth() {
|
|
return this.body.clientWidth;
|
|
}
|
|
|
|
get visibleTop() {
|
|
return this.body.scrollTop;
|
|
}
|
|
|
|
get rowHeight() {
|
|
return this[ROWHEIGHT] || Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
resetRowHeight() {
|
|
this[ROWHEIGHT] = Number.MAX_SAFE_INTEGER;
|
|
}
|
|
|
|
get totalHeight() {
|
|
return this.rowCount * this.rowHeight;
|
|
}
|
|
|
|
get visibleRange() {
|
|
const {rowHeight, visibleTop} = this;
|
|
const inset = visibleTop % rowHeight;
|
|
let top = visibleTop;
|
|
let height = this.visibleHeight;
|
|
if (inset) {
|
|
top += rowHeight - inset;
|
|
height -= rowHeight - inset;
|
|
}
|
|
const firstIdx = clampUInt(top / rowHeight);
|
|
const lastIdx = firstIdx + clampUInt(
|
|
Math.floor(height / rowHeight) - 1, this.rowCount - 1 - firstIdx);
|
|
return new SelectionRange(firstIdx, lastIdx);
|
|
}
|
|
|
|
get config() {
|
|
return {
|
|
version: this.version,
|
|
columns: this.columnConfig
|
|
};
|
|
}
|
|
|
|
get columnConfig() {
|
|
return this[COLS].config;
|
|
}
|
|
|
|
invalidate() {
|
|
this.lastIdx = this.firstIdx = 0;
|
|
this[VISIBLE].clear();
|
|
this[ROWCACHE].clear();
|
|
this[ROWREUSE].length = 0;
|
|
this[FOCUSROW] = -1;
|
|
this.selection.clear();
|
|
this.update();
|
|
}
|
|
|
|
invalidateRow(rowid: number) {
|
|
this.invalidateCell(rowid, -1);
|
|
}
|
|
|
|
invalidateCell(rowid: number, colid: number) {
|
|
const row = this[VISIBLE].get(rowid) || this[ROWCACHE].get(rowid);
|
|
if (!row) {
|
|
return;
|
|
}
|
|
this.invalidated.add({row, colid});
|
|
}
|
|
|
|
processInvalidated() {
|
|
for (const {colid, row} of this.invalidated) {
|
|
if (colid >= 0) {
|
|
row.invalidateCell(colid);
|
|
}
|
|
else {
|
|
row.invalidate();
|
|
}
|
|
}
|
|
}
|
|
|
|
beginUpdate() {
|
|
this.updating++;
|
|
}
|
|
|
|
_endUpdate() {
|
|
return this.updating = Math.max(0, this.updating - 1);
|
|
}
|
|
|
|
endUpdate() {
|
|
if (!this._endUpdate()) {
|
|
this.update();
|
|
}
|
|
}
|
|
|
|
createRow(rowid: number, cols: Column[]) {
|
|
let row = this[ROWCACHE].get(rowid);
|
|
if (row) {
|
|
return row;
|
|
}
|
|
row = this[ROWREUSE].pop();
|
|
if (row) {
|
|
row.rowid = rowid;
|
|
row.invalidate();
|
|
return row;
|
|
}
|
|
return new Row(this, rowid, cols);
|
|
}
|
|
|
|
update() {
|
|
if (this.updating) {
|
|
return;
|
|
}
|
|
const record = new UpdateRecord(this, this[COLS].visible);
|
|
|
|
const firstRemaining = record.firstVisibleIdx - this.firstIdx;
|
|
const lastRemaining = this.lastIdx - record.lastVisibleIdx;
|
|
const firstRequired = Math.min(record.firstVisibleIdx, ROWS_SMALL_UPDATE);
|
|
const lastRequired = Math.min(
|
|
this.rowCount - record.lastVisibleIdx - 1, ROWS_SMALL_UPDATE);
|
|
if (record.rowHeight !== Number.MAX_SAFE_INTEGER &&
|
|
firstRemaining >= firstRequired &&
|
|
lastRemaining > lastRequired) {
|
|
if (!this.rowCount) {
|
|
this.record = record;
|
|
if (!this.updateDOMId) {
|
|
this.updateDOMId = APOOL.schedule(this, this.updateDOM);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
this.beginUpdate();
|
|
this[VISIBLE].clear();
|
|
for (let i = record.firstIdx; i <= record.lastIdx; ++i) {
|
|
const row = this.createRow(i, record.cols);
|
|
row.selected(this.selection.contains(i));
|
|
row.focused(this[FOCUSROW] === i);
|
|
this[ROWCACHE].set(i, row);
|
|
this[VISIBLE].set(i, row);
|
|
record.add(row);
|
|
}
|
|
if (!record.rows) {
|
|
this._endUpdate();
|
|
return;
|
|
}
|
|
// We might have been re-run before we updated the DOM
|
|
// Still need to apply this most recent changes
|
|
this.record = record;
|
|
if (!this.updateDOMId) {
|
|
this.updateDOMId = APOOL.schedule(this, this.updateDOM);
|
|
}
|
|
}
|
|
|
|
updateDOM() {
|
|
this.updateDOMId = null;
|
|
if (!this.record) {
|
|
return;
|
|
}
|
|
const {record, table} = this;
|
|
this.record = null;
|
|
const [first] = record.rows;
|
|
this[FIRSTROW] = first;
|
|
try {
|
|
if (table.firstChild) {
|
|
for (const row of Array.from(table.children)) {
|
|
if (!record.children.has(row)) {
|
|
table.removeChild(row);
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0, e = record.rows.length; i < e; ++i) {
|
|
const row = record.rows[i];
|
|
if (table.children[i] === row.elem) {
|
|
continue;
|
|
}
|
|
table.insertBefore(row.elem, table.children[i + 1]);
|
|
}
|
|
if (first) {
|
|
first.setWidths(record.cols);
|
|
}
|
|
if (record.rowHeight === Number.MAX_SAFE_INTEGER) {
|
|
if (first) {
|
|
setTimeout(() => {
|
|
this[ROWHEIGHT] = first.elem.getBoundingClientRect().height;
|
|
if ((this[ROWHEIGHT] | 0) !== this[ROWHEIGHT]) {
|
|
console.warn(
|
|
"Client height is not an integer, rounding errors ahead",
|
|
this[ROWHEIGHT]);
|
|
}
|
|
|
|
if (this[ROWHEIGHT]) {
|
|
this.update();
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
else {
|
|
this.firstIdx = record.firstIdx;
|
|
this.lastIdx = record.lastIdx;
|
|
|
|
table.style.marginTop = `${record.top.toFixed(PIXEL_PREC)}px`;
|
|
table.style.marginBottom = `${record.bottom.toFixed(PIXEL_PREC)}px`;
|
|
}
|
|
}
|
|
finally {
|
|
this._endUpdate();
|
|
this.emit("updated", this);
|
|
}
|
|
}
|
|
|
|
setWidths() {
|
|
const first = this[FIRSTROW];
|
|
if (first) {
|
|
first.setWidths(this[COLS].visible);
|
|
const diff = this.head.clientWidth - this.body.clientWidth;
|
|
this[COLS].setScrollWidth(diff);
|
|
}
|
|
}
|
|
|
|
rowCountChanged(pos: number, items: number) {
|
|
// Just clear, will be refilled by update anyway
|
|
this[VISIBLE].clear();
|
|
this[ROWCACHE].clear();
|
|
|
|
this.selection.offset(pos, items); // adjust selection
|
|
this.lastIdx = this.firstIdx = 0; // Make sure we update
|
|
this.update(); // force an update
|
|
}
|
|
|
|
moveTo(rowid: number, evt: KeyEvent) {
|
|
if (evt.ctrlKey || evt.metaKey) {
|
|
// just move focus
|
|
}
|
|
else {
|
|
this.selection.replace(rowid);
|
|
}
|
|
this.selStartRow = rowid;
|
|
this.focusRow = rowid;
|
|
}
|
|
|
|
selectTo(rowid: number, evt: KeyEvent) {
|
|
if ((!IS_MAC && evt.ctrlKey) || evt.metaKey) {
|
|
this.selection.toggle(rowid);
|
|
this.selStartRow = rowid;
|
|
}
|
|
else if (evt.shiftKey && this.focusRow >= 0) {
|
|
if (this.selStartRow > rowid) {
|
|
this.selection.replace(rowid, this.selStartRow);
|
|
}
|
|
else {
|
|
this.selection.replace(this.selStartRow, rowid);
|
|
}
|
|
}
|
|
else {
|
|
this.selection.replace(rowid);
|
|
this.selStartRow = rowid;
|
|
}
|
|
this.focusRow = rowid;
|
|
}
|
|
|
|
toggleCurrent() {
|
|
const rowid = clampUInt(this.focusRow);
|
|
this.selection.toggle(rowid);
|
|
this.selStartRow = rowid;
|
|
this.focusRow = rowid;
|
|
}
|
|
|
|
isCheckClick(evt: MouseEvent) {
|
|
return /virtualtable-check/.test((evt.target as HTMLElement).className) &&
|
|
!evt.ctrlKey && !evt.shiftKey && !evt.metaKey;
|
|
}
|
|
|
|
scrollIntoView(rowid: number) {
|
|
const vrange = this.visibleRange;
|
|
if (vrange.contains(rowid - 1) && vrange.contains(rowid + 1)) {
|
|
return;
|
|
}
|
|
let newTop;
|
|
if (rowid + 1 >= vrange.end) {
|
|
// Move down
|
|
const vrow = rowid - vrange.length + 2;
|
|
newTop = vrow * this.rowHeight;
|
|
}
|
|
else {
|
|
// Move up
|
|
newTop = (rowid - 1) * this.rowHeight;
|
|
}
|
|
newTop = clampUInt(newTop, this.totalHeight);
|
|
this.body.scrollTop = newTop;
|
|
}
|
|
|
|
navigate(rowid: number, evt: KeyEvent) {
|
|
if (!this.singleSelect && evt.shiftKey) {
|
|
this.selectTo(rowid, evt);
|
|
}
|
|
else {
|
|
this.moveTo(rowid, evt);
|
|
}
|
|
}
|
|
|
|
navigateUp(evt: KeyboardEvent) {
|
|
const rowid = clampUInt(this.focusRow - 1, this.rowCount - 1);
|
|
this.navigate(rowid, evt);
|
|
}
|
|
|
|
navigateDown(evt: KeyboardEvent) {
|
|
const rowid = clampUInt(this.focusRow + 1, this.rowCount - 1);
|
|
this.navigate(rowid, evt);
|
|
}
|
|
|
|
navigateTop(evt: KeyboardEvent) {
|
|
this.navigate(0, evt);
|
|
}
|
|
|
|
navigateBottom(evt: KeyboardEvent) {
|
|
const rowid = clampUInt(this.rowCount - 1);
|
|
this.navigate(rowid, evt);
|
|
}
|
|
|
|
navigatePageUp(evt: KeyboardEvent) {
|
|
const rowid = clampUInt(
|
|
this.focusRow - this.visibleRange.length, this.rowCount - 1);
|
|
this.navigate(rowid, evt);
|
|
}
|
|
|
|
navigatePageDown(evt: KeyboardEvent) {
|
|
const rowid = clampUInt(
|
|
this.focusRow + this.visibleRange.length, this.rowCount - 1);
|
|
this.navigate(rowid, evt);
|
|
}
|
|
|
|
getRow(rowid: number) {
|
|
return this[VISIBLE].get(rowid) || this[ROWCACHE].get(rowid);
|
|
}
|
|
|
|
toJSON() {
|
|
return this.config;
|
|
}
|
|
}
|