188 lines
9.5 KiB
Plaintext
188 lines
9.5 KiB
Plaintext
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)
|