Perf: Achieve Zero-Scan virtual scroll foundation
This commit is contained in:
parent
9eea16cdab
commit
bff76354dc
49
TEST.md
49
TEST.md
@ -1,18 +1,37 @@
|
|||||||
# datatable Benchmark & Test Results
|
# DataTable Performance Tracking
|
||||||
|
|
||||||
## 基准测试 (Benchmark)
|
## v1: Minimal Foundation
|
||||||
*测试环境: Playwright / Chromium*
|
- **Data**: 10,000 items, 1 column.
|
||||||
|
- **Scroll Test**: 100 scrolls (20,000px total).
|
||||||
|
- **Results**:
|
||||||
|
- refreshCount: 101
|
||||||
|
- refreshTime (total): ~45.7ms
|
||||||
|
- averageRefreshTime: **0.45ms**
|
||||||
|
- totalNodes: 3030 (avg 30/frame)
|
||||||
|
- **Notes**: Silk smooth, base VirtualScroll works perfectly.
|
||||||
|
|
||||||
| 指标 | v1.0.0 |
|
## v4: Optimized Responsiveness (Immediate Refresh)
|
||||||
| :--- | :--- |
|
- **Data**: 10,000 items, 10 columns.
|
||||||
| **Render 1000 rows** | TBD |
|
- **Scroll Test**: 100 scrolls (20,000px total).
|
||||||
| **Scroll through 1000 rows** | TBD |
|
- **Results**:
|
||||||
| **Cell Edit Latency** | TBD |
|
- refreshCount: 101
|
||||||
|
- refreshTime (total): ~101.2ms
|
||||||
|
- averageRefreshTime: **1.01ms**
|
||||||
|
- totalNodes: 3030
|
||||||
|
- **Notes**: Removed `.dt-body` wrapper to simplify DOM depth. Changed `onScroll` to execute `container.refresh()` immediately instead of wrapping it in `requestAnimationFrame`.
|
||||||
|
|
||||||
## 测试覆盖 (Coverage)
|
## v5: Zero-Scan Extreme Performance
|
||||||
- [ ] 初始渲染 (Initial Rendering)
|
- **Data**: 10,000 items, 10 columns.
|
||||||
- [ ] 虚拟滚动 (Virtual Scrolling)
|
- **Scroll Test**: 100 scrolls (20,000px total).
|
||||||
- [ ] 单元格内编辑 (In-place Editing)
|
- **Results**:
|
||||||
- [ ] 复杂编辑器弹出 (Overlay Editing)
|
- refreshCount: 101
|
||||||
- [ ] 固定列 (Pinned Columns)
|
- refreshTime (total): ~100-120ms
|
||||||
- [ ] 列宽调整 (Resizing)
|
- averageRefreshTime: **1.0ms - 1.2ms**
|
||||||
|
- totalNodes: 6060 (avg 60/frame)
|
||||||
|
- **scanCount (per frame)**: **0** (Achieved Zero-Scan status)
|
||||||
|
- **moveCount (per frame)**: 60 (Exact match for row count)
|
||||||
|
- **Notes**:
|
||||||
|
- Optimized framework `dom.js` to avoid redundant sub-tree scans during identity-based node reuse.
|
||||||
|
- Doubled the virtual buffer (from 3x to 5x) to eliminate fast-scroll "white areas".
|
||||||
|
- Switched from CSS Grid to Flexbox for lower layout overhead.
|
||||||
|
- The performance is now physically limited only by the browser's ability to move 60 DIVs per frame, with zero JS binding work during scrolling.
|
||||||
|
|||||||
29
bak/_del_DataTable_Migration_TODO.md
Normal file
29
bak/_del_DataTable_Migration_TODO.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# DataTable Migration & Optimization TODO
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Port advanced features from the v1.0.3 backup while leveraging the new **Keyed Each** framework optimization for extreme performance.
|
||||||
|
|
||||||
|
## Status: Framework Breakthrough
|
||||||
|
- **Engine Version**: @web/state v1.0.11 (Keyed Each & Node Recycling)
|
||||||
|
- **Measured Refresh Performance**: **16ms** (Target: < 20ms).
|
||||||
|
- **Architecture**: Zero manual DOM patching required. Standard reactive templates are now performant enough.
|
||||||
|
|
||||||
|
## Migration Steps
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure
|
||||||
|
- [x] **Framework Optimization**. Implemented Keyed Each and node recycling in `dom.js`.
|
||||||
|
- [ ] **Step 1: Port Selection System**. Re-implement ID System and row selection without compromising the 16ms refresh.
|
||||||
|
- [ ] **Step 2: Column Metadata & Features**. Port `Resizer`, `pinned` support, and icons.
|
||||||
|
- [ ] **Step 3: Advanced Formatters**. Re-enable custom formatting logic.
|
||||||
|
|
||||||
|
### Phase 2: Interactivity
|
||||||
|
- [ ] **Step 4: Enhanced Selection (Multi-select, Keyboard Nav)**.
|
||||||
|
- [ ] **Step 5: Drag & Drop (Score-based sorting)**.
|
||||||
|
- [ ] **Step 6: Bulk Editing**.
|
||||||
|
|
||||||
|
## Performance Gate
|
||||||
|
**Continuous Monitoring:**
|
||||||
|
Every feature port must maintain the **< 20ms** `Assign` threshold in `refresh()`.
|
||||||
|
|
||||||
|
## Backup Reference
|
||||||
|
- File: `dataTable/src/index.js.bak` (Full v1.0.3 feature set)
|
||||||
29
bak/_del_DataTable_Surgical_Migration.md
Normal file
29
bak/_del_DataTable_Surgical_Migration.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# DataTable Surgical Migration Plan (Safety First)
|
||||||
|
|
||||||
|
## Current Status: Clean Baseline
|
||||||
|
- Source: v1.0.1
|
||||||
|
- Performance: ~16ms Keyed Refresh (verified)
|
||||||
|
- Frame: @web/state v1.0.11 (Node Recycling enabled)
|
||||||
|
|
||||||
|
## Phase 1: Zero-Interaction Infrastructure
|
||||||
|
- [ ] **Step 1.1: Column Width Pre-computation**. Move `width` calculation from template to `fields` watch. No logic change.
|
||||||
|
- [ ] **Step 1.2: Column Pinning Pre-computation**. Move `left/right` offset logic to `fields` watch.
|
||||||
|
- [ ] **Step 1.3: Apply Pre-computed Styles**. Update template to use `f._preStyle`.
|
||||||
|
- *Checkpoint*: Verify scrolling smoothness.
|
||||||
|
|
||||||
|
## Phase 2: Primitive Interaction (Selection)
|
||||||
|
- [ ] **Step 2.1: Top-level Coordinate State**. Add `activeCell: { rIdx, fIdx }` to state.
|
||||||
|
- [ ] **Step 2.2: Safe MouseDown Listener**. Add listener that only sets `activeCell` without touching `state.list`.
|
||||||
|
- [ ] **Step 2.3: Orderly MouseUp Cleanup**. Implement global listener to terminate selection mode.
|
||||||
|
- *Checkpoint*: Verify click doesn't "stick" and no lag.
|
||||||
|
|
||||||
|
## Phase 3: Advanced Features (Port one by one)
|
||||||
|
- [ ] **Step 3.1: Resizer Integration**.
|
||||||
|
- [ ] **Step 3.2: JIT Row Proxying for Edit**. (Discuss implementation details first).
|
||||||
|
- [ ] **Step 3.3: Metadata & Icons**.
|
||||||
|
|
||||||
|
## Mandatory Procedure
|
||||||
|
1. Implement ONE step.
|
||||||
|
2. Run `npm run test` + Manual browser check.
|
||||||
|
3. Report Benchmark & Interaction status.
|
||||||
|
4. WAIT for user confirmation.
|
||||||
275
bak/index.js.bak
Normal file
275
bak/index.js.bak
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
import { Component, NewState, Util, RefreshState } from '@web/state'
|
||||||
|
import { VirtualScroll } from '@web/base'
|
||||||
|
|
||||||
|
Component.register('DataTable', container => {
|
||||||
|
const vs = VirtualScroll()
|
||||||
|
// 如果 state 还未由框架自动创建,则手动创建
|
||||||
|
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) => {
|
||||||
|
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 && globalThis.UI?.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('\t') || 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 === '\t') { 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('\t')
|
||||||
|
).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)
|
||||||
235
bak/index.js.v101.bak
Normal file
235
bak/index.js.v101.bak
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { Component, NewState, Util, RefreshState } from '@web/state'
|
||||||
|
import { VirtualScroll } from '@web/base'
|
||||||
|
|
||||||
|
Component.register('DataTable', container => {
|
||||||
|
const vs = VirtualScroll()
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
container.refresh = () => {
|
||||||
|
const scrollEl = container.querySelector('.dt-body')
|
||||||
|
if (!scrollEl) return
|
||||||
|
const res = vs.calc(scrollEl, state.list)
|
||||||
|
if (res) Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex, renderedList: res.renderedList
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
state.__watch('list', list => {
|
||||||
|
if (list && list.length > 0 && !list[0].__watch) {
|
||||||
|
state.list = list.map(item => item.__watch ? item : NewState(item))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state._listStartIndex = 0
|
||||||
|
const scrollEl = container.querySelector('.dt-body')
|
||||||
|
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || []
|
||||||
|
if (scrollEl) {
|
||||||
|
vs.init(list, container.refresh)
|
||||||
|
requestAnimationFrame(() => container.refresh())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node)
|
||||||
|
|
||||||
|
container.getOffset = (fields, index, side) => {
|
||||||
|
const f = fields.slice(0, index).filter(f => f.pinned === 'left')
|
||||||
|
const r = fields.slice(index + 1).filter(f => f.pinned === 'right')
|
||||||
|
return (side === 'left' ? f : r).reduce((sum, f) => sum + (f.width || 150), 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._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
|
||||||
|
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) => {
|
||||||
|
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 && globalThis.UI?.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++) {
|
||||||
|
if (state.list[r] !== row) state.list[r][f.id] = val
|
||||||
|
}
|
||||||
|
unwatch()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeTSV = val => {
|
||||||
|
const str = String(val ?? '')
|
||||||
|
return (str.includes('\t') || 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 === '\t') { 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('\t')
|
||||||
|
).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) => {
|
||||||
|
const row = state.list[rStart + i]
|
||||||
|
if (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(this.state.fields, index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(this.state.fields, 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(this.state.fields, index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(this.state.fields, 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)
|
||||||
187
bak/index.js.v101b.bak
Normal file
187
bak/index.js.v101b.bak
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
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,
|
||||||
|
idField: 'id',
|
||||||
|
activeCell: { rIdx: -1, fIdx: -1 },
|
||||||
|
editingCell: { row: null, rowId: null, fieldId: null, fIdx: -1 },
|
||||||
|
features: { idSystem: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- 渲染刷新 ---
|
||||||
|
const refresh = () => {
|
||||||
|
const start = performance.now()
|
||||||
|
const scrollEl = container.querySelector('.dt-body')
|
||||||
|
if (!scrollEl) return
|
||||||
|
const res = vs.calc(scrollEl, state.list)
|
||||||
|
if (res) {
|
||||||
|
Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex, renderedList: res.renderedList
|
||||||
|
})
|
||||||
|
const end = performance.now()
|
||||||
|
if (end - start > 10) console.warn(`[DataTable] Keyed Refresh: ${(end - start).toFixed(2)}ms`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
container.refresh = refresh
|
||||||
|
|
||||||
|
state.__watch('list', list => {
|
||||||
|
if (!list) return
|
||||||
|
if (!state.idField) {
|
||||||
|
if (list.length && list[0].id !== undefined) state.idField = 'id';
|
||||||
|
else state.idField = '_rowId';
|
||||||
|
}
|
||||||
|
const idKey = state.idField;
|
||||||
|
list.forEach((item, i) => {
|
||||||
|
if (item[idKey] === undefined) item[idKey] = 'ui_' + Math.random().toString(36).substr(2, 9)
|
||||||
|
})
|
||||||
|
|
||||||
|
state._listStartIndex = 0
|
||||||
|
const scrollEl = container.querySelector('.dt-body')
|
||||||
|
state.renderedList = vs.reset(list, scrollEl || container) || []
|
||||||
|
if (scrollEl) { vs.init(list, refresh); requestAnimationFrame(refresh); }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node)
|
||||||
|
|
||||||
|
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.editingCell = { row: null, rowId: null, fieldId: null, fIdx: -1 }
|
||||||
|
state.activeCell = { rIdx: -1, fIdx: -1 }
|
||||||
|
if (!keepSelection) {
|
||||||
|
state.selStartR = -1; state.multiSelections = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.startSelect = (r, f, e) => {
|
||||||
|
const alreadySelected = container.isCellSelected(r, f)
|
||||||
|
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
|
||||||
|
state.activeCell = { rIdx: r, fIdx: f }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f)
|
||||||
|
container.endSelect = () => { state.isSelecting = false }
|
||||||
|
|
||||||
|
container.editCell = (row, f, fIdx) => {
|
||||||
|
container.clearAllActive(true)
|
||||||
|
const wrappedRow = row.__watch ? row : NewState(row)
|
||||||
|
const listIdx = state.list.indexOf(row)
|
||||||
|
if (listIdx !== -1) state.list[listIdx] = wrappedRow
|
||||||
|
|
||||||
|
state.editingCell = { row: wrappedRow, rowId: wrappedRow[state.idField], fieldId: f.id, fIdx }
|
||||||
|
}
|
||||||
|
|
||||||
|
const onGlobalMouseUp = () => state.isSelecting && container.endSelect()
|
||||||
|
window.addEventListener('mouseup', onGlobalMouseUp)
|
||||||
|
const onGlobalMouseDown = e => !container.contains(e.target) && container.clearAllActive()
|
||||||
|
document.addEventListener('mousedown', onGlobalMouseDown)
|
||||||
|
container._onUnload = () => {
|
||||||
|
document.removeEventListener('mousedown', onGlobalMouseDown)
|
||||||
|
window.removeEventListener('mouseup', onGlobalMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
fields.forEach((f, i) => {
|
||||||
|
const width = f.width || 150;
|
||||||
|
const left = leftOffsets[i] || 0;
|
||||||
|
const right = rightOffsets[i] || 0;
|
||||||
|
f._preStyle = `width: var(--w-${f.id}, ${width}px); min-width: var(--w-${f.id}, ${width}px); height:40px;` +
|
||||||
|
(f.pinned ? 'position:sticky; z-index:10;' : '') +
|
||||||
|
(f.pinned === 'left' ? `left:${left}px;` : '') +
|
||||||
|
(f.pinned === 'right' ? `right:${right}px;` : '');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
}, 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="f._preStyle">
|
||||||
|
<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" index="fIdx" 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, fIdx) ? ' bg-primary-subtle' : '') + (this.state.activeCell.rIdx === rIdx + this.state._listStartIndex && this.state.activeCell.fIdx === fIdx ? ' dt-cell-active' : '')"
|
||||||
|
$style="f._preStyle"
|
||||||
|
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, fIdx, event)"
|
||||||
|
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, fIdx)"
|
||||||
|
$ondblclick="this.editCell(row, f, fIdx)">
|
||||||
|
|
||||||
|
<div $if="this.state.editingCell.rowId !== row[this.state.idField] || this.state.editingCell.fieldId !== 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="this.state.editingCell.rowId === row[this.state.idField] && this.state.editingCell.fieldId === 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="this.state.editingCell.row"
|
||||||
|
$onkeydown="(event.key === 'Enter' && f.type !== 'textarea') && (this.state.editingCell = {rowId:null}); event.key === 'Escape' && (this.state.editingCell = {rowId: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)
|
||||||
52
bak/index.v1.js
Normal file
52
bak/index.v1.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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: [], _renderedList: [],
|
||||||
|
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||||
|
perf: { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.refresh = () => {
|
||||||
|
const start = performance.now()
|
||||||
|
const res = vs.calc(container, state.list)
|
||||||
|
if (res) {
|
||||||
|
state.perf.refreshCount++
|
||||||
|
Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex, _renderedList: res.renderedList
|
||||||
|
})
|
||||||
|
state.perf.refreshTime += (performance.now() - start)
|
||||||
|
state.perf.totalNodes += res.renderedList.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.onItemUpdate = (index, node) => vs.update(index + (state._listStartIndex || 0), node)
|
||||||
|
|
||||||
|
state.__watch('list', list => {
|
||||||
|
state._listStartIndex = 0
|
||||||
|
state._renderedList = vs.reset(list, container) || []
|
||||||
|
setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) })
|
||||||
|
})
|
||||||
|
|
||||||
|
container.onScroll = () => {
|
||||||
|
state.perf.scrollCount++
|
||||||
|
container.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
}, Util.makeDom(/*html*/`
|
||||||
|
<div class="dt-root h-100 overflow-auto" onscroll="this.onScroll()" style="overflow-anchor:none; display: block">
|
||||||
|
<div $if="(this.state?.prevHeight || 0) > 0" $style="'height:' + this.state.prevHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
|
<div $each="this.state?._renderedList" class="dt-row border-bottom d-flex align-items-center px-2" style="height:40px; flex-shrink:0" $onupdate="this.onItemUpdate(index, thisNode)">
|
||||||
|
<span $text="item.label || item.id"></span>
|
||||||
|
</div>
|
||||||
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
`))
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||||
86
bak/index.v2.js
Normal file
86
bak/index.v2.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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,
|
||||||
|
perf: { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.refresh = () => {
|
||||||
|
const start = performance.now()
|
||||||
|
const res = vs.calc(container, state.list)
|
||||||
|
if (res) {
|
||||||
|
state.perf.refreshCount++
|
||||||
|
Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex, _renderedList: res.renderedList
|
||||||
|
})
|
||||||
|
state.perf.refreshTime += (performance.now() - start)
|
||||||
|
state.perf.totalNodes += res.renderedList.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.onItemUpdate = (index, node) => vs.update(index + (state._listStartIndex || 0), node)
|
||||||
|
|
||||||
|
state.__watch('list', list => {
|
||||||
|
state._listStartIndex = 0
|
||||||
|
state._renderedList = vs.reset(list, container) || []
|
||||||
|
setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) })
|
||||||
|
})
|
||||||
|
|
||||||
|
state.__watch('fields', fields => {
|
||||||
|
if (!fields) return
|
||||||
|
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')
|
||||||
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||||
|
})
|
||||||
|
|
||||||
|
container.onScroll = () => {
|
||||||
|
state.perf.scrollCount++
|
||||||
|
container.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
}, Util.makeDom(/*html*/`
|
||||||
|
<div class="dt-root h-100 overflow-auto" onscroll="this.onScroll()" style="overflow-anchor:none; display: block">
|
||||||
|
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:10">
|
||||||
|
<div class="dt-grid-row fw-bold text-muted small" style="height:40px">
|
||||||
|
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden">
|
||||||
|
<span $text="item.name" class="text-truncate"></span>
|
||||||
|
</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 $each="this.state?._renderedList" class="dt-row dt-grid-row border-bottom bg-white" style="height:40px" $onupdate="this.onItemUpdate(index, thisNode)">
|
||||||
|
<div $each="this.state.fields" as="f" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden">
|
||||||
|
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`), Util.makeDom(/*html*/`
|
||||||
|
<style>
|
||||||
|
DataTable { display: block; }
|
||||||
|
.dt-grid-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--dt-grid-template);
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.dt-cell {
|
||||||
|
background: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dt-row:hover {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`))
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||||
86
bak/index.v2_unkeyed.js
Normal file
86
bak/index.v2_unkeyed.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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,
|
||||||
|
perf: { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.refresh = () => {
|
||||||
|
const start = performance.now()
|
||||||
|
const res = vs.calc(container, state.list)
|
||||||
|
if (res) {
|
||||||
|
state.perf.refreshCount++
|
||||||
|
Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex, _renderedList: res.renderedList
|
||||||
|
})
|
||||||
|
state.perf.refreshTime += (performance.now() - start)
|
||||||
|
state.perf.totalNodes += res.renderedList.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.onItemUpdate = (index, node) => vs.update(index + (state._listStartIndex || 0), node)
|
||||||
|
|
||||||
|
state.__watch('list', list => {
|
||||||
|
state._listStartIndex = 0
|
||||||
|
state._renderedList = vs.reset(list, container) || []
|
||||||
|
setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) })
|
||||||
|
})
|
||||||
|
|
||||||
|
state.__watch('fields', fields => {
|
||||||
|
if (!fields) return
|
||||||
|
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')
|
||||||
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||||
|
})
|
||||||
|
|
||||||
|
container.onScroll = () => {
|
||||||
|
state.perf.scrollCount++
|
||||||
|
container.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
}, Util.makeDom(/*html*/`
|
||||||
|
<div class="dt-root h-100 overflow-auto" onscroll="this.onScroll()" style="overflow-anchor:none; display: block">
|
||||||
|
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:10">
|
||||||
|
<div class="dt-grid-row fw-bold text-muted small" style="height:40px">
|
||||||
|
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden">
|
||||||
|
<span $text="item.name" class="text-truncate"></span>
|
||||||
|
</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 $each="this.state?._renderedList" class="dt-row dt-grid-row border-bottom bg-white" style="height:40px" $onupdate="this.onItemUpdate(index, thisNode)">
|
||||||
|
<div $each="this.state.fields" as="f" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden">
|
||||||
|
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`), Util.makeDom(/*html*/`
|
||||||
|
<style>
|
||||||
|
DataTable { display: block; }
|
||||||
|
.dt-grid-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--dt-grid-template);
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.dt-cell {
|
||||||
|
background: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dt-row:hover {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`))
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||||
86
bak/index.v3_keyed.js
Normal file
86
bak/index.v3_keyed.js
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
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,
|
||||||
|
perf: { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
container.refresh = () => {
|
||||||
|
const start = performance.now()
|
||||||
|
const res = vs.calc(container, state.list)
|
||||||
|
if (res) {
|
||||||
|
state.perf.refreshCount++
|
||||||
|
Object.assign(state, {
|
||||||
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
|
_listStartIndex: res.listStartIndex, _renderedList: res.renderedList
|
||||||
|
})
|
||||||
|
state.perf.refreshTime += (performance.now() - start)
|
||||||
|
state.perf.totalNodes += res.renderedList.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.onItemUpdate = (index, node) => vs.update(index + (state._listStartIndex || 0), node)
|
||||||
|
|
||||||
|
state.__watch('list', list => {
|
||||||
|
state._listStartIndex = 0
|
||||||
|
state._renderedList = vs.reset(list, container) || []
|
||||||
|
setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) })
|
||||||
|
})
|
||||||
|
|
||||||
|
state.__watch('fields', fields => {
|
||||||
|
if (!fields) return
|
||||||
|
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')
|
||||||
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||||
|
})
|
||||||
|
|
||||||
|
container.onScroll = () => {
|
||||||
|
state.perf.scrollCount++
|
||||||
|
container.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
}, Util.makeDom(/*html*/`
|
||||||
|
<div class="dt-root h-100 overflow-auto" onscroll="this.onScroll()" style="overflow-anchor:none; display: block">
|
||||||
|
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:10">
|
||||||
|
<div class="dt-grid-row fw-bold text-muted small" style="height:40px">
|
||||||
|
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden">
|
||||||
|
<span $text="item.name" class="text-truncate"></span>
|
||||||
|
</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 $each="this.state?._renderedList" key="id" class="dt-row dt-grid-row border-bottom bg-white" style="height:40px" $onupdate="this.onItemUpdate(index, thisNode)">
|
||||||
|
<div $each="this.state.fields" as="f" class="dt-cell border-end px-2 d-flex align-items-center overflow-hidden">
|
||||||
|
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`), Util.makeDom(/*html*/`
|
||||||
|
<style>
|
||||||
|
DataTable { display: block; }
|
||||||
|
.dt-grid-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--dt-grid-template);
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.dt-cell {
|
||||||
|
background: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dt-row:hover {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`))
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||||
316
src/index.js
316
src/index.js
@ -3,273 +3,115 @@ import { VirtualScroll } from '@web/base'
|
|||||||
|
|
||||||
Component.register('DataTable', container => {
|
Component.register('DataTable', container => {
|
||||||
const vs = VirtualScroll()
|
const vs = VirtualScroll()
|
||||||
// 如果 state 还未由框架自动创建,则手动创建
|
|
||||||
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,
|
||||||
selStartR: -1, selStartF: -1, selEndR: -1, selEndF: -1,
|
perf: { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 }
|
||||||
multiSelections: [],
|
|
||||||
isSelecting: false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const refresh = () => {
|
/* PERFORMANCE_TELEMETRY_START - THIS BLOCK IS FOR TESTING AND SHOULD BE REMOVED IN PRODUCTION */
|
||||||
const scrollEl = container.querySelector('.dt-body')
|
if (!window.__statePerformanceTelemetry) {
|
||||||
if (!scrollEl) return
|
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
/* PERFORMANCE_TELEMETRY_END */
|
||||||
|
|
||||||
|
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, {
|
Object.assign(state, {
|
||||||
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||||
_listStartIndex: res.listStartIndex, renderedList: res.renderedList
|
_listStartIndex: res.listStartIndex, _renderedList: res.renderedList
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
container.refresh = refresh
|
|
||||||
|
|
||||||
state.__watch('fields', fields => {
|
const elapsed = performance.now() - start;
|
||||||
if (!fields) return
|
state.perf.refreshTime += elapsed;
|
||||||
const leftOffsets = [], rightOffsets = []
|
state.perf.totalNodes += res.renderedList.length;
|
||||||
let lSum = 0
|
|
||||||
fields.forEach((f, i) => {
|
/* PERFORMANCE_TELEMETRY_START */
|
||||||
if (f.pinned === 'left') {
|
const frameScans = window.__statePerformanceTelemetry.scanCount - frameStartScan;
|
||||||
leftOffsets[i] = lSum
|
const frameMoves = window.__statePerformanceTelemetry.moveCount - frameStartMove;
|
||||||
lSum += (f.width || 150)
|
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 */
|
||||||
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
|
container.onItemUpdate = (index, node) => {
|
||||||
state._rightOffsets = rightOffsets
|
// vs.update(index + (state._listStartIndex || 0), node)
|
||||||
})
|
}
|
||||||
|
|
||||||
state.__watch('list', list => {
|
state.__watch('list', list => {
|
||||||
state._listStartIndex = 0
|
state._listStartIndex = 0
|
||||||
const scrollEl = container.querySelector('.dt-body')
|
state._renderedList = vs.reset(list, container) || []
|
||||||
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || []
|
setTimeout(() => { if (state.list === list) vs.init(list, container.refresh) })
|
||||||
if (scrollEl) {
|
|
||||||
vs.init(list, refresh)
|
|
||||||
requestAnimationFrame(refresh)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node)
|
state.__watch('fields', fields => {
|
||||||
|
if (!fields) return
|
||||||
container.getOffset = (index, side) => (state._leftOffsets || [])[index] || (state._rightOffsets || [])[index] || 0
|
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')
|
||||||
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||||
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) => {
|
|
||||||
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 && globalThis.UI?.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('\t') || 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 === '\t') { 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('\t')
|
|
||||||
).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()
|
container.onScroll = () => {
|
||||||
document.addEventListener('mousedown', onGlobalMouseDown)
|
state.perf.scrollCount++
|
||||||
window.addEventListener('mouseup', container.endSelect)
|
container.refresh()
|
||||||
container._onUnload = () => {
|
|
||||||
document.removeEventListener('mousedown', onGlobalMouseDown)
|
|
||||||
window.removeEventListener('mouseup', container.endSelect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}, Util.makeDom(/*html*/`
|
}, 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-root h-100 overflow-auto" onscroll="this.onScroll()" style="overflow-anchor:none; display: block">
|
||||||
<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 class="dt-header border-bottom bg-light sticky-top" style="z-index:10">
|
||||||
<div $each="this.state.fields" as="f" class="dt-col border-end d-flex align-items-center px-2 bg-body-tertiary"
|
<div class="dt-flex-row fw-bold text-muted small" style="height:40px">
|
||||||
$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;' : '')">
|
<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)'">
|
||||||
<span $text="f.name" $class="'text-truncate flex-grow-1 ' + (f.type === 'number' ? 'text-end' : '')"></span>
|
<span $text="item.name" class="text-truncate"></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>
|
||||||
</div>
|
</div>
|
||||||
<div $if="this.state.postHeight>0" $style="'height:' + this.state.postHeight + 'px;'"></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.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)'">
|
||||||
|
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
</div>
|
||||||
.dt-root { font-size: 0.875rem; --dt-active-border: var(--bs-primary); outline: none; }
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
||||||
.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>
|
</div>
|
||||||
|
`), Util.makeDom(/*html*/`
|
||||||
|
<style>
|
||||||
|
DataTable { display: block; }
|
||||||
|
.dt-flex-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
.dt-cell {
|
||||||
|
background: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.dt-row:hover {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
`))
|
`))
|
||||||
|
|
||||||
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user