diff --git a/packages/vtable/__tests__/listTable-arrow-key-scroll.test.ts b/packages/vtable/__tests__/listTable-arrow-key-scroll.test.ts new file mode 100644 index 0000000000..abe62b1cd4 --- /dev/null +++ b/packages/vtable/__tests__/listTable-arrow-key-scroll.test.ts @@ -0,0 +1,188 @@ +// @ts-nocheck +/** + * 测试键盘方向键导航时滚动和视图更新的正确性 + * 对应 issue: https://github.com/VisActor/VTable/issues/5105 + */ +import { ListTable } from '../src'; +import { createDiv } from './dom'; +global.__VERSION__ = 'none'; + +describe('arrow key scroll - issue #5105', () => { + const containerDom: HTMLElement = createDiv(); + containerDom.style.position = 'relative'; + containerDom.style.width = '800px'; + containerDom.style.height = '600px'; + + // 生成足够多的列来触发水平虚拟滚动 + const colCount = 200; + const columns = Array.from({ length: colCount }, (_, i) => ({ + field: `col_${i}`, + title: `Column ${i}`, + width: 100 + })); + + // 生成足够多的行来触发垂直虚拟滚动 + const rowCount = 500; + const records = Array.from({ length: rowCount }, (_, rowIdx) => { + const record: Record = {}; + for (let col = 0; col < colCount; col++) { + record[`col_${col}`] = `R${rowIdx}C${col}`; + } + return record; + }); + + const option = { + columns, + records, + defaultColWidth: 100, + defaultRowHeight: 40 + }; + + const listTable = new ListTable(containerDom, option); + + test('selectCell 向右移动单元格时 scrollLeft 应正确更新', () => { + // 选中初始位置 + listTable.selectCell(0, 1); + const initialScrollLeft = listTable.scrollLeft; + expect(initialScrollLeft).toBe(0); + + // 逐步向右移动到超出可视区域的列 + // 800px 宽度 / 100px 每列 ≈ 8 列可见 + // 移动到第 10 列应该触发水平滚动 + for (let col = 1; col <= 10; col++) { + listTable.selectCell(col, 1); + } + // 到第10列时应该已经触发了滚动 + expect(listTable.scrollLeft).toBeGreaterThan(0); + }); + + test('selectCell 向右移动时 body group x 位置应正确(无白色空白)', () => { + listTable.setScrollLeft(0); + listTable.selectCell(0, 1); + + // 连续向右移动到第 15 列 + for (let col = 1; col <= 15; col++) { + listTable.selectCell(col, 1); + } + + const scenegraph = listTable.scenegraph; + const bodyGroupX = scenegraph.bodyGroup.attribute.x; + const frozenColsWidth = listTable.getFrozenColsWidth(); + const scrollLeft = listTable.scrollLeft; + + // body group 的 x 位置应该和 scrollLeft 对应 + // bodyGroup.x = frozenColsWidth + offset, 其中 offset 是基于 scrollLeft 计算的 + // 关键检查:body group 不应该留有右侧空白 + const bodyGroupRight = bodyGroupX + scenegraph.bodyGroup.attribute.width; + const tableWidth = listTable.tableNoFrameWidth; + + // body group 的右边缘应该至少覆盖到可视区域的右边缘 + expect(bodyGroupRight).toBeGreaterThanOrEqual(tableWidth); + }); + + test('大幅度向右移动后视图状态应一致', () => { + listTable.setScrollLeft(0); + listTable.selectCell(0, 1); + + // 直接跳到远处的列(模拟 Ctrl+ArrowRight 跳到很远的位置) + listTable.selectCell(150, 1); + const scrollLeft = listTable.scrollLeft; + + // 滚动位置应该大于 0(因为第150列远超可视范围) + expect(scrollLeft).toBeGreaterThan(0); + + // proxy 的 colStart/colEnd 应该包含当前可见列 + const proxy = listTable.scenegraph.proxy; + expect(proxy.colStart).toBeLessThanOrEqual(150); + expect(proxy.colEnd).toBeGreaterThanOrEqual(150); + }); + + test('向右再向左移动时滚动位置应正确恢复', () => { + listTable.setScrollLeft(0); + listTable.selectCell(0, 1); + + // 先向右移动 + for (let col = 1; col <= 20; col++) { + listTable.selectCell(col, 1); + } + const scrollAfterRight = listTable.scrollLeft; + expect(scrollAfterRight).toBeGreaterThan(0); + + // 再向左移动回来 + for (let col = 19; col >= 0; col--) { + listTable.selectCell(col, 1); + } + // 回到第0列时 scrollLeft 应该回到 0 + expect(listTable.scrollLeft).toBe(0); + }); + + test('向下再向上移动时 scrollTop 应正确更新', () => { + listTable.setScrollTop(0); + listTable.selectCell(0, 1); + + // 600px 高度 / 40px 行高 ≈ 15 行可见(含表头) + // 移动到第 20 行应该触发垂直滚动 + for (let row = 2; row <= 20; row++) { + listTable.selectCell(0, row); + } + expect(listTable.scrollTop).toBeGreaterThan(0); + }); + + test('dynamicSetX 处理 screenLeft 为 null 时不应导致白色空白', () => { + // 滚动到表格中间区域 + listTable.setScrollLeft(5000); + + const scenegraph = listTable.scenegraph; + const proxy = scenegraph.proxy; + + // 调用 proxy.setX 并确保即使 screenLeft 为 null 也不会崩溃 + // 保存当前 body 位置 + const bodyXBefore = scenegraph.bodyGroup.attribute.x; + + // 正常滚动后 body group 位置应该已被更新 + expect(bodyXBefore).toBeDefined(); + expect(typeof bodyXBefore).toBe('number'); + }); + + test('setBodyAndColHeaderX 应正确跳过 border 元素获取列组', () => { + const scenegraph = listTable.scenegraph; + + // 验证 setBodyAndColHeaderX 不会因 border 元素导致异常 + // 滚动到最右端 + const maxScrollLeft = listTable.getAllColsWidth() - listTable.tableNoFrameWidth; + listTable.setScrollLeft(maxScrollLeft); + + const bodyGroupX = scenegraph.bodyGroup.attribute.x; + const tableWidth = listTable.tableNoFrameWidth; + + // 到最右端时,body 内容的右边缘应该对齐或超过可视区域右边缘 + // 不应有白色空白 + expect(bodyGroupX).toBeDefined(); + expect(typeof bodyGroupX).toBe('number'); + }); + + test('连续快速向右 selectCell 模拟快速按键', () => { + listTable.setScrollLeft(0); + + // 模拟快速按住 ArrowRight 不放,连续选中 50 个单元格 + for (let col = 0; col <= 50; col++) { + listTable.selectCell(col, 1); + } + + const scrollLeft = listTable.scrollLeft; + const scenegraph = listTable.scenegraph; + const proxy = scenegraph.proxy; + + // scrollLeft 应该合理增长 + expect(scrollLeft).toBeGreaterThan(0); + + // proxy 维护的列范围应该包含第50列 + expect(proxy.colEnd).toBeGreaterThanOrEqual(50); + expect(proxy.colStart).toBeLessThanOrEqual(50); + + // body group 位置应该合理 + const bodyGroupX = scenegraph.bodyGroup.attribute.x; + expect(bodyGroupX).toBeDefined(); + expect(typeof bodyGroupX).toBe('number'); + }); +}); diff --git a/packages/vtable/examples/interactive/arrow-key-scroll.ts b/packages/vtable/examples/interactive/arrow-key-scroll.ts new file mode 100644 index 0000000000..069458bf04 --- /dev/null +++ b/packages/vtable/examples/interactive/arrow-key-scroll.ts @@ -0,0 +1,122 @@ +import * as VTable from '../../src'; +const ListTable = VTable.ListTable; +const CONTAINER_ID = 'vTable'; + +export function createTable() { + const colCount = 50; + const rowCount = 200; + + const departments = ['Engineering', 'Marketing', 'Sales', 'Design', 'Finance', 'HR', 'Operations', 'Legal']; + const statuses = ['Active', 'On Leave', 'Remote', 'In Office']; + const levels = ['Junior', 'Mid', 'Senior', 'Lead', 'Principal']; + const cities = [ + 'Beijing', + 'Shanghai', + 'Shenzhen', + 'Hangzhou', + 'Guangzhou', + 'Chengdu', + 'Nanjing', + 'Wuhan', + 'Tokyo', + 'Singapore' + ]; + + const columns: VTable.ColumnsDefine = [ + { field: 'id', title: 'ID', width: 60 }, + { field: 'name', title: 'Name', width: 120 }, + { field: 'dept', title: 'Department', width: 110 }, + { field: 'level', title: 'Level', width: 90 }, + { field: 'city', title: 'City', width: 100 }, + { field: 'status', title: 'Status', width: 90 }, + { field: 'email', title: 'Email', width: 200 } + ]; + + for (let i = 1; i <= colCount - 7; i++) { + const quarter = `Q${((i - 1) % 4) + 1}`; + const year = 2020 + Math.floor((i - 1) / 4); + columns.push({ + field: `metric_${i}`, + title: `${quarter} ${year}`, + width: 100, + style: { + textAlign: 'right' + }, + headerStyle: { + textAlign: 'center' + } + }); + } + + const fnames = ['Alex', 'Emma', 'Liam', 'Mia', 'Noah', 'Olivia', 'James', 'Sophia', 'Lucas', 'Ava']; + const lnames = ['Chen', 'Wang', 'Li', 'Zhang', 'Liu', 'Yang', 'Huang', 'Wu', 'Zhou', 'Xu']; + + const records = Array.from({ length: rowCount }, (_, i) => { + const rec: Record = { + id: i + 1, + name: `${fnames[i % fnames.length]} ${lnames[Math.floor(i / fnames.length) % lnames.length]}`, + dept: departments[i % departments.length], + level: levels[i % levels.length], + city: cities[i % cities.length], + status: statuses[i % statuses.length], + email: + `${fnames[i % fnames.length].toLowerCase()}.` + + `${lnames[Math.floor(i / fnames.length) % lnames.length].toLowerCase()}@company.com` + }; + for (let j = 1; j <= colCount - 7; j++) { + rec[`metric_${j}`] = (Math.random() * 10000).toFixed(0); + } + return rec; + }); + + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + columns, + records, + defaultRowHeight: 36, + widthMode: 'standard', + frozenColCount: 1, + keyboardOptions: { + moveSelectedCellOnArrowKeys: true + }, + theme: VTable.themes.ARCO.extends({ + scrollStyle: { + visible: 'always', + width: 8, + hoverOn: true + }, + selectionStyle: { + cellBgColor: 'rgba(0, 100, 250, 0.12)', + cellBorderColor: '#0064FA', + cellBorderLineWidth: 2 + } + }), + hover: { + highlightMode: 'cross', + disableHeaderHover: false + }, + select: { + headerSelectMode: 'cell' + } + }; + + const instance = new ListTable(option); + + instance.selectCell(1, 1); + + const infoDiv = document.createElement('div'); + infoDiv.style.cssText = + 'position:fixed;top:12px;right:16px;padding:12px 20px;background:rgba(0,0,0,0.75);' + + 'color:#fff;border-radius:8px;font:14px/1.6 system-ui,sans-serif;z-index:999;max-width:360px;' + + 'box-shadow:0 4px 12px rgba(0,0,0,0.15)'; + infoDiv.innerHTML = + 'Arrow Key Navigation Demo
' + + 'Use ' + + ' ' + + ' ' + + ' to navigate
' + + '50 columns × 200 rows'; + document.body.appendChild(infoDiv); + + window.tableInstance = instance; +} diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index 5e6313acf3..86c8b6f231 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -874,6 +874,10 @@ export const menus = [ { path: 'interactive', name: 'custom-scroll' + }, + { + path: 'interactive', + name: 'arrow-key-scroll' } ] }, diff --git a/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-x.ts b/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-x.ts index 8bcbafd187..0b8b07b5e0 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-x.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-x.ts @@ -9,6 +9,9 @@ import { checkFirstColMerge, getFirstChild, getLastChild } from './util'; export async function dynamicSetX(x: number, screenLeft: ColumnInfo | null, isEnd: boolean, proxy: SceneProxy) { if (!screenLeft) { + // screenLeft 为 null 时仍需更新 body 位置并触发渲染,避免滚动后出现空白区域 + proxy.table.scenegraph.setBodyAndColHeaderX(-x + proxy.deltaX); + proxy.table.scenegraph.updateNextFrame(); return; } const screenLeftCol = screenLeft.col; diff --git a/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-y.ts b/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-y.ts index 4722cf1ef5..968a636f53 100644 --- a/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-y.ts +++ b/packages/vtable/src/scenegraph/group-creater/progress/update-position/dynamic-set-y.ts @@ -8,6 +8,9 @@ import { getLastChild } from './util'; export async function dynamicSetY(y: number, screenTop: RowInfo | null, isEnd: boolean, proxy: SceneProxy) { if (!screenTop) { + // screenTop 为 null 时仍需更新 body 位置并触发渲染,避免滚动后出现空白区域 + proxy.updateBody(y - proxy.deltaY); + proxy.table.scenegraph.updateNextFrame(); return; } const screenTopRow = screenTop.row; diff --git a/packages/vtable/src/scenegraph/scenegraph.ts b/packages/vtable/src/scenegraph/scenegraph.ts index 022f2db9bd..58e4ed73fd 100644 --- a/packages/vtable/src/scenegraph/scenegraph.ts +++ b/packages/vtable/src/scenegraph/scenegraph.ts @@ -1554,10 +1554,14 @@ export class Scenegraph { */ setBodyAndRowHeaderY(y: number) { // correct y, avoid scroll out of range - const firstBodyCell = - (this.bodyGroup.firstChild?.firstChild as Group) ?? (this.rowHeaderGroup.firstChild?.firstChild as Group); - const lastBodyCell = - (this.bodyGroup.firstChild?.lastChild as Group) ?? (this.rowHeaderGroup.firstChild?.lastChild as Group); + // border 始终作为最后一个子元素(addChild/appendChild),firstChild 无需过滤 + const firstBodyColGroup = this.bodyGroup.firstChild as Group; + const firstRowHeaderColGroup = this.rowHeaderGroup.firstChild as Group; + const firstBodyCell = (firstBodyColGroup?.firstChild as Group) ?? (firstRowHeaderColGroup?.firstChild as Group); + let lastBodyCell = (firstBodyColGroup?.lastChild ?? firstRowHeaderColGroup?.lastChild) as Group; + if (lastBodyCell && lastBodyCell.type !== 'group') { + lastBodyCell = lastBodyCell._prev as Group; + } if ( y === 0 && firstBodyCell && @@ -1612,8 +1616,12 @@ export class Scenegraph { */ setBodyAndColHeaderX(x: number) { // correct x, avoid scroll out of range + // border 始终作为最后一个子元素(addChild/appendChild),firstChild 无需过滤 const firstBodyCol = this.bodyGroup.firstChild as Group; - const lastBodyCol = this.bodyGroup.lastChild as Group; + let lastBodyCol = this.bodyGroup.lastChild as Group; + if (lastBodyCol && lastBodyCol.type !== 'group') { + lastBodyCol = lastBodyCol._prev as Group; + } if (x === 0 && firstBodyCol && firstBodyCol.col === this.table.frozenColCount && firstBodyCol.attribute.x + x < 0) { x = -firstBodyCol.attribute.x; } else if (