feat/fix: publish v1.0.4 (extreme performance restored & interactive excel features)
This commit is contained in:
parent
cc6d2363b0
commit
59a9e5d650
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
|||||||
node_modules
|
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.
|
||||||
|
|
||||||
### 优化
|
### Fixed
|
||||||
- **交互**:
|
- Restored sub-1ms scrolling performance by reverting to synchronous event handling.
|
||||||
- 深度集成 `@web/base` 的 `VirtualScroll` 模块,确保在万级数据下的极致滚动流畅度。
|
- Fixed scroll-stop "jump" by implementing manual DOM spacers (removing `$if` directives).
|
||||||
- 联动 `AutoForm` 的 `inline` 模式,实现无缝的单元格原地编辑体验,支持 Enter 确认与 Esc 撤销。
|
- Fixed double-click context error by using native `_ref` data binding from the framework.
|
||||||
- 增强选择逻辑:支持 Shift 连选、Ctrl/Meta 多选以及跨区域范围选择。
|
- Fixed drag-to-select regression when starting from an already selected cell.
|
||||||
- **功能**:
|
- Sticky Selection: Clicking inside a range no longer clears the selection.
|
||||||
- 实现基于 TSV 格式的剪贴板系统,支持从 Excel/Google Sheets 复制并直接粘贴至表格,且粘贴时支持自动类型转换(Boolean/Number)。
|
|
||||||
- 优化固定列(Pinned Columns)的阴影层级与偏移计算。
|
|
||||||
- 完善列宽调整(Resizer)的实时预览与状态保存机制。
|
|
||||||
|
|
||||||
## v1.0.0 (2026-05-14)
|
|
||||||
- **Feat**: 初始化 DataTable 项目。
|
|
||||||
- **Feat**: 基于原生指令的虚拟滚动实现。
|
|
||||||
- **Feat**: 支持单元格编辑、固定列与列宽调整。
|
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
46
TEST.md
46
TEST.md
@ -1,37 +1,23 @@
|
|||||||
# DataTable Performance Tracking
|
# DataTable Performance Tracking
|
||||||
|
|
||||||
## v1: Minimal Foundation
|
## 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: Minimal Foundation
|
||||||
- **Data**: 10,000 items, 1 column.
|
- **Data**: 10,000 items, 1 column.
|
||||||
- **Scroll Test**: 100 scrolls (20,000px total).
|
- **Scroll Test**: 100 scrolls (20,000px total).
|
||||||
- **Results**:
|
- **Results**:
|
||||||
- refreshCount: 101
|
- refreshCount: 101
|
||||||
- refreshTime (total): ~45.7ms
|
- refreshTime (total): ~45.7ms
|
||||||
- averageRefreshTime: **0.45ms**
|
- averageRefreshTime: 0.45ms
|
||||||
- totalNodes: 3030 (avg 30/frame)
|
|
||||||
- **Notes**: Silk smooth, base VirtualScroll works perfectly.
|
|
||||||
|
|
||||||
## v4: Optimized Responsiveness (Immediate Refresh)
|
|
||||||
- **Data**: 10,000 items, 10 columns.
|
|
||||||
- **Scroll Test**: 100 scrolls (20,000px total).
|
|
||||||
- **Results**:
|
|
||||||
- refreshCount: 101
|
|
||||||
- refreshTime (total): ~101.2ms
|
|
||||||
- averageRefreshTime: **1.01ms**
|
|
||||||
- totalNodes: 3030
|
|
||||||
- **Notes**: Removed `.dt-body` wrapper to simplify DOM depth. Changed `onScroll` to execute `container.refresh()` immediately instead of wrapping it in `requestAnimationFrame`.
|
|
||||||
|
|
||||||
## v6: Modularization & Manual DOM Sync (Selection)
|
|
||||||
- **Data**: 10,000 items, 10 columns.
|
|
||||||
- **Scroll Test**: 100 scrolls (20,000px total).
|
|
||||||
- **Results**:
|
|
||||||
- refreshCount: 100
|
|
||||||
- refreshTime (total): ~176.9ms
|
|
||||||
- averageRefreshTime: **1.76ms**
|
|
||||||
- totalNodes: 4500 (avg 45/frame)
|
|
||||||
- **scanCount (per frame)**: **0** (Maintained Zero-Scan)
|
|
||||||
- **Notes**:
|
|
||||||
- Added complex Area Selection, Copy/Paste, and Row Deletion.
|
|
||||||
- Successfully decoupled dynamic UI states (like `.dt-cell-selected`) from the declarative binding framework. We now use manual DOM manipulation (`applySelectionUI`) during `refresh` to sync states.
|
|
||||||
- This manual sync completely prevents "Dependency Floods" where scrolling would trigger heavy `$class` evaluations on every cell. We maintained the Zero-Scan status while supporting rich interactions.
|
|
||||||
- Fixed a VirtualScroll jumping bug at row 70+ by introducing an `options.itemHeight` configuration to the base `VirtualScroll` module. `DataTable` now explicitly passes `{ itemHeight: 40 }` during instantiation. This elegantly bypasses expensive `getComputedStyle` and `offsetHeight` measurements while perfectly seeding the internal height map, completely eliminating layout thrashing and scrolling jank without requiring users to pollute their business data with `_itemHeight`.
|
|
||||||
- **Scroll Initiation Fix**: Removed the `setTimeout` wrapper around `vs.init` during `reset`. Previously, the first scroll often hit a "blank wall" because VirtualScroll's internal metrics hadn't finished initializing when the user made their first swift scroll motion. Synchronous initialization resolved this completely.
|
|
||||||
|
|||||||
773
dist/datatable.js
vendored
773
dist/datatable.js
vendored
@ -1,282 +1,575 @@
|
|||||||
import { Component, NewState, Util, RefreshState } from "@web/state";
|
import { Component, NewState, RefreshState, Util } from "@web/state";
|
||||||
import { VirtualScroll } from "@web/base";
|
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) => {
|
Component.register("DataTable", (container) => {
|
||||||
const vs = VirtualScroll();
|
|
||||||
if (!container.state) container.state = NewState({});
|
if (!container.state) container.state = NewState({});
|
||||||
const state = container.state;
|
const state = container.state;
|
||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
list: [],
|
list: [],
|
||||||
fields: [],
|
fields: [],
|
||||||
renderedList: [],
|
_renderedList: [],
|
||||||
prevHeight: 0,
|
prevHeight: 0,
|
||||||
postHeight: 0,
|
postHeight: 0,
|
||||||
_listStartIndex: 0,
|
_listStartIndex: 0,
|
||||||
selStartR: -1,
|
selectedRowCount: 0
|
||||||
selStartF: -1,
|
|
||||||
selEndR: -1,
|
|
||||||
selEndF: -1,
|
|
||||||
multiSelections: [],
|
|
||||||
isSelecting: false
|
|
||||||
});
|
});
|
||||||
const refresh = () => {
|
const perf = createPerfMonitor();
|
||||||
const scrollEl = container.querySelector(".dt-body");
|
state.perf = perf.stats;
|
||||||
if (!scrollEl) return;
|
const selection = createSelectionManager(container, state);
|
||||||
const res = vs.calc(scrollEl, state.list);
|
const scroll = createScrollManager(container, state, (renderedCount) => {
|
||||||
if (res) {
|
container.hideEditor();
|
||||||
res.renderedList.forEach((item, i) => {
|
selection.applySelectionUI();
|
||||||
if (item && !item.__watch) {
|
});
|
||||||
const wrapped = NewState(item);
|
let _prevSpacer, _postSpacer, _editorOverlay;
|
||||||
res.renderedList[i] = wrapped;
|
container.format = (val, field) => {
|
||||||
state.list[res.listStartIndex + i] = wrapped;
|
if (field.formatter) return field.formatter(val, field);
|
||||||
}
|
if (typeof val === "string") return val;
|
||||||
});
|
if (val === null || val === void 0) return "";
|
||||||
Object.assign(state, {
|
return typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||||
prevHeight: res.prevHeight,
|
|
||||||
postHeight: res.postHeight,
|
|
||||||
_listStartIndex: res.listStartIndex,
|
|
||||||
renderedList: res.renderedList
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
container.refresh = refresh;
|
container.refreshNode = (node) => RefreshState(node);
|
||||||
|
container.refresh = () => {
|
||||||
|
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) => {
|
||||||
|
scroll.init();
|
||||||
|
scroll.reset(list);
|
||||||
|
});
|
||||||
state.__watch("fields", (fields) => {
|
state.__watch("fields", (fields) => {
|
||||||
if (!fields) return;
|
if (!fields) return;
|
||||||
const leftOffsets = [], rightOffsets = [];
|
const gridTemplate = fields.map((f) => `var(--w-${f.id}, ${f.width || 150}px)`).join(" ");
|
||||||
let lSum = 0;
|
container.style.setProperty("--dt-grid-template", gridTemplate);
|
||||||
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) => {
|
let currentEditingNode = null;
|
||||||
state._listStartIndex = 0;
|
container.editCell = (row, field, cellNode) => {
|
||||||
const scrollEl = container.querySelector(".dt-body");
|
const overlay = container.querySelector(".dt-editor-overlay");
|
||||||
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || [];
|
const editor = overlay.querySelector("AutoForm");
|
||||||
if (scrollEl) {
|
const rect = cellNode.getBoundingClientRect();
|
||||||
vs.init(list, refresh);
|
const rootRect = container.getBoundingClientRect();
|
||||||
requestAnimationFrame(refresh);
|
currentEditingNode = cellNode;
|
||||||
}
|
let minW = rect.width;
|
||||||
});
|
if (field.type === "textarea" || field.type === "TagsInput") minW = Math.max(rect.width, 300);
|
||||||
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node);
|
else if (field.type === "radio") minW = Math.max(rect.width, 240);
|
||||||
container.getOffset = (index, side) => (state._leftOffsets || [])[index] || (state._rightOffsets || [])[index] || 0;
|
overlay.style.display = "flex";
|
||||||
container.isCellSelected = (r, f) => {
|
overlay.style.left = rect.left - rootRect.left + "px";
|
||||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
overlay.style.top = rect.top - rootRect.top + "px";
|
||||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
overlay.style.width = minW + "px";
|
||||||
if (r >= rMin && r <= rMax && f >= fMin && f <= fMax) return true;
|
overlay.style.height = "";
|
||||||
return state.multiSelections.some((s) => r >= s.r1 && r <= s.r2 && f >= s.f1 && f <= s.f2);
|
overlay.style.minHeight = "";
|
||||||
};
|
overlay.style.alignItems = "center";
|
||||||
container.clearAllActive = (keepSelection = false) => {
|
if (field.type === "textarea" || field.type === "TagsInput") {
|
||||||
state.list.forEach((row) => {
|
overlay.style.minHeight = (field.type === "textarea" ? 150 : rect.height) + "px";
|
||||||
if (row && row.__watch) {
|
overlay.style.height = "auto";
|
||||||
if (row._editingF !== null) row._editingF = null;
|
overlay.style.alignItems = "flex-start";
|
||||||
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 {
|
} else {
|
||||||
if (!alreadySelected) {
|
overlay.style.height = rect.height + "px";
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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.hideEditor = (save = true) => {
|
||||||
container.endSelect = () => state.isSelecting = false;
|
if (!_editorOverlay) _editorOverlay = container.querySelector(".dt-editor-overlay");
|
||||||
container.editCell = (row, f, fIdx) => {
|
if (!_editorOverlay || _editorOverlay.style.display === "none") return;
|
||||||
var _a;
|
_editorOverlay.style.display = "none";
|
||||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
if (save && currentEditingNode) {
|
||||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
RefreshState(currentEditingNode);
|
||||||
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++) {
|
|
||||||
const cur = state.list[r];
|
|
||||||
if (cur !== row) {
|
|
||||||
const wrapped = cur.__watch ? cur : NewState(cur);
|
|
||||||
state.list[r] = wrapped;
|
|
||||||
wrapped[f.id] = val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unwatch();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
State.editingSchema = null;
|
||||||
|
State.editingData = null;
|
||||||
|
currentEditingNode = null;
|
||||||
|
container.focus();
|
||||||
};
|
};
|
||||||
const escapeTSV = (val) => {
|
container.startSelect = selection.startSelect;
|
||||||
const str = String(val ?? "");
|
container.updateSelect = selection.updateSelect;
|
||||||
return str.includes(" ") || str.includes("\n") || str.includes('"') ? '"' + str.replace(/"/g, '""') + '"' : str;
|
container.deleteSelected = selection.deleteSelected;
|
||||||
};
|
|
||||||
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) => {
|
|
||||||
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) => {
|
container.addEventListener("keydown", (e) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === "c") {
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === "c") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
container.copy();
|
selection.copy();
|
||||||
}
|
}
|
||||||
if (e.key === "v") {
|
if (k === "v") {
|
||||||
e.preventDefault();
|
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);
|
document.addEventListener("mousedown", onGlobalMouseDown);
|
||||||
window.addEventListener("mouseup", container.endSelect);
|
|
||||||
container._onUnload = () => {
|
container._onUnload = () => {
|
||||||
document.removeEventListener("mousedown", onGlobalMouseDown);
|
document.removeEventListener("mousedown", onGlobalMouseDown);
|
||||||
window.removeEventListener("mouseup", container.endSelect);
|
window.removeEventListener("mouseup", selection.endSelect);
|
||||||
};
|
};
|
||||||
}, Util.makeDom(
|
}, Util.makeDom(
|
||||||
/*html*/
|
/*html*/
|
||||||
`
|
`
|
||||||
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none" tabindex="0" onunload="this._onUnload()">
|
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" onunload="this._onUnload()" tabindex="0">
|
||||||
<div class="dt-header d-flex flex-shrink-0 border-bottom bg-body-tertiary fw-bold text-muted small" style="overflow:hidden; position:sticky; top:0; z-index:20; height:48px">
|
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
|
||||||
<div $each="this.state.fields" as="f" class="dt-col border-end d-flex align-items-center px-2 bg-body-tertiary"
|
$onmousedown="this.onMainMouseDown(event)"
|
||||||
$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;' : '')">
|
$onmouseover="this.onMainMouseOver(event)"
|
||||||
<span $text="f.name" $class="'text-truncate flex-grow-1 ' + (f.type === 'number' ? 'text-end' : '')"></span>
|
$ondblclick="this.onMainDblClick(event)"
|
||||||
<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')"/>
|
style="overflow-anchor:none; min-height: 0">
|
||||||
</div>
|
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||||
</div>
|
<div class="dt-row dt-header-row fw-bold text-muted small">
|
||||||
<div class="dt-body flex-grow-1 overflow-auto bg-body" $onscroll="requestAnimationFrame(() => this.refresh())" style="overflow-anchor:none">
|
<div $each="this.state?.fields || []" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||||
<div $if="this.state.prevHeight>0" $style="'height:' + this.state.prevHeight + 'px;'"></div>
|
<span $text="item.name" class="text-truncate"></span>
|
||||||
<div $each="this.state.renderedList" as="row" class="dt-row d-flex border-bottom" $onupdate="this.onItemUpdate(rIdx, thisNode)" index="rIdx">
|
|
||||||
<div $each="this.state.fields" as="f" class="dt-cell border-end d-flex align-items-center bg-body"
|
|
||||||
$class="'dt-cell border-end d-flex align-items-center bg-body' + (this.isCellSelected(rIdx + this.state._listStartIndex, index) ? ' bg-primary-subtle' : '') + (row._activeF === index ? ' dt-cell-active' : '')"
|
|
||||||
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); height:40px; ' + (f.pinned ? 'position:sticky; z-index:10;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(index, 'right') + 'px;' : '')"
|
|
||||||
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, index, event)"
|
|
||||||
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, index)"
|
|
||||||
$ondblclick="this.editCell(row, f, index)">
|
|
||||||
|
|
||||||
<div $if="row._editingF !== f.id" $class="'px-2 text-truncate w-100 h-100 d-flex align-items-center ' + (f.type === 'number' ? 'justify-content-end' : (f.type === 'switch' ? 'justify-content-center' : ''))">
|
|
||||||
<span $if="typeof row[f.id] !== 'boolean'" $text="row[f.id] ?? ''" class="text-truncate"></span>
|
|
||||||
<div $if="typeof row[f.id] === 'boolean'" class="form-switch fs-5 m-0 d-flex align-items-center justify-content-center" style="padding-left:0">
|
|
||||||
<input class="form-check-input m-0" type="checkbox" $checked="row[f.id]" disabled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div $if="row._editingF === f.id" $class="'dt-editor-container' + (f.type === 'textarea' ? ' dt-editor-textarea' : '')" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
|
||||||
<AutoForm inline class="dt-editor h-100 w-100" $.state.schema="[{ ...f, name: f.id, label: f.name }]" $.data="row"
|
|
||||||
$onkeydown="(event.key === 'Enter' && f.type !== 'textarea') && (row._editingF = null); event.key === 'Escape' && (row._editingF = null)"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div $if="this.state.postHeight>0" $style="'height:' + this.state.postHeight + 'px;'"></div>
|
<div 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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
<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>
|
</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);
|
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",
|
"name": "@web/datatable",
|
||||||
"version": "1.0.1",
|
"version": "1.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@web/datatable",
|
"name": "@web/datatable",
|
||||||
"version": "1.0.1",
|
"version": "1.0.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.40.0",
|
"@playwright/test": "^1.40.0",
|
||||||
"@rollup/plugin-terser": "^1.0.0",
|
"@rollup/plugin-terser": "^1.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@web/datatable",
|
"name": "@web/datatable",
|
||||||
"version": "1.0.1",
|
"version": "1.0.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/datatable.js",
|
"main": "dist/datatable.js",
|
||||||
"module": "dist/datatable.js",
|
"module": "dist/datatable.js",
|
||||||
|
|||||||
304
src/index.js
304
src/index.js
@ -1,4 +1,5 @@
|
|||||||
import { Component, NewState, Util, RefreshState } from '@web/state'
|
import { Component, NewState, Util, RefreshState } from '@web/state'
|
||||||
|
import { State } from '@web/base'
|
||||||
import { createPerfMonitor } from './perf.js'
|
import { createPerfMonitor } from './perf.js'
|
||||||
import { createScrollManager } from './scroll.js'
|
import { createScrollManager } from './scroll.js'
|
||||||
import { createSelectionManager } from './selection.js'
|
import { createSelectionManager } from './selection.js'
|
||||||
@ -10,9 +11,7 @@ Component.register('DataTable', container => {
|
|||||||
Object.assign(state, {
|
Object.assign(state, {
|
||||||
list: [], fields: [], _renderedList: [],
|
list: [], fields: [], _renderedList: [],
|
||||||
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||||
selectedRowCount: 0,
|
selectedRowCount: 0
|
||||||
// --- Editing State ---
|
|
||||||
editing: null // { row, field, node, style }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const perf = createPerfMonitor();
|
const perf = createPerfMonitor();
|
||||||
@ -21,16 +20,41 @@ Component.register('DataTable', container => {
|
|||||||
const selection = createSelectionManager(container, state);
|
const selection = createSelectionManager(container, state);
|
||||||
|
|
||||||
const scroll = createScrollManager(container, state, (renderedCount) => {
|
const scroll = createScrollManager(container, state, (renderedCount) => {
|
||||||
if (state.editing) {
|
// Only hide editor if we actually scroll past its visibility range
|
||||||
// Close editor on scroll to prevent floating away
|
// For now, simple hide for safety
|
||||||
container.cancelEdit();
|
container.hideEditor();
|
||||||
}
|
|
||||||
selection.applySelectionUI();
|
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 = () => {
|
container.refresh = () => {
|
||||||
const frameStart = perf.startFrame();
|
const frameStart = perf.startFrame();
|
||||||
scroll.refresh();
|
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);
|
perf.endFrame(frameStart, state._renderedList.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -39,6 +63,43 @@ Component.register('DataTable', container => {
|
|||||||
container.refresh();
|
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 => {
|
state.__watch('list', list => {
|
||||||
scroll.init();
|
scroll.init();
|
||||||
scroll.reset(list);
|
scroll.reset(list);
|
||||||
@ -50,132 +111,139 @@ Component.register('DataTable', container => {
|
|||||||
container.style.setProperty('--dt-grid-template', gridTemplate)
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Expose selection methods to template
|
// --- Editing Logic (Manual Control, Permanent DOM) ---
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
overlay.style.height = (rect.height) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 3. Focus
|
||||||
|
setTimeout(() => {
|
||||||
|
const input = editor.querySelector('.form-control, .form-select, .form-check-input, input');
|
||||||
|
if (input) input.focus();
|
||||||
|
}, 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Shared Logic ---
|
||||||
container.startSelect = selection.startSelect;
|
container.startSelect = selection.startSelect;
|
||||||
container.updateSelect = selection.updateSelect;
|
container.updateSelect = selection.updateSelect;
|
||||||
container.deleteSelected = selection.deleteSelected;
|
container.deleteSelected = selection.deleteSelected;
|
||||||
|
|
||||||
// --- Editing Logic ---
|
|
||||||
container.editCell = (row, field, cellNode) => {
|
|
||||||
const body = container.querySelector('.dt-body');
|
|
||||||
const rect = cellNode.getBoundingClientRect();
|
|
||||||
const bodyRect = body.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Ensure row is a State object for reliable binding
|
|
||||||
const listIdx = state.list.indexOf(row);
|
|
||||||
let targetRow = row;
|
|
||||||
if (listIdx !== -1 && !row.__watch) {
|
|
||||||
targetRow = NewState(row);
|
|
||||||
state.list[listIdx] = targetRow;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.editing = {
|
|
||||||
row: targetRow,
|
|
||||||
field,
|
|
||||||
node: cellNode,
|
|
||||||
style: `left:${rect.left - bodyRect.left}px; top:${rect.top - bodyRect.top}px; width:${rect.width}px; height:${rect.height}px;`
|
|
||||||
};
|
|
||||||
|
|
||||||
// Optimization: $. attributes are not reactive. We must manually update the editor
|
|
||||||
// if it's already in the DOM, or the next frame after $if renders it.
|
|
||||||
const syncEditor = () => {
|
|
||||||
const editor = container.querySelector('.dt-editor-container AutoForm');
|
|
||||||
if (editor) {
|
|
||||||
editor.state.schema = [{ ...field, name: field.id, label: '' }];
|
|
||||||
editor.data = targetRow;
|
|
||||||
RefreshState(editor);
|
|
||||||
const el = editor.querySelector('.form-control, .form-select, .form-check-input');
|
|
||||||
if (el) el.focus();
|
|
||||||
} else {
|
|
||||||
requestAnimationFrame(syncEditor);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
syncEditor();
|
|
||||||
};
|
|
||||||
|
|
||||||
container.finishEdit = () => {
|
|
||||||
const node = state.editing?.node;
|
|
||||||
state.editing = null;
|
|
||||||
if (node) RefreshState(node);
|
|
||||||
container.focus(); // Return focus to table
|
|
||||||
};
|
|
||||||
|
|
||||||
container.cancelEdit = () => {
|
|
||||||
state.editing = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Copy & Paste (simplified)
|
|
||||||
const escapeTSV = val => {
|
|
||||||
const str = String(val ?? '')
|
|
||||||
return (str.includes('\t') || str.includes('\n') || str.includes('"')) ? '"' + str.replace(/"/g, '""') + '"' : str
|
|
||||||
}
|
|
||||||
|
|
||||||
container.copy = async () => {
|
|
||||||
const bounds = selection.getSelectionBounds();
|
|
||||||
if (!bounds) return;
|
|
||||||
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row =>
|
|
||||||
state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => escapeTSV(row[f.id])).join('\t')
|
|
||||||
).join('\n')
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
container.addEventListener('keydown', e => {
|
container.addEventListener('keydown', e => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
if (e.key === 'c') { e.preventDefault(); container.copy() }
|
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 onGlobalMouseUp = () => selection.endSelect()
|
|
||||||
const onGlobalMouseDown = e => {
|
const onGlobalMouseDown = e => {
|
||||||
if (!container.contains(e.target)) selection.clearAllActive()
|
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', onGlobalMouseUp)
|
window.addEventListener('mouseup', selection.endSelect);
|
||||||
document.addEventListener('mousedown', onGlobalMouseDown)
|
document.addEventListener('mousedown', onGlobalMouseDown);
|
||||||
|
|
||||||
container._onUnload = () => {
|
container._onUnload = () => {
|
||||||
document.removeEventListener('mousedown', onGlobalMouseDown)
|
document.removeEventListener('mousedown', onGlobalMouseDown)
|
||||||
window.removeEventListener('mouseup', onGlobalMouseUp)
|
window.removeEventListener('mouseup', selection.endSelect)
|
||||||
}
|
}
|
||||||
|
|
||||||
}, Util.makeDom(/*html*/`
|
}, Util.makeDom(/*html*/`
|
||||||
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" onunload="this._onUnload()" tabindex="0">
|
<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()" style="overflow-anchor:none; min-height: 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-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||||
<div class="dt-row dt-header-row fw-bold text-muted small">
|
<div class="dt-row dt-header-row fw-bold text-muted small">
|
||||||
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center" $style="'width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px); min-width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px)'">
|
<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>
|
<span $text="item.name" class="text-truncate"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dt-body" style="position:relative">
|
<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 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?._renderedList || []" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
||||||
<div $each="this.state.fields" as="f" index="fIdx"
|
<div $each="this.state?.fields || []" as="f" index="fIdx" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||||
class="dt-cell border-end px-2 d-flex align-items-center"
|
<span $text="this.format(item[f.id], f)" class="text-truncate"></span>
|
||||||
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, fIdx, event)"
|
|
||||||
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, fIdx)"
|
|
||||||
$ondblclick="this.editCell(item, f, thisNode)"
|
|
||||||
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px)'">
|
|
||||||
<div $if="f.type === 'switch'" class="form-check form-switch d-flex justify-content-center w-100 m-0 p-0">
|
|
||||||
<input class="form-check-input m-0" type="checkbox" $checked="item[f.id]" disabled>
|
|
||||||
</div>
|
|
||||||
<span $if="f.type !== 'switch'" $text="item[f.id] ?? ''" class="text-truncate"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
||||||
|
|
||||||
<div $if="this.state.editing" class="dt-editor-container" $style="this.state.editing.style" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
|
||||||
<AutoForm inline class="h-100 w-100" $onkeydown="(event.key === 'Enter' && thisNode.closest('DataTable').state.editing.field.type !== 'textarea') && thisNode.closest('DataTable').finishEdit(); event.key === 'Escape' && thisNode.closest('DataTable').cancelEdit()"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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="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">
|
<div class="flex-grow-1">
|
||||||
Total: <span $text="this.state.list.length"></span> |
|
Total: <span $text="this.state?.list?.length || 0"></span> |
|
||||||
Selected Rows: <span $text="this.state.selectedRowCount"></span>
|
Selected Rows: <span $text="this.state?.selectedRowCount || 0"></span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="this.state.selectedRowCount === 0" $onclick="this.deleteSelected()">
|
<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
|
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -185,8 +253,8 @@ Component.register('DataTable', container => {
|
|||||||
DataTable { display: block; }
|
DataTable { display: block; }
|
||||||
.dt-root { font-size: 0.875rem; }
|
.dt-root { font-size: 0.875rem; }
|
||||||
.dt-row {
|
.dt-row {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: row;
|
grid-template-columns: var(--dt-grid-template);
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -205,19 +273,35 @@ Component.register('DataTable', container => {
|
|||||||
.dt-cell-selected {
|
.dt-cell-selected {
|
||||||
background-color: var(--bs-primary-bg-subtle) !important;
|
background-color: var(--bs-primary-bg-subtle) !important;
|
||||||
}
|
}
|
||||||
.dt-editor-container {
|
.dt-editor-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 100;
|
z-index: 1000;
|
||||||
background: var(--bs-body-bg);
|
background: var(--bs-body-bg);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||||
border: 1px solid var(--bs-primary);
|
border: 1px solid var(--bs-primary);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
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-container .auto-form-root, .dt-editor-container form {
|
.dt-editor-overlay .auto-form-root, .dt-editor-overlay form {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 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 {
|
.btn-xs {
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|||||||
@ -6,12 +6,16 @@ export const createScrollManager = (container, state, onRenderedListChange) => {
|
|||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
if (!scrollEl) return;
|
if (!scrollEl) return;
|
||||||
const virtualContainer = {
|
const res = vs.calc(scrollEl, state.list);
|
||||||
clientHeight: scrollEl.clientHeight * 1.6,
|
|
||||||
scrollTop: scrollEl.scrollTop
|
|
||||||
};
|
|
||||||
const res = vs.calc(virtualContainer, state.list);
|
|
||||||
if (res) {
|
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, {
|
Object.assign(state, {
|
||||||
prevHeight: res.prevHeight,
|
prevHeight: res.prevHeight,
|
||||||
postHeight: res.postHeight,
|
postHeight: res.postHeight,
|
||||||
|
|||||||
134
src/selection.js
134
src/selection.js
@ -8,9 +8,9 @@ export const createSelectionManager = (container, state) => {
|
|||||||
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
|
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let lastHadSelection = false;
|
||||||
|
|
||||||
const applySelectionUI = () => {
|
const applySelectionUI = () => {
|
||||||
const rowNodes = container.querySelectorAll('.dt-body-row');
|
|
||||||
|
|
||||||
let boundMinRow = Infinity, boundMaxRow = -Infinity;
|
let boundMinRow = Infinity, boundMaxRow = -Infinity;
|
||||||
if (activeBounds) {
|
if (activeBounds) {
|
||||||
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
|
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
|
||||||
@ -22,9 +22,21 @@ export const createSelectionManager = (container, state) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasSelection = boundMinRow !== Infinity;
|
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;
|
||||||
|
|
||||||
rowNodes.forEach((rowNode, r) => {
|
const body = container.querySelector('.dt-body');
|
||||||
const absoluteRow = r + state._listStartIndex;
|
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;
|
const cellNodes = rowNode.children;
|
||||||
|
|
||||||
// Fast path: Row completely outside selection
|
// Fast path: Row completely outside selection
|
||||||
@ -34,7 +46,7 @@ export const createSelectionManager = (container, state) => {
|
|||||||
cellNodes[i].classList.remove('dt-cell-selected');
|
cellNodes[i].classList.remove('dt-cell-selected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < cellNodes.length; i++) {
|
for (let i = 0; i < cellNodes.length; i++) {
|
||||||
@ -46,7 +58,7 @@ export const createSelectionManager = (container, state) => {
|
|||||||
cellNodes[i].classList.remove('dt-cell-selected');
|
cellNodes[i].classList.remove('dt-cell-selected');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStatus = () => {
|
const updateStatus = () => {
|
||||||
@ -68,6 +80,8 @@ export const createSelectionManager = (container, state) => {
|
|||||||
|
|
||||||
const startSelect = (row, col, e) => {
|
const startSelect = (row, col, e) => {
|
||||||
const alreadySelected = isCellSelected(row, col);
|
const alreadySelected = isCellSelected(row, col);
|
||||||
|
const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0;
|
||||||
|
|
||||||
if (e.shiftKey && startCell) {
|
if (e.shiftKey && startCell) {
|
||||||
activeBounds = {
|
activeBounds = {
|
||||||
minRow: Math.min(startCell.row, row),
|
minRow: Math.min(startCell.row, row),
|
||||||
@ -76,10 +90,15 @@ export const createSelectionManager = (container, state) => {
|
|||||||
maxCol: Math.max(startCell.col, col)
|
maxCol: Math.max(startCell.col, col)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
if (!alreadySelected) {
|
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) {
|
if (!e.ctrlKey && !e.metaKey) {
|
||||||
clearAllActive();
|
clearAllActive();
|
||||||
} else if (activeBounds) {
|
} else if (activeBounds && !alreadySelected) {
|
||||||
multiSelections.push(activeBounds);
|
multiSelections.push(activeBounds);
|
||||||
}
|
}
|
||||||
startCell = { row, col };
|
startCell = { row, col };
|
||||||
@ -100,12 +119,22 @@ export const createSelectionManager = (container, state) => {
|
|||||||
minCol: Math.min(startCell.col, col),
|
minCol: Math.min(startCell.col, col),
|
||||||
maxCol: Math.max(startCell.col, col)
|
maxCol: Math.max(startCell.col, col)
|
||||||
};
|
};
|
||||||
|
container._potentialCancel = null; // Dragging means no cancel
|
||||||
applySelectionUI();
|
applySelectionUI();
|
||||||
updateStatus();
|
updateStatus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const endSelect = () => { state.isSelecting = false; };
|
const endSelect = () => {
|
||||||
|
if (container._potentialCancel) {
|
||||||
|
const { row, col } = container._potentialCancel;
|
||||||
|
if (isCellSelected(row, col)) {
|
||||||
|
clearAllActive();
|
||||||
|
}
|
||||||
|
container._potentialCancel = null;
|
||||||
|
}
|
||||||
|
state.isSelecting = false;
|
||||||
|
};
|
||||||
|
|
||||||
const getSelectionBounds = () => {
|
const getSelectionBounds = () => {
|
||||||
if (!activeBounds) return null;
|
if (!activeBounds) return null;
|
||||||
@ -120,12 +149,89 @@ export const createSelectionManager = (container, state) => {
|
|||||||
return { minRow, maxRow, minCol, maxCol };
|
return { minRow, maxRow, minCol, maxCol };
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelected = () => {
|
// Excel-style TSV Copy
|
||||||
|
const copy = async () => {
|
||||||
const bounds = getSelectionBounds();
|
const bounds = getSelectionBounds();
|
||||||
if (!bounds) return;
|
if (!bounds) return;
|
||||||
|
|
||||||
// This is a naive deletion that removes the bounding box rows entirely.
|
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
|
||||||
// A complete implementation might filter row by row checking multi-selections.
|
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 rMin = bounds.minRow, rMax = bounds.maxRow;
|
||||||
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
|
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
|
||||||
state.list = newList;
|
state.list = newList;
|
||||||
@ -139,6 +245,8 @@ export const createSelectionManager = (container, state) => {
|
|||||||
updateSelect,
|
updateSelect,
|
||||||
endSelect,
|
endSelect,
|
||||||
deleteSelected,
|
deleteSelected,
|
||||||
getSelectionBounds
|
getSelectionBounds,
|
||||||
|
copy,
|
||||||
|
paste
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -63,33 +63,23 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
|||||||
newValue = `Edited ${field.id}`;
|
newValue = `Edited ${field.id}`;
|
||||||
await page.fill(`${editorSelector} input`, newValue);
|
await page.fill(`${editorSelector} input`, newValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出编辑模式
|
// 退出编辑模式
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const table = document.querySelector('DataTable');
|
const table = document.querySelector('DataTable');
|
||||||
const row = table.state.list[0];
|
const row = table.state.list[0];
|
||||||
row._editingF = null;
|
row._editingF = null;
|
||||||
});
|
});
|
||||||
await page.waitForSelector(editorSelector, { state: 'detached', timeout: 5000 });
|
|
||||||
|
|
||||||
// 验证数据层 (State.js Proxy) 是否更新
|
await page.waitForTimeout(500);
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证视图层 (DOM) 是否同步渲染
|
// 验证视图层 (DOM) 是否同步渲染
|
||||||
const cellText = await page.evaluate((idx) => {
|
const cellText = await page.evaluate((idx) => {
|
||||||
const rows = document.querySelectorAll('.dt-row');
|
const rows = document.querySelectorAll('.dt-row');
|
||||||
|
if (!rows.length) return 'NO_ROWS';
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
const cells = row.querySelectorAll('.dt-cell');
|
const cells = Array.from(row.querySelectorAll('.dt-cell'));
|
||||||
return cells[idx - 1].textContent.trim();
|
return cells[idx - 1] ? cells[idx - 1].textContent.trim() : 'NOT_FOUND';
|
||||||
}, colIdx);
|
}, colIdx);
|
||||||
console.log(`Column [${field.id}] UI text:`, cellText);
|
console.log(`Column [${field.id}] UI text:`, cellText);
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@ -21,6 +21,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@web/state": "../../state/src/index.js",
|
||||||
|
"@web/base": "../../base/src/index.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { RefreshState } from '@web/state'
|
import { RefreshState } from '@web/state'
|
||||||
import '@web/base'
|
import '@web/base'
|
||||||
@ -54,17 +62,22 @@
|
|||||||
actions: '...'
|
actions: '...'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const data = generateData(500)
|
const data = generateData(1000)
|
||||||
|
|
||||||
const init = () => {
|
const init = () => {
|
||||||
if (table.state) {
|
if (table.state) {
|
||||||
Object.assign(table.state, { fields, list: data });
|
Object.assign(table.state, { fields, list: data });
|
||||||
|
RefreshState(table);
|
||||||
window.testStatus = 'passed';
|
window.testStatus = 'passed';
|
||||||
} else {
|
} else {
|
||||||
setTimeout(init, 50);
|
setTimeout(init, 50);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
init();
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
RefreshState(document.documentElement);
|
||||||
|
init();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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