diff --git a/src/index.js b/src/index.js index c58f890..91d4bc1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,69 +1,43 @@ 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 => { - 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, - 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 */ - if (!window.__statePerformanceTelemetry) { - window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 }; - } - /* PERFORMANCE_TELEMETRY_END */ + const perf = createPerfMonitor(); + state.perf = perf.stats; + + const selection = createSelectionManager(container, state); + + const scroll = createScrollManager(container, state, (renderedCount) => { + selection.applySelectionUI(); + }); container.refresh = () => { - const start = performance.now() - // Optimization: Expand the virtual viewport to 1.6x height to create a buffer - // that prevents "white flashes" during extremely fast scrolling. - 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++ - Object.assign(state, { - prevHeight: res.prevHeight, postHeight: res.postHeight, - _listStartIndex: res.listStartIndex, _renderedList: res.renderedList - }) - - const elapsed = performance.now() - start; - state.perf.refreshTime += elapsed; - state.perf.totalNodes += res.renderedList.length; + const frameStart = perf.startFrame(); + scroll.refresh(); + perf.endFrame(frameStart, state._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.onScroll = () => { + perf.onScroll(); + container.refresh(); + }; - container.onItemUpdate = (index, node) => { - // vs.update(index + (state._listStartIndex || 0), node) - } + container.onItemUpdate = (index, node) => scroll.updateRowHeight(index, node); state.__watch('list', list => { - state._listStartIndex = 0 - state._renderedList = vs.reset(list, container) || [] - setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) }) + scroll.init(); + scroll.reset(list); }) state.__watch('fields', fields => { @@ -72,44 +46,107 @@ Component.register('DataTable', container => { container.style.setProperty('--dt-grid-template', gridTemplate) }) - container.onScroll = () => { - state.perf.scrollCount++ - container.refresh() + // Expose selection methods to template + container.startSelect = selection.startSelect; + 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*/` `)) diff --git a/src/perf.js b/src/perf.js new file mode 100644 index 0000000..a666365 --- /dev/null +++ b/src/perf.js @@ -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}`); + } + } + } + }; +}; \ No newline at end of file diff --git a/src/scroll.js b/src/scroll.js new file mode 100644 index 0000000..2a52092 --- /dev/null +++ b/src/scroll.js @@ -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 + }; +}; diff --git a/src/selection.js b/src/selection.js new file mode 100644 index 0000000..4614fd3 --- /dev/null +++ b/src/selection.js @@ -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 + }; +}; diff --git a/test/correctness.spec.js b/test/correctness.spec.js new file mode 100644 index 0000000..8ebc19b --- /dev/null +++ b/test/correctness.spec.js @@ -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); +}); diff --git a/test/minimal_perf.html b/test/minimal_perf.html new file mode 100644 index 0000000..2fac90e --- /dev/null +++ b/test/minimal_perf.html @@ -0,0 +1,52 @@ + + + + + + + + diff --git a/test/minimal_perf.spec.js b/test/minimal_perf.spec.js new file mode 100644 index 0000000..35f1050 --- /dev/null +++ b/test/minimal_perf.spec.js @@ -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); +});