dataTable/dist/datatable.js

283 lines
13 KiB
JavaScript

import { Component, NewState, Util, RefreshState } from "@web/state";
import { VirtualScroll } from "@web/base";
Component.register("DataTable", (container) => {
const vs = VirtualScroll();
if (!container.state) container.state = NewState({});
const state = container.state;
Object.assign(state, {
list: [],
fields: [],
renderedList: [],
prevHeight: 0,
postHeight: 0,
_listStartIndex: 0,
selStartR: -1,
selStartF: -1,
selEndR: -1,
selEndF: -1,
multiSelections: [],
isSelecting: false
});
const refresh = () => {
const scrollEl = container.querySelector(".dt-body");
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
res.renderedList.forEach((item, i) => {
if (item && !item.__watch) {
const wrapped = NewState(item);
res.renderedList[i] = wrapped;
state.list[res.listStartIndex + i] = wrapped;
}
});
Object.assign(state, {
prevHeight: res.prevHeight,
postHeight: res.postHeight,
_listStartIndex: res.listStartIndex,
renderedList: res.renderedList
});
}
};
container.refresh = refresh;
state.__watch("fields", (fields) => {
if (!fields) return;
const leftOffsets = [], rightOffsets = [];
let lSum = 0;
fields.forEach((f, i) => {
if (f.pinned === "left") {
leftOffsets[i] = lSum;
lSum += f.width || 150;
}
});
fields.forEach((f, i) => {
if (f.pinned === "right") {
let rs = 0;
for (let j = i + 1; j < fields.length; j++) {
if (fields[j].pinned === "right") rs += fields[j].width || 150;
}
rightOffsets[i] = rs;
}
});
state._leftOffsets = leftOffsets;
state._rightOffsets = rightOffsets;
});
state.__watch("list", (list) => {
state._listStartIndex = 0;
const scrollEl = container.querySelector(".dt-body");
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || [];
if (scrollEl) {
vs.init(list, refresh);
requestAnimationFrame(refresh);
}
});
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node);
container.getOffset = (index, side) => (state._leftOffsets || [])[index] || (state._rightOffsets || [])[index] || 0;
container.isCellSelected = (r, f) => {
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
if (r >= rMin && r <= rMax && f >= fMin && f <= fMax) return true;
return state.multiSelections.some((s) => r >= s.r1 && r <= s.r2 && f >= s.f1 && f <= s.f2);
};
container.clearAllActive = (keepSelection = false) => {
state.list.forEach((row) => {
if (row && row.__watch) {
if (row._editingF !== null) row._editingF = null;
if (row._activeF !== null) row._activeF = null;
}
});
if (!keepSelection) {
state.selStartR = -1;
state.multiSelections = [];
}
};
container.startSelect = (r, f, e) => {
const alreadySelected = container.isCellSelected(r, f);
if (state.editingCell) state.editingCell = null;
if (e.shiftKey && state.selStartR !== -1) {
state.selEndR = r;
state.selEndF = f;
} else {
if (!alreadySelected) {
if (!e.ctrlKey && !e.metaKey) container.clearAllActive();
else if (state.selStartR !== -1) {
state.multiSelections.push({
r1: Math.min(state.selStartR, state.selEndR),
r2: Math.max(state.selStartR, state.selEndR),
f1: Math.min(state.selStartF, state.selEndF),
f2: Math.max(state.selStartF, state.selEndF)
});
}
state.selStartR = state.selEndR = r;
state.selStartF = state.selEndF = f;
}
state.isSelecting = true;
if (state.list[r] && state.list[r].__watch) state.list[r]._activeF = f;
}
};
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f);
container.endSelect = () => state.isSelecting = false;
container.editCell = (row, f, fIdx) => {
var _a;
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
const rIdx = state.list.indexOf(row);
const isInRange = state.selStartR !== -1 && rIdx >= rMin && rIdx <= rMax && fIdx >= fMin && fIdx <= fMax;
const count = isInRange ? rMax - rMin + 1 : 0;
if (count > 1 && ((_a = globalThis.UI) == null ? void 0 : _a.toast)) {
UI.toast(`{#Bulk Editing {num} rows... || ${count}#}`);
}
container.clearAllActive(true);
row._editingF = f.id;
row._activeF = fIdx;
if (count > 1) {
const unwatch = row.__watch(f.id, (val) => {
for (let r = rMin; r <= rMax; r++) {
const cur = state.list[r];
if (cur !== row) {
const wrapped = cur.__watch ? cur : NewState(cur);
state.list[r] = wrapped;
wrapped[f.id] = val;
}
}
unwatch();
});
}
};
const escapeTSV = (val) => {
const str = String(val ?? "");
return str.includes(" ") || str.includes("\n") || str.includes('"') ? '"' + str.replace(/"/g, '""') + '"' : str;
};
const parseTSV = (text) => {
const rows = [];
let curRow = [], curCell = "", inQuote = false;
for (let i = 0; i < text.length; i++) {
const c = text[i], next = text[i + 1];
if (inQuote) {
if (c === '"' && next === '"') {
curCell += '"';
i++;
} else if (c === '"') inQuote = false;
else curCell += c;
} else {
if (c === '"') inQuote = true;
else if (c === " ") {
curRow.push(curCell);
curCell = "";
} else if (c === "\n") {
curRow.push(curCell);
rows.push(curRow);
curRow = [];
curCell = "";
} else if (c !== "\r") curCell += c;
}
}
curRow.push(curCell);
rows.push(curRow);
return rows;
};
container.copy = async () => {
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
if (rMin === -1) return;
const text = state.list.slice(rMin, rMax + 1).map(
(row) => state.fields.slice(fMin, fMax + 1).map((f) => escapeTSV(row[f.id])).join(" ")
).join("\n");
await navigator.clipboard.writeText(text);
};
container.paste = async () => {
const text = await navigator.clipboard.readText();
const rows = parseTSV(text);
const rStart = Math.min(state.selStartR, state.selEndR);
const fStart = Math.min(state.selStartF, state.selEndF);
if (rStart === -1) return;
rows.forEach((rowData, i) => {
let row = state.list[rStart + i];
if (row) {
if (!row.__watch) {
row = NewState(row);
state.list[rStart + i] = row;
}
rowData.forEach((val, j) => {
const field = state.fields[fStart + j];
if (field) {
if (typeof row[field.id] === "boolean") row[field.id] = val.toLowerCase() === "true";
else if (typeof row[field.id] === "number") row[field.id] = Number(val);
else row[field.id] = val;
}
});
}
});
};
container.addEventListener("keydown", (e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "c") {
e.preventDefault();
container.copy();
}
if (e.key === "v") {
e.preventDefault();
container.paste();
}
}
});
const onGlobalMouseDown = (e) => !container.contains(e.target) && container.clearAllActive();
document.addEventListener("mousedown", onGlobalMouseDown);
window.addEventListener("mouseup", container.endSelect);
container._onUnload = () => {
document.removeEventListener("mousedown", onGlobalMouseDown);
window.removeEventListener("mouseup", container.endSelect);
};
}, Util.makeDom(
/*html*/
`
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none" tabindex="0" onunload="this._onUnload()">
<div class="dt-header d-flex flex-shrink-0 border-bottom bg-body-tertiary fw-bold text-muted small" style="overflow:hidden; position:sticky; top:0; z-index:20; height:48px">
<div $each="this.state.fields" as="f" class="dt-col border-end d-flex align-items-center px-2 bg-body-tertiary"
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); ' + (f.pinned ? 'position:sticky; z-index:11;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(index, 'right') + 'px;' : '')">
<span $text="f.name" $class="'text-truncate flex-grow-1 ' + (f.type === 'number' ? 'text-end' : '')"></span>
<Resizer class="dt-resizer" $.target="thisNode.parentNode" min="50" max="600" $bind="f.width" $onresizing="thisNode.closest('.dt-root').style.setProperty('--w-' + f.id, event.detail.newSize + 'px')"/>
</div>
</div>
<div class="dt-body flex-grow-1 overflow-auto bg-body" $onscroll="requestAnimationFrame(() => this.refresh())" style="overflow-anchor:none">
<div $if="this.state.prevHeight>0" $style="'height:' + this.state.prevHeight + 'px;'"></div>
<div $each="this.state.renderedList" as="row" class="dt-row d-flex border-bottom" $onupdate="this.onItemUpdate(rIdx, thisNode)" index="rIdx">
<div $each="this.state.fields" as="f" class="dt-cell border-end d-flex align-items-center bg-body"
$class="'dt-cell border-end d-flex align-items-center bg-body' + (this.isCellSelected(rIdx + this.state._listStartIndex, index) ? ' bg-primary-subtle' : '') + (row._activeF === index ? ' dt-cell-active' : '')"
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); height:40px; ' + (f.pinned ? 'position:sticky; z-index:10;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(index, 'right') + 'px;' : '')"
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, index, event)"
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, index)"
$ondblclick="this.editCell(row, f, index)">
<div $if="row._editingF !== f.id" $class="'px-2 text-truncate w-100 h-100 d-flex align-items-center ' + (f.type === 'number' ? 'justify-content-end' : (f.type === 'switch' ? 'justify-content-center' : ''))">
<span $if="typeof row[f.id] !== 'boolean'" $text="row[f.id] ?? ''" class="text-truncate"></span>
<div $if="typeof row[f.id] === 'boolean'" class="form-switch fs-5 m-0 d-flex align-items-center justify-content-center" style="padding-left:0">
<input class="form-check-input m-0" type="checkbox" $checked="row[f.id]" disabled>
</div>
</div>
<div $if="row._editingF === f.id" $class="'dt-editor-container' + (f.type === 'textarea' ? ' dt-editor-textarea' : '')" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
<AutoForm inline class="dt-editor h-100 w-100" $.state.schema="[{ ...f, name: f.id, label: f.name }]" $.data="row"
$onkeydown="(event.key === 'Enter' && f.type !== 'textarea') && (row._editingF = null); event.key === 'Escape' && (row._editingF = null)"/>
</div>
</div>
</div>
<div $if="this.state.postHeight>0" $style="'height:' + this.state.postHeight + 'px;'"></div>
</div>
<style>
.dt-root { font-size: 0.875rem; --dt-active-border: var(--bs-primary); outline: none; }
.dt-row:hover .dt-cell { background-color: var(--bs-secondary-bg) !important; }
.dt-cell { position: relative; transition: background 0.05s; border-color: var(--bs-border-color) !important; }
.dt-cell.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; }
.dt-cell-active::after { content: ''; position: absolute; inset: 0; border: 2px solid var(--dt-active-border); pointer-events: none; z-index: 12; }
.dt-col { position: relative; }
.dt-editor-container { position: absolute; left: -1px; top: -1px; width: fit-content; min-width: calc(100% + 2px); height: auto; min-height: calc(100% + 2px); background: var(--bs-body-bg); z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,.3); border: 1px solid var(--dt-active-border); display: flex; align-items: center; }
.dt-editor-textarea { height: auto; min-height: 100%; min-width: 260px; align-items: flex-start; }
.dt-resizer { width: 4px !important; opacity: 0; transition: opacity 0.2s; position: absolute; right: 0; top: 0; height: 100%; background: var(--bs-primary); cursor: col-resize; z-index: 15; }
.dt-col:hover .dt-resizer { opacity: 0.5; }
.dt-resizer:hover { opacity: 1 !important; }
</style>
</div>
`
));
if (typeof document !== "undefined") RefreshState(document.documentElement);