Refactor: Modularize DataTable and manual DOM selection logic
This commit is contained in:
parent
bff76354dc
commit
d45a815d55
157
src/index.js
157
src/index.js
@ -1,69 +1,43 @@
|
|||||||
import { Component, NewState, Util, RefreshState } from '@web/state'
|
import { Component, NewState, Util, RefreshState } from '@web/state'
|
||||||
import { VirtualScroll } from '@web/base'
|
import { createPerfMonitor } from './perf.js'
|
||||||
|
import { createScrollManager } from './scroll.js'
|
||||||
|
import { createSelectionManager } from './selection.js'
|
||||||
|
|
||||||
Component.register('DataTable', container => {
|
Component.register('DataTable', container => {
|
||||||
const vs = VirtualScroll()
|
|
||||||
if (!container.state) container.state = NewState({})
|
if (!container.state) container.state = NewState({})
|
||||||
const state = container.state
|
const state = container.state
|
||||||
|
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
list: [], fields: [], _renderedList: [],
|
list: [], fields: [], _renderedList: [],
|
||||||
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||||
perf: { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }
|
selectedRowCount: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
/* PERFORMANCE_TELEMETRY_START - THIS BLOCK IS FOR TESTING AND SHOULD BE REMOVED IN PRODUCTION */
|
const perf = createPerfMonitor();
|
||||||
if (!window.__statePerformanceTelemetry) {
|
state.perf = perf.stats;
|
||||||
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
|
|
||||||
}
|
const selection = createSelectionManager(container, state);
|
||||||
/* PERFORMANCE_TELEMETRY_END */
|
|
||||||
|
const scroll = createScrollManager(container, state, (renderedCount) => {
|
||||||
|
selection.applySelectionUI();
|
||||||
|
});
|
||||||
|
|
||||||
container.refresh = () => {
|
container.refresh = () => {
|
||||||
const start = performance.now()
|
const frameStart = perf.startFrame();
|
||||||
// Optimization: Expand the virtual viewport to 1.6x height to create a buffer
|
scroll.refresh();
|
||||||
// that prevents "white flashes" during extremely fast scrolling.
|
perf.endFrame(frameStart, state._renderedList.length);
|
||||||
const virtualContainer = {
|
|
||||||
clientHeight: container.clientHeight * 1.6,
|
|
||||||
scrollTop: container.scrollTop
|
|
||||||
};
|
};
|
||||||
const res = vs.calc(virtualContainer, state.list)
|
|
||||||
if (res) {
|
|
||||||
/* PERFORMANCE_TELEMETRY_START */
|
|
||||||
const frameStartScan = window.__statePerformanceTelemetry.scanCount;
|
|
||||||
const frameStartMove = window.__statePerformanceTelemetry.moveCount;
|
|
||||||
const frameStartReuse = window.__statePerformanceTelemetry.reuseCount;
|
|
||||||
/* PERFORMANCE_TELEMETRY_END */
|
|
||||||
|
|
||||||
state.perf.refreshCount++
|
container.onScroll = () => {
|
||||||
Object.assign(state, {
|
perf.onScroll();
|
||||||
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
container.refresh();
|
||||||
_listStartIndex: res.listStartIndex, _renderedList: res.renderedList
|
};
|
||||||
})
|
|
||||||
|
|
||||||
const elapsed = performance.now() - start;
|
container.onItemUpdate = (index, node) => scroll.updateRowHeight(index, node);
|
||||||
state.perf.refreshTime += elapsed;
|
|
||||||
state.perf.totalNodes += res.renderedList.length;
|
|
||||||
|
|
||||||
/* PERFORMANCE_TELEMETRY_START */
|
|
||||||
const frameScans = window.__statePerformanceTelemetry.scanCount - frameStartScan;
|
|
||||||
const frameMoves = window.__statePerformanceTelemetry.moveCount - frameStartMove;
|
|
||||||
const frameReuses = window.__statePerformanceTelemetry.reuseCount - frameStartReuse;
|
|
||||||
|
|
||||||
if (frameScans > 0 || elapsed > 2) {
|
|
||||||
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${frameScans}, Moves: ${frameMoves}, Reuses: ${frameReuses}, Rows: ${res.renderedList.length}`);
|
|
||||||
}
|
|
||||||
/* PERFORMANCE_TELEMETRY_END */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
container.onItemUpdate = (index, node) => {
|
|
||||||
// vs.update(index + (state._listStartIndex || 0), node)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.__watch('list', list => {
|
state.__watch('list', list => {
|
||||||
state._listStartIndex = 0
|
scroll.init();
|
||||||
state._renderedList = vs.reset(list, container) || []
|
scroll.reset(list);
|
||||||
setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
state.__watch('fields', fields => {
|
state.__watch('fields', fields => {
|
||||||
@ -72,44 +46,107 @@ Component.register('DataTable', container => {
|
|||||||
container.style.setProperty('--dt-grid-template', gridTemplate)
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||||
})
|
})
|
||||||
|
|
||||||
container.onScroll = () => {
|
// Expose selection methods to template
|
||||||
state.perf.scrollCount++
|
container.startSelect = selection.startSelect;
|
||||||
container.refresh()
|
container.updateSelect = selection.updateSelect;
|
||||||
|
container.deleteSelected = selection.deleteSelected;
|
||||||
|
|
||||||
|
// Copy & Paste (simplified)
|
||||||
|
const escapeTSV = val => {
|
||||||
|
const str = String(val ?? '')
|
||||||
|
return (str.includes('\t') || str.includes('\n') || str.includes('"')) ? '"' + str.replace(/"/g, '""') + '"' : str
|
||||||
|
}
|
||||||
|
|
||||||
|
container.copy = async () => {
|
||||||
|
const bounds = selection.getSelectionBounds();
|
||||||
|
if (!bounds) return;
|
||||||
|
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row =>
|
||||||
|
state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => escapeTSV(row[f.id])).join('\t')
|
||||||
|
).join('\n')
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('keydown', e => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 'c') { e.preventDefault(); container.copy() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onGlobalMouseUp = () => selection.endSelect()
|
||||||
|
const onGlobalMouseDown = e => {
|
||||||
|
if (!container.contains(e.target)) selection.clearAllActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', onGlobalMouseUp)
|
||||||
|
document.addEventListener('mousedown', onGlobalMouseDown)
|
||||||
|
|
||||||
|
container._onUnload = () => {
|
||||||
|
document.removeEventListener('mousedown', onGlobalMouseDown)
|
||||||
|
window.removeEventListener('mouseup', onGlobalMouseUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
}, Util.makeDom(/*html*/`
|
}, Util.makeDom(/*html*/`
|
||||||
<div class="dt-root h-100 overflow-auto" onscroll="this.onScroll()" style="overflow-anchor:none; display: block">
|
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" onunload="this._onUnload()" tabindex="0">
|
||||||
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:10">
|
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()" style="overflow-anchor:none; min-height: 0">
|
||||||
<div class="dt-flex-row fw-bold text-muted small" style="height:40px">
|
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||||
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden" $style="'width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px); min-width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px)'">
|
<div class="dt-row dt-header-row fw-bold text-muted small">
|
||||||
|
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center" $style="'width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px); min-width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px)'">
|
||||||
<span $text="item.name" class="text-truncate"></span>
|
<span $text="item.name" class="text-truncate"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dt-body" style="position:relative">
|
||||||
<div $if="(this.state?.prevHeight || 0) > 0" $style="'height:' + this.state.prevHeight + 'px;'" class="flex-shrink-0"></div>
|
<div $if="(this.state?.prevHeight || 0) > 0" $style="'height:' + this.state.prevHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
<div $each="this.state?._renderedList" key="id" class="dt-row dt-flex-row border-bottom bg-white" style="height:40px" $onupdate="this.onItemUpdate(index, thisNode)">
|
<div $each="this.state?._renderedList" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white" $onupdate="this.onItemUpdate(rIdx, thisNode)">
|
||||||
<div $each="this.state.fields" as="f" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden" $style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px)'">
|
<div $each="this.state.fields" as="f" index="fIdx"
|
||||||
|
class="dt-cell border-end px-2 d-flex align-items-center"
|
||||||
|
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, fIdx, event)"
|
||||||
|
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, fIdx)"
|
||||||
|
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px)'">
|
||||||
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
|
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
Total: <span $text="this.state.list.length"></span> |
|
||||||
|
Selected Rows: <span $text="this.state.selectedRowCount"></span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="this.state.selectedRowCount === 0" $onclick="this.deleteSelected()">
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`), Util.makeDom(/*html*/`
|
`), Util.makeDom(/*html*/`
|
||||||
<style>
|
<style>
|
||||||
DataTable { display: block; }
|
DataTable { display: block; }
|
||||||
.dt-flex-row {
|
.dt-root { font-size: 0.875rem; }
|
||||||
|
.dt-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.dt-header-row {
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
}
|
}
|
||||||
.dt-cell {
|
.dt-cell {
|
||||||
background: inherit;
|
background: inherit;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.dt-row:hover {
|
.dt-body-row:hover {
|
||||||
background-color: #f8f9fa !important;
|
background-color: var(--bs-secondary-bg) !important;
|
||||||
|
}
|
||||||
|
.dt-cell-selected {
|
||||||
|
background-color: var(--bs-primary-bg-subtle) !important;
|
||||||
|
}
|
||||||
|
.btn-xs {
|
||||||
|
padding: 1px 5px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
`))
|
`))
|
||||||
|
|||||||
43
src/perf.js
Normal file
43
src/perf.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
export const createPerfMonitor = () => {
|
||||||
|
let enabled = !!window.__DT_PERF_MODE__;
|
||||||
|
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
|
||||||
|
|
||||||
|
if (enabled && !window.__statePerformanceTelemetry) {
|
||||||
|
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get stats() { return stats; },
|
||||||
|
enable: () => { enabled = true; },
|
||||||
|
disable: () => { enabled = false; },
|
||||||
|
onScroll: () => { if (enabled) stats.scrollCount++; },
|
||||||
|
startFrame: () => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
return {
|
||||||
|
start: performance.now(),
|
||||||
|
scan: window.__statePerformanceTelemetry?.scanCount || 0,
|
||||||
|
move: window.__statePerformanceTelemetry?.moveCount || 0,
|
||||||
|
reuse: window.__statePerformanceTelemetry?.reuseCount || 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
endFrame: (startData, renderedCount) => {
|
||||||
|
if (!enabled || !startData) return;
|
||||||
|
stats.refreshCount++;
|
||||||
|
stats.totalNodes += renderedCount;
|
||||||
|
|
||||||
|
const elapsed = performance.now() - startData.start;
|
||||||
|
stats.refreshTime += elapsed;
|
||||||
|
|
||||||
|
const stPerf = window.__statePerformanceTelemetry;
|
||||||
|
if (stPerf) {
|
||||||
|
const scans = stPerf.scanCount - startData.scan;
|
||||||
|
const moves = stPerf.moveCount - startData.move;
|
||||||
|
const reuses = stPerf.reuseCount - startData.reuse;
|
||||||
|
|
||||||
|
if (scans > 0 || elapsed > 2) {
|
||||||
|
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
48
src/scroll.js
Normal file
48
src/scroll.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { VirtualScroll } from '@web/base';
|
||||||
|
|
||||||
|
export const createScrollManager = (container, state, onRenderedListChange) => {
|
||||||
|
const vs = VirtualScroll();
|
||||||
|
let scrollEl = null;
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
if (!scrollEl) return;
|
||||||
|
// Expand the virtual viewport to 1.6x height to create a buffer
|
||||||
|
const virtualContainer = {
|
||||||
|
clientHeight: scrollEl.clientHeight * 1.6,
|
||||||
|
scrollTop: scrollEl.scrollTop
|
||||||
|
};
|
||||||
|
const res = vs.calc(virtualContainer, state.list);
|
||||||
|
if (res) {
|
||||||
|
Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight,
|
||||||
|
postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex,
|
||||||
|
_renderedList: res.renderedList
|
||||||
|
});
|
||||||
|
onRenderedListChange?.(res.renderedList.length);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: () => {
|
||||||
|
scrollEl = container.querySelector('.dt-main');
|
||||||
|
},
|
||||||
|
reset: (list) => {
|
||||||
|
state._listStartIndex = 0;
|
||||||
|
state._renderedList = vs.reset(list, scrollEl || container) || [];
|
||||||
|
setTimeout(() => {
|
||||||
|
if (state.list === list) {
|
||||||
|
vs.init(list, refresh);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateRowHeight: (index, node) => {
|
||||||
|
// Restore this call so VirtualScroll can learn the actual height
|
||||||
|
// of the rows instead of using its 32px default. This fixes
|
||||||
|
// layout shifts/jumps when scrolling past the first group.
|
||||||
|
vs.update(index + (state._listStartIndex || 0), node);
|
||||||
|
},
|
||||||
|
refresh,
|
||||||
|
onScroll: refresh
|
||||||
|
};
|
||||||
|
};
|
||||||
144
src/selection.js
Normal file
144
src/selection.js
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
export const createSelectionManager = (container, state) => {
|
||||||
|
let activeBounds = null; // { minRow, maxRow, minCol, maxCol }
|
||||||
|
let startCell = null; // { row, col }
|
||||||
|
let multiSelections = [];
|
||||||
|
|
||||||
|
const isCellSelected = (r, c) => {
|
||||||
|
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
|
||||||
|
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySelectionUI = () => {
|
||||||
|
const rowNodes = container.querySelectorAll('.dt-body-row');
|
||||||
|
|
||||||
|
let boundMinRow = Infinity, boundMaxRow = -Infinity;
|
||||||
|
if (activeBounds) {
|
||||||
|
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
|
||||||
|
boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow);
|
||||||
|
}
|
||||||
|
multiSelections.forEach(s => {
|
||||||
|
boundMinRow = Math.min(boundMinRow, s.minRow);
|
||||||
|
boundMaxRow = Math.max(boundMaxRow, s.maxRow);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasSelection = boundMinRow !== Infinity;
|
||||||
|
|
||||||
|
rowNodes.forEach((rowNode, r) => {
|
||||||
|
const absoluteRow = r + state._listStartIndex;
|
||||||
|
const cellNodes = rowNode.children;
|
||||||
|
|
||||||
|
// Fast path: Row completely outside selection
|
||||||
|
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
|
||||||
|
for (let i = 0; i < cellNodes.length; i++) {
|
||||||
|
if (cellNodes[i].classList.contains('dt-cell-selected')) {
|
||||||
|
cellNodes[i].classList.remove('dt-cell-selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < cellNodes.length; i++) {
|
||||||
|
const isSelected = isCellSelected(absoluteRow, i);
|
||||||
|
const hasClass = cellNodes[i].classList.contains('dt-cell-selected');
|
||||||
|
if (isSelected && !hasClass) {
|
||||||
|
cellNodes[i].classList.add('dt-cell-selected');
|
||||||
|
} else if (!isSelected && hasClass) {
|
||||||
|
cellNodes[i].classList.remove('dt-cell-selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = () => {
|
||||||
|
let count = 0;
|
||||||
|
if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1);
|
||||||
|
multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1));
|
||||||
|
state.selectedRowCount = count;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAllActive = (keepSelection = false) => {
|
||||||
|
if (!keepSelection) {
|
||||||
|
activeBounds = null;
|
||||||
|
startCell = null;
|
||||||
|
multiSelections = [];
|
||||||
|
applySelectionUI();
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSelect = (row, col, e) => {
|
||||||
|
const alreadySelected = isCellSelected(row, col);
|
||||||
|
if (e.shiftKey && startCell) {
|
||||||
|
activeBounds = {
|
||||||
|
minRow: Math.min(startCell.row, row),
|
||||||
|
maxRow: Math.max(startCell.row, row),
|
||||||
|
minCol: Math.min(startCell.col, col),
|
||||||
|
maxCol: Math.max(startCell.col, col)
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (!alreadySelected) {
|
||||||
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
|
clearAllActive();
|
||||||
|
} else if (activeBounds) {
|
||||||
|
multiSelections.push(activeBounds);
|
||||||
|
}
|
||||||
|
startCell = { row, col };
|
||||||
|
activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col };
|
||||||
|
}
|
||||||
|
state.isSelecting = true;
|
||||||
|
}
|
||||||
|
applySelectionUI();
|
||||||
|
updateStatus();
|
||||||
|
container.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSelect = (row, col) => {
|
||||||
|
if (state.isSelecting && startCell) {
|
||||||
|
activeBounds = {
|
||||||
|
minRow: Math.min(startCell.row, row),
|
||||||
|
maxRow: Math.max(startCell.row, row),
|
||||||
|
minCol: Math.min(startCell.col, col),
|
||||||
|
maxCol: Math.max(startCell.col, col)
|
||||||
|
};
|
||||||
|
applySelectionUI();
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endSelect = () => { state.isSelecting = false; };
|
||||||
|
|
||||||
|
const getSelectionBounds = () => {
|
||||||
|
if (!activeBounds) return null;
|
||||||
|
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow;
|
||||||
|
let minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
|
||||||
|
multiSelections.forEach(s => {
|
||||||
|
minRow = Math.min(minRow, s.minRow);
|
||||||
|
maxRow = Math.max(maxRow, s.maxRow);
|
||||||
|
minCol = Math.min(minCol, s.minCol);
|
||||||
|
maxCol = Math.max(maxCol, s.maxCol);
|
||||||
|
});
|
||||||
|
return { minRow, maxRow, minCol, maxCol };
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = () => {
|
||||||
|
const bounds = getSelectionBounds();
|
||||||
|
if (!bounds) return;
|
||||||
|
|
||||||
|
// This is a naive deletion that removes the bounding box rows entirely.
|
||||||
|
// A complete implementation might filter row by row checking multi-selections.
|
||||||
|
const rMin = bounds.minRow, rMax = bounds.maxRow;
|
||||||
|
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
|
||||||
|
state.list = newList;
|
||||||
|
clearAllActive();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
applySelectionUI,
|
||||||
|
clearAllActive,
|
||||||
|
startSelect,
|
||||||
|
updateSelect,
|
||||||
|
endSelect,
|
||||||
|
deleteSelected,
|
||||||
|
getSelectionBounds
|
||||||
|
};
|
||||||
|
};
|
||||||
40
test/correctness.spec.js
Normal file
40
test/correctness.spec.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('DataTable data correctness test', async ({ page }) => {
|
||||||
|
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||||
|
await page.goto('/test/index.html');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Check initial data
|
||||||
|
const initialData = await page.evaluate(() => {
|
||||||
|
const firstRow = document.querySelector('.dt-body-row');
|
||||||
|
const cells = firstRow.querySelectorAll('.dt-cell span');
|
||||||
|
return Array.from(cells).map(c => c.textContent);
|
||||||
|
});
|
||||||
|
console.log('Initial Row 1 Data:', initialData);
|
||||||
|
expect(initialData[0]).toBe('1'); // ID column
|
||||||
|
|
||||||
|
// Scroll down and check if data updates correctly
|
||||||
|
const scrolledData = await page.evaluate(async () => {
|
||||||
|
const dt = document.getElementById('myTable');
|
||||||
|
const scrollEl = dt.querySelector('.dt-main');
|
||||||
|
scrollEl.scrollTop = 2000; // Scroll past buffer
|
||||||
|
scrollEl.dispatchEvent(new Event('scroll')); // Force trigger
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
|
const firstRow = document.querySelector('.dt-body-row');
|
||||||
|
const cells = firstRow.querySelectorAll('.dt-cell span');
|
||||||
|
return {
|
||||||
|
scrollTop: scrollEl.scrollTop,
|
||||||
|
listStartIndex: dt.state._listStartIndex,
|
||||||
|
row1Data: Array.from(cells).map(c => c.textContent)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Scrolled Data:', JSON.stringify(scrolledData, null, 2));
|
||||||
|
|
||||||
|
// If scrolling worked, listStartIndex should be around 2000 / 40 = 50
|
||||||
|
expect(scrolledData.listStartIndex).toBeGreaterThan(30);
|
||||||
|
// The ID of the first visible row should match listStartIndex + 1 (since our mock data id is i+1)
|
||||||
|
expect(parseInt(scrolledData.row1Data[0])).toBe(scrolledData.listStartIndex + 1);
|
||||||
|
});
|
||||||
52
test/minimal_perf.html
Normal file
52
test/minimal_perf.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
window.__DT_PERF_MODE__ = true;
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import { Component, RefreshState } from '@web/state';
|
||||||
|
import '../src/index.js';
|
||||||
|
|
||||||
|
window.startTest = async () => {
|
||||||
|
const fields = Array.from({length: 10}, (_, i) => ({
|
||||||
|
id: 'col' + i,
|
||||||
|
name: 'Column ' + i,
|
||||||
|
width: 150
|
||||||
|
}));
|
||||||
|
|
||||||
|
const data = Array.from({length: 10000}, (_, i) => {
|
||||||
|
const row = { id: i };
|
||||||
|
fields.forEach(f => row[f.id] = `Row ${i} ${f.name}`);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('style', 'height: 400px; width: 800px; border: 1px solid red');
|
||||||
|
div.innerHTML = '<DataTable id="dt" style="display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden"></DataTable>';
|
||||||
|
document.body.appendChild(div);
|
||||||
|
|
||||||
|
RefreshState(document.body);
|
||||||
|
|
||||||
|
const dt = document.getElementById('dt');
|
||||||
|
dt.state.fields = fields;
|
||||||
|
dt.state.list = data;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
const scrollTarget = dt.querySelector('.dt-main');
|
||||||
|
console.log('root clientHeight:', scrollTarget.clientHeight);
|
||||||
|
console.log('root scrollHeight:', scrollTarget.scrollHeight);
|
||||||
|
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
scrollTarget.scrollTop += 200;
|
||||||
|
scrollTarget.dispatchEvent(new Event('scroll'));
|
||||||
|
await new Promise(r => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(JSON.stringify(dt.state.perf));
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
17
test/minimal_perf.spec.js
Normal file
17
test/minimal_perf.spec.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('Minimal DataTable performance test', async ({ page }) => {
|
||||||
|
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||||
|
page.on('pageerror', err => console.log('PAGE ERROR:', err));
|
||||||
|
await page.goto('/test/minimal_perf.html');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const perf = await window.startTest();
|
||||||
|
return {
|
||||||
|
perf,
|
||||||
|
stPerf: window.__stPerf
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log('Final Performance Stats:', JSON.stringify(result, null, 2));
|
||||||
|
expect(result.perf.scrollCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user