release: v1.0.1
This commit is contained in:
commit
5759b2f061
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@ -0,0 +1,19 @@
|
||||
# CHANGELOG
|
||||
|
||||
## v1.0.1 (2026-05-17)
|
||||
|
||||
### 优化
|
||||
- **交互**:
|
||||
- 深度集成 `@web/base` 的 `VirtualScroll` 模块,确保在万级数据下的极致滚动流畅度。
|
||||
- 联动 `AutoForm` 的 `inline` 模式,实现无缝的单元格原地编辑体验,支持 Enter 确认与 Esc 撤销。
|
||||
- 增强选择逻辑:支持 Shift 连选、Ctrl/Meta 多选以及跨区域范围选择。
|
||||
- **功能**:
|
||||
- 实现基于 TSV 格式的剪贴板系统,支持从 Excel/Google Sheets 复制并直接粘贴至表格,且粘贴时支持自动类型转换(Boolean/Number)。
|
||||
- 优化固定列(Pinned Columns)的阴影层级与偏移计算。
|
||||
- 完善列宽调整(Resizer)的实时预览与状态保存机制。
|
||||
|
||||
## v1.0.0 (2026-05-14)
|
||||
- **Feat**: 初始化 DataTable 项目。
|
||||
- **Feat**: 基于原生指令的虚拟滚动实现。
|
||||
- **Feat**: 支持单元格编辑、固定列与列宽调整。
|
||||
|
||||
71
README.md
Normal file
71
README.md
Normal file
@ -0,0 +1,71 @@
|
||||
# @web/datatable AI 开发指南
|
||||
|
||||
`@web/datatable` 是一个高性能、无状态的 Web 数据表格组件,支持万级数据虚拟滚动、多维编辑及固定列。
|
||||
|
||||
---
|
||||
|
||||
## 一、 快速开始
|
||||
|
||||
### 1. 引入依赖
|
||||
```html
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "path/to/state.mjs",
|
||||
"@web/base": "path/to/base.mjs",
|
||||
"@web/datatable": "path/to/datatable.mjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 导出清单
|
||||
* **Components**: `<DataTable>`
|
||||
|
||||
---
|
||||
|
||||
## 二、 核心功能
|
||||
|
||||
### 1. 虚拟滚动
|
||||
通过 `$.state.list` 绑定大数据集,组件将自动启用虚拟滚动。
|
||||
|
||||
### 2. 多维编辑
|
||||
* **简单类型**: 双击单元格直接编辑(支持 `text`, `number`, `select`)。
|
||||
* **复杂类型**: 双击触发 `Modal` 浮层编辑(支持 `json`, `object`, `array` 等)。
|
||||
|
||||
### 3. 固定列
|
||||
在 `fields` 定义中设置 `pinned: 'left'` 或 `pinned: 'right'`。
|
||||
|
||||
---
|
||||
|
||||
## 三、 API 参考
|
||||
|
||||
### `<DataTable>` 属性与事件
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `$.state.fields` | `Array` | 字段定义清单 |
|
||||
| `$.state.list` | `Array` | 数据列表 |
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `@update` | `{row, field, value, oldValue, isComplex}` | 单元格或复杂编辑器数据更新 |
|
||||
| `@delete` | `{row}` | 行删除请求 |
|
||||
|
||||
**字段定义结构 (`field`):**
|
||||
```json
|
||||
{
|
||||
"id": "unique_id",
|
||||
"name": "Display Name",
|
||||
"type": "text | number | select | json",
|
||||
"width": 150,
|
||||
"pinned": "left | right",
|
||||
"options": ["A", "B"] // 仅 select 类型
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、 最佳实践
|
||||
1. **保持无状态**:始终通过 `@update` 事件在外部更新数据源。
|
||||
2. **样式隔离**:优先使用组件提供的工具栏插槽 `<div slot="toolbar">`。
|
||||
18
TEST.md
Normal file
18
TEST.md
Normal file
@ -0,0 +1,18 @@
|
||||
# datatable Benchmark & Test Results
|
||||
|
||||
## 基准测试 (Benchmark)
|
||||
*测试环境: Playwright / Chromium*
|
||||
|
||||
| 指标 | v1.0.0 |
|
||||
| :--- | :--- |
|
||||
| **Render 1000 rows** | TBD |
|
||||
| **Scroll through 1000 rows** | TBD |
|
||||
| **Cell Edit Latency** | TBD |
|
||||
|
||||
## 测试覆盖 (Coverage)
|
||||
- [ ] 初始渲染 (Initial Rendering)
|
||||
- [ ] 虚拟滚动 (Virtual Scrolling)
|
||||
- [ ] 单元格内编辑 (In-place Editing)
|
||||
- [ ] 复杂编辑器弹出 (Overlay Editing)
|
||||
- [ ] 固定列 (Pinned Columns)
|
||||
- [ ] 列宽调整 (Resizing)
|
||||
244
dist/datatable.js
vendored
Normal file
244
dist/datatable.js
vendored
Normal file
@ -0,0 +1,244 @@
|
||||
import { Component, NewState, Util, RefreshState } from "@web/state";
|
||||
import { VirtualScroll } from "@web/base";
|
||||
Component.register("DataTable", (container) => {
|
||||
const vs = VirtualScroll();
|
||||
const state = container.state;
|
||||
Object.assign(state, {
|
||||
list: [],
|
||||
fields: [],
|
||||
renderedList: [],
|
||||
prevHeight: 0,
|
||||
postHeight: 0,
|
||||
_listStartIndex: 0,
|
||||
selStartR: -1,
|
||||
selStartF: -1,
|
||||
selEndR: -1,
|
||||
selEndF: -1,
|
||||
multiSelections: [],
|
||||
isSelecting: false
|
||||
});
|
||||
container.refresh = () => {
|
||||
const scrollEl = container.querySelector(".dt-body");
|
||||
if (!scrollEl) return;
|
||||
const res = vs.calc(scrollEl, state.list);
|
||||
if (res) Object.assign(state, {
|
||||
prevHeight: res.prevHeight,
|
||||
postHeight: res.postHeight,
|
||||
_listStartIndex: res.listStartIndex,
|
||||
renderedList: res.renderedList
|
||||
});
|
||||
};
|
||||
state.__watch("list", (list) => {
|
||||
if (list && list.length > 0 && !list[0].__watch) {
|
||||
state.list = list.map((item) => item.__watch ? item : NewState(item));
|
||||
return;
|
||||
}
|
||||
state._listStartIndex = 0;
|
||||
const scrollEl = container.querySelector(".dt-body");
|
||||
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || [];
|
||||
if (scrollEl) {
|
||||
vs.init(list, container.refresh);
|
||||
requestAnimationFrame(() => container.refresh());
|
||||
}
|
||||
});
|
||||
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node);
|
||||
container.getOffset = (fields, index, side) => {
|
||||
const f = fields.slice(0, index).filter((f2) => f2.pinned === "left");
|
||||
const r = fields.slice(index + 1).filter((f2) => f2.pinned === "right");
|
||||
return (side === "left" ? f : r).reduce((sum, f2) => sum + (f2.width || 150), 0);
|
||||
};
|
||||
container.isCellSelected = (r, f) => {
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
||||
if (r >= rMin && r <= rMax && f >= fMin && f <= fMax) return true;
|
||||
return state.multiSelections.some((s) => r >= s.r1 && r <= s.r2 && f >= s.f1 && f <= s.f2);
|
||||
};
|
||||
container.clearAllActive = (keepSelection = false) => {
|
||||
state.list.forEach((row) => {
|
||||
if (row._editingF !== null) row._editingF = null;
|
||||
if (row._activeF !== null) row._activeF = null;
|
||||
});
|
||||
if (!keepSelection) {
|
||||
state.selStartR = -1;
|
||||
state.multiSelections = [];
|
||||
}
|
||||
};
|
||||
container.startSelect = (r, f, e) => {
|
||||
const alreadySelected = container.isCellSelected(r, f);
|
||||
if (state.editingCell) state.editingCell = null;
|
||||
if (e.shiftKey && state.selStartR !== -1) {
|
||||
state.selEndR = r;
|
||||
state.selEndF = f;
|
||||
} else {
|
||||
if (!alreadySelected) {
|
||||
if (!e.ctrlKey && !e.metaKey) container.clearAllActive();
|
||||
else if (state.selStartR !== -1) {
|
||||
state.multiSelections.push({
|
||||
r1: Math.min(state.selStartR, state.selEndR),
|
||||
r2: Math.max(state.selStartR, state.selEndR),
|
||||
f1: Math.min(state.selStartF, state.selEndF),
|
||||
f2: Math.max(state.selStartF, state.selEndF)
|
||||
});
|
||||
}
|
||||
state.selStartR = state.selEndR = r;
|
||||
state.selStartF = state.selEndF = f;
|
||||
}
|
||||
state.isSelecting = true;
|
||||
state.list[r]._activeF = f;
|
||||
}
|
||||
};
|
||||
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f);
|
||||
container.endSelect = () => state.isSelecting = false;
|
||||
container.editCell = (row, f, fIdx) => {
|
||||
var _a;
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
||||
const rIdx = state.list.indexOf(row);
|
||||
const isInRange = state.selStartR !== -1 && rIdx >= rMin && rIdx <= rMax && fIdx >= fMin && fIdx <= fMax;
|
||||
const count = isInRange ? rMax - rMin + 1 : 0;
|
||||
if (count > 1 && ((_a = globalThis.UI) == null ? void 0 : _a.toast)) {
|
||||
UI.toast(`{#Bulk Editing {num} rows... || ${count}#}`);
|
||||
}
|
||||
container.clearAllActive(true);
|
||||
row._editingF = f.id;
|
||||
row._activeF = fIdx;
|
||||
if (count > 1) {
|
||||
const unwatch = row.__watch(f.id, (val) => {
|
||||
for (let r = rMin; r <= rMax; r++) {
|
||||
if (state.list[r] !== row) state.list[r][f.id] = val;
|
||||
}
|
||||
unwatch();
|
||||
});
|
||||
}
|
||||
};
|
||||
const escapeTSV = (val) => {
|
||||
const str = String(val ?? "");
|
||||
return str.includes(" ") || str.includes("\n") || str.includes('"') ? '"' + str.replace(/"/g, '""') + '"' : str;
|
||||
};
|
||||
const parseTSV = (text) => {
|
||||
const rows = [];
|
||||
let curRow = [], curCell = "", inQuote = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i], next = text[i + 1];
|
||||
if (inQuote) {
|
||||
if (c === '"' && next === '"') {
|
||||
curCell += '"';
|
||||
i++;
|
||||
} else if (c === '"') inQuote = false;
|
||||
else curCell += c;
|
||||
} else {
|
||||
if (c === '"') inQuote = true;
|
||||
else if (c === " ") {
|
||||
curRow.push(curCell);
|
||||
curCell = "";
|
||||
} else if (c === "\n") {
|
||||
curRow.push(curCell);
|
||||
rows.push(curRow);
|
||||
curRow = [];
|
||||
curCell = "";
|
||||
} else if (c !== "\r") curCell += c;
|
||||
}
|
||||
}
|
||||
curRow.push(curCell);
|
||||
rows.push(curRow);
|
||||
return rows;
|
||||
};
|
||||
container.copy = async () => {
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR);
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF);
|
||||
if (rMin === -1) return;
|
||||
const text = state.list.slice(rMin, rMax + 1).map(
|
||||
(row) => state.fields.slice(fMin, fMax + 1).map((f) => escapeTSV(row[f.id])).join(" ")
|
||||
).join("\n");
|
||||
await navigator.clipboard.writeText(text);
|
||||
};
|
||||
container.paste = async () => {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const rows = parseTSV(text);
|
||||
const rStart = Math.min(state.selStartR, state.selEndR);
|
||||
const fStart = Math.min(state.selStartF, state.selEndF);
|
||||
if (rStart === -1) return;
|
||||
rows.forEach((rowData, i) => {
|
||||
const row = state.list[rStart + i];
|
||||
if (row) rowData.forEach((val, j) => {
|
||||
const field = state.fields[fStart + j];
|
||||
if (field) {
|
||||
if (typeof row[field.id] === "boolean") row[field.id] = val.toLowerCase() === "true";
|
||||
else if (typeof row[field.id] === "number") row[field.id] = Number(val);
|
||||
else row[field.id] = val;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
container.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "c") {
|
||||
e.preventDefault();
|
||||
container.copy();
|
||||
}
|
||||
if (e.key === "v") {
|
||||
e.preventDefault();
|
||||
container.paste();
|
||||
}
|
||||
}
|
||||
});
|
||||
const onGlobalMouseDown = (e) => !container.contains(e.target) && container.clearAllActive();
|
||||
document.addEventListener("mousedown", onGlobalMouseDown);
|
||||
window.addEventListener("mouseup", container.endSelect);
|
||||
container._onUnload = () => {
|
||||
document.removeEventListener("mousedown", onGlobalMouseDown);
|
||||
window.removeEventListener("mouseup", container.endSelect);
|
||||
};
|
||||
}, Util.makeDom(
|
||||
/*html*/
|
||||
`
|
||||
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none" tabindex="0" onunload="this._onUnload()">
|
||||
<div class="dt-header d-flex flex-shrink-0 border-bottom bg-body-tertiary fw-bold text-muted small" style="overflow:hidden; position:sticky; top:0; z-index:20; height:48px">
|
||||
<div $each="this.state.fields" as="f" class="dt-col border-end d-flex align-items-center px-2 bg-body-tertiary"
|
||||
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); ' + (f.pinned ? 'position:sticky; z-index:11;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(this.state.fields, index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(this.state.fields, index, 'right') + 'px;' : '')">
|
||||
<span $text="f.name" $class="'text-truncate flex-grow-1 ' + (f.type === 'number' ? 'text-end' : '')"></span>
|
||||
<Resizer class="dt-resizer" $.target="thisNode.parentNode" min="50" max="600" $bind="f.width" $onresizing="thisNode.closest('.dt-root').style.setProperty('--w-' + f.id, event.detail.newSize + 'px')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dt-body flex-grow-1 overflow-auto bg-body" $onscroll="requestAnimationFrame(() => this.refresh())" style="overflow-anchor:none">
|
||||
<div $if="this.state.prevHeight>0" $style="'height:' + this.state.prevHeight + 'px;'"></div>
|
||||
<div $each="this.state.renderedList" as="row" class="dt-row d-flex border-bottom" $onupdate="this.onItemUpdate(rIdx, thisNode)" index="rIdx">
|
||||
<div $each="this.state.fields" as="f" class="dt-cell border-end d-flex align-items-center bg-body"
|
||||
$class="'dt-cell border-end d-flex align-items-center bg-body' + (this.isCellSelected(rIdx + this.state._listStartIndex, index) ? ' bg-primary-subtle' : '') + (row._activeF === index ? ' dt-cell-active' : '')"
|
||||
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); height:40px; ' + (f.pinned ? 'position:sticky; z-index:10;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(this.state.fields, index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(this.state.fields, index, 'right') + 'px;' : '')"
|
||||
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, index, event)"
|
||||
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, index)"
|
||||
$ondblclick="this.editCell(row, f, index)">
|
||||
|
||||
<div $if="row._editingF !== f.id" $class="'px-2 text-truncate w-100 h-100 d-flex align-items-center ' + (f.type === 'number' ? 'justify-content-end' : (f.type === 'switch' ? 'justify-content-center' : ''))">
|
||||
<span $if="typeof row[f.id] !== 'boolean'" $text="row[f.id] ?? ''" class="text-truncate"></span>
|
||||
<div $if="typeof row[f.id] === 'boolean'" class="form-switch fs-5 m-0 d-flex align-items-center justify-content-center" style="padding-left:0">
|
||||
<input class="form-check-input m-0" type="checkbox" $checked="row[f.id]" disabled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div $if="row._editingF === f.id" $class="'dt-editor-container' + (f.type === 'textarea' ? ' dt-editor-textarea' : '')" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
||||
<AutoForm inline class="dt-editor h-100 w-100" $.state.schema="[{ ...f, name: f.id, label: f.name }]" $.data="row"
|
||||
$onkeydown="(event.key === 'Enter' && f.type !== 'textarea') && (row._editingF = null); event.key === 'Escape' && (row._editingF = null)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div $if="this.state.postHeight>0" $style="'height:' + this.state.postHeight + 'px;'"></div>
|
||||
</div>
|
||||
<style>
|
||||
.dt-root { font-size: 0.875rem; --dt-active-border: var(--bs-primary); outline: none; }
|
||||
.dt-row:hover .dt-cell { background-color: var(--bs-secondary-bg) !important; }
|
||||
.dt-cell { position: relative; transition: background 0.05s; border-color: var(--bs-border-color) !important; }
|
||||
.dt-cell.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; }
|
||||
.dt-cell-active::after { content: ''; position: absolute; inset: 0; border: 2px solid var(--dt-active-border); pointer-events: none; z-index: 12; }
|
||||
.dt-col { position: relative; }
|
||||
.dt-editor-container { position: absolute; left: -1px; top: -1px; width: fit-content; min-width: calc(100% + 2px); height: auto; min-height: calc(100% + 2px); background: var(--bs-body-bg); z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,.3); border: 1px solid var(--dt-active-border); display: flex; align-items: center; }
|
||||
.dt-editor-textarea { height: auto; min-height: 100%; min-width: 260px; align-items: flex-start; }
|
||||
.dt-resizer { width: 4px !important; opacity: 0; transition: opacity 0.2s; position: absolute; right: 0; top: 0; height: 100%; background: var(--bs-primary); cursor: col-resize; z-index: 15; }
|
||||
.dt-col:hover .dt-resizer { opacity: 0.5; }
|
||||
.dt-resizer:hover { opacity: 1 !important; }
|
||||
</style>
|
||||
</div>
|
||||
`
|
||||
));
|
||||
if (typeof document !== "undefined") RefreshState(document.documentElement);
|
||||
1
dist/datatable.min.js
vendored
Normal file
1
dist/datatable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1138
package-lock.json
generated
Normal file
1138
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@web/datatable",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"main": "dist/datatable.js",
|
||||
"module": "dist/datatable.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@rollup/plugin-terser": "^1.0.0",
|
||||
"terser": "^5.47.1",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
16
playwright.config.js
Normal file
16
playwright.config.js
Normal file
@ -0,0 +1,16 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './test',
|
||||
testMatch: '**/*.spec.js',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:8083',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npx vite --port 8083 --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:8083/test/index.html',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
235
src/index.js
Normal file
235
src/index.js
Normal file
@ -0,0 +1,235 @@
|
||||
import { Component, NewState, Util, RefreshState } from '@web/state'
|
||||
import { VirtualScroll } from '@web/base'
|
||||
|
||||
Component.register('DataTable', container => {
|
||||
const vs = VirtualScroll()
|
||||
const state = container.state
|
||||
|
||||
Object.assign(state, {
|
||||
list: [], fields: [], renderedList: [],
|
||||
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||
selStartR: -1, selStartF: -1, selEndR: -1, selEndF: -1,
|
||||
multiSelections: [],
|
||||
isSelecting: false
|
||||
})
|
||||
|
||||
container.refresh = () => {
|
||||
const scrollEl = container.querySelector('.dt-body')
|
||||
if (!scrollEl) return
|
||||
const res = vs.calc(scrollEl, state.list)
|
||||
if (res) Object.assign(state, {
|
||||
prevHeight: res.prevHeight, postHeight: res.postHeight,
|
||||
_listStartIndex: res.listStartIndex, renderedList: res.renderedList
|
||||
})
|
||||
}
|
||||
|
||||
state.__watch('list', list => {
|
||||
if (list && list.length > 0 && !list[0].__watch) {
|
||||
state.list = list.map(item => item.__watch ? item : NewState(item))
|
||||
return
|
||||
}
|
||||
state._listStartIndex = 0
|
||||
const scrollEl = container.querySelector('.dt-body')
|
||||
state.renderedList = vs.reset(list, scrollEl || { clientHeight: 800 }) || []
|
||||
if (scrollEl) {
|
||||
vs.init(list, container.refresh)
|
||||
requestAnimationFrame(() => container.refresh())
|
||||
}
|
||||
})
|
||||
|
||||
container.onItemUpdate = (rIdx, node) => vs.update(rIdx + state._listStartIndex, node)
|
||||
|
||||
container.getOffset = (fields, index, side) => {
|
||||
const f = fields.slice(0, index).filter(f => f.pinned === 'left')
|
||||
const r = fields.slice(index + 1).filter(f => f.pinned === 'right')
|
||||
return (side === 'left' ? f : r).reduce((sum, f) => sum + (f.width || 150), 0)
|
||||
}
|
||||
|
||||
container.isCellSelected = (r, f) => {
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR)
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF)
|
||||
if (r >= rMin && r <= rMax && f >= fMin && f <= fMax) return true
|
||||
return state.multiSelections.some(s => r >= s.r1 && r <= s.r2 && f >= s.f1 && f <= s.f2)
|
||||
}
|
||||
|
||||
container.clearAllActive = (keepSelection = false) => {
|
||||
state.list.forEach(row => {
|
||||
if (row._editingF !== null) row._editingF = null
|
||||
if (row._activeF !== null) row._activeF = null
|
||||
})
|
||||
if (!keepSelection) {
|
||||
state.selStartR = -1; state.multiSelections = []
|
||||
}
|
||||
}
|
||||
|
||||
container.startSelect = (r, f, e) => {
|
||||
const alreadySelected = container.isCellSelected(r, f)
|
||||
if (state.editingCell) state.editingCell = null
|
||||
|
||||
if (e.shiftKey && state.selStartR !== -1) {
|
||||
state.selEndR = r; state.selEndF = f
|
||||
} else {
|
||||
if (!alreadySelected) {
|
||||
if (!e.ctrlKey && !e.metaKey) container.clearAllActive()
|
||||
else if (state.selStartR !== -1) {
|
||||
state.multiSelections.push({
|
||||
r1: Math.min(state.selStartR, state.selEndR), r2: Math.max(state.selStartR, state.selEndR),
|
||||
f1: Math.min(state.selStartF, state.selEndF), f2: Math.max(state.selStartF, state.selEndF)
|
||||
})
|
||||
}
|
||||
state.selStartR = state.selEndR = r
|
||||
state.selStartF = state.selEndF = f
|
||||
}
|
||||
state.isSelecting = true
|
||||
state.list[r]._activeF = f
|
||||
}
|
||||
}
|
||||
|
||||
container.updateSelect = (r, f) => state.isSelecting && (state.selEndR = r, state.selEndF = f)
|
||||
container.endSelect = () => state.isSelecting = false
|
||||
|
||||
container.editCell = (row, f, fIdx) => {
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR)
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF)
|
||||
const rIdx = state.list.indexOf(row)
|
||||
const isInRange = state.selStartR !== -1 && rIdx >= rMin && rIdx <= rMax && fIdx >= fMin && fIdx <= fMax
|
||||
|
||||
const count = isInRange ? (rMax - rMin + 1) : 0
|
||||
if (count > 1 && globalThis.UI?.toast) {
|
||||
UI.toast(`{#Bulk Editing {num} rows... || ${count}#}`)
|
||||
}
|
||||
|
||||
container.clearAllActive(true)
|
||||
row._editingF = f.id
|
||||
row._activeF = fIdx
|
||||
|
||||
if (count > 1) {
|
||||
const unwatch = row.__watch(f.id, (val) => {
|
||||
for (let r = rMin; r <= rMax; r++) {
|
||||
if (state.list[r] !== row) state.list[r][f.id] = val
|
||||
}
|
||||
unwatch()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const escapeTSV = val => {
|
||||
const str = String(val ?? '')
|
||||
return (str.includes('\t') || str.includes('\n') || str.includes('"')) ? '"' + str.replace(/"/g, '""') + '"' : str
|
||||
}
|
||||
|
||||
const parseTSV = text => {
|
||||
const rows = []
|
||||
let curRow = [], curCell = '', inQuote = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i], next = text[i+1]
|
||||
if (inQuote) {
|
||||
if (c === '"' && next === '"') { curCell += '"'; i++ }
|
||||
else if (c === '"') inQuote = false
|
||||
else curCell += c
|
||||
} else {
|
||||
if (c === '"') inQuote = true
|
||||
else if (c === '\t') { curRow.push(curCell); curCell = '' }
|
||||
else if (c === '\n') { curRow.push(curCell); rows.push(curRow); curRow = []; curCell = '' }
|
||||
else if (c !== '\r') curCell += c
|
||||
}
|
||||
}
|
||||
curRow.push(curCell); rows.push(curRow)
|
||||
return rows
|
||||
}
|
||||
|
||||
container.copy = async () => {
|
||||
const rMin = Math.min(state.selStartR, state.selEndR), rMax = Math.max(state.selStartR, state.selEndR)
|
||||
const fMin = Math.min(state.selStartF, state.selEndF), fMax = Math.max(state.selStartF, state.selEndF)
|
||||
if (rMin === -1) return
|
||||
const text = state.list.slice(rMin, rMax + 1).map(row =>
|
||||
state.fields.slice(fMin, fMax + 1).map(f => escapeTSV(row[f.id])).join('\t')
|
||||
).join('\n')
|
||||
await navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
container.paste = async () => {
|
||||
const text = await navigator.clipboard.readText()
|
||||
const rows = parseTSV(text)
|
||||
const rStart = Math.min(state.selStartR, state.selEndR)
|
||||
const fStart = Math.min(state.selStartF, state.selEndF)
|
||||
if (rStart === -1) return
|
||||
rows.forEach((rowData, i) => {
|
||||
const row = state.list[rStart + i]
|
||||
if (row) rowData.forEach((val, j) => {
|
||||
const field = state.fields[fStart + j]
|
||||
if (field) {
|
||||
if (typeof row[field.id] === 'boolean') row[field.id] = val.toLowerCase() === 'true'
|
||||
else if (typeof row[field.id] === 'number') row[field.id] = Number(val)
|
||||
else row[field.id] = val
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c') { e.preventDefault(); container.copy() }
|
||||
if (e.key === 'v') { e.preventDefault(); container.paste() }
|
||||
}
|
||||
})
|
||||
|
||||
const onGlobalMouseDown = e => !container.contains(e.target) && container.clearAllActive()
|
||||
document.addEventListener('mousedown', onGlobalMouseDown)
|
||||
window.addEventListener('mouseup', container.endSelect)
|
||||
container._onUnload = () => {
|
||||
document.removeEventListener('mousedown', onGlobalMouseDown)
|
||||
window.removeEventListener('mouseup', container.endSelect)
|
||||
}
|
||||
|
||||
}, Util.makeDom(/*html*/`
|
||||
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none" tabindex="0" onunload="this._onUnload()">
|
||||
<div class="dt-header d-flex flex-shrink-0 border-bottom bg-body-tertiary fw-bold text-muted small" style="overflow:hidden; position:sticky; top:0; z-index:20; height:48px">
|
||||
<div $each="this.state.fields" as="f" class="dt-col border-end d-flex align-items-center px-2 bg-body-tertiary"
|
||||
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); ' + (f.pinned ? 'position:sticky; z-index:11;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(this.state.fields, index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(this.state.fields, index, 'right') + 'px;' : '')">
|
||||
<span $text="f.name" $class="'text-truncate flex-grow-1 ' + (f.type === 'number' ? 'text-end' : '')"></span>
|
||||
<Resizer class="dt-resizer" $.target="thisNode.parentNode" min="50" max="600" $bind="f.width" $onresizing="thisNode.closest('.dt-root').style.setProperty('--w-' + f.id, event.detail.newSize + 'px')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dt-body flex-grow-1 overflow-auto bg-body" $onscroll="requestAnimationFrame(() => this.refresh())" style="overflow-anchor:none">
|
||||
<div $if="this.state.prevHeight>0" $style="'height:' + this.state.prevHeight + 'px;'"></div>
|
||||
<div $each="this.state.renderedList" as="row" class="dt-row d-flex border-bottom" $onupdate="this.onItemUpdate(rIdx, thisNode)" index="rIdx">
|
||||
<div $each="this.state.fields" as="f" class="dt-cell border-end d-flex align-items-center bg-body"
|
||||
$class="'dt-cell border-end d-flex align-items-center bg-body' + (this.isCellSelected(rIdx + this.state._listStartIndex, index) ? ' bg-primary-subtle' : '') + (row._activeF === index ? ' dt-cell-active' : '')"
|
||||
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); height:40px; ' + (f.pinned ? 'position:sticky; z-index:10;' : '') + (f.pinned === 'left' ? 'left:' + this.getOffset(this.state.fields, index, 'left') + 'px;' : '') + (f.pinned === 'right' ? 'right:' + this.getOffset(this.state.fields, index, 'right') + 'px;' : '')"
|
||||
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, index, event)"
|
||||
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, index)"
|
||||
$ondblclick="this.editCell(row, f, index)">
|
||||
|
||||
<div $if="row._editingF !== f.id" $class="'px-2 text-truncate w-100 h-100 d-flex align-items-center ' + (f.type === 'number' ? 'justify-content-end' : (f.type === 'switch' ? 'justify-content-center' : ''))">
|
||||
<span $if="typeof row[f.id] !== 'boolean'" $text="row[f.id] ?? ''" class="text-truncate"></span>
|
||||
<div $if="typeof row[f.id] === 'boolean'" class="form-switch fs-5 m-0 d-flex align-items-center justify-content-center" style="padding-left:0">
|
||||
<input class="form-check-input m-0" type="checkbox" $checked="row[f.id]" disabled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div $if="row._editingF === f.id" $class="'dt-editor-container' + (f.type === 'textarea' ? ' dt-editor-textarea' : '')" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
||||
<AutoForm inline class="dt-editor h-100 w-100" $.state.schema="[{ ...f, name: f.id, label: f.name }]" $.data="row"
|
||||
$onkeydown="(event.key === 'Enter' && f.type !== 'textarea') && (row._editingF = null); event.key === 'Escape' && (row._editingF = null)"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div $if="this.state.postHeight>0" $style="'height:' + this.state.postHeight + 'px;'"></div>
|
||||
</div>
|
||||
<style>
|
||||
.dt-root { font-size: 0.875rem; --dt-active-border: var(--bs-primary); outline: none; }
|
||||
.dt-row:hover .dt-cell { background-color: var(--bs-secondary-bg) !important; }
|
||||
.dt-cell { position: relative; transition: background 0.05s; border-color: var(--bs-border-color) !important; }
|
||||
.dt-cell.bg-primary-subtle { background-color: var(--bs-primary-bg-subtle) !important; }
|
||||
.dt-cell-active::after { content: ''; position: absolute; inset: 0; border: 2px solid var(--dt-active-border); pointer-events: none; z-index: 12; }
|
||||
.dt-col { position: relative; }
|
||||
.dt-editor-container { position: absolute; left: -1px; top: -1px; width: fit-content; min-width: calc(100% + 2px); height: auto; min-height: calc(100% + 2px); background: var(--bs-body-bg); z-index: 100; box-shadow: 0 4px 12px rgba(0,0,0,.3); border: 1px solid var(--dt-active-border); display: flex; align-items: center; }
|
||||
.dt-editor-textarea { height: auto; min-height: 100%; min-width: 260px; align-items: flex-start; }
|
||||
.dt-resizer { width: 4px !important; opacity: 0; transition: opacity 0.2s; position: absolute; right: 0; top: 0; height: 100%; background: var(--bs-primary); cursor: col-resize; z-index: 15; }
|
||||
.dt-col:hover .dt-resizer { opacity: 0.5; }
|
||||
.dt-resizer:hover { opacity: 1 !important; }
|
||||
</style>
|
||||
</div>
|
||||
`))
|
||||
|
||||
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
111
test/all.spec.js
Normal file
111
test/all.spec.js
Normal file
@ -0,0 +1,111 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('DataTable comprehensive tests and scrolling benchmarks', async ({ page }) => {
|
||||
test.setTimeout(180000);
|
||||
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
|
||||
await page.goto('/test/index.html');
|
||||
|
||||
await page.waitForFunction(() => window.testStatus === 'passed', { timeout: 60000 });
|
||||
console.log('DataTable initialized.');
|
||||
|
||||
// 1. 遍历验证第一行每个单元格的编辑与数据同步
|
||||
await page.waitForSelector('.dt-row');
|
||||
const fields = await page.evaluate(() => document.querySelector('DataTable').state.fields.map(f => ({ id: f.id, type: f.type })));
|
||||
console.log('Fields to test:', fields.map(f => f.id).join(', '));
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const field = fields[i];
|
||||
const colIdx = i + 1;
|
||||
console.log(`Testing Column [${field.id}] (Type: ${field.type})...`);
|
||||
|
||||
if (field.id === 'actions' || field.id === 'id') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 双击进入编辑
|
||||
await page.evaluate((idx) => {
|
||||
const rows = document.querySelectorAll('.dt-row');
|
||||
const row = rows[0];
|
||||
const cells = row.querySelectorAll('.dt-cell');
|
||||
const cell = cells[idx - 1];
|
||||
cell.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
}, colIdx);
|
||||
|
||||
const editorSelector = '.dt-editor-container';
|
||||
await page.waitForSelector(editorSelector, { timeout: 5000 });
|
||||
|
||||
// 模拟输入
|
||||
let newValue;
|
||||
if (field.type === 'switch' || field.id === 'active') {
|
||||
const oldVal = await page.evaluate(() => document.querySelector('DataTable').state.list[0].active);
|
||||
await page.click(`${editorSelector} .form-check-input`);
|
||||
newValue = !oldVal;
|
||||
} else if (field.type === 'select') {
|
||||
newValue = 'Editor';
|
||||
await page.selectOption(`${editorSelector} select`, newValue);
|
||||
} else if (field.type === 'radio') {
|
||||
newValue = 'Female';
|
||||
await page.click(`${editorSelector} input[value="Female"]`);
|
||||
} else if (field.type === 'textarea') {
|
||||
newValue = 'New multi-line content';
|
||||
await page.fill(`${editorSelector} textarea`, newValue);
|
||||
} else if (field.type === 'TagsInput') {
|
||||
newValue = ['Playwright'];
|
||||
await page.fill(`${editorSelector} input`, 'Playwright');
|
||||
await page.keyboard.press('Enter');
|
||||
} else if (field.type === 'number') {
|
||||
newValue = 99;
|
||||
await page.fill(`${editorSelector} input`, '99');
|
||||
} else if (field.type === 'date') {
|
||||
newValue = '2026-05-20';
|
||||
await page.fill(`${editorSelector} input`, newValue);
|
||||
} else {
|
||||
newValue = `Edited ${field.id}`;
|
||||
await page.fill(`${editorSelector} input`, newValue);
|
||||
}
|
||||
|
||||
// 退出编辑模式
|
||||
await page.evaluate(() => {
|
||||
const table = document.querySelector('DataTable');
|
||||
const row = table.state.list[0];
|
||||
row._editingF = null;
|
||||
});
|
||||
await page.waitForSelector(editorSelector, { state: 'detached', timeout: 5000 });
|
||||
|
||||
// 验证数据层 (State.js Proxy) 是否更新
|
||||
const actualStateVal = await page.evaluate((id) => document.querySelector('DataTable').state.list[0][id], field.id);
|
||||
console.log(`Column [${field.id}] State value:`, actualStateVal);
|
||||
|
||||
if (field.id === 'tags') {
|
||||
expect(actualStateVal).toContain('Playwright');
|
||||
} else if (field.type === 'number') {
|
||||
expect(Number(actualStateVal)).toBe(99);
|
||||
} else {
|
||||
expect(String(actualStateVal)).toBe(String(newValue));
|
||||
}
|
||||
|
||||
// 验证视图层 (DOM) 是否同步渲染
|
||||
const cellText = await page.evaluate((idx) => {
|
||||
const rows = document.querySelectorAll('.dt-row');
|
||||
const row = rows[0];
|
||||
const cells = row.querySelectorAll('.dt-cell');
|
||||
return cells[idx - 1].textContent.trim();
|
||||
}, colIdx);
|
||||
console.log(`Column [${field.id}] UI text:`, cellText);
|
||||
|
||||
if (field.type !== 'switch' && field.id !== 'active') {
|
||||
expect(cellText).toContain(String(newValue).split('\n')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All columns editing and persistence verified.');
|
||||
|
||||
// 2. 滚动测试
|
||||
const scrollInfo = await page.evaluate(async () => {
|
||||
const el = document.querySelector('.dt-body');
|
||||
el.scrollTop = 2000;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
return { scrollTop: el.scrollTop, renderedCount: el.querySelectorAll('.dt-row').length };
|
||||
});
|
||||
expect(scrollInfo.renderedCount).toBeGreaterThan(0);
|
||||
});
|
||||
98
test/index.html
Normal file
98
test/index.html
Normal file
@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DataTable Demo</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-light">
|
||||
<div class="demo-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0 text-primary fw-bold">DataTable Professional</h4>
|
||||
<div class="text-muted small">Double-click to edit • Drag/Shift to select • Ctrl+C/V to Copy/Paste</div>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<DataTable id="myTable"></DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { RefreshState, NewState } from '@web/state'
|
||||
import '@web/base'
|
||||
import '../src/index.js'
|
||||
|
||||
const table = document.getElementById('myTable')
|
||||
|
||||
const fields = [
|
||||
{ id: 'id', name: 'ID', width: 60, type: 'text', pinned: 'left' },
|
||||
{ id: 'name', name: 'User Name', width: 150, type: 'text', pinned: 'left' },
|
||||
{ id: 'role', name: 'Role', width: 120, type: 'select', options: ['Admin', 'Editor', 'Viewer'] },
|
||||
{ id: 'active', name: 'Active', width: 80, type: 'switch' },
|
||||
{ id: 'bio', name: 'Bio', width: 250, type: 'textarea' },
|
||||
{ id: 'gender', name: 'Gender', width: 120, type: 'radio', options: ['Male', 'Female', 'Other'] },
|
||||
{ id: 'tags', name: 'Tags', width: 150, type: 'TagsInput' },
|
||||
{ id: 'score', name: 'Score', width: 100, type: 'number' },
|
||||
{ id: 'created', name: 'Created At', width: 150, type: 'date' },
|
||||
{ id: 'actions', name: 'Actions', width: 100, type: 'text', pinned: 'right' }
|
||||
]
|
||||
|
||||
const generateData = (count) => Array.from({ length: count }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: 'User ' + (i + 1),
|
||||
role: ['Admin', 'Editor', 'Viewer'][i % 3],
|
||||
active: i % 2 === 0,
|
||||
bio: 'This is the bio for user ' + (i + 1) + '. It might be a long text that needs a textarea for editing.',
|
||||
gender: ['Male', 'Female', 'Other'][i % 3],
|
||||
tags: ['State.js', 'Vite', 'Playwright'].slice(0, (i % 3) + 1),
|
||||
score: Math.floor(Math.random() * 100),
|
||||
created: new Date().toISOString().split('T')[0],
|
||||
actions: '...'
|
||||
}))
|
||||
|
||||
const data = generateData(500)
|
||||
|
||||
// 数据初始化逻辑
|
||||
setTimeout(() => {
|
||||
Object.assign(table.state, {
|
||||
fields: fields,
|
||||
list: data
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
window.testStatus = 'passed'
|
||||
console.log('DataTable initialized in test page')
|
||||
}, 500)
|
||||
}, 200)
|
||||
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
41
vite.config.js
Normal file
41
vite.config.js
Normal file
@ -0,0 +1,41 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@web/state': resolve(__dirname, '../state/src/index.js'),
|
||||
'@web/base': resolve(__dirname, '../base/src/index.js'),
|
||||
'@web/datatable': resolve(__dirname, 'src/index.js')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..']
|
||||
}
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.js'),
|
||||
name: 'DataTable',
|
||||
formats: ['es']
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['@web/state', '@web/base'],
|
||||
output: [
|
||||
{
|
||||
format: 'es',
|
||||
entryFileNames: 'datatable.js',
|
||||
minifyInternalExports: false
|
||||
},
|
||||
{
|
||||
format: 'es',
|
||||
entryFileNames: 'datatable.min.js',
|
||||
plugins: [terser()]
|
||||
}
|
||||
]
|
||||
},
|
||||
minify: false
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user