stk-table-vue 1.2.1 → 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.
@@ -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 && 'sorter-' + sortState?.order,
1538
+ isSorted ? 'sorter-' + sortState?.order : '',
1531
1539
  col.headerClassName,
1532
1540
  fixedColClassMap.value.get(colKey),
1533
- col.headerAlign &&
1534
- (col.headerAlign === 'left' ? 'text-l' : col.headerAlign === 'right' ? 'text-r' : col.headerAlign === 'center' ? 'text-c' : null),
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) left = Math.min(Math.max(left, 0), Math.max(0, getColLeft(tableHeaderLast.value.length) - virtualScrollX.value.containerWidth));
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
- for (let i = rowIndex; i < rowIndex + rowspan; i++) {
216
- const targetRow = virtual_dataSourcePart.value[i];
217
- if (!targetRow) break;
218
- hideCells(rowKeyGen(targetRow), colKey, colspan, i === rowIndex, mergedCellKey);
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
- return { colspan, rowspan: adjustRowspanForMergedRows(absRowIndex, rowspan) };
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
  /**
@@ -21,7 +21,11 @@ export type VirtualScrollStore = {
21
21
  rowHeight: number;
22
22
  /** 表格定位上边距 */
23
23
  offsetTop: number;
24
- /** 纵向滚动条位置,用于判断是横向滚动还是纵向 */
24
+ /**
25
+ * 纵向滚动条位置,用于判断是横向滚动还是纵向。
26
+ * 静默字段:非 relative 模式下写入不触发 triggerRef(滚动窗口未变的帧零重渲染),
27
+ * 不得在响应式上下文(computed/模板/watch)中依赖此字段。
28
+ */
25
29
  scrollTop: number;
26
30
  /** 总滚动高度 */
27
31
  scrollHeight: number;
@@ -43,7 +47,10 @@ export type VirtualScrollXStore = {
43
47
  endIndex: number;
44
48
  /** 表格定位左边距 */
45
49
  offsetLeft: number;
46
- /** 横向滚动位置,用于判断是横向滚动还是纵向 */
50
+ /**
51
+ * 横向滚动位置,用于判断是横向滚动还是纵向。
52
+ * 静默字段:非 relative 模式下写入不触发 triggerRef,不得在响应式上下文中依赖此字段。
53
+ */
47
54
  scrollLeft: number;
48
55
  };
49
56
 
@@ -91,6 +98,10 @@ function useColWidthCache<T extends { fixed?: StkTableColumn<PrivateRowDT>['fixe
91
98
  /** vue2 优化滚动回收延时 */
92
99
  const VUE2_SCROLL_TIMEOUT_MS = 200;
93
100
 
101
+ /** 静默字段常量(模块级复用,避免每次滚动帧分配数组);relative 模式不静默(见 assignVs 注释) */
102
+ const SILENT_Y_KEYS: ['scrollTop'] = ['scrollTop'];
103
+ const SILENT_X_KEYS: ['scrollLeft'] = ['scrollLeft'];
104
+
94
105
  /**
95
106
  * 合并单元格可视范围修正的最大迭代次数。
96
107
  * 合法(不重叠)的合并配置最多 2 轮即收敛(1 轮扩展 + 1 轮验证);
@@ -115,6 +126,8 @@ export function useVirtualScroll(
115
126
  getMaxRowSpanValue: () => number,
116
127
  scrollbarOptions: Ref<Required<ScrollbarOptions>>,
117
128
  isExperimentalScrollY: Ref<boolean | undefined>,
129
+ /** relative 固定模式的样式(useFixedStyle)以响应式方式依赖 scrollTop/scrollLeft,该模式下保留滚动位置的响应式触发 */
130
+ isRelativeMode: Ref<boolean>,
118
131
  /** mergeCells 结果共享缓存(与 useMergeCells 共用,避免重复调用用户回调) */
119
132
  mergeCellsCache: MergeCellsCache,
120
133
  ) {
@@ -538,15 +551,20 @@ export function useVirtualScroll(
538
551
  * shallowRef 内部属性变更不会触发响应式,必须手动 triggerRef;但无条件触发会让
539
552
  * 「值未变」的调用也强制整表重渲染(每帧滚动多付 1~5ms 渲染成本)。
540
553
  * 本函数恢复 Vue 深响应式 ref 的 hasChanged 语义,滚动重算回到 0.02ms 级。
554
+ *
555
+ * silentKeys:写入 store 但不参与 triggerRef 判定的字段。滚动位置(scrollTop/scrollLeft)
556
+ * 每帧必变且模板不直接消费,静默写入使「滚动窗口(startIndex/endIndex)未变」的帧
557
+ * 完全跳过组件重渲染(消费方均在滚动处理函数中即时读取 .value)。
558
+ * relative 固定模式例外:useFixedStyle 的样式计算响应式依赖滚动位置,该模式下不静默。
541
559
  */
542
- function assignVs<T extends object>(ref: ShallowRef<T>, patch: Partial<T>) {
560
+ function assignVs<T extends object>(ref: ShallowRef<T>, patch: Partial<T>, silentKeys?: (keyof T)[]) {
543
561
  const store = ref.value;
544
562
  let changed = false;
545
563
  for (const key in patch) {
546
564
  const k = key as keyof T;
547
565
  if (store[k] !== patch[k]) {
548
566
  store[k] = patch[k] as T[keyof T];
549
- changed = true;
567
+ if (!silentKeys?.includes(k)) changed = true;
550
568
  }
551
569
  }
552
570
  if (changed) triggerRef(ref);
@@ -736,16 +754,21 @@ export function useVirtualScroll(
736
754
  }
737
755
  vsValue.scrollTop = sTop;
738
756
 
739
- assignVs(virtualScroll, vsValue);
757
+ // relative 模式固定表头样式响应式依赖 scrollTop,保留触发;其余模式静默写入
758
+ assignVs(virtualScroll, vsValue, isRelativeMode.value ? undefined : SILENT_Y_KEYS);
740
759
 
741
760
  if (!virtual_on.value) {
742
761
  // github #34 init
762
+ // endIndex 重置为最后一行而非 0:virtual_on=false 时虽不参与切片(全量渲染),
763
+ // 但若后续数据变多而未被重算(如旧版筛选/排序路径,见 #80),残留 endIndex=0
764
+ // 会使 slice(startIndex, 0+1) 只渲染 1 行;指向末行可让残留窗口覆盖全量数据。
765
+ const lastIdx = Math.max(0, dataLength - 1);
743
766
  assignVs(virtualScroll, {
744
767
  startIndex: 0,
745
- endIndex: 0,
768
+ endIndex: lastIdx,
746
769
  offsetTop: 0,
747
770
  viewportStartIndex: 0,
748
- viewportEndIndex: 0,
771
+ viewportEndIndex: lastIdx,
749
772
  });
750
773
  return;
751
774
  }
@@ -864,8 +887,9 @@ export function useVirtualScroll(
864
887
  endIndex = Math.min(endIndex, dataLength);
865
888
 
866
889
  if (startIndex >= endIndex) {
867
- // fallback
868
- startIndex = endIndex - pageSize;
890
+ // fallback(#80:scrollTop 残留超出新数据长度时 endIndex 被钳到 dataLength,
891
+ // 需回退到末页窗口;钳非负防 pageSize 并常时产生负 startIndex → 负 offsetTop)
892
+ startIndex = Math.max(0, endIndex - pageSize);
869
893
  }
870
894
 
871
895
  if (vue2ScrollYTimeout) {
@@ -958,13 +982,16 @@ export function useVirtualScroll(
958
982
  window.clearTimeout(vue2ScrollXTimeout);
959
983
  }
960
984
 
985
+ // relative 模式固定列样式响应式依赖 scrollLeft,保留触发;其余模式静默写入
986
+ const silentXKeys = isRelativeMode.value ? undefined : SILENT_X_KEYS;
987
+
961
988
  // <= 等于是因为初始化时要赋值
962
989
  if (!props.optimizeVue2Scroll || sLeft <= scrollLeft) {
963
990
  // 向左滚动
964
- assignVs(virtualScrollX, { startIndex, endIndex, offsetLeft, scrollLeft: sLeft });
991
+ assignVs(virtualScrollX, { startIndex, endIndex, offsetLeft, scrollLeft: sLeft }, silentXKeys);
965
992
  } else {
966
993
  // vue2 向右滚动优化
967
- assignVs(virtualScrollX, { endIndex, scrollLeft: sLeft });
994
+ assignVs(virtualScrollX, { endIndex, scrollLeft: sLeft }, silentXKeys);
968
995
  vue2ScrollXTimeout = window.setTimeout(() => {
969
996
  assignVs(virtualScrollX, { startIndex, offsetLeft });
970
997
  }, VUE2_SCROLL_TIMEOUT_MS);
@@ -1,25 +0,0 @@
1
- import { Ref, ShallowRef } from 'vue';
2
- import { AreaSelectionConfig, AreaSelectionRange, CellKeyGen, ColKeyGen, StkTableColumn, UniqKey, AreaSelectionSetterRange, AreaSelectionSetterOption } from '../types';
3
- import { VirtualScrollStore, VirtualScrollXStore } from '../useVirtualScroll';
4
-
5
- /**
6
- * 单元格区域选择功能
7
- * 支持鼠标拖拽选择、键盘导航、复制粘贴等功能
8
- * en: Cell area selection feature with mouse drag, keyboard navigation, copy-paste, etc.
9
- */
10
- export declare function useAreaSelection<DT extends Record<string, any>>(props: any, emits: any, tableContainerRef: Ref<HTMLDivElement | undefined>, dataSourceCopy: ShallowRef<DT[]>, tableHeaderLast: ShallowRef<StkTableColumn<DT>[]>, colKeyGen: ColKeyGen, cellKeyGen: CellKeyGen, scrollTo: (top: number | null, left: number | null) => void, virtualScroll: Ref<VirtualScrollStore>, virtualScrollX: Ref<VirtualScrollXStore>, getRowIndex: (row: DT) => number, getColumnIndex: (col: StkTableColumn<DT>) => number): {
11
- config: import('vue').ComputedRef<AreaSelectionConfig>;
12
- isSelecting: Ref<boolean, boolean>;
13
- getClass: (cellKey: string, absoluteRowIndex: number, colKey: UniqKey) => string[];
14
- getRowClass: (absoluteRowIndex: number) => string[];
15
- get: () => {
16
- rows: DT[];
17
- cols: StkTableColumn<DT>[];
18
- ranges: AreaSelectionRange[];
19
- };
20
- set: (ranges?: AreaSelectionSetterRange<DT>, option?: AreaSelectionSetterOption) => AreaSelectionRange[];
21
- clear: () => void;
22
- copy: () => string;
23
- onMD: (e: MouseEvent) => void;
24
- };
25
- export declare const useAreaSelectionName = "useAreaSelection";
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1,28 +0,0 @@
1
- export default class DragResize {
2
- /**
3
- *
4
- * @param {HTMLElement} el
5
- */
6
- constructor(el: HTMLElement);
7
- buttonSize: {
8
- width: number;
9
- height: number;
10
- };
11
- /** @type {ResizeObserver} */
12
- resizeObserver: ResizeObserver;
13
- /** @type {DOMRect} */
14
- targetDOMRect: DOMRect;
15
- /** @type {{left:number,top:number}} */
16
- resizeButtonStyle: {
17
- left: number;
18
- top: number;
19
- };
20
- el: HTMLElement;
21
- initData(): void;
22
- createResizeButton(): void;
23
- resizeButton: HTMLElement | undefined;
24
- /** 监听元素大小改变 */
25
- onResize(): void;
26
- addButtonHoverStyle(): void;
27
- addEvent(): void;
28
- }
@@ -1,10 +0,0 @@
1
- /**
2
- * createElement function
3
- * h(tag[, text[,children]])
4
- * h(tag[, attrs[,children]])
5
- * h(tag[, children])
6
- * @param {String} tag 标签名称,支持tag#id.class emmet写法,暂支持id ,class
7
- * @param {Object | String | Number | Array<HTMLElement>} attrs 传Object为属性,传String为textContent,传数组为children
8
- * @param {Array<HTMLElement>} children
9
- */
10
- export default function h(tag: string, attrs: Object | string | number | Array<HTMLElement>, children: Array<HTMLElement>): HTMLElement;