Compare commits
11 Commits
5759b2f061
...
59a9e5d650
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a9e5d650 | ||
|
|
cc6d2363b0 | ||
|
|
3e0dcc7df5 | ||
|
|
e03cf3a099 | ||
|
|
667b08c895 | ||
|
|
e8d8e17b7d | ||
|
|
d45a815d55 | ||
|
|
bff76354dc | ||
|
|
9eea16cdab | ||
|
|
3f19fb3a83 | ||
|
|
0bae1edceb |
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
||||
node_modules
|
||||
/bak/
|
||||
/test-results/
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@ -1,19 +1,19 @@
|
||||
# CHANGELOG
|
||||
# Changelog
|
||||
|
||||
## v1.0.1 (2026-05-17)
|
||||
## [1.0.4] - 2026-05-23
|
||||
### Added
|
||||
- Excel-compatible Copy/Paste with standard TSV serialization.
|
||||
- Quotation wrapping and escaping for special characters (tabs, newlines, quotes) during copy.
|
||||
- Selection-constrained paste: clipboard data is strictly clipped to current selection bounds.
|
||||
|
||||
### 优化
|
||||
- **交互**:
|
||||
- 深度集成 `@web/base` 的 `VirtualScroll` 模块,确保在万级数据下的极致滚动流畅度。
|
||||
- 联动 `AutoForm` 的 `inline` 模式,实现无缝的单元格原地编辑体验,支持 Enter 确认与 Esc 撤销。
|
||||
- 增强选择逻辑:支持 Shift 连选、Ctrl/Meta 多选以及跨区域范围选择。
|
||||
- **功能**:
|
||||
- 实现基于 TSV 格式的剪贴板系统,支持从 Excel/Google Sheets 复制并直接粘贴至表格,且粘贴时支持自动类型转换(Boolean/Number)。
|
||||
- 优化固定列(Pinned Columns)的阴影层级与偏移计算。
|
||||
- 完善列宽调整(Resizer)的实时预览与状态保存机制。
|
||||
|
||||
## v1.0.0 (2026-05-14)
|
||||
- **Feat**: 初始化 DataTable 项目。
|
||||
- **Feat**: 基于原生指令的虚拟滚动实现。
|
||||
- **Feat**: 支持单元格编辑、固定列与列宽调整。
|
||||
### Fixed
|
||||
- Restored sub-1ms scrolling performance by reverting to synchronous event handling.
|
||||
- Fixed scroll-stop "jump" by implementing manual DOM spacers (removing `$if` directives).
|
||||
- Fixed double-click context error by using native `_ref` data binding from the framework.
|
||||
- Fixed drag-to-select regression when starting from an already selected cell.
|
||||
- Sticky Selection: Clicking inside a range no longer clears the selection.
|
||||
|
||||
### Changed
|
||||
- Refactored layout to CSS Grid, eliminating thousands of per-cell `$style` reactive bindings.
|
||||
- Fully implemented Event Delegation for all cell interactions.
|
||||
- Optimized paste functionality with targeted `RefreshState` for affected rows only.
|
||||
|
||||
37
TEST.md
37
TEST.md
@ -1,18 +1,23 @@
|
||||
# datatable Benchmark & Test Results
|
||||
# DataTable Performance Tracking
|
||||
|
||||
## 基准测试 (Benchmark)
|
||||
*测试环境: Playwright / Chromium*
|
||||
## v1.0.4: Extreme Optimization (Restored)
|
||||
- **Data**: 10,000 items, 10 columns.
|
||||
- **Scroll Test**: 100 scrolls.
|
||||
- **Results**:
|
||||
- refreshTime (total): ~103ms (for 100 frames)
|
||||
- averageRefreshTime: **1.03ms** per frame
|
||||
- **Optimizations**:
|
||||
- Full Event Delegation (MouseDown/MouseOver/DblClick).
|
||||
- CSS Grid Layout (Removed thousands of per-cell `$style` bindings).
|
||||
- Synchronous Scroll Handling (Removed rAF to ensure zero-latency補位).
|
||||
- Native Context Access (`_ref`) for zero-cost item resolution.
|
||||
- Targeted Row Refresh for Paste/Edit operations.
|
||||
- Manual DOM Spacers (Removed `$if` to prevent scroll anchoring jumps).
|
||||
|
||||
| 指标 | v1.0.0 |
|
||||
| :--- | :--- |
|
||||
| **Render 1000 rows** | TBD |
|
||||
| **Scroll through 1000 rows** | TBD |
|
||||
| **Cell Edit Latency** | TBD |
|
||||
|
||||
## 测试覆盖 (Coverage)
|
||||
- [ ] 初始渲染 (Initial Rendering)
|
||||
- [ ] 虚拟滚动 (Virtual Scrolling)
|
||||
- [ ] 单元格内编辑 (In-place Editing)
|
||||
- [ ] 复杂编辑器弹出 (Overlay Editing)
|
||||
- [ ] 固定列 (Pinned Columns)
|
||||
- [ ] 列宽调整 (Resizing)
|
||||
## v1.0.0: Minimal Foundation
|
||||
- **Data**: 10,000 items, 1 column.
|
||||
- **Scroll Test**: 100 scrolls (20,000px total).
|
||||
- **Results**:
|
||||
- refreshCount: 101
|
||||
- refreshTime (total): ~45.7ms
|
||||
- averageRefreshTime: 0.45ms
|
||||
|
||||
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)
|
||||
727
dist/datatable.js
vendored
727
dist/datatable.js
vendored
@ -1,244 +1,575 @@
|
||||
import { Component, NewState, Util, RefreshState } from "@web/state";
|
||||
import { VirtualScroll } from "@web/base";
|
||||
import { Component, NewState, RefreshState, Util } from "@web/state";
|
||||
import { VirtualScroll, State } from "@web/base";
|
||||
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: () => {
|
||||
var _a, _b, _c;
|
||||
if (!enabled) return null;
|
||||
return {
|
||||
start: performance.now(),
|
||||
scan: ((_a = window.__statePerformanceTelemetry) == null ? void 0 : _a.scanCount) || 0,
|
||||
move: ((_b = window.__statePerformanceTelemetry) == null ? void 0 : _b.moveCount) || 0,
|
||||
reuse: ((_c = window.__statePerformanceTelemetry) == null ? void 0 : _c.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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
const vs = VirtualScroll({ itemHeight: 40 });
|
||||
let scrollEl = null;
|
||||
const refresh = () => {
|
||||
if (!scrollEl) return;
|
||||
const res = vs.calc(scrollEl, state.list);
|
||||
if (res) {
|
||||
if (state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) {
|
||||
return;
|
||||
}
|
||||
Object.assign(state, {
|
||||
prevHeight: res.prevHeight,
|
||||
postHeight: res.postHeight,
|
||||
_listStartIndex: res.listStartIndex,
|
||||
_renderedList: res.renderedList
|
||||
});
|
||||
onRenderedListChange == null ? void 0 : onRenderedListChange(res.renderedList.length);
|
||||
}
|
||||
};
|
||||
return {
|
||||
init: () => {
|
||||
scrollEl = container.querySelector(".dt-main");
|
||||
},
|
||||
reset: (list) => {
|
||||
state._listStartIndex = 0;
|
||||
state._renderedList = vs.reset(list, scrollEl || container) || [];
|
||||
if (state.list === list) {
|
||||
vs.init(list, refresh);
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
onScroll: refresh
|
||||
};
|
||||
};
|
||||
const createSelectionManager = (container, state) => {
|
||||
let activeBounds = null;
|
||||
let startCell = null;
|
||||
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);
|
||||
};
|
||||
let lastHadSelection = false;
|
||||
const applySelectionUI = () => {
|
||||
var _a;
|
||||
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;
|
||||
if (!hasSelection && !lastHadSelection) return;
|
||||
lastHadSelection = hasSelection;
|
||||
const body = container.querySelector(".dt-body");
|
||||
if (!body) return;
|
||||
const rowNodes = body.children;
|
||||
for (let r = 0; r < rowNodes.length; r++) {
|
||||
const rowNode = rowNodes[r];
|
||||
if (!rowNode.classList.contains("dt-body-row")) continue;
|
||||
const absoluteRow = (((_a = rowNode._ref) == null ? void 0 : _a.rIdx) ?? -1) + state._listStartIndex;
|
||||
const cellNodes = rowNode.children;
|
||||
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");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
const isRange = activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol) || multiSelections.length > 0;
|
||||
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 && !e.ctrlKey && !e.metaKey) {
|
||||
if (!isRange) container._potentialCancel = { row, col };
|
||||
} else {
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
clearAllActive();
|
||||
} else if (activeBounds && !alreadySelected) {
|
||||
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)
|
||||
};
|
||||
container._potentialCancel = null;
|
||||
applySelectionUI();
|
||||
updateStatus();
|
||||
}
|
||||
};
|
||||
const endSelect = () => {
|
||||
if (container._potentialCancel) {
|
||||
const { row, col } = container._potentialCancel;
|
||||
if (isCellSelected(row, col)) {
|
||||
clearAllActive();
|
||||
}
|
||||
container._potentialCancel = null;
|
||||
}
|
||||
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 copy = async () => {
|
||||
const bounds = getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map((row) => {
|
||||
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map((f) => {
|
||||
let val = String(row[f.id] ?? "");
|
||||
if (val.includes(" ") || val.includes("\n") || val.includes('"')) {
|
||||
val = '"' + val.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return val;
|
||||
}).join(" ");
|
||||
}).join("\n");
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
const paste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (!text) return;
|
||||
const bounds = getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
const rows = text.split(/\r?\n/).filter((line) => line.length > 0).map((line) => {
|
||||
const cells = [];
|
||||
let current = "", inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else inQuotes = !inQuotes;
|
||||
} else if (char === " " && !inQuotes) {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
cells.push(current);
|
||||
return cells;
|
||||
});
|
||||
const startRow = bounds.minRow;
|
||||
const startCol = bounds.minCol;
|
||||
const maxRow = bounds.maxRow;
|
||||
const maxCol = bounds.maxCol;
|
||||
const body = container.querySelector(".dt-body");
|
||||
const rowNodes = body ? Array.from(body.childNodes).filter((n) => {
|
||||
var _a;
|
||||
return (_a = n.classList) == null ? void 0 : _a.contains("dt-body-row");
|
||||
}) : [];
|
||||
rows.forEach((rowData, rOffset) => {
|
||||
const rIdx = startRow + rOffset;
|
||||
if (rIdx > maxRow || rIdx >= state.list.length) return;
|
||||
const rowItem = state.list[rIdx];
|
||||
let rowChanged = false;
|
||||
rowData.forEach((cellData, cOffset) => {
|
||||
const cIdx = startCol + cOffset;
|
||||
if (cIdx > maxCol || cIdx >= state.fields.length) return;
|
||||
const field = state.fields[cIdx];
|
||||
rowItem[field.id] = cellData;
|
||||
rowChanged = true;
|
||||
});
|
||||
if (rowChanged && container.refreshNode) {
|
||||
const domNode = rowNodes.find((n) => {
|
||||
var _a;
|
||||
return (((_a = n._ref) == null ? void 0 : _a.rIdx) ?? -1) + state._listStartIndex === rIdx;
|
||||
});
|
||||
if (domNode) container.refreshNode(domNode);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Paste Error:", err);
|
||||
}
|
||||
};
|
||||
const deleteSelected = () => {
|
||||
const bounds = getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
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,
|
||||
copy,
|
||||
paste
|
||||
};
|
||||
};
|
||||
Component.register("DataTable", (container) => {
|
||||
const vs = VirtualScroll();
|
||||
if (!container.state) container.state = NewState({});
|
||||
const state = container.state;
|
||||
Object.assign(state, {
|
||||
list: [],
|
||||
fields: [],
|
||||
renderedList: [],
|
||||
_renderedList: [],
|
||||
prevHeight: 0,
|
||||
postHeight: 0,
|
||||
_listStartIndex: 0,
|
||||
selStartR: -1,
|
||||
selStartF: -1,
|
||||
selEndR: -1,
|
||||
selEndF: -1,
|
||||
multiSelections: [],
|
||||
isSelecting: false
|
||||
selectedRowCount: 0
|
||||
});
|
||||
const perf = createPerfMonitor();
|
||||
state.perf = perf.stats;
|
||||
const selection = createSelectionManager(container, state);
|
||||
const scroll = createScrollManager(container, state, (renderedCount) => {
|
||||
container.hideEditor();
|
||||
selection.applySelectionUI();
|
||||
});
|
||||
let _prevSpacer, _postSpacer, _editorOverlay;
|
||||
container.format = (val, field) => {
|
||||
if (field.formatter) return field.formatter(val, field);
|
||||
if (typeof val === "string") return val;
|
||||
if (val === null || val === void 0) return "";
|
||||
return typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
};
|
||||
container.refreshNode = (node) => RefreshState(node);
|
||||
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
|
||||
});
|
||||
const frameStart = perf.startFrame();
|
||||
scroll.refresh();
|
||||
if (!_prevSpacer) _prevSpacer = container.querySelector(".dt-spacer-prev");
|
||||
if (_prevSpacer) {
|
||||
const h = state.prevHeight || 0;
|
||||
_prevSpacer.style.height = h + "px";
|
||||
_prevSpacer.style.display = h > 0 ? "block" : "none";
|
||||
}
|
||||
if (!_postSpacer) _postSpacer = container.querySelector(".dt-spacer-post");
|
||||
if (_postSpacer) {
|
||||
const h = state.postHeight || 0;
|
||||
_postSpacer.style.height = h + "px";
|
||||
_postSpacer.style.display = h > 0 ? "block" : "none";
|
||||
}
|
||||
perf.endFrame(frameStart, state._renderedList.length);
|
||||
};
|
||||
container.onScroll = () => {
|
||||
perf.onScroll();
|
||||
container.refresh();
|
||||
};
|
||||
container.onMainMouseDown = (e) => {
|
||||
var _a, _b;
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (!cell) return;
|
||||
const row = cell.closest(".dt-row");
|
||||
if (!row || row.classList.contains("dt-header-row")) return;
|
||||
const fIdx = ((_a = cell._ref) == null ? void 0 : _a.fIdx) ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = ((_b = row._ref) == null ? void 0 : _b.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.startSelect(absoluteRow, fIdx, e);
|
||||
};
|
||||
container.onMainMouseOver = (e) => {
|
||||
var _a, _b;
|
||||
if (!state.isSelecting) return;
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (!cell) return;
|
||||
const row = cell.closest(".dt-row");
|
||||
if (!row || row.classList.contains("dt-header-row")) return;
|
||||
const fIdx = ((_a = cell._ref) == null ? void 0 : _a.fIdx) ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = ((_b = row._ref) == null ? void 0 : _b.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.updateSelect(absoluteRow, fIdx);
|
||||
};
|
||||
container.onMainDblClick = (e) => {
|
||||
var _a, _b;
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (!cell) return;
|
||||
const row = cell.closest(".dt-row");
|
||||
if (!row || row.classList.contains("dt-header-row")) return;
|
||||
const item = (_a = row._ref) == null ? void 0 : _a.item;
|
||||
const field = (_b = cell._ref) == null ? void 0 : _b.f;
|
||||
if (item && field) container.editCell(item, field, cell);
|
||||
};
|
||||
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());
|
||||
}
|
||||
scroll.init();
|
||||
scroll.reset(list);
|
||||
});
|
||||
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node);
|
||||
container.getOffset = (fields, index, side) => {
|
||||
const f = fields.slice(0, index).filter((f2) => f2.pinned === "left");
|
||||
const r = fields.slice(index + 1).filter((f2) => f2.pinned === "right");
|
||||
return (side === "left" ? f : r).reduce((sum, f2) => sum + (f2.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;
|
||||
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);
|
||||
});
|
||||
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;
|
||||
let currentEditingNode = null;
|
||||
container.editCell = (row, field, cellNode) => {
|
||||
const overlay = container.querySelector(".dt-editor-overlay");
|
||||
const editor = overlay.querySelector("AutoForm");
|
||||
const rect = cellNode.getBoundingClientRect();
|
||||
const rootRect = container.getBoundingClientRect();
|
||||
currentEditingNode = cellNode;
|
||||
let minW = rect.width;
|
||||
if (field.type === "textarea" || field.type === "TagsInput") minW = Math.max(rect.width, 300);
|
||||
else if (field.type === "radio") minW = Math.max(rect.width, 240);
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.left = rect.left - rootRect.left + "px";
|
||||
overlay.style.top = rect.top - rootRect.top + "px";
|
||||
overlay.style.width = minW + "px";
|
||||
overlay.style.height = "";
|
||||
overlay.style.minHeight = "";
|
||||
overlay.style.alignItems = "center";
|
||||
if (field.type === "textarea" || field.type === "TagsInput") {
|
||||
overlay.style.minHeight = (field.type === "textarea" ? 150 : rect.height) + "px";
|
||||
overlay.style.height = "auto";
|
||||
overlay.style.alignItems = "flex-start";
|
||||
} 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;
|
||||
overlay.style.height = rect.height + "px";
|
||||
}
|
||||
State.editingSchema = [{ ...field, name: field.id, label: "" }];
|
||||
State.editingData = row;
|
||||
RefreshState(overlay);
|
||||
setTimeout(() => {
|
||||
const input = editor.querySelector(".form-control, .form-select, .form-check-input, input");
|
||||
if (input) input.focus();
|
||||
}, 30);
|
||||
};
|
||||
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f);
|
||||
container.endSelect = () => state.isSelecting = false;
|
||||
container.editCell = (row, f, fIdx) => {
|
||||
var _a;
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
||||
const rIdx = state.list.indexOf(row);
|
||||
const isInRange = state.selStartR !== -1 && rIdx >= rMin && rIdx <= rMax && fIdx >= fMin && fIdx <= fMax;
|
||||
const count = isInRange ? rMax - rMin + 1 : 0;
|
||||
if (count > 1 && ((_a = globalThis.UI) == null ? void 0 : _a.toast)) {
|
||||
UI.toast(`{#Bulk Editing {num} rows... || ${count}#}`);
|
||||
}
|
||||
container.clearAllActive(true);
|
||||
row._editingF = f.id;
|
||||
row._activeF = fIdx;
|
||||
if (count > 1) {
|
||||
const unwatch = row.__watch(f.id, (val) => {
|
||||
for (let r = rMin; r <= rMax; r++) {
|
||||
if (state.list[r] !== row) state.list[r][f.id] = val;
|
||||
}
|
||||
unwatch();
|
||||
});
|
||||
container.hideEditor = (save = true) => {
|
||||
if (!_editorOverlay) _editorOverlay = container.querySelector(".dt-editor-overlay");
|
||||
if (!_editorOverlay || _editorOverlay.style.display === "none") return;
|
||||
_editorOverlay.style.display = "none";
|
||||
if (save && currentEditingNode) {
|
||||
RefreshState(currentEditingNode);
|
||||
}
|
||||
State.editingSchema = null;
|
||||
State.editingData = null;
|
||||
currentEditingNode = null;
|
||||
container.focus();
|
||||
};
|
||||
const escapeTSV = (val) => {
|
||||
const str = String(val ?? "");
|
||||
return str.includes(" ") || str.includes("\n") || str.includes('"') ? '"' + str.replace(/"/g, '""') + '"' : str;
|
||||
};
|
||||
const parseTSV = (text) => {
|
||||
const rows = [];
|
||||
let curRow = [], curCell = "", inQuote = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i], next = text[i + 1];
|
||||
if (inQuote) {
|
||||
if (c === '"' && next === '"') {
|
||||
curCell += '"';
|
||||
i++;
|
||||
} else if (c === '"') inQuote = false;
|
||||
else curCell += c;
|
||||
} else {
|
||||
if (c === '"') inQuote = true;
|
||||
else if (c === " ") {
|
||||
curRow.push(curCell);
|
||||
curCell = "";
|
||||
} else if (c === "\n") {
|
||||
curRow.push(curCell);
|
||||
rows.push(curRow);
|
||||
curRow = [];
|
||||
curCell = "";
|
||||
} else if (c !== "\r") curCell += c;
|
||||
}
|
||||
}
|
||||
curRow.push(curCell);
|
||||
rows.push(curRow);
|
||||
return rows;
|
||||
};
|
||||
container.copy = async () => {
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
||||
if (rMin === -1) return;
|
||||
const text = state.list.slice(rMin, rMax + 1).map(
|
||||
(row) => state.fields.slice(fMin, fMax + 1).map((f) => escapeTSV(row[f.id])).join(" ")
|
||||
).join("\n");
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
container.paste = async () => {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const rows = parseTSV(text);
|
||||
const rStart = Math.min(state.selStartR, state.selEndR);
|
||||
const fStart = Math.min(state.selStartF, state.selEndF);
|
||||
if (rStart === -1) return;
|
||||
rows.forEach((rowData, i) => {
|
||||
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.startSelect = selection.startSelect;
|
||||
container.updateSelect = selection.updateSelect;
|
||||
container.deleteSelected = selection.deleteSelected;
|
||||
container.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "c") {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "c") {
|
||||
e.preventDefault();
|
||||
container.copy();
|
||||
selection.copy();
|
||||
}
|
||||
if (e.key === "v") {
|
||||
if (k === "v") {
|
||||
e.preventDefault();
|
||||
container.paste();
|
||||
selection.paste();
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") container.hideEditor(false);
|
||||
});
|
||||
const onGlobalMouseDown = (e) => !container.contains(e.target) && container.clearAllActive();
|
||||
const onGlobalMouseDown = (e) => {
|
||||
const overlay = container.querySelector(".dt-editor-overlay");
|
||||
if (overlay && overlay.style.display !== "none" && !overlay.contains(e.target)) {
|
||||
container.hideEditor(true);
|
||||
}
|
||||
if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target))) selection.clearAllActive();
|
||||
};
|
||||
window.addEventListener("mouseup", selection.endSelect);
|
||||
document.addEventListener("mousedown", onGlobalMouseDown);
|
||||
window.addEventListener("mouseup", container.endSelect);
|
||||
container._onUnload = () => {
|
||||
document.removeEventListener("mousedown", onGlobalMouseDown);
|
||||
window.removeEventListener("mouseup", container.endSelect);
|
||||
window.removeEventListener("mouseup", selection.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 class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" onunload="this._onUnload()" tabindex="0">
|
||||
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
|
||||
$onmousedown="this.onMainMouseDown(event)"
|
||||
$onmouseover="this.onMainMouseOver(event)"
|
||||
$ondblclick="this.onMainDblClick(event)"
|
||||
style="overflow-anchor:none; min-height: 0">
|
||||
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||
<div class="dt-row dt-header-row fw-bold text-muted small">
|
||||
<div $each="this.state?.fields || []" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="item.name" class="text-truncate"></span>
|
||||
</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 class="dt-body" style="position:relative">
|
||||
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
|
||||
<div $each="this.state?._renderedList || []" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
||||
<div $each="this.state?.fields || []" as="f" index="fIdx" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="this.format(item[f.id], f)" class="text-truncate"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
||||
</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)"/>
|
||||
<!-- Shared Editor Overlay -->
|
||||
<div class="dt-editor-overlay" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
||||
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
|
||||
</div>
|
||||
|
||||
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
|
||||
<div class="flex-grow-1">
|
||||
Total: <span $text="this.state?.list?.length || 0"></span> |
|
||||
Selected Rows: <span $text="this.state?.selectedRowCount || 0"></span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="!this.state?.selectedRowCount" $onclick="this.deleteSelected()">
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
</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>
|
||||
`
|
||||
), Util.makeDom(
|
||||
/*html*/
|
||||
`
|
||||
<style>
|
||||
DataTable { display: block; }
|
||||
.dt-root { font-size: 0.875rem; }
|
||||
.dt-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dt-grid-template);
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
.dt-header-row {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
.dt-cell {
|
||||
background: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-body-row:hover {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
.dt-cell-selected {
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
.dt-editor-overlay {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
border: 1px solid var(--bs-primary);
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
transition: top 0.1s ease, left 0.1s ease, width 0.1s ease, height 0.1s ease, min-height 0.1s ease;
|
||||
}
|
||||
.dt-editor-overlay .auto-form-root, .dt-editor-overlay form {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.dt-editor-overlay .form-control, .dt-editor-overlay .form-select {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.dt-editor-overlay .form-check.form-switch {
|
||||
padding-left: 2.5em;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
));
|
||||
if (typeof document !== "undefined") RefreshState(document.documentElement);
|
||||
|
||||
2
dist/datatable.min.js
vendored
2
dist/datatable.min.js
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@web/datatable",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@web/datatable",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@web/datatable",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.4",
|
||||
"type": "module",
|
||||
"main": "dist/datatable.js",
|
||||
"module": "dist/datatable.js",
|
||||
|
||||
453
src/index.js
453
src/index.js
@ -1,235 +1,312 @@
|
||||
import { Component, NewState, Util, RefreshState } from '@web/state'
|
||||
import { VirtualScroll } from '@web/base'
|
||||
import { State } 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: [],
|
||||
list: [], fields: [], _renderedList: [],
|
||||
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||
selStartR: -1, selStartF: -1, selEndR: -1, selEndF: -1,
|
||||
multiSelections: [],
|
||||
isSelecting: false
|
||||
selectedRowCount: 0
|
||||
})
|
||||
|
||||
const perf = createPerfMonitor();
|
||||
state.perf = perf.stats;
|
||||
|
||||
const selection = createSelectionManager(container, state);
|
||||
|
||||
const scroll = createScrollManager(container, state, (renderedCount) => {
|
||||
// Only hide editor if we actually scroll past its visibility range
|
||||
// For now, simple hide for safety
|
||||
container.hideEditor();
|
||||
selection.applySelectionUI();
|
||||
});
|
||||
|
||||
let _prevSpacer, _postSpacer, _editorOverlay;
|
||||
|
||||
container.format = (val, field) => {
|
||||
if (field.formatter) return field.formatter(val, field);
|
||||
if (typeof val === 'string') return val;
|
||||
if (val === null || val === undefined) return '';
|
||||
return typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
};
|
||||
|
||||
container.refreshNode = (node) => RefreshState(node);
|
||||
|
||||
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
|
||||
})
|
||||
const frameStart = perf.startFrame();
|
||||
scroll.refresh();
|
||||
|
||||
// Manual DOM Sync for Spacers (Authorized optimization to avoid $if/$style overhead)
|
||||
if (!_prevSpacer) _prevSpacer = container.querySelector('.dt-spacer-prev');
|
||||
if (_prevSpacer) {
|
||||
const h = state.prevHeight || 0;
|
||||
_prevSpacer.style.height = h + 'px';
|
||||
_prevSpacer.style.display = h > 0 ? 'block' : 'none';
|
||||
}
|
||||
if (!_postSpacer) _postSpacer = container.querySelector('.dt-spacer-post');
|
||||
if (_postSpacer) {
|
||||
const h = state.postHeight || 0;
|
||||
_postSpacer.style.height = h + 'px';
|
||||
_postSpacer.style.display = h > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
perf.endFrame(frameStart, state._renderedList.length);
|
||||
};
|
||||
|
||||
container.onScroll = () => {
|
||||
perf.onScroll();
|
||||
container.refresh();
|
||||
};
|
||||
|
||||
// --- Optimized Event Delegation & DOM Interaction ---
|
||||
container.onMainMouseDown = e => {
|
||||
const cell = e.target.closest('.dt-cell');
|
||||
if (!cell) return;
|
||||
const row = cell.closest('.dt-row');
|
||||
if (!row || row.classList.contains('dt-header-row')) return;
|
||||
|
||||
const fIdx = cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.startSelect(absoluteRow, fIdx, e);
|
||||
};
|
||||
|
||||
container.onMainMouseOver = e => {
|
||||
if (!state.isSelecting) return;
|
||||
const cell = e.target.closest('.dt-cell');
|
||||
if (!cell) return;
|
||||
const row = cell.closest('.dt-row');
|
||||
if (!row || row.classList.contains('dt-header-row')) return;
|
||||
|
||||
const fIdx = cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.updateSelect(absoluteRow, fIdx);
|
||||
};
|
||||
|
||||
container.onMainDblClick = e => {
|
||||
const cell = e.target.closest('.dt-cell');
|
||||
if (!cell) return;
|
||||
const row = cell.closest('.dt-row');
|
||||
if (!row || row.classList.contains('dt-header-row')) return;
|
||||
|
||||
const item = row._ref?.item;
|
||||
const field = cell._ref?.f;
|
||||
if (item && field) container.editCell(item, field, cell);
|
||||
};
|
||||
|
||||
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())
|
||||
}
|
||||
scroll.init();
|
||||
scroll.reset(list);
|
||||
})
|
||||
|
||||
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
|
||||
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)
|
||||
})
|
||||
if (!keepSelection) {
|
||||
state.selStartR = -1; state.multiSelections = []
|
||||
}
|
||||
}
|
||||
|
||||
container.startSelect = (r, f, e) => {
|
||||
const alreadySelected = container.isCellSelected(r, f)
|
||||
if (state.editingCell) state.editingCell = null
|
||||
// --- Editing Logic (Manual Control, Permanent DOM) ---
|
||||
let currentEditingNode = null;
|
||||
|
||||
if (e.shiftKey && state.selStartR !== -1) {
|
||||
state.selEndR = r; state.selEndF = f
|
||||
container.editCell = (row, field, cellNode) => {
|
||||
const overlay = container.querySelector('.dt-editor-overlay');
|
||||
const editor = overlay.querySelector('AutoForm');
|
||||
const rect = cellNode.getBoundingClientRect();
|
||||
const rootRect = container.getBoundingClientRect();
|
||||
|
||||
currentEditingNode = cellNode;
|
||||
|
||||
// 1. Precise Positioning with expansion and Z-index
|
||||
let minW = rect.width;
|
||||
if (field.type === 'textarea' || field.type === 'TagsInput') minW = Math.max(rect.width, 300);
|
||||
else if (field.type === 'radio') minW = Math.max(rect.width, 240);
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.left = (rect.left - rootRect.left) + 'px';
|
||||
overlay.style.top = (rect.top - rootRect.top) + 'px';
|
||||
overlay.style.width = minW + 'px';
|
||||
|
||||
// Reset styles to prevent crosstalk from previous editor types
|
||||
overlay.style.height = '';
|
||||
overlay.style.minHeight = '';
|
||||
overlay.style.alignItems = 'center';
|
||||
|
||||
if (field.type === 'textarea' || field.type === 'TagsInput') {
|
||||
overlay.style.minHeight = (field.type === 'textarea' ? 150 : rect.height) + 'px';
|
||||
overlay.style.height = 'auto';
|
||||
overlay.style.alignItems = 'flex-start';
|
||||
} 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
|
||||
}
|
||||
overlay.style.height = (rect.height) + 'px';
|
||||
}
|
||||
|
||||
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f)
|
||||
container.endSelect = () => state.isSelecting = false
|
||||
// 2. Direct Driver: Switch context
|
||||
// Use global State for both schema and data to ensure atomic updates
|
||||
State.editingSchema = [{ ...field, name: field.id, label: '' }];
|
||||
State.editingData = row;
|
||||
RefreshState(overlay);
|
||||
|
||||
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
|
||||
// 3. Focus
|
||||
setTimeout(() => {
|
||||
const input = editor.querySelector('.form-control, .form-select, .form-check-input, input');
|
||||
if (input) input.focus();
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const count = isInRange ? (rMax - rMin + 1) : 0
|
||||
if (count > 1 && globalThis.UI?.toast) {
|
||||
UI.toast(`{#Bulk Editing {num} rows... || ${count}#}`)
|
||||
}
|
||||
container.hideEditor = (save = true) => {
|
||||
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
|
||||
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
|
||||
|
||||
container.clearAllActive(true)
|
||||
row._editingF = f.id
|
||||
row._activeF = fIdx
|
||||
_editorOverlay.style.display = 'none';
|
||||
if (save && currentEditingNode) {
|
||||
RefreshState(currentEditingNode);
|
||||
}
|
||||
State.editingSchema = null;
|
||||
State.editingData = null;
|
||||
currentEditingNode = null;
|
||||
container.focus();
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
// --- Shared Logic ---
|
||||
container.startSelect = selection.startSelect;
|
||||
container.updateSelect = selection.updateSelect;
|
||||
container.deleteSelected = selection.deleteSelected;
|
||||
|
||||
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 k = e.key.toLowerCase();
|
||||
if (k === 'c') {
|
||||
e.preventDefault();
|
||||
selection.copy();
|
||||
}
|
||||
})
|
||||
if (k === 'v') {
|
||||
e.preventDefault();
|
||||
selection.paste();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') container.hideEditor(false);
|
||||
});
|
||||
|
||||
const onGlobalMouseDown = e => {
|
||||
const overlay = container.querySelector('.dt-editor-overlay');
|
||||
if (overlay && overlay.style.display !== 'none' && !overlay.contains(e.target)) {
|
||||
container.hideEditor(true);
|
||||
}
|
||||
if (!container.contains(e.target) && !overlay?.contains(e.target)) selection.clearAllActive();
|
||||
}
|
||||
|
||||
window.addEventListener('mouseup', selection.endSelect);
|
||||
document.addEventListener('mousedown', onGlobalMouseDown);
|
||||
|
||||
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)
|
||||
window.removeEventListener('mouseup', selection.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 class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" onunload="this._onUnload()" tabindex="0">
|
||||
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
|
||||
$onmousedown="this.onMainMouseDown(event)"
|
||||
$onmouseover="this.onMainMouseOver(event)"
|
||||
$ondblclick="this.onMainDblClick(event)"
|
||||
style="overflow-anchor:none; min-height: 0">
|
||||
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||
<div class="dt-row dt-header-row fw-bold text-muted small">
|
||||
<div $each="this.state?.fields || []" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="item.name" class="text-truncate"></span>
|
||||
</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 class="dt-body" style="position:relative">
|
||||
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
|
||||
<div $each="this.state?._renderedList || []" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
||||
<div $each="this.state?.fields || []" as="f" index="fIdx" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="this.format(item[f.id], f)" class="text-truncate"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
||||
</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)"/>
|
||||
<!-- Shared Editor Overlay -->
|
||||
<div class="dt-editor-overlay" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
||||
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
|
||||
</div>
|
||||
|
||||
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
|
||||
<div class="flex-grow-1">
|
||||
Total: <span $text="this.state?.list?.length || 0"></span> |
|
||||
Selected Rows: <span $text="this.state?.selectedRowCount || 0"></span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="!this.state?.selectedRowCount" $onclick="this.deleteSelected()">
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
</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>
|
||||
`), Util.makeDom(/*html*/`
|
||||
<style>
|
||||
DataTable { display: block; }
|
||||
.dt-root { font-size: 0.875rem; }
|
||||
.dt-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dt-grid-template);
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
.dt-header-row {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
.dt-cell {
|
||||
background: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-body-row:hover {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
.dt-cell-selected {
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
.dt-editor-overlay {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
border: 1px solid var(--bs-primary);
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
transition: top 0.1s ease, left 0.1s ease, width 0.1s ease, height 0.1s ease, min-height 0.1s ease;
|
||||
}
|
||||
.dt-editor-overlay .auto-form-root, .dt-editor-overlay form {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.dt-editor-overlay .form-control, .dt-editor-overlay .form-select {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.dt-editor-overlay .form-check.form-switch {
|
||||
padding-left: 2.5em;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
`))
|
||||
|
||||
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||
|
||||
43
src/perf.js
Normal file
43
src/perf.js
Normal file
@ -0,0 +1,43 @@
|
||||
export const createPerfMonitor = () => {
|
||||
let enabled = !!window.__DT_PERF_MODE__;
|
||||
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
|
||||
|
||||
if (enabled && !window.__statePerformanceTelemetry) {
|
||||
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
get stats() { return stats; },
|
||||
enable: () => { enabled = true; },
|
||||
disable: () => { enabled = false; },
|
||||
onScroll: () => { if (enabled) stats.scrollCount++; },
|
||||
startFrame: () => {
|
||||
if (!enabled) return null;
|
||||
return {
|
||||
start: performance.now(),
|
||||
scan: window.__statePerformanceTelemetry?.scanCount || 0,
|
||||
move: window.__statePerformanceTelemetry?.moveCount || 0,
|
||||
reuse: window.__statePerformanceTelemetry?.reuseCount || 0
|
||||
};
|
||||
},
|
||||
endFrame: (startData, renderedCount) => {
|
||||
if (!enabled || !startData) return;
|
||||
stats.refreshCount++;
|
||||
stats.totalNodes += renderedCount;
|
||||
|
||||
const elapsed = performance.now() - startData.start;
|
||||
stats.refreshTime += elapsed;
|
||||
|
||||
const stPerf = window.__statePerformanceTelemetry;
|
||||
if (stPerf) {
|
||||
const scans = stPerf.scanCount - startData.scan;
|
||||
const moves = stPerf.moveCount - startData.move;
|
||||
const reuses = stPerf.reuseCount - startData.reuse;
|
||||
|
||||
if (scans > 0 || elapsed > 2) {
|
||||
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
43
src/scroll.js
Normal file
43
src/scroll.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { VirtualScroll } from '@web/base';
|
||||
|
||||
export const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
const vs = VirtualScroll({ itemHeight: 40 });
|
||||
let scrollEl = null;
|
||||
|
||||
const refresh = () => {
|
||||
if (!scrollEl) return;
|
||||
const res = vs.calc(scrollEl, state.list);
|
||||
if (res) {
|
||||
// Dirty check: Abort if layout boundaries haven't actually changed
|
||||
if (state.prevHeight === res.prevHeight &&
|
||||
state.postHeight === res.postHeight &&
|
||||
state._listStartIndex === res.listStartIndex &&
|
||||
state._renderedList.length === res.renderedList.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) || [];
|
||||
if (state.list === list) {
|
||||
vs.init(list, refresh);
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
onScroll: refresh
|
||||
};
|
||||
};
|
||||
252
src/selection.js
Normal file
252
src/selection.js
Normal file
@ -0,0 +1,252 @@
|
||||
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);
|
||||
};
|
||||
|
||||
let lastHadSelection = false;
|
||||
|
||||
const applySelectionUI = () => {
|
||||
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;
|
||||
|
||||
// Fast path: If no current selection AND last frame also had no selection, skip EVERYTHING.
|
||||
if (!hasSelection && !lastHadSelection) return;
|
||||
lastHadSelection = hasSelection;
|
||||
|
||||
const body = container.querySelector('.dt-body');
|
||||
if (!body) return;
|
||||
const rowNodes = body.children;
|
||||
|
||||
// Fast path for scroll performance: Only iterate if selection overlaps or we need to clear
|
||||
for (let r = 0; r < rowNodes.length; r++) {
|
||||
const rowNode = rowNodes[r];
|
||||
if (!rowNode.classList.contains('dt-body-row')) continue;
|
||||
|
||||
const absoluteRow = (rowNode._ref?.rIdx ?? -1) + 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');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0;
|
||||
|
||||
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 && !e.ctrlKey && !e.metaKey) {
|
||||
// If clicking inside a range or a single selection, don't clear yet.
|
||||
// Mark for potential single-cell toggle-off on mouseup if it was just a single cell.
|
||||
if (!isRange) container._potentialCancel = { row, col };
|
||||
// else: Keep current selection as is (sticky selection)
|
||||
} else {
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
clearAllActive();
|
||||
} else if (activeBounds && !alreadySelected) {
|
||||
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)
|
||||
};
|
||||
container._potentialCancel = null; // Dragging means no cancel
|
||||
applySelectionUI();
|
||||
updateStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const endSelect = () => {
|
||||
if (container._potentialCancel) {
|
||||
const { row, col } = container._potentialCancel;
|
||||
if (isCellSelected(row, col)) {
|
||||
clearAllActive();
|
||||
}
|
||||
container._potentialCancel = null;
|
||||
}
|
||||
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 };
|
||||
};
|
||||
|
||||
// Excel-style TSV Copy
|
||||
const copy = async () => {
|
||||
const bounds = getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
|
||||
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
|
||||
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
|
||||
let val = String(row[f.id] ?? '');
|
||||
if (val.includes('\t') || val.includes('\n') || val.includes('"')) {
|
||||
val = '"' + val.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return val;
|
||||
}).join('\t');
|
||||
}).join('\n');
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
// Excel-style TSV Paste (Optimized for targeted refresh)
|
||||
const paste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (!text) return;
|
||||
|
||||
const bounds = getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
|
||||
// Simple TSV parser
|
||||
const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => {
|
||||
const cells = [];
|
||||
let current = '', inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i+1] === '"') { current += '"'; i++; }
|
||||
else inQuotes = !inQuotes;
|
||||
} else if (char === '\t' && !inQuotes) {
|
||||
cells.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
cells.push(current);
|
||||
return cells;
|
||||
});
|
||||
|
||||
const startRow = bounds.minRow;
|
||||
const startCol = bounds.minCol;
|
||||
const maxRow = bounds.maxRow;
|
||||
const maxCol = bounds.maxCol;
|
||||
const body = container.querySelector('.dt-body');
|
||||
const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : [];
|
||||
|
||||
rows.forEach((rowData, rOffset) => {
|
||||
const rIdx = startRow + rOffset;
|
||||
if (rIdx > maxRow || rIdx >= state.list.length) return;
|
||||
|
||||
const rowItem = state.list[rIdx];
|
||||
let rowChanged = false;
|
||||
|
||||
rowData.forEach((cellData, cOffset) => {
|
||||
const cIdx = startCol + cOffset;
|
||||
if (cIdx > maxCol || cIdx >= state.fields.length) return;
|
||||
const field = state.fields[cIdx];
|
||||
rowItem[field.id] = cellData;
|
||||
rowChanged = true;
|
||||
});
|
||||
|
||||
// Targeted DOM refresh to avoid full list scan lag
|
||||
if (rowChanged && container.refreshNode) {
|
||||
const domNode = rowNodes.find(n => (n._ref?.rIdx ?? -1) + state._listStartIndex === rIdx);
|
||||
if (domNode) container.refreshNode(domNode);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Paste Error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSelected = () => {
|
||||
const bounds = getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
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,
|
||||
copy,
|
||||
paste
|
||||
};
|
||||
};
|
||||
@ -70,26 +70,16 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
||||
const row = table.state.list[0];
|
||||
row._editingF = null;
|
||||
});
|
||||
await page.waitForSelector(editorSelector, { state: 'detached', timeout: 5000 });
|
||||
|
||||
// 验证数据层 (State.js Proxy) 是否更新
|
||||
const actualStateVal = await page.evaluate((id) => document.querySelector('DataTable').state.list[0][id], field.id);
|
||||
console.log(`Column [${field.id}] State value:`, actualStateVal);
|
||||
|
||||
if (field.id === 'tags') {
|
||||
expect(actualStateVal).toContain('Playwright');
|
||||
} else if (field.type === 'number') {
|
||||
expect(Number(actualStateVal)).toBe(99);
|
||||
} else {
|
||||
expect(String(actualStateVal)).toBe(String(newValue));
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 验证视图层 (DOM) 是否同步渲染
|
||||
const cellText = await page.evaluate((idx) => {
|
||||
const rows = document.querySelectorAll('.dt-row');
|
||||
if (!rows.length) return 'NO_ROWS';
|
||||
const row = rows[0];
|
||||
const cells = row.querySelectorAll('.dt-cell');
|
||||
return cells[idx - 1].textContent.trim();
|
||||
const cells = Array.from(row.querySelectorAll('.dt-cell'));
|
||||
return cells[idx - 1] ? cells[idx - 1].textContent.trim() : 'NOT_FOUND';
|
||||
}, colIdx);
|
||||
console.log(`Column [${field.id}] UI text:`, cellText);
|
||||
|
||||
|
||||
40
test/correctness.spec.js
Normal file
40
test/correctness.spec.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('DataTable data correctness test', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
await page.goto('/test/index.html');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check initial data
|
||||
const initialData = await page.evaluate(() => {
|
||||
const firstRow = document.querySelector('.dt-body-row');
|
||||
const cells = firstRow.querySelectorAll('.dt-cell span');
|
||||
return Array.from(cells).map(c => c.textContent);
|
||||
});
|
||||
console.log('Initial Row 1 Data:', initialData);
|
||||
expect(initialData[0]).toBe('1'); // ID column
|
||||
|
||||
// Scroll down and check if data updates correctly
|
||||
const scrolledData = await page.evaluate(async () => {
|
||||
const dt = document.getElementById('myTable');
|
||||
const scrollEl = dt.querySelector('.dt-main');
|
||||
scrollEl.scrollTop = 2000; // Scroll past buffer
|
||||
scrollEl.dispatchEvent(new Event('scroll')); // Force trigger
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
|
||||
const firstRow = document.querySelector('.dt-body-row');
|
||||
const cells = firstRow.querySelectorAll('.dt-cell span');
|
||||
return {
|
||||
scrollTop: scrollEl.scrollTop,
|
||||
listStartIndex: dt.state._listStartIndex,
|
||||
row1Data: Array.from(cells).map(c => c.textContent)
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Scrolled Data:', JSON.stringify(scrolledData, null, 2));
|
||||
|
||||
// If scrolling worked, listStartIndex should update correctly
|
||||
expect(scrolledData.listStartIndex).toBeGreaterThan(10);
|
||||
// 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);
|
||||
});
|
||||
68
test/diagnostic.html
Normal file
68
test/diagnostic.html
Normal file
@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AutoForm Diagnostic</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "../../state/src/index.js",
|
||||
"@web/base": "../../base/src/index.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import { Component, NewState, RefreshState } from '@web/state';
|
||||
import '@web/base';
|
||||
window.RefreshState = RefreshState;
|
||||
|
||||
window.DiagnosticState = NewState({
|
||||
schema: [],
|
||||
data: {}
|
||||
});
|
||||
|
||||
window.runDiagnostic = async () => {
|
||||
const form = document.getElementById('diagForm');
|
||||
if (!form.state) form.state = NewState({ schema: [] });
|
||||
|
||||
console.log('--- STEP 1: TEXT ---');
|
||||
DiagnosticState.schema = [{ id: 't1', name: 't1', label: '', type: 'text' }];
|
||||
DiagnosticState.data = { t1: 'Hello World' };
|
||||
form.state.schema = DiagnosticState.schema;
|
||||
form.data = DiagnosticState.data;
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
console.log('Input value:', form.querySelector('input')?.value);
|
||||
|
||||
console.log('--- STEP 2: TAGS ---');
|
||||
DiagnosticState.schema = [{ id: 'tags', name: 'tags', label: '', type: 'TagsInput' }];
|
||||
DiagnosticState.data = { tags: ['A', 'B'] };
|
||||
form.state.schema = DiagnosticState.schema;
|
||||
form.data = DiagnosticState.data;
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
console.log('Tags count:', form.querySelectorAll('TagsInput button').length);
|
||||
|
||||
console.log('--- STEP 3: RADIO ---');
|
||||
DiagnosticState.schema = [{ id: 'r1', name: 'r1', label: '', type: 'radio', options: ['Op1', 'Op2'] }];
|
||||
DiagnosticState.data = { r1: 'Op2' };
|
||||
form.state.schema = DiagnosticState.schema;
|
||||
form.data = DiagnosticState.data;
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
console.log('Radio Op2 checked:', form.querySelector('input[value="Op2"]')?.checked);
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
RefreshState(document.documentElement);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="p-4">
|
||||
<div style="width: 300px; border: 1px solid blue; min-height: 100px;">
|
||||
<AutoForm id="diagForm" inline></AutoForm>
|
||||
</div>
|
||||
<button class="btn btn-danger mt-3" onclick="runDiagnostic()">START DIAGNOSTIC</button>
|
||||
</body>
|
||||
</html>
|
||||
12
test/diagnostic.spec.js
Normal file
12
test/diagnostic.spec.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('AutoForm granular diagnostics', 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/diagnostic.html');
|
||||
await page.waitForTimeout(500);
|
||||
await page.click('button:has-text("START DIAGNOSTIC")');
|
||||
await page.waitForTimeout(3000);
|
||||
const html = await page.evaluate(() => document.getElementById('diagForm').innerHTML);
|
||||
console.log('FORM HTML:', html);
|
||||
});
|
||||
103
test/diagnostic_full.html
Normal file
103
test/diagnostic_full.html
Normal file
@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AutoForm Comprehensive Diagnostic</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "../../state/src/index.js",
|
||||
"@web/base": "../../base/src/index.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import { Component, NewState, RefreshState } from '@web/state';
|
||||
import '@web/base';
|
||||
window.RefreshState = RefreshState;
|
||||
window.NewState = NewState;
|
||||
|
||||
window.DiagnosticState = {
|
||||
data: NewState({
|
||||
text: 'initial',
|
||||
tags: ['A', 'B'],
|
||||
area: 'long text',
|
||||
sel: 'v1'
|
||||
})
|
||||
};
|
||||
|
||||
const log = (msg, color = 'black') => {
|
||||
const pre = document.getElementById('log');
|
||||
const div = document.createElement('div');
|
||||
div.style.color = color;
|
||||
div.textContent = msg;
|
||||
pre.appendChild(div);
|
||||
console.log(msg);
|
||||
};
|
||||
|
||||
window.runFullDiagnostic = async () => {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
const form = document.getElementById('diagForm');
|
||||
if (!form.state) form.state = NewState({ schema: [] });
|
||||
|
||||
const assert = (condition, msg) => {
|
||||
if (condition) log('✅ ' + msg, 'green');
|
||||
else log('❌ ' + msg, 'red');
|
||||
};
|
||||
|
||||
log('--- STEP 1: Text Input ---');
|
||||
form.state.schema = [{ id: 'text', name: 'text', label: '', type: 'text' }];
|
||||
form.data = DiagnosticState.data;
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(form.querySelector('input[type="text"]') !== null, 'Text input rendered');
|
||||
assert(form.querySelector('input[type="text"]').value === 'initial', 'Text value correct');
|
||||
|
||||
log('--- STEP 2: Switch to TextArea ---');
|
||||
form.state.schema = [{ id: 'area', name: 'area', label: '', type: 'textarea' }];
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(form.querySelector('textarea') !== null, 'TextArea rendered');
|
||||
assert(form.querySelector('input[type="text"]') === null || form.querySelector('input[type="text"]').offsetParent === null, 'Text input hidden/removed');
|
||||
assert(form.querySelector('textarea').value === 'long text', 'TextArea value correct');
|
||||
|
||||
log('--- STEP 3: Switch to TagsInput ---');
|
||||
form.state.schema = [{ id: 'tags', name: 'tags', label: '', type: 'TagsInput' }];
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(form.querySelector('TagsInput') !== null, 'TagsInput rendered');
|
||||
assert(form.querySelector('textarea') === null || form.querySelector('textarea').offsetParent === null, 'TextArea hidden/removed');
|
||||
const tagButtons = form.querySelectorAll('TagsInput button');
|
||||
assert(tagButtons.length === 2, 'Tags count correct: ' + tagButtons.length);
|
||||
assert(tagButtons[0].textContent === 'A', 'Tag A rendered');
|
||||
|
||||
log('--- STEP 4: Test Data Persistence ---');
|
||||
const input = form.querySelector('TagsInput input');
|
||||
input.value = 'C';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(DiagnosticState.data.tags.includes('C'), 'Data saved to proxy: ' + DiagnosticState.data.tags);
|
||||
|
||||
log('--- STEP 5: Switch back to Text & Verify ---');
|
||||
form.state.schema = [{ id: 'text', name: 'text', label: '', type: 'text' }];
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const textInput = form.querySelector('input[type="text"]');
|
||||
textInput.value = 'modified';
|
||||
textInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
assert(DiagnosticState.data.text === 'modified', 'Text data saved: ' + DiagnosticState.data.text);
|
||||
|
||||
log('--- DIAGNOSTIC COMPLETE ---');
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body class="p-4">
|
||||
<h3>AutoForm Comprehensive Diagnostic</h3>
|
||||
<div style="width: 400px; border: 2px solid blue; min-height: 100px; padding: 10px;" class="bg-light">
|
||||
<AutoForm id="diagForm" inline></AutoForm>
|
||||
</div>
|
||||
<button class="btn btn-primary mt-3" onclick="runFullDiagnostic()">RUN COMPREHENSIVE DIAGNOSTIC</button>
|
||||
<div id="log" class="mt-4 p-3 border bg-dark text-light" style="font-family: monospace; white-space: pre-wrap; height: 300px; overflow-y: auto;"></div>
|
||||
</body>
|
||||
</html>
|
||||
23
test/diagnostic_full.spec.js
Normal file
23
test/diagnostic_full.spec.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('AutoForm comprehensive diagnostics', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
await page.goto('/test/diagnostic_full.html');
|
||||
await page.click('button:has-text("RUN COMPREHENSIVE DIAGNOSTIC")');
|
||||
|
||||
// Wait for completion (look for the "DIAGNOSTIC COMPLETE" log entry)
|
||||
const log = page.locator('#log');
|
||||
await expect(log).toContainText('DIAGNOSTIC COMPLETE', { timeout: 10000 });
|
||||
|
||||
// Check for any red "❌" marks
|
||||
const failures = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('#log div'))
|
||||
.filter(div => div.textContent.includes('❌'))
|
||||
.map(div => div.textContent);
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Diagnostic Failures:', failures);
|
||||
}
|
||||
expect(failures).toHaveLength(0);
|
||||
});
|
||||
@ -8,44 +8,29 @@
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
body, html { height: 100%; margin: 0; padding: 0; overflow: hidden; }
|
||||
.demo-container { height: 100vh; padding: 20px; display: flex; flex-direction: column; }
|
||||
.table-wrapper { flex-grow: 1; min-height: 0; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-light">
|
||||
<div class="demo-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0 text-primary fw-bold">DataTable Professional</h4>
|
||||
<div class="text-muted small">Double-click to edit • Drag/Shift to select • Ctrl+C/V to Copy/Paste</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<DataTable id="myTable"></DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "../../state/src/index.js",
|
||||
"@web/base": "../../base/src/index.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import { RefreshState, NewState } from '@web/state'
|
||||
import { RefreshState } from '@web/state'
|
||||
import '@web/base'
|
||||
import '../src/index.js'
|
||||
|
||||
@ -69,30 +54,30 @@
|
||||
name: 'User ' + (i + 1),
|
||||
role: ['Admin', 'Editor', 'Viewer'][i % 3],
|
||||
active: i % 2 === 0,
|
||||
bio: 'This is the bio for user ' + (i + 1) + '. It might be a long text that needs a textarea for editing.',
|
||||
bio: 'Bio ' + (i + 1),
|
||||
gender: ['Male', 'Female', 'Other'][i % 3],
|
||||
tags: ['State.js', 'Vite', 'Playwright'].slice(0, (i % 3) + 1),
|
||||
score: Math.floor(Math.random() * 100),
|
||||
created: new Date().toISOString().split('T')[0],
|
||||
tags: ['State.js', 'Vite'].slice(0, (i % 2) + 1),
|
||||
score: 80,
|
||||
created: '2026-05-17',
|
||||
actions: '...'
|
||||
}))
|
||||
|
||||
const data = generateData(500)
|
||||
const data = generateData(1000)
|
||||
|
||||
// 数据初始化逻辑
|
||||
setTimeout(() => {
|
||||
Object.assign(table.state, {
|
||||
fields: fields,
|
||||
list: data
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
window.testStatus = 'passed'
|
||||
console.log('DataTable initialized in test page')
|
||||
}, 500)
|
||||
}, 200)
|
||||
const init = () => {
|
||||
if (table.state) {
|
||||
Object.assign(table.state, { fields, list: data });
|
||||
RefreshState(table);
|
||||
window.testStatus = 'passed';
|
||||
} else {
|
||||
setTimeout(init, 50);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
RefreshState(document.documentElement);
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
52
test/minimal_perf.html
Normal file
52
test/minimal_perf.html
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script>
|
||||
window.__DT_PERF_MODE__ = true;
|
||||
</script>
|
||||
<script type="module">
|
||||
import { Component, RefreshState } from '@web/state';
|
||||
import '../src/index.js';
|
||||
|
||||
window.startTest = async () => {
|
||||
const fields = Array.from({length: 10}, (_, i) => ({
|
||||
id: 'col' + i,
|
||||
name: 'Column ' + i,
|
||||
width: 150
|
||||
}));
|
||||
|
||||
const data = Array.from({length: 10000}, (_, i) => {
|
||||
const row = { id: i };
|
||||
fields.forEach(f => row[f.id] = `Row ${i} ${f.name}`);
|
||||
return row;
|
||||
});
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('style', 'height: 400px; width: 800px; border: 1px solid red');
|
||||
div.innerHTML = '<DataTable id="dt" style="display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden"></DataTable>';
|
||||
document.body.appendChild(div);
|
||||
|
||||
RefreshState(document.body);
|
||||
|
||||
const dt = document.getElementById('dt');
|
||||
dt.state.fields = fields;
|
||||
dt.state.list = data;
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
const scrollTarget = dt.querySelector('.dt-main');
|
||||
console.log('root clientHeight:', scrollTarget.clientHeight);
|
||||
console.log('root scrollHeight:', scrollTarget.scrollHeight);
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
scrollTarget.scrollTop += 200;
|
||||
scrollTarget.dispatchEvent(new Event('scroll'));
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(dt.state.perf));
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
17
test/minimal_perf.spec.js
Normal file
17
test/minimal_perf.spec.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('Minimal DataTable performance test', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
page.on('pageerror', err => console.log('PAGE ERROR:', err));
|
||||
await page.goto('/test/minimal_perf.html');
|
||||
await page.waitForTimeout(1000);
|
||||
const result = await page.evaluate(async () => {
|
||||
const perf = await window.startTest();
|
||||
return {
|
||||
perf,
|
||||
stPerf: window.__stPerf
|
||||
};
|
||||
});
|
||||
console.log('Final Performance Stats:', JSON.stringify(result, null, 2));
|
||||
expect(result.perf.scrollCount).toBeGreaterThan(0);
|
||||
});
|
||||
26
test/validation.spec.js
Normal file
26
test/validation.spec.js
Normal file
@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('DataTable DOM Inspection', async ({ page }) => {
|
||||
await page.goto('/test/index.html');
|
||||
await expect(page.locator('DataTable')).toBeVisible();
|
||||
await page.waitForTimeout(1000); // Wait longer for state.js to sync
|
||||
|
||||
const firstCol = page.locator('.dt-header .dt-col').first();
|
||||
const html = await firstCol.innerHTML();
|
||||
console.log('First Col Inner HTML:', html);
|
||||
|
||||
const structure = await firstCol.evaluate(el => {
|
||||
return {
|
||||
tagName: el.tagName,
|
||||
className: el.className,
|
||||
style: el.getAttribute('style'),
|
||||
children: Array.from(el.children).map(c => ({
|
||||
tagName: c.tagName,
|
||||
className: c.className,
|
||||
text: c.innerText,
|
||||
width: c.offsetWidth
|
||||
}))
|
||||
};
|
||||
});
|
||||
console.log('First Col Structure:', JSON.stringify(structure, null, 2));
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user