Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@visactor/vtable",
"comment": "feat: support skipping cross-boundary merged cells in frozen column shadows",
"type": "minor"
}
],
"packageName": "@visactor/vtable"
}
12 changes: 12 additions & 0 deletions docs/assets/option/en/common/frozen-column-line-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ Shadow Visible Time, default is `always`.
- always: always show
- scrolling: show when scrolling
- overflow: show only at frozen boundaries with body content hidden beyond that edge. The left shadow appears after scrolling away from the start; the right shadow appears while content remains to the right. Shadows persist after scrolling stops and disappear at the corresponding edge. No shadow is shown without frozen columns or horizontal body overflow.

###${prefix} skipMergedCells(boolean)

Skip merged cells crossing a frozen-column boundary. Defaults to `false`.

When enabled, the shadow is omitted within each cross-boundary merged cell and resumes below it. Supports multiple gaps, left and right frozen columns, and merged cells in the top, body and bottom frozen regions. Merges contained entirely on one side of the boundary keep the original shadow. Shadow width, colors and visibility are unchanged.

```js
theme: VTable.themes.DEFAULT.extends({
frozenColumnLine: { shadow: { skipMergedCells: true } }
})
```
12 changes: 12 additions & 0 deletions docs/assets/option/zh/common/frozen-column-line-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,15 @@
- always: 总是显示
- scrolling: 滚动时显示
- overflow: 仅在冻结边界外有被遮挡的表格主体内容时显示。左侧阴影在滚离起点后显示,右侧阴影在右边仍有内容时显示;停止滚动后保持,滚到对应边缘立即隐藏。没有冻结列或主体没有水平溢出时不显示。

###${prefix} skipMergedCells(boolean)

是否跳过跨越冻结列边界的合并单元格,默认 `false`。

开启后,每个跨冻结边界的合并单元格所在区间不绘制阴影,区间结束后恢复绘制。支持多段截断、左右冻结列,以及顶部、滚动区和底部冻结区的合并单元格。完全位于边界同一侧的合并单元格保持原有阴影。阴影宽度、颜色和显示时机不变。

```js
theme: VTable.themes.DEFAULT.extends({
frozenColumnLine: { shadow: { skipMergedCells: true } }
})
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
138 changes: 138 additions & 0 deletions packages/vtable/__tests__/frozen-shadow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { getFrozenShadowSegments, subtractShadowIntervals } from '../src/scenegraph/component/frozen-shadow';
import type { BaseTableAPI } from '../src/ts-types/base-table';
import type { CellRange } from '../src/ts-types';

type Merge = CellRange & { rect?: { top: number; bottom: number } };

function merge(startCol: number, endCol: number, startRow: number, endRow = startRow, rect?: Merge['rect']): Merge {
return { start: { col: startCol, row: startRow }, end: { col: endCol, row: endRow }, rect };
}

function createTable(merges: Merge[] = [], overrides: Partial<BaseTableAPI> = {}) {
const visited: number[] = [];
const table = {
colCount: 9,
rowCount: 12,
frozenRowCount: 2,
bottomFrozenRowCount: 1,
getFrozenRowsHeight: () => 80,
getBottomFrozenRowsHeight: () => 40,
getBodyVisibleCellRange: () => ({ rowStart: 2, rowEnd: 9 }),
getCellRange: (col: number, row: number) => {
visited.push(row);
return (
merges.find(m => m.start.row <= row && m.end.row >= row && m.start.col <= col && m.end.col >= col) ?? {
start: { col, row },
end: { col, row }
}
);
},
getCellRangeRelativeRect: (range: Merge) => {
if (range.rect) {
return range.rect;
}
const top = range.start.row === 11 ? 380 : range.start.row * 40;
return { top, bottom: top + (range.end.row - range.start.row + 1) * 40 };
},
...overrides
} as unknown as BaseTableAPI;
return { table, visited };
}

describe('frozen column shadows skipping merged cells', () => {
test('truncates the shadow above a cross-boundary bottom summary', () => {
const { table } = createTable([merge(0, 8, 11)]);
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([{ start: 0, end: 380 }]);
});

test('keeps the shadow for ordinary bottom frozen cells', () => {
const { table } = createTable();
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([{ start: 0, end: 420 }]);
});

test('resumes the shadow after multiple body merges including a multi-row merge', () => {
const { table } = createTable([merge(0, 3, 3), merge(0, 3, 6, 7), merge(0, 8, 11)]);
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([
{ start: 0, end: 120 },
{ start: 160, end: 240 },
{ start: 320, end: 380 }
]);
});

test('handles both header and bottom merges', () => {
const { table } = createTable([merge(0, 3, 0, 1), merge(0, 8, 11)]);
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([{ start: 80, end: 380 }]);
});

test('does not skip merges contained on one side of the frozen boundary', () => {
const { table } = createTable([merge(0, 1, 3), merge(2, 4, 5)]);
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([{ start: 0, end: 420 }]);
});

test('handles the right frozen boundary', () => {
const { table } = createTable([merge(5, 8, 3), merge(0, 8, 11)]);
expect(getFrozenShadowSegments(table, 6, 420)).toEqual([
{ start: 0, end: 120 },
{ start: 160, end: 380 }
]);
});

test('clips a scrolled body merge to the body viewport and visits only visible rows', () => {
const { table, visited } = createTable([merge(0, 3, 2, 8, { top: -60, bottom: 220 })], {
getBodyVisibleCellRange: () => ({ rowStart: 5, rowEnd: 10, colStart: 2, colEnd: 8 })
});
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([
{ start: 0, end: 80 },
{ start: 220, end: 420 }
]);
expect(visited).toEqual([0, 1, 5, 9, 10, 11]);
});

test('handles an empty body and multiple bottom frozen rows', () => {
const { table } = createTable([merge(0, 8, 10, 11, { top: 340, bottom: 420 })], {
bottomFrozenRowCount: 2,
getBottomFrozenRowsHeight: () => 80,
getBodyVisibleCellRange: () => null
});
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([{ start: 0, end: 340 }]);
});

test('unions overlapping intervals and clips them to the drawing height', () => {
expect(
subtractShadowIntervals(420, [
{ start: 100, end: 200 },
{ start: 180, end: 240 },
{ start: -30, end: 20 },
{ start: 380, end: 900 },
{ start: 20, end: 20 }
])
).toEqual([
{ start: 20, end: 100 },
{ start: 240, end: 380 }
]);
expect(subtractShadowIntervals(0, [])).toEqual([]);
expect(subtractShadowIntervals(420, [{ start: 0, end: 420 }])).toEqual([]);
});

test('keeps the full-height shadow when there is no column boundary', () => {
const { table } = createTable([merge(0, 8, 11)]);
expect(getFrozenShadowSegments(table, -1, 420)).toEqual([{ start: 0, end: 420 }]);
expect(getFrozenShadowSegments(table, 8, 420)).toEqual([{ start: 0, end: 420 }]);
});

test('recomputes intervals after row geometry or merge definitions change', () => {
const merges = [merge(0, 3, 3, 3, { top: 120, bottom: 160 })];
const { table } = createTable(merges);
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([
{ start: 0, end: 120 },
{ start: 160, end: 420 }
]);
merges[0].rect = { top: 120, bottom: 190 };
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([
{ start: 0, end: 120 },
{ start: 190, end: 420 }
]);
merges.length = 0;
expect(getFrozenShadowSegments(table, 1, 420)).toEqual([{ start: 0, end: 420 }]);
});
});
78 changes: 78 additions & 0 deletions packages/vtable/__tests__/visual/cases/frozen/merged-shadow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* 验证目的:左右冻结阴影跳过多个跨边界合并区间,并跟随真实滚动和主题切换。
* 来源:独立固定数据样例;覆盖单行、多行、底部合并和默认主题样式。
*/
export default {
mount(container) {
container.style.width = '720px';
container.style.height = '480px';
const columns = Array.from({ length: 8 }, (_, col) => ({ field: `c${col}`, title: `C${col + 1}`, width: 140 }));
const records = Array.from({ length: 14 }, (_, row) =>
Object.fromEntries(columns.map((column, col) => [column.field, `R${row + 1} / C${col + 1}`]))
);
const theme = skipMergedCells => window.VTable.themes.DEFAULT.extends({
frozenColumnLine: { shadow: { skipMergedCells } }
});
const table = new window.VTable.ListTable(container, {
columns, records, frozenColCount: 2, rightFrozenColCount: 1, bottomFrozenRowCount: 1,
defaultRowHeight: 36, defaultHeaderRowHeight: 44,
theme: theme(true),
customMergeCell: (col, row, instance) => {
const firstRow = row === 3 ? 3 : row >= 6 && row <= 7 ? 6 : instance.rowCount - 1;
const lastRow = firstRow === 6 ? 7 : firstRow;
if (row < firstRow || row > lastRow) return;
return {
text: firstRow === 3 ? 'Merged A' : firstRow === 6 ? 'Merged B (two rows)' : 'Summary',
range: { start: { col: 0, row: firstRow }, end: { col: instance.colCount - 1, row: lastRow } }
};
}
});
// 通过实际 checkbox 事件更新主题,验证运行时开关,而非直接写内部状态。
const control = document.createElement('input');
control.type = 'checkbox';
control.checked = true;
control.setAttribute('aria-label', 'Skip merged shadows');
control.addEventListener('change', () => table.updateTheme(theme(control.checked)));
container.after(control);
return table;
},
async exercise(page) {
await page.getByRole('checkbox', { name: 'Skip merged shadows' }).uncheck();
await page.waitForFunction(() => {
const component = window.__visualTable.scenegraph.component;
return !component.frozenShadowLine.pathProxy && !component.rightFrozenShadowLine.pathProxy;
});
await page.getByRole('checkbox', { name: 'Skip merged shadows' }).check();
const host = await page.locator('#table').boundingBox();
await page.mouse.move(host.x + host.width / 2, host.y + 240);
await page.mouse.wheel(180, 0);
await page.waitForFunction(() => window.__visualTable.scrollLeft > 0);
await page.mouse.wheel(0, 36);
await page.waitForFunction(() => window.__visualTable.scrollTop > 0);
},
async verify(page) {
// 检查路径对应的真实矩形区间,确保合并区域没有被阴影穿过且普通行恢复。
await page.evaluate(() => {
const table = window.__visualTable;
const defaults = window.VTable.themes.DEFAULT.frozenColumnLine.shadow;
const theme = table.theme.frozenColumnLine.shadow;
if (!theme.skipMergedCells || theme.width !== defaults.width ||
theme.startColor !== defaults.startColor || theme.endColor !== defaults.endColor)
throw new Error('阴影开关或默认样式错误');
const mergedRows = [3, 6, 7, table.rowCount - 1];
for (const shadow of [table.scenegraph.component.frozenShadowLine, table.scenegraph.component.rightFrozenShadowLine]) {
if (typeof shadow.pathProxy !== 'function') throw new Error('冻结阴影缺少截断路径');
const segments = shadow.pathProxy(shadow.attribute).commandList.map(command => ({
start: command[2], end: command[2] + command[4]
}));
for (const row of [2, 3, 4, 5, 6, 7, 8, table.rowCount - 1]) {
const rect = table.getCellRangeRelativeRect({ col: 1, row });
const y = (rect.top + rect.bottom) / 2;
if (y < table.getFrozenRowsHeight() || y >= shadow.attribute.height) continue;
const covered = segments.some(segment => y >= segment.start && y < segment.end);
if (covered === mergedRows.includes(row)) throw new Error(`行 ${row} 的阴影片段错误`);
}
}
});
}
};
1 change: 1 addition & 0 deletions packages/vtable/__tests__/visual/cases/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export const cases = [
{ id: 'frozen-shadow-start', purpose: '冻结列 overflow 阴影在 start 位置的可见性', file: './frozen/frozen-shadow-start.mjs', bundles: ['vtable'] },
{ id: 'frozen-shadow-end', purpose: '冻结列 overflow 阴影在 end 位置的可见性', file: './frozen/frozen-shadow-end.mjs', bundles: ['vtable'] },
{ id: 'frozen-shadow-middle', purpose: '冻结列 overflow 阴影在 middle 位置的可见性', file: './frozen/frozen-shadow-middle.mjs', bundles: ['vtable'] },
{ id: 'frozen-shadow-merged-cells', purpose: '左右冻结阴影多段截断、真实滚动和主题切换', file: './frozen/merged-shadow.mjs', bundles: ['vtable'] },
{ id: 'scrollbar-ignore-frozen', purpose: '主横向滚动条跨越冻结列宽度', file: './frozen/scrollbar-ignore-frozen.mjs', bundles: ['vtable'] },
{ id: 'frozen-empty-records-header', purpose: '大量列滚动后清空记录保留冻结表头', file: './frozen/empty-records-header.mjs', bundles: ['vtable'] },
{ id: 'checkbox-header-options', purpose: '复选框表头回调固定选中与禁用配置', file: './cells/checkbox-header-options.mjs', bundles: ['vtable'] },
Expand Down
38 changes: 38 additions & 0 deletions packages/vtable/examples/frozen/list-table-merged-shadow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as VTable from '../../src';

/** 多个跨冻结边界的合并区域分别截断阴影,普通行继续显示默认阴影。 */
export function createTable() {
const container = document.getElementById('vTable');
container.style.width = '900px';
container.style.height = '480px';
const columns = Array.from({ length: 8 }, (_, col) => ({
field: `c${col}`,
title: `Column ${col + 1}`,
width: 160
}));
const records = Array.from({ length: 14 }, (_, row) =>
Object.fromEntries(columns.map((column, col) => [column.field, `R${row + 1} / C${col + 1}`]))
);
const table = new VTable.ListTable(container, {
columns,
records,
frozenColCount: 2,
rightFrozenColCount: 1,
bottomFrozenRowCount: 1,
theme: VTable.themes.DEFAULT.extends({
frozenColumnLine: { shadow: { skipMergedCells: true } }
}),
customMergeCell: (col, row, instance) => {
const firstRow = row === 3 ? 3 : row >= 6 && row <= 7 ? 6 : instance.rowCount - 1;
const lastRow = firstRow === 6 ? 7 : firstRow;
if (row < firstRow || row > lastRow) {
return;
}
return {
text: firstRow === 3 ? 'Merged A' : firstRow === 6 ? 'Merged B (two rows)' : 'Frozen summary',
range: { start: { col: 0, row: firstRow }, end: { col: instance.colCount - 1, row: lastRow } }
};
}
});
window.tableInstance = table;
}
66 changes: 66 additions & 0 deletions packages/vtable/src/scenegraph/component/frozen-shadow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { BaseTableAPI } from '../../ts-types/base-table';

export type ShadowSegment = { start: number; end: number };

/** Subtract merged-cell intervals from a vertical shadow. */
export function subtractShadowIntervals(height: number, blocked: ShadowSegment[]): ShadowSegment[] {
const segments: ShadowSegment[] = [];
let cursor = 0;
for (const interval of blocked.slice().sort((a, b) => a.start - b.start)) {
const start = Math.max(0, Math.min(height, interval.start));
const end = Math.max(0, Math.min(height, interval.end));
if (end <= start) {
continue;
}
if (start > cursor) {
segments.push({ start: cursor, end: start });
}
cursor = Math.max(cursor, end);
}
if (cursor < height) {
segments.push({ start: cursor, end: height });
}
return segments;
}

/** Only inspect visible rows at the boundary, including top/bottom frozen bands. */
export function getFrozenShadowSegments(table: BaseTableAPI, boundaryCol: number, height: number): ShadowSegment[] {
if (boundaryCol < 0 || boundaryCol >= table.colCount - 1) {
return height > 0 ? [{ start: 0, end: height }] : [];
}
const blocked: ShadowSegment[] = [];
const top = Math.min(height, table.getFrozenRowsHeight());
const bottom = Math.max(top, height - table.getBottomFrozenRowsHeight());
const bottomRow = table.rowCount - table.bottomFrozenRowCount;
const visibleBody = table.getBodyVisibleCellRange();
const bands = [
{ rowStart: 0, rowEnd: table.frozenRowCount - 1, start: 0, end: top },
{
rowStart: Math.max(table.frozenRowCount, visibleBody?.rowStart ?? table.frozenRowCount),
rowEnd: Math.min(bottomRow - 1, visibleBody?.rowEnd ?? -1),
start: top,
end: bottom
},
{ rowStart: bottomRow, rowEnd: table.rowCount - 1, start: bottom, end: height }
];

for (const band of bands) {
if (band.end <= band.start) {
continue;
}
let row = band.rowStart;
while (row <= band.rowEnd) {
const range = table.getCellRange(boundaryCol, row);
if (range && range.start.col <= boundaryCol && range.end.col > boundaryCol) {
const rect = table.getCellRangeRelativeRect(range);
const start = Math.max(band.start, rect.top);
const end = Math.min(band.end, rect.bottom);
if (end > start) {
blocked.push({ start, end });
}
}
row = Math.max(row + 1, (range?.end.row ?? row) + 1);
}
}
return subtractShadowIntervals(height, blocked);
}
Loading
Loading