From 20035e537462b9a3507c7e0a4a902c8b4cb66b05 Mon Sep 17 00:00:00 2001 From: SupGiant <42214265+SupGiant@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:04:21 +0800 Subject: [PATCH 1/2] feat: skip merged cells in frozen column shadows Add opt-in skipMergedCells shadow configuration for cross-boundary merges. Preserve the default rectangle renderer, shadow styling and visibility. Include unit coverage, an interactive visual case, an example and bilingual docs. --- ...ow-skip-merged-cells_2026-10-12-12-00.json | 10 ++ .../en/common/frozen-column-line-style.md | 12 ++ .../zh/common/frozen-column-line-style.md | 12 ++ .../vtable/__tests__/frozen-shadow.test.ts | 138 ++++++++++++++++++ .../visual/cases/frozen/merged-shadow.mjs | 78 ++++++++++ .../vtable/__tests__/visual/cases/index.mjs | 1 + .../frozen/list-table-merged-shadow.ts | 38 +++++ .../src/scenegraph/component/frozen-shadow.ts | 66 +++++++++ .../scenegraph/component/table-component.ts | 40 ++++- packages/vtable/src/themes/theme-define.ts | 11 +- packages/vtable/src/ts-types/theme.ts | 2 + 11 files changed, 401 insertions(+), 7 deletions(-) create mode 100644 common/changes/@visactor/vtable/feat-frozen-shadow-skip-merged-cells_2026-10-12-12-00.json create mode 100644 packages/vtable/__tests__/frozen-shadow.test.ts create mode 100644 packages/vtable/__tests__/visual/cases/frozen/merged-shadow.mjs create mode 100644 packages/vtable/examples/frozen/list-table-merged-shadow.ts create mode 100644 packages/vtable/src/scenegraph/component/frozen-shadow.ts diff --git a/common/changes/@visactor/vtable/feat-frozen-shadow-skip-merged-cells_2026-10-12-12-00.json b/common/changes/@visactor/vtable/feat-frozen-shadow-skip-merged-cells_2026-10-12-12-00.json new file mode 100644 index 000000000..235726c92 --- /dev/null +++ b/common/changes/@visactor/vtable/feat-frozen-shadow-skip-merged-cells_2026-10-12-12-00.json @@ -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" +} diff --git a/docs/assets/option/en/common/frozen-column-line-style.md b/docs/assets/option/en/common/frozen-column-line-style.md index 5066d6b9d..ba85bb1db 100644 --- a/docs/assets/option/en/common/frozen-column-line-style.md +++ b/docs/assets/option/en/common/frozen-column-line-style.md @@ -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 } } +}) +``` diff --git a/docs/assets/option/zh/common/frozen-column-line-style.md b/docs/assets/option/zh/common/frozen-column-line-style.md index 925ed5fd7..5b5bae7ec 100644 --- a/docs/assets/option/zh/common/frozen-column-line-style.md +++ b/docs/assets/option/zh/common/frozen-column-line-style.md @@ -23,3 +23,15 @@ - always: 总是显示 - scrolling: 滚动时显示 - overflow: 仅在冻结边界外有被遮挡的表格主体内容时显示。左侧阴影在滚离起点后显示,右侧阴影在右边仍有内容时显示;停止滚动后保持,滚到对应边缘立即隐藏。没有冻结列或主体没有水平溢出时不显示。 + +###${prefix} skipMergedCells(boolean) + +是否跳过跨越冻结列边界的合并单元格,默认 `false`。 + +开启后,每个跨冻结边界的合并单元格所在区间不绘制阴影,区间结束后恢复绘制。支持多段截断、左右冻结列,以及顶部、滚动区和底部冻结区的合并单元格。完全位于边界同一侧的合并单元格保持原有阴影。阴影宽度、颜色和显示时机不变。 + +```js +theme: VTable.themes.DEFAULT.extends({ + frozenColumnLine: { shadow: { skipMergedCells: true } } +}) +``` diff --git a/packages/vtable/__tests__/frozen-shadow.test.ts b/packages/vtable/__tests__/frozen-shadow.test.ts new file mode 100644 index 000000000..e3716fa15 --- /dev/null +++ b/packages/vtable/__tests__/frozen-shadow.test.ts @@ -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 = {}) { + 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 }]); + }); +}); diff --git a/packages/vtable/__tests__/visual/cases/frozen/merged-shadow.mjs b/packages/vtable/__tests__/visual/cases/frozen/merged-shadow.mjs new file mode 100644 index 000000000..8aa918be4 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/merged-shadow.mjs @@ -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} 的阴影片段错误`); + } + } + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/index.mjs b/packages/vtable/__tests__/visual/cases/index.mjs index 1072fdd97..b1d8f113e 100644 --- a/packages/vtable/__tests__/visual/cases/index.mjs +++ b/packages/vtable/__tests__/visual/cases/index.mjs @@ -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'] }, diff --git a/packages/vtable/examples/frozen/list-table-merged-shadow.ts b/packages/vtable/examples/frozen/list-table-merged-shadow.ts new file mode 100644 index 000000000..bfbb48c43 --- /dev/null +++ b/packages/vtable/examples/frozen/list-table-merged-shadow.ts @@ -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; +} diff --git a/packages/vtable/src/scenegraph/component/frozen-shadow.ts b/packages/vtable/src/scenegraph/component/frozen-shadow.ts new file mode 100644 index 000000000..e0a8755eb --- /dev/null +++ b/packages/vtable/src/scenegraph/component/frozen-shadow.ts @@ -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); +} diff --git a/packages/vtable/src/scenegraph/component/table-component.ts b/packages/vtable/src/scenegraph/component/table-component.ts index 8352992d2..b459f98f7 100644 --- a/packages/vtable/src/scenegraph/component/table-component.ts +++ b/packages/vtable/src/scenegraph/component/table-component.ts @@ -1,6 +1,6 @@ import { theme } from './../../themes'; import type { ILine, IRect, IGroup, FederatedPointerEvent, Text, IText } from '@src/vrender'; -import { createRect, createLine, createText, createGroup, createSymbol } from '@src/vrender'; +import { createRect, createLine, createText, createGroup, createSymbol, CustomPath2D } from '@src/vrender'; import { ScrollBar } from '@src/vrender'; import type { Group } from '../graphic/group'; import { MenuHandler } from './menu'; @@ -9,6 +9,7 @@ import { CellMover } from './cell-mover'; import { getBodyHorizontalScrollRange, getColX, getRowY } from './util'; import type { BaseTableAPI } from '../../ts-types/base-table'; import { isValid } from '@visactor/vutils'; +import { getFrozenShadowSegments } from './frozen-shadow'; /** * @description: 表格内容外组件 @@ -229,6 +230,8 @@ export class TableComponent { } }); + this.updateFrozenShadowPaths(); + // TO BE DONE 冻结列border(theme.frozenColumnLine?.border) // 弹出窗口 @@ -782,11 +785,35 @@ export class TableComponent { this.cellMover.update(backX, lineX, backY, lineY); } - /** - * @description: 显示冻结列shadow - * @param {number} col - * @return {*} - */ + /** Use the original rectangle renderer unless merged-cell clipping is enabled. */ + private updateFrozenShadowPaths() { + const skipMergedCells = this.table.theme.frozenColumnLine?.shadow?.skipMergedCells; + // Resolve at draw time so scrolling, resizing and merge updates stay in sync. + this.frozenShadowLine.pathProxy = skipMergedCells + ? () => this.createFrozenShadowPath(this.frozenShadowLine, this.table.frozenColCount - 1) + : undefined; + this.rightFrozenShadowLine.pathProxy = skipMergedCells + ? () => + this.createFrozenShadowPath( + this.rightFrozenShadowLine, + this.table.colCount - this.table.rightFrozenColCount - 1 + ) + : undefined; + } + + /** Draw the vertical intervals that do not intersect a cross-boundary merged cell. */ + private createFrozenShadowPath(shadow: IRect, boundaryCol: number): CustomPath2D { + const path = new CustomPath2D(); + const width = shadow.attribute.width ?? 0; + const height = shadow.attribute.height ?? 0; + const segments = getFrozenShadowSegments(this.table, boundaryCol, height); + for (const segment of segments) { + path.rect(0, segment.start, width, segment.end - segment.start); + } + return path; + } + + /** 显示冻结列阴影。 */ setFrozenColumnShadow(col: number, isRightFrozen?: boolean) { const colX = !isRightFrozen && col === this.table.frozenColCount - 1 && (this.table.getFrozenColsOffset?.() ?? 0) > 0 @@ -1135,6 +1162,7 @@ export class TableComponent { } }); + this.updateFrozenShadowPaths(); this.cellMover.updateStyle(); // this.menu.updateStyle(); // this.drillIcon.updateStyle(); diff --git a/packages/vtable/src/themes/theme-define.ts b/packages/vtable/src/themes/theme-define.ts index 0a1fa0d2f..e65342e65 100644 --- a/packages/vtable/src/themes/theme-define.ts +++ b/packages/vtable/src/themes/theme-define.ts @@ -660,7 +660,13 @@ export class TableTheme implements ITableThemeDefine { ); this._frozenColumnLine = { get shadow(): - | { width: number; startColor: string; endColor: string; visible: 'always' | 'scrolling' | 'overflow' } + | { + width: number; + startColor: string; + endColor: string; + visible: 'always' | 'scrolling' | 'overflow'; + skipMergedCells: boolean; + } | undefined { if (frozenColumnLine.shadow) { return { @@ -675,6 +681,9 @@ export class TableTheme implements ITableThemeDefine { }, get visible(): 'always' | 'scrolling' | 'overflow' { return frozenColumnLine.shadow?.visible ?? 'always'; + }, + get skipMergedCells(): boolean { + return frozenColumnLine.shadow?.skipMergedCells ?? false; } }; } diff --git a/packages/vtable/src/ts-types/theme.ts b/packages/vtable/src/ts-types/theme.ts index 2eb114a36..3f785f154 100644 --- a/packages/vtable/src/ts-types/theme.ts +++ b/packages/vtable/src/ts-types/theme.ts @@ -136,6 +136,8 @@ export interface ITableThemeDefine { * 'overflow' shows each frozen boundary only while body content is scrolled beneath it. */ visible?: 'always' | 'scrolling' | 'overflow'; + /** Skip merged cells crossing the frozen-column boundary. Defaults to false. */ + skipMergedCells?: boolean; }; /** TODO 暂未生效 */ border?: { From 2f562a4b7933df80282844c7c3fc36cdeaee3058 Mon Sep 17 00:00:00 2001 From: SupGiant <42214265+SupGiant@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:32:43 +0800 Subject: [PATCH 2/2] docs: illustrate merged frozen shadow clipping --- .../vtable/guide/frozen-shadow-merged-cells.png | Bin 0 -> 29582 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/public/vtable/guide/frozen-shadow-merged-cells.png diff --git a/docs/public/vtable/guide/frozen-shadow-merged-cells.png b/docs/public/vtable/guide/frozen-shadow-merged-cells.png new file mode 100644 index 0000000000000000000000000000000000000000..cf9a1f94df8ea3f09e14727fec7a72ccc881b207 GIT binary patch literal 29582 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 literal 0 HcmV?d00001