stk-table-vue 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/{Dropdown-Bq4DxXYJ.js → Dropdown-D40QlY91.js} +2 -2
- package/lib/{StkTable-yOz9wkCz.js → StkTable-g_qwf6BE.js} +180 -124
- package/lib/src/StkTable/useSorter.d.ts +2 -1
- package/lib/src/StkTable/useVirtualScroll.d.ts +11 -2
- package/lib/stk-table-vue.js +3 -3
- package/lib/style.css +160 -1
- package/package.json +2 -2
- package/src/StkTable/StkTable.vue +19 -7
- package/src/StkTable/useFixedStyle.ts +2 -2
- package/src/StkTable/useMergeCells.ts +149 -111
- package/src/StkTable/useSorter.ts +9 -1
- package/src/StkTable/useVirtualScroll.ts +38 -11
- package/lib/src/StkTable/features/useAreaSelection.bak.d.ts +0 -25
- package/lib/test/StkTable.browser.test.d.ts +0 -1
- package/lib/test/autoHeightStripe.repro.test.d.ts +0 -1
- package/lib/test/autoRowHeightVirtualScroll.test.d.ts +0 -1
- package/lib/test/defaultSort.test.d.ts +0 -1
- package/lib/test/insertToOrderedArray.test.d.ts +0 -1
- package/lib/test/perf/scrollPerf.test.d.ts +0 -1
- package/lib/test/rowMergeVirtualScroll.repro.test.d.ts +0 -1
- package/lib/test/scrollTo.test.d.ts +0 -1
- package/lib/test/setSorter.test.d.ts +0 -1
- package/lib/test/setTreeExpandParents.test.d.ts +0 -1
- package/lib/test/trReuse.repro.test.d.ts +0 -1
- package/lib/test/utils/DragResize.d.ts +0 -28
- package/lib/test/utils/h.d.ts +0 -10
- package/lib/test/virtualXMerge.test.d.ts +0 -1
- package/src/StkTable/features/useAreaSelection.bak.ts +0 -951
package/lib/style.css
CHANGED
|
@@ -1,11 +1,170 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* name: stk-table-vue
|
|
3
|
-
* version: v1.2.
|
|
3
|
+
* version: v1.2.2
|
|
4
4
|
* description: High performance realtime virtual table for vue3 and vue2.7
|
|
5
5
|
* author: japlus
|
|
6
6
|
* homepage: https://ja-plus.github.io/stk-table-vue/
|
|
7
7
|
* license: MIT
|
|
8
8
|
*/
|
|
9
|
+
.stk-table.header-text-overflow .stk-filter > span{
|
|
10
|
+
text-overflow:ellipsis;
|
|
11
|
+
white-space:nowrap;
|
|
12
|
+
}
|
|
13
|
+
.stk-filter{
|
|
14
|
+
display:inline-flex;
|
|
15
|
+
align-items:center;
|
|
16
|
+
gap:4px;
|
|
17
|
+
cursor:pointer;
|
|
18
|
+
flex:1;
|
|
19
|
+
min-width:0;
|
|
20
|
+
}
|
|
21
|
+
.stk-filter--light{
|
|
22
|
+
--icon-active-color:#1b63d9;
|
|
23
|
+
--icon-hover-color:#1890ff;
|
|
24
|
+
}
|
|
25
|
+
.stk-filter--dark{
|
|
26
|
+
--icon-active-color:#1b63d9;
|
|
27
|
+
--icon-hover-color:#4f8df4;
|
|
28
|
+
}
|
|
29
|
+
.stk-filter.stk-filter--active .stk-filter-icon{
|
|
30
|
+
color:var(--icon-active-color);
|
|
31
|
+
}
|
|
32
|
+
.stk-filter > span{
|
|
33
|
+
flex:1;
|
|
34
|
+
min-width:0;
|
|
35
|
+
overflow:hidden;
|
|
36
|
+
}
|
|
37
|
+
.stk-filter .stk-filter-icon{
|
|
38
|
+
display:inline-flex;
|
|
39
|
+
width:16px;
|
|
40
|
+
height:16px;
|
|
41
|
+
cursor:pointer;
|
|
42
|
+
opacity:0.6;
|
|
43
|
+
transition:opacity 0.2s;
|
|
44
|
+
flex-shrink:0;
|
|
45
|
+
}
|
|
46
|
+
.stk-filter .stk-filter-icon:hover{
|
|
47
|
+
opacity:1;
|
|
48
|
+
color:var(--icon-hover-color);
|
|
49
|
+
}
|
|
50
|
+
.stk-filter-dropdown{
|
|
51
|
+
--border-color:#303439;
|
|
52
|
+
--btn-hover-color:#2a2f34;
|
|
53
|
+
--bg-color:#181c21;
|
|
54
|
+
--text-color:rgba(255, 255, 255, 0.85);
|
|
55
|
+
--cell-hover-color:#2a2f34;
|
|
56
|
+
position:absolute;
|
|
57
|
+
width:120px;
|
|
58
|
+
max-height:200px;
|
|
59
|
+
background-color:#181c21;
|
|
60
|
+
background-color:var(--bg-color);
|
|
61
|
+
border:1px solid #303439;
|
|
62
|
+
border:1px solid var(--border-color);
|
|
63
|
+
border-radius:4px;
|
|
64
|
+
box-shadow:0 2px 4px rgba(0, 0, 0, 0.1);
|
|
65
|
+
z-index:1000;
|
|
66
|
+
display:flex;
|
|
67
|
+
flex-direction:column;
|
|
68
|
+
color:rgba(255, 255, 255, 0.85);
|
|
69
|
+
color:var(--text-color);
|
|
70
|
+
}
|
|
71
|
+
.stk-filter-dropdown--light{
|
|
72
|
+
--border-color:#e8e8e8;
|
|
73
|
+
--btn-hover-color:#f5f5f5;
|
|
74
|
+
--bg-color:#ffffff;
|
|
75
|
+
--text-color:rgba(0, 0, 0, 0.85);
|
|
76
|
+
--cell-hover-color:#fafafa;
|
|
77
|
+
box-shadow:0 2px 8px rgba(0, 0, 0, 0.15);
|
|
78
|
+
}
|
|
79
|
+
.stk-filter-dropdown > .stk-table{
|
|
80
|
+
--cell-padding-x:4px;
|
|
81
|
+
padding:8px 0;
|
|
82
|
+
flex:1;
|
|
83
|
+
min-height:0;
|
|
84
|
+
}
|
|
85
|
+
.stk-filter-dropdown > .stk-table tr{
|
|
86
|
+
cursor:pointer;
|
|
87
|
+
}
|
|
88
|
+
.stk-filter-dropdown .stk-filter-dropdown-checkbox{
|
|
89
|
+
text-align:center;
|
|
90
|
+
}
|
|
91
|
+
.stk-filter-dropdown .stk-filter-dropdown-checkbox > [type='checkbox']{
|
|
92
|
+
vertical-align:middle;
|
|
93
|
+
}
|
|
94
|
+
.stk-filter-dropdown footer{
|
|
95
|
+
display:flex;
|
|
96
|
+
border-top:1px solid var(--border-color);
|
|
97
|
+
}
|
|
98
|
+
.stk-filter-dropdown footer button{
|
|
99
|
+
flex:1;
|
|
100
|
+
cursor:pointer;
|
|
101
|
+
background:transparent;
|
|
102
|
+
border:none;
|
|
103
|
+
color:var(--text-color);
|
|
104
|
+
}
|
|
105
|
+
.stk-filter-dropdown footer button:first-child{
|
|
106
|
+
border-right:1px solid var(--border-color);
|
|
107
|
+
}
|
|
108
|
+
.stk-filter-dropdown footer button:hover{
|
|
109
|
+
background-color:var(--btn-hover-color);
|
|
110
|
+
}
|
|
111
|
+
.stk-editable-cell{
|
|
112
|
+
height:100%;
|
|
113
|
+
display:flex;
|
|
114
|
+
align-items:center;
|
|
115
|
+
cursor:default;
|
|
116
|
+
min-width:0;
|
|
117
|
+
}
|
|
118
|
+
.stk-editable-cell-input{
|
|
119
|
+
width:100%;
|
|
120
|
+
font-size:inherit;
|
|
121
|
+
line-height:inherit;
|
|
122
|
+
font-family:inherit;
|
|
123
|
+
box-sizing:border-box;
|
|
124
|
+
outline:none;
|
|
125
|
+
padding:0 4px;
|
|
126
|
+
border:none;
|
|
127
|
+
background:transparent;
|
|
128
|
+
}
|
|
129
|
+
.stk-checkbox-cell{
|
|
130
|
+
display:flex;
|
|
131
|
+
align-items:center;
|
|
132
|
+
justify-content:center;
|
|
133
|
+
width:100%;
|
|
134
|
+
height:100%;
|
|
135
|
+
}
|
|
136
|
+
.stk-checkbox-cell .stk-checkbox-native{
|
|
137
|
+
width:16px;
|
|
138
|
+
height:16px;
|
|
139
|
+
cursor:pointer;
|
|
140
|
+
vertical-align:middle;
|
|
141
|
+
}
|
|
142
|
+
.stk-change-cell{
|
|
143
|
+
--stk-change-red:#e02020;
|
|
144
|
+
--stk-change-green:#00a870;
|
|
145
|
+
--stk-change-flat:#666;
|
|
146
|
+
display:inline-flex;
|
|
147
|
+
align-items:center;
|
|
148
|
+
gap:2px;
|
|
149
|
+
}
|
|
150
|
+
.stk-change-cell--red{
|
|
151
|
+
color:var(--stk-change-red);
|
|
152
|
+
}
|
|
153
|
+
.stk-change-cell--green{
|
|
154
|
+
color:var(--stk-change-green);
|
|
155
|
+
}
|
|
156
|
+
.stk-change-cell--flat{
|
|
157
|
+
color:var(--stk-change-flat);
|
|
158
|
+
}
|
|
159
|
+
.stk-change-cell__arrow{
|
|
160
|
+
font-size:0.85em;
|
|
161
|
+
line-height:1;
|
|
162
|
+
}
|
|
163
|
+
.stk-table.dark .stk-change-cell{
|
|
164
|
+
--stk-change-red:#ff5c5c;
|
|
165
|
+
--stk-change-green:#26c281;
|
|
166
|
+
--stk-change-flat:#aaa;
|
|
167
|
+
}
|
|
9
168
|
@keyframes stk-table-dim{
|
|
10
169
|
from{
|
|
11
170
|
background-color:var(--highlight-color);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stk-table-vue",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "High performance realtime virtual table for vue3 and vue2.7",
|
|
5
5
|
"main": "./lib/stk-table-vue.js",
|
|
6
6
|
"types": "./lib/src/StkTable/index.d.ts",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"test": "test"
|
|
20
20
|
},
|
|
21
21
|
"type": "module",
|
|
22
|
-
"sideEffects":
|
|
22
|
+
"sideEffects": ["./src/**", "./lib/**/*.css"],
|
|
23
23
|
"scripts": {
|
|
24
24
|
"dev": "vite",
|
|
25
25
|
"build": "vite build",
|
|
@@ -882,6 +882,7 @@ const [sortStates, sortCol, onColumnSort, setSorter, resetSorter, getSortColumns
|
|
|
882
882
|
tableHeaderLast,
|
|
883
883
|
dataSourceCopy,
|
|
884
884
|
initDataSource,
|
|
885
|
+
onDataSourceChange,
|
|
885
886
|
);
|
|
886
887
|
|
|
887
888
|
const [isSRBRActive] = useScrollRowByRow(props, tableContainerRef);
|
|
@@ -936,6 +937,7 @@ const [
|
|
|
936
937
|
getMaxRowSpanValue,
|
|
937
938
|
scrollbarOptions,
|
|
938
939
|
isExperimentalScrollY,
|
|
940
|
+
isRelativeMode,
|
|
939
941
|
mergeCellsCache,
|
|
940
942
|
);
|
|
941
943
|
|
|
@@ -1219,6 +1221,8 @@ onMounted(() => {
|
|
|
1219
1221
|
});
|
|
1220
1222
|
|
|
1221
1223
|
async function onDataSourceChange() {
|
|
1224
|
+
// 数据顺序/行数变化后重算 rowspan 映射(排序后 mergeCells 的 rowIndex 会变化)
|
|
1225
|
+
updateMaxRowSpan();
|
|
1222
1226
|
await nextTick();
|
|
1223
1227
|
initVirtualScrollY();
|
|
1224
1228
|
updateCustomScrollbar();
|
|
@@ -1250,6 +1254,9 @@ function setFilter(
|
|
|
1250
1254
|
filterStatus.value = status;
|
|
1251
1255
|
if (!option?.remote) {
|
|
1252
1256
|
initDataSource();
|
|
1257
|
+
// #80:筛选改变 dataSourceCopy 后重算虚拟滚动窗口,
|
|
1258
|
+
// 否则残留 startIndex/endIndex/scrollHeight 会导致只渲染 1 行或空白视口
|
|
1259
|
+
onDataSourceChange();
|
|
1253
1260
|
}
|
|
1254
1261
|
if (!option?.silent) {
|
|
1255
1262
|
emits('filter-change', status);
|
|
@@ -1525,14 +1532,16 @@ function getTHProps(col: PrivateStkTableColumn<DT>) {
|
|
|
1525
1532
|
colspan: col.__C_SP__,
|
|
1526
1533
|
style: cellStyleMap.value[TagType.TH].get(colKey),
|
|
1527
1534
|
title: getHeaderTitle(col),
|
|
1535
|
+
// class 用预拼接字符串(而非数组),降低每格 vnode diff 与 GC 开销
|
|
1528
1536
|
class: [
|
|
1529
1537
|
col.sorter ? 'sortable' : '',
|
|
1530
|
-
isSorted
|
|
1538
|
+
isSorted ? 'sorter-' + sortState?.order : '',
|
|
1531
1539
|
col.headerClassName,
|
|
1532
1540
|
fixedColClassMap.value.get(colKey),
|
|
1533
|
-
col.headerAlign
|
|
1534
|
-
|
|
1535
|
-
|
|
1541
|
+
col.headerAlign === 'left' ? 'text-l' : col.headerAlign === 'right' ? 'text-r' : col.headerAlign === 'center' ? 'text-c' : '',
|
|
1542
|
+
]
|
|
1543
|
+
.filter(Boolean)
|
|
1544
|
+
.join(' '),
|
|
1536
1545
|
};
|
|
1537
1546
|
}
|
|
1538
1547
|
|
|
@@ -1546,7 +1555,9 @@ function getTFProps(col: StkTableColumn<DT>) {
|
|
|
1546
1555
|
fixedColClassMap.value.get(colKey),
|
|
1547
1556
|
col.type === 'seq' ? 'seq-column' : '',
|
|
1548
1557
|
col.align === 'center' ? 'text-c' : col.align === 'right' ? 'text-r' : '',
|
|
1549
|
-
]
|
|
1558
|
+
]
|
|
1559
|
+
.filter(Boolean)
|
|
1560
|
+
.join(' '),
|
|
1550
1561
|
};
|
|
1551
1562
|
}
|
|
1552
1563
|
|
|
@@ -1596,7 +1607,7 @@ function getTDProps(row: PrivateRowDT | null | undefined, col: StkTableColumn<Pr
|
|
|
1596
1607
|
return {
|
|
1597
1608
|
'data-col-key': colKey,
|
|
1598
1609
|
style: cellStyleMap.value[TagType.TD].get(colKey),
|
|
1599
|
-
class: classList,
|
|
1610
|
+
class: classList.filter(Boolean).join(' '),
|
|
1600
1611
|
...mergeCellsWrapper(row, col, rowIndex, (col as PrivateStkTableColumn<DT>).__LF_S__ ?? 0),
|
|
1601
1612
|
};
|
|
1602
1613
|
}
|
|
@@ -2090,7 +2101,8 @@ function scrollTo(options?: ScrollToOptions | number | null, leftArg?: number |
|
|
|
2090
2101
|
if (top === null && left === null) return;
|
|
2091
2102
|
// 钳制到合法滚动范围(基于理论内容尺寸,与滚动条语义一致)
|
|
2092
2103
|
if (top !== null) top = Math.min(Math.max(top, 0), getMaxScrollTop());
|
|
2093
|
-
if (left !== null)
|
|
2104
|
+
if (left !== null)
|
|
2105
|
+
left = Math.min(Math.max(left, 0), Math.max(0, getColLeft(tableHeaderLast.value.length) - virtualScrollX.value.containerWidth));
|
|
2094
2106
|
if (options.behavior === 'smooth') {
|
|
2095
2107
|
smoothScrollTo(top, left);
|
|
2096
2108
|
} else {
|
|
@@ -34,8 +34,6 @@ export function useFixedStyle<DT extends Record<string, any>>(
|
|
|
34
34
|
|
|
35
35
|
const { headerRowHeight, rowHeight } = props;
|
|
36
36
|
const isFixedLeft = fixed === 'left';
|
|
37
|
-
const { scrollLeft, scrollWidth, offsetLeft, containerWidth } = virtualScrollX.value;
|
|
38
|
-
const scrollRight = scrollWidth - containerWidth - scrollLeft;
|
|
39
37
|
|
|
40
38
|
let style = '';
|
|
41
39
|
|
|
@@ -60,6 +58,8 @@ export function useFixedStyle<DT extends Record<string, any>>(
|
|
|
60
58
|
style += `right:${lr};`;
|
|
61
59
|
}
|
|
62
60
|
} else {
|
|
61
|
+
const { scrollLeft, scrollWidth, offsetLeft, containerWidth } = virtualScrollX.value;
|
|
62
|
+
const scrollRight = scrollWidth - containerWidth - scrollLeft;
|
|
63
63
|
if (isFixedLeft) {
|
|
64
64
|
style += `left:${scrollLeft - (virtualX_on.value ? offsetLeft : 0)}px;`;
|
|
65
65
|
} else {
|
|
@@ -48,6 +48,131 @@ export function useMergeCells(
|
|
|
48
48
|
|
|
49
49
|
/** column index cache */
|
|
50
50
|
let colIndexCache: Map<UniqKey, number> | null = null;
|
|
51
|
+
/** 空合并段列表(start 为绝对行索引)。禁止合并时返回共享空数组 */
|
|
52
|
+
const EMPTY_BLOCKS: { start: number; count: number }[] = [];
|
|
53
|
+
|
|
54
|
+
/** 空下方占位段列表。无下方合并行时返回共享空数组 */
|
|
55
|
+
const EMPTY_SEGMENTS: { count: number }[] = [];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* mergeCellsWrapper 返回值记忆化缓存(WeakMap<行对象, Map<colKey, 结果>>)。
|
|
59
|
+
* 签名 = 绝对行索引 + 代际(wrapperCacheGen):窗口/列/视口修正(上方空行段、
|
|
60
|
+
* 下方占位段、viewportEndIndex、下方行数)任一变化即整体失效。
|
|
61
|
+
*/
|
|
62
|
+
const wrapperCache = new WeakMap<
|
|
63
|
+
object,
|
|
64
|
+
Map<UniqKey, { absRowIndex: number; gen: number; result: { colspan?: number; rowspan?: number } | undefined }>
|
|
65
|
+
>();
|
|
66
|
+
|
|
67
|
+
/** 是否存在合并列(仅依赖列配置,跨滚动帧缓存,不随数据/滚动重算) */
|
|
68
|
+
const hasMergeColumn = computed(() => tableHeaderLast.value.some(col => !!col.mergeCells));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 视口上方连续「无 td」空行段(行数 >= 2 才成段;单行保留独立 tr)。
|
|
72
|
+
* 空行判定与渲染保持一致:aboveViewportColumnMap 中列结果为空且非展开行。
|
|
73
|
+
* 供模板合并渲染(StkTable aboveRenderParts)与 rowspan 属性修正共用。
|
|
74
|
+
*/
|
|
75
|
+
const aboveEmptyBlocks = computed<{ start: number; count: number }[]>(() => {
|
|
76
|
+
if (!canMergeEmptyRows.value) return EMPTY_BLOCKS;
|
|
77
|
+
const data = virtual_dataSourcePart.value;
|
|
78
|
+
const { startIndex, viewportStartIndex } = virtualScroll.value;
|
|
79
|
+
const aboveCount = Math.min(data.length, Math.max(0, viewportStartIndex - startIndex));
|
|
80
|
+
if (aboveCount <= 0) return EMPTY_BLOCKS;
|
|
81
|
+
const colMap = aboveViewportColumnMap.value;
|
|
82
|
+
|
|
83
|
+
const isEmptyRow = (row: PrivateRowDT): boolean => {
|
|
84
|
+
// 展开行有独立行高且渲染内容,不能参与合并
|
|
85
|
+
if (!row || row.__EXP_R__) return false;
|
|
86
|
+
const cols = colMap.get(rowKeyGen(row));
|
|
87
|
+
// 仅当映射中明确为空列结果时才算空行;缺失(异常)时保留原渲染
|
|
88
|
+
return cols !== void 0 && cols.length === 0;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const blocks: { start: number; count: number }[] = [];
|
|
92
|
+
let runStart = -1;
|
|
93
|
+
const flushRun = (endExclusive: number) => {
|
|
94
|
+
if (endExclusive - runStart >= 2) {
|
|
95
|
+
blocks.push({ start: startIndex + runStart, count: endExclusive - runStart });
|
|
96
|
+
}
|
|
97
|
+
runStart = -1;
|
|
98
|
+
};
|
|
99
|
+
for (let i = 0; i < aboveCount; i++) {
|
|
100
|
+
if (isEmptyRow(data[i])) {
|
|
101
|
+
if (runStart < 0) runStart = i;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (runStart >= 0) flushRun(i);
|
|
105
|
+
}
|
|
106
|
+
if (runStart >= 0) flushRun(aboveCount);
|
|
107
|
+
return blocks.length ? blocks : EMPTY_BLOCKS;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 视口下方占位段:把 [viewportEndIndex+1, endIndex] 按跨界 rowspan 的
|
|
112
|
+
* 去重结束行切分为多段,每段渲染一个占位 tr。
|
|
113
|
+
*
|
|
114
|
+
* 单个占位 tr 无法表达多个 rowspan 的不同逻辑结束位置(不规律合并下
|
|
115
|
+
* 多个单元格会塌缩到同一 tr 底边,滚动时高度跳动);按结束行切段后,
|
|
116
|
+
* 每个单元格的修正 rowspan 恰好止于包含其逻辑结束行的段。
|
|
117
|
+
* 超长 rowspan 场景只有一个结束行(= 区域末端),仍只产生 1 段,不增加 DOM。
|
|
118
|
+
*/
|
|
119
|
+
const belowPhSegments = computed<{ count: number }[]>(() => {
|
|
120
|
+
const belowCount = belowViewportRowCount.value;
|
|
121
|
+
if (belowCount <= 0) return EMPTY_SEGMENTS;
|
|
122
|
+
const data = virtual_dataSourcePart.value;
|
|
123
|
+
const { startIndex, viewportEndIndex, endIndex } = virtualScroll.value;
|
|
124
|
+
// 下方区域末端钳制到窗口最后一行(endIndex 可能超出数据长度)
|
|
125
|
+
const regionEnd = Math.min(endIndex, startIndex + data.length - 1);
|
|
126
|
+
const columns = virtualX_columnPart.value;
|
|
127
|
+
|
|
128
|
+
// 跨界 rowspan 的锚点只可能在视口上方保留行与视口行内,收集其去重结束行
|
|
129
|
+
const endsSet = new Set<number>();
|
|
130
|
+
const anchorRowEnd = Math.min(data.length - 1, viewportEndIndex - startIndex);
|
|
131
|
+
for (let rowOffset = 0; rowOffset <= anchorRowEnd; rowOffset++) {
|
|
132
|
+
const row = data[rowOffset];
|
|
133
|
+
if (!row) continue;
|
|
134
|
+
const absRowIndex = startIndex + rowOffset;
|
|
135
|
+
for (let colIdx = 0; colIdx < columns.length; colIdx++) {
|
|
136
|
+
const col = columns[colIdx];
|
|
137
|
+
if (!col.mergeCells) continue;
|
|
138
|
+
const leafIndex = col.__LF_S__ ?? colIdx;
|
|
139
|
+
const { rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, leafIndex);
|
|
140
|
+
if (rowspan <= 1) continue;
|
|
141
|
+
const spanEnd = absRowIndex + rowspan - 1;
|
|
142
|
+
if (spanEnd > viewportEndIndex) endsSet.add(Math.min(spanEnd, regionEnd));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 兜底:未收集到跨界结束行时保持单段(与合并前行为一致)
|
|
147
|
+
if (!endsSet.size) return [{ count: belowCount }];
|
|
148
|
+
|
|
149
|
+
const ends = Array.from(endsSet).sort((a, b) => a - b);
|
|
150
|
+
const segments: { count: number }[] = [];
|
|
151
|
+
let prev = viewportEndIndex;
|
|
152
|
+
for (let i = 0; i < ends.length; i++) {
|
|
153
|
+
const count = ends[i] - prev;
|
|
154
|
+
if (count > 0) segments.push({ count });
|
|
155
|
+
prev = ends[i];
|
|
156
|
+
}
|
|
157
|
+
if (regionEnd > prev) segments.push({ count: regionEnd - prev });
|
|
158
|
+
return segments;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let wrapperCacheGen = 0;
|
|
162
|
+
|
|
163
|
+
watch(
|
|
164
|
+
[
|
|
165
|
+
virtual_dataSourcePart,
|
|
166
|
+
tableHeaderLast,
|
|
167
|
+
aboveEmptyBlocks,
|
|
168
|
+
belowPhSegments,
|
|
169
|
+
() => virtualScroll.value.viewportEndIndex,
|
|
170
|
+
belowViewportRowCount,
|
|
171
|
+
],
|
|
172
|
+
() => {
|
|
173
|
+
wrapperCacheGen++;
|
|
174
|
+
},
|
|
175
|
+
);
|
|
51
176
|
|
|
52
177
|
/**
|
|
53
178
|
* 数据/列真正变化时才清空 mergeCells 结果缓存。
|
|
@@ -65,9 +190,6 @@ export function useMergeCells(
|
|
|
65
190
|
buildHiddenCellMap();
|
|
66
191
|
});
|
|
67
192
|
|
|
68
|
-
/** 是否存在合并列(仅依赖列配置,跨滚动帧缓存,不随数据/滚动重算) */
|
|
69
|
-
const hasMergeColumn = computed(() => tableHeaderLast.value.some(col => !!col.mergeCells));
|
|
70
|
-
|
|
71
193
|
/**
|
|
72
194
|
* Pre-build hiddenCellMap and hoverRowMap before rendering to avoid
|
|
73
195
|
* a two-render cycle (null → populated) that causes visual flicker.
|
|
@@ -204,21 +326,34 @@ export function useMergeCells(
|
|
|
204
326
|
// 渲染传入的是切片内相对索引,统一换算为绝对行索引后再调用 mergeCells,
|
|
205
327
|
// 保证与 buildHiddenCellMap / aboveViewportColumnMap 的入参及缓存键一致
|
|
206
328
|
const absRowIndex = virtualScroll.value.startIndex + rowIndex;
|
|
207
|
-
const { colspan, rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, colIndex);
|
|
208
|
-
|
|
209
|
-
if (colspan === 1 && rowspan === 1) return;
|
|
210
|
-
|
|
211
|
-
const rowKey = rowKeyGen(row);
|
|
212
329
|
const colKey = colKeyGen.value(col);
|
|
213
|
-
const mergedCellKey = pureCellKeyGen(rowKey, colKey);
|
|
214
330
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
331
|
+
// 返回值记忆化:命中时跳过 getMergeCellsResult、rowspan 修正循环与对象分配。
|
|
332
|
+
// hideCells 为幂等写入(buildHiddenCellMap 已在渲染前按相同输入构建),命中时跳过安全。
|
|
333
|
+
let rowCache = wrapperCache.get(row);
|
|
334
|
+
if (!rowCache) {
|
|
335
|
+
rowCache = new Map();
|
|
336
|
+
wrapperCache.set(row, rowCache);
|
|
219
337
|
}
|
|
338
|
+
const hit = rowCache.get(colKey);
|
|
339
|
+
if (hit && hit.absRowIndex === absRowIndex && hit.gen === wrapperCacheGen) return hit.result;
|
|
220
340
|
|
|
221
|
-
|
|
341
|
+
const { colspan, rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, colIndex);
|
|
342
|
+
|
|
343
|
+
let result: { colspan?: number; rowspan?: number } | undefined;
|
|
344
|
+
if (colspan === 1 && rowspan === 1) {
|
|
345
|
+
result = void 0;
|
|
346
|
+
} else {
|
|
347
|
+
const mergedCellKey = pureCellKeyGen(rowKeyGen(row), colKey);
|
|
348
|
+
for (let i = rowIndex; i < rowIndex + rowspan; i++) {
|
|
349
|
+
const targetRow = virtual_dataSourcePart.value[i];
|
|
350
|
+
if (!targetRow) break;
|
|
351
|
+
hideCells(rowKeyGen(targetRow), colKey, colspan, i === rowIndex, mergedCellKey);
|
|
352
|
+
}
|
|
353
|
+
result = { colspan, rowspan: adjustRowspanForMergedRows(absRowIndex, rowspan) };
|
|
354
|
+
}
|
|
355
|
+
rowCache.set(colKey, { absRowIndex, gen: wrapperCacheGen, result });
|
|
356
|
+
return result;
|
|
222
357
|
}
|
|
223
358
|
|
|
224
359
|
/**
|
|
@@ -383,103 +518,6 @@ export function useMergeCells(
|
|
|
383
518
|
return map;
|
|
384
519
|
});
|
|
385
520
|
|
|
386
|
-
/** 空合并段列表(start 为绝对行索引)。禁止合并时返回共享空数组 */
|
|
387
|
-
const EMPTY_BLOCKS: { start: number; count: number }[] = [];
|
|
388
|
-
|
|
389
|
-
/** 空下方占位段列表。无下方合并行时返回共享空数组 */
|
|
390
|
-
const EMPTY_SEGMENTS: { count: number }[] = [];
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* 视口上方连续「无 td」空行段(行数 >= 2 才成段;单行保留独立 tr)。
|
|
394
|
-
* 空行判定与渲染保持一致:aboveViewportColumnMap 中列结果为空且非展开行。
|
|
395
|
-
* 供模板合并渲染(StkTable aboveRenderParts)与 rowspan 属性修正共用。
|
|
396
|
-
*/
|
|
397
|
-
const aboveEmptyBlocks = computed<{ start: number; count: number }[]>(() => {
|
|
398
|
-
if (!canMergeEmptyRows.value) return EMPTY_BLOCKS;
|
|
399
|
-
const data = virtual_dataSourcePart.value;
|
|
400
|
-
const { startIndex, viewportStartIndex } = virtualScroll.value;
|
|
401
|
-
const aboveCount = Math.min(data.length, Math.max(0, viewportStartIndex - startIndex));
|
|
402
|
-
if (aboveCount <= 0) return EMPTY_BLOCKS;
|
|
403
|
-
const colMap = aboveViewportColumnMap.value;
|
|
404
|
-
|
|
405
|
-
const isEmptyRow = (row: PrivateRowDT): boolean => {
|
|
406
|
-
// 展开行有独立行高且渲染内容,不能参与合并
|
|
407
|
-
if (!row || row.__EXP_R__) return false;
|
|
408
|
-
const cols = colMap.get(rowKeyGen(row));
|
|
409
|
-
// 仅当映射中明确为空列结果时才算空行;缺失(异常)时保留原渲染
|
|
410
|
-
return cols !== void 0 && cols.length === 0;
|
|
411
|
-
};
|
|
412
|
-
|
|
413
|
-
const blocks: { start: number; count: number }[] = [];
|
|
414
|
-
let runStart = -1;
|
|
415
|
-
const flushRun = (endExclusive: number) => {
|
|
416
|
-
if (endExclusive - runStart >= 2) {
|
|
417
|
-
blocks.push({ start: startIndex + runStart, count: endExclusive - runStart });
|
|
418
|
-
}
|
|
419
|
-
runStart = -1;
|
|
420
|
-
};
|
|
421
|
-
for (let i = 0; i < aboveCount; i++) {
|
|
422
|
-
if (isEmptyRow(data[i])) {
|
|
423
|
-
if (runStart < 0) runStart = i;
|
|
424
|
-
continue;
|
|
425
|
-
}
|
|
426
|
-
if (runStart >= 0) flushRun(i);
|
|
427
|
-
}
|
|
428
|
-
if (runStart >= 0) flushRun(aboveCount);
|
|
429
|
-
return blocks.length ? blocks : EMPTY_BLOCKS;
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* 视口下方占位段:把 [viewportEndIndex+1, endIndex] 按跨界 rowspan 的
|
|
434
|
-
* 去重结束行切分为多段,每段渲染一个占位 tr。
|
|
435
|
-
*
|
|
436
|
-
* 单个占位 tr 无法表达多个 rowspan 的不同逻辑结束位置(不规律合并下
|
|
437
|
-
* 多个单元格会塌缩到同一 tr 底边,滚动时高度跳动);按结束行切段后,
|
|
438
|
-
* 每个单元格的修正 rowspan 恰好止于包含其逻辑结束行的段。
|
|
439
|
-
* 超长 rowspan 场景只有一个结束行(= 区域末端),仍只产生 1 段,不增加 DOM。
|
|
440
|
-
*/
|
|
441
|
-
const belowPhSegments = computed<{ count: number }[]>(() => {
|
|
442
|
-
const belowCount = belowViewportRowCount.value;
|
|
443
|
-
if (belowCount <= 0) return EMPTY_SEGMENTS;
|
|
444
|
-
const data = virtual_dataSourcePart.value;
|
|
445
|
-
const { startIndex, viewportEndIndex, endIndex } = virtualScroll.value;
|
|
446
|
-
// 下方区域末端钳制到窗口最后一行(endIndex 可能超出数据长度)
|
|
447
|
-
const regionEnd = Math.min(endIndex, startIndex + data.length - 1);
|
|
448
|
-
const columns = virtualX_columnPart.value;
|
|
449
|
-
|
|
450
|
-
// 跨界 rowspan 的锚点只可能在视口上方保留行与视口行内,收集其去重结束行
|
|
451
|
-
const endsSet = new Set<number>();
|
|
452
|
-
const anchorRowEnd = Math.min(data.length - 1, viewportEndIndex - startIndex);
|
|
453
|
-
for (let rowOffset = 0; rowOffset <= anchorRowEnd; rowOffset++) {
|
|
454
|
-
const row = data[rowOffset];
|
|
455
|
-
if (!row) continue;
|
|
456
|
-
const absRowIndex = startIndex + rowOffset;
|
|
457
|
-
for (let colIdx = 0; colIdx < columns.length; colIdx++) {
|
|
458
|
-
const col = columns[colIdx];
|
|
459
|
-
if (!col.mergeCells) continue;
|
|
460
|
-
const leafIndex = col.__LF_S__ ?? colIdx;
|
|
461
|
-
const { rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, leafIndex);
|
|
462
|
-
if (rowspan <= 1) continue;
|
|
463
|
-
const spanEnd = absRowIndex + rowspan - 1;
|
|
464
|
-
if (spanEnd > viewportEndIndex) endsSet.add(Math.min(spanEnd, regionEnd));
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// 兜底:未收集到跨界结束行时保持单段(与合并前行为一致)
|
|
469
|
-
if (!endsSet.size) return [{ count: belowCount }];
|
|
470
|
-
|
|
471
|
-
const ends = Array.from(endsSet).sort((a, b) => a - b);
|
|
472
|
-
const segments: { count: number }[] = [];
|
|
473
|
-
let prev = viewportEndIndex;
|
|
474
|
-
for (let i = 0; i < ends.length; i++) {
|
|
475
|
-
const count = ends[i] - prev;
|
|
476
|
-
if (count > 0) segments.push({ count });
|
|
477
|
-
prev = ends[i];
|
|
478
|
-
}
|
|
479
|
-
if (regionEnd > prev) segments.push({ count: regionEnd - prev });
|
|
480
|
-
return segments;
|
|
481
|
-
});
|
|
482
|
-
|
|
483
521
|
function updateActiveMergedCells(clear?: boolean, rowKey?: UniqKey) {
|
|
484
522
|
if (!rowActiveProp.value.enabled) return;
|
|
485
523
|
if (clear) {
|
|
@@ -13,11 +13,12 @@ const SORT_SWITCH_ORDER: Order[] = [null, 'desc', 'asc'] as const;
|
|
|
13
13
|
* 排序 Hook
|
|
14
14
|
* 管理表格排序状态和相关操作
|
|
15
15
|
* @param props 表格 props
|
|
16
|
-
* @param colKeyGen 列 key 生成函数
|
|
16
|
+
* @param colKeyGen 列 key 生成函数
|
|
17
17
|
* @param tableHeaderLast 表头最后一行(叶子节点)
|
|
18
18
|
* @param dataSourceCopy 数据源副本 ref
|
|
19
19
|
* @param initDataSource 初始化数据源函数
|
|
20
20
|
* @param emits 事件发射函数
|
|
21
|
+
* @param onDataSourceChange 数据源变化后的刷新回调(重算虚拟滚动等,见 #80)
|
|
21
22
|
* @returns 排序相关状态和方法
|
|
22
23
|
*/
|
|
23
24
|
export function useSorter<DT extends Record<string, any>>(
|
|
@@ -27,6 +28,7 @@ export function useSorter<DT extends Record<string, any>>(
|
|
|
27
28
|
tableHeaderLast: Ref<StkTableColumn<DT>[]>,
|
|
28
29
|
dataSourceCopy: Ref<DT[]>,
|
|
29
30
|
initDataSource: (data?: DT[], option?: { forceSort?: boolean }) => void,
|
|
31
|
+
onDataSourceChange: () => void,
|
|
30
32
|
) {
|
|
31
33
|
/** 多列排序状态数组 */
|
|
32
34
|
const sortStates = ref<SortState<DT>[]>([]);
|
|
@@ -182,6 +184,8 @@ export function useSorter<DT extends Record<string, any>>(
|
|
|
182
184
|
|
|
183
185
|
if (!props.sortRemote) {
|
|
184
186
|
initDataSource();
|
|
187
|
+
// #80:排序后数据窗口需重算,否则残留 startIndex/endIndex 会“吃掉”数据
|
|
188
|
+
onDataSourceChange();
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
emits('sort-change', col, order, toRaw(dataSourceCopy.value), sortConfig);
|
|
@@ -220,6 +224,8 @@ export function useSorter<DT extends Record<string, any>>(
|
|
|
220
224
|
if (newOption.sort && dataSourceCopy.value?.length) {
|
|
221
225
|
if (!props.sortRemote || newOption.force) {
|
|
222
226
|
initDataSource(props.dataSource, { forceSort: newOption.force });
|
|
227
|
+
// #80:与 onColumnSort 一致,排序后重算虚拟滚动窗口
|
|
228
|
+
onDataSourceChange();
|
|
223
229
|
}
|
|
224
230
|
}
|
|
225
231
|
|
|
@@ -243,6 +249,8 @@ export function useSorter<DT extends Record<string, any>>(
|
|
|
243
249
|
function resetSorter() {
|
|
244
250
|
sortStates.value = [];
|
|
245
251
|
initDataSource();
|
|
252
|
+
// #80:重置后同样重算虚拟滚动窗口
|
|
253
|
+
onDataSourceChange();
|
|
246
254
|
}
|
|
247
255
|
|
|
248
256
|
/**
|