diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a1a93e3e2d..e5705096ec 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -32,7 +32,7 @@ jobs: run: node common/scripts/install-run-rush.js install --bypass-policy - name: Build env: - NODE_OPTIONS: "--max_old_space_size=4096" - run: node common/scripts/install-run-rush.js build -t @visactor/vtable + NODE_OPTIONS: '--max_old_space_size=4096' + run: node common/scripts/install-run-rush.js build -t @visactor/vtable-search - name: Test - run: node common/scripts/install-run-rush.js test -t @visactor/vtable + run: node common/scripts/install-run-rush.js test -t @visactor/vtable-search diff --git a/common/changes/@visactor/vtable-search/fix-issue-5036-master-detail-search_2026-08-29-02-12.json b/common/changes/@visactor/vtable-search/fix-issue-5036-master-detail-search_2026-08-29-02-12.json new file mode 100644 index 0000000000..61c5bfb7ff --- /dev/null +++ b/common/changes/@visactor/vtable-search/fix-issue-5036-master-detail-search_2026-08-29-02-12.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable-search): search expanded master-detail tables (#5036)", + "type": "patch", + "packageName": "@visactor/vtable-search" + } + ], + "packageName": "@visactor/vtable-search", + "email": "biukam.w@gmail.com" +} diff --git a/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json b/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json new file mode 100644 index 0000000000..95357570ef --- /dev/null +++ b/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable): support indexed custom style overlays", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index d37d1fc8c5..7ec0174aa7 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1415,6 +1415,9 @@ importers: '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 + '@visactor/vtable-plugins': + specifier: workspace:1.26.8 + version: link:../vtable-plugins '@vitejs/plugin-react': specifier: 3.1.0 version: 3.1.0(vite@3.2.6(@types/node@25.5.0)(less@4.1.3)(sass@1.43.5)(terser@5.17.1)) diff --git a/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts new file mode 100644 index 0000000000..e487c55d55 --- /dev/null +++ b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts @@ -0,0 +1,104 @@ +/* eslint-env jest */ +/* eslint-disable no-undef */ +// @ts-nocheck + +import { SearchComponent } from '../src'; + +function createTable(values: string[][]) { + const arrangements: { col: number; row: number; style: string }[] = []; + const customCellStyleArrangement: { cellPosition: { col: number; row: number }; customStyleId: string }[] = []; + const table = { + options: { + columns: [{ field: 'name' }] + }, + rowCount: values.length + 1, + colCount: values[0]?.length ?? 1, + isReleased: false, + isHeader: jest.fn((_col: number, row: number) => row === 0), + getCellValue: jest.fn((col: number, row: number) => (row === 0 ? 'Name' : values[row - 1][col])), + getCellRange: jest.fn((col: number, row: number) => ({ + start: { col, row }, + end: { col, row } + })), + registerCustomCellStyle: jest.fn(), + hasCustomCellStyle: jest.fn(() => true), + arrangeCustomCellStyle: jest.fn((position: { col: number; row: number }, style: string) => { + if (style) { + arrangements.push({ col: position.col, row: position.row, style }); + customCellStyleArrangement.push({ cellPosition: position, customStyleId: style }); + } + }), + customCellStylePlugin: { + customCellStyleArrangement, + addCustomCellStyleArrangement: jest.fn((position: { col: number; row: number }, style: string) => { + arrangements.push({ col: position.col, row: position.row, style }); + customCellStyleArrangement.push({ cellPosition: position, customStyleId: style }); + }), + clearCustomCellStyleArrangement: jest.fn(() => { + arrangements.splice(0, arrangements.length); + customCellStyleArrangement.splice(0, customCellStyleArrangement.length); + }) + }, + scenegraph: { + updateCellContent: jest.fn(), + updateNextFrame: jest.fn() + }, + getBodyVisibleRowRange: jest.fn(() => ({ rowStart: 1, rowEnd: values.length + 1 })), + getBodyVisibleColRange: jest.fn(() => ({ colStart: 0, colEnd: values[0]?.length ?? 1 })), + scrollToCell: jest.fn() + }; + + return { table, arrangements }; +} + +test('search includes and highlights values in expanded master-detail tables', () => { + const main = createTable([['Alice']]); + const detail = createTable([['Widget']]); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ + table: main.table as any, + autoJump: false + }); + + const result = search.search('i'); + + expect(result.results).toHaveLength(2); + expect(detail.arrangements).toEqual([ + { + col: 0, + row: 1, + style: '__search_component_highlight' + } + ]); +}); + +test('focus navigation and clear operate on the matching detail table', () => { + const main = createTable([['Alice']]); + const detail = createTable([['Widget']]); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ + table: main.table as any, + autoJump: false + }); + + search.search('i'); + search.next(); + search.next(); + + expect(detail.table.customCellStylePlugin.customCellStyleArrangement).toEqual([ + { + cellPosition: { col: 0, row: 1 }, + customStyleId: '__search_component_focus' + } + ]); + + search.clear(); + + expect(detail.table.customCellStylePlugin.customCellStyleArrangement).toHaveLength(0); +}); diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts new file mode 100644 index 0000000000..32bdc90319 --- /dev/null +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -0,0 +1,1189 @@ +/* eslint-env jest */ +/* eslint-disable no-undef */ +// @ts-nocheck + +import { SearchComponent } from '../src'; +import { CustomCellStylePlugin } from '../../vtable/src/plugins/custom-cell-style'; + +afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ''; +}); + +function createCellTable( + values: string[][], + options: { + columns?: any[]; + records?: any[]; + visibleRows?: { rowStart: number; rowEnd: number }; + visibleCols?: { colStart: number; colEnd: number }; + initialArrangements?: { col: number; row: number; style: string }[]; + columnHeaderLevelCount?: number; + rowHierarchyType?: 'grid' | 'tree' | 'grid-tree'; + viewBox?: { x1: number; y1: number; x2: number; y2: number }; + tableNoFrameHeight?: number; + frozenRowsHeight?: number; + bottomFrozenRowsHeight?: number; + frozenRowCount?: number; + bottomFrozenRowCount?: number; + cellRect?: (col: number, row: number) => { left: number; top: number; width: number; height: number }; + cellRangeRelativeRect?: (position: any) => { + left: number; + top: number; + width: number; + height: number; + }; + isMasterDetail?: boolean; + } = {} +) { + const getArrangementKey = (cellPosition: any) => { + if (cellPosition?.range) { + const { start, end } = cellPosition.range; + return `range:${start.col},${start.row},${end.col},${end.row}`; + } + if (typeof cellPosition?.col === 'number' && typeof cellPosition?.row === 'number') { + return `cell:${cellPosition.col},${cellPosition.row}`; + } + return undefined; + }; + const arrangements = (options.initialArrangements || []).map(item => ({ + cellPosition: { col: item.col, row: item.row }, + customStyleId: item.style + })); + const arrangementIndex = new Map( + arrangements + .map((item, index) => [getArrangementKey(item.cellPosition), index]) + .filter(([key]) => key !== undefined) + ); + const registeredStyles = new Set(); + const customCellStylePlugin = { + customCellStyleArrangement: arrangements, + addCustomCellStyleArrangement: jest.fn((cellPosition, customStyleId) => { + customCellStylePlugin.customCellStyleArrangement.push({ cellPosition, customStyleId }); + }), + clearCustomCellStyleArrangement: jest.fn(() => { + customCellStylePlugin.customCellStyleArrangement = []; + }), + _rebuildCustomCellStyleArrangementIndex: jest.fn(() => { + arrangementIndex.clear(); + customCellStylePlugin.customCellStyleArrangement.forEach((item, index) => { + if (!item.customStyleId) { + return; + } + const key = getArrangementKey(item.cellPosition); + if (key) { + arrangementIndex.set(key, index); + } + }); + }) + }; + const table = { + isPivotTable: jest.fn(() => false), + id: `table-${Math.random()}`, + rowHierarchyType: options.rowHierarchyType, + records: options.records, + rowCount: values.length + 1, + colCount: values[0]?.length ?? 1, + columnHeaderLevelCount: options.columnHeaderLevelCount ?? 1, + isReleased: false, + isHeader: jest.fn((_col, row) => row === 0), + getCellValue: jest.fn((col, row) => (row === 0 ? 'Name' : values[row - 1]?.[col])), + getCellOriginValue: jest.fn((col, row) => (row === 0 ? 'Name' : values[row - 1]?.[col])), + getCellHeaderPaths: jest.fn(() => undefined), + getCellRange: jest.fn((col, row) => ({ + start: { col, row }, + end: { col, row } + })), + registerCustomCellStyle: jest.fn(styleId => { + registeredStyles.add(styleId); + }), + hasCustomCellStyle: jest.fn(styleId => registeredStyles.has(styleId)), + arrangeCustomCellStyle: jest.fn((position, style) => { + if (style) { + const key = getArrangementKey(position); + const index = arrangementIndex.get(key); + if (index === undefined) { + customCellStylePlugin.customCellStyleArrangement.push({ + cellPosition: position, + customStyleId: style + }); + if (key) { + arrangementIndex.set(key, customCellStylePlugin.customCellStyleArrangement.length - 1); + } + } else { + customCellStylePlugin.customCellStyleArrangement[index].customStyleId = style; + } + } + }), + customCellStylePlugin, + scenegraph: { + updateCellContent: jest.fn(), + updateNextFrame: jest.fn() + }, + getBodyVisibleRowRange: jest.fn(() => options.visibleRows || { rowStart: 1, rowEnd: values.length + 1 }), + getBodyVisibleColRange: jest.fn(() => options.visibleCols || { colStart: 0, colEnd: values[0]?.length ?? 1 }), + getCellRect: jest.fn( + options.cellRect || ((col: number, row: number) => ({ left: col * 100, top: row * 20, width: 100, height: 20 })) + ), + getCellRangeRelativeRect: jest.fn( + options.cellRangeRelativeRect || + ((position: any) => { + const col = position.col ?? position.start?.col ?? 0; + const row = position.row ?? position.start?.row ?? 0; + return { left: col * 100, top: row * 20, width: 100, height: 20 }; + }) + ), + getVisibleRect: jest.fn(() => ({ + top: 0, + bottom: options.tableNoFrameHeight ?? 200, + left: 0, + right: 800, + height: options.tableNoFrameHeight ?? 200, + width: 800 + })), + tableNoFrameHeight: options.tableNoFrameHeight ?? 200, + frozenRowCount: options.frozenRowCount ?? 0, + bottomFrozenRowCount: options.bottomFrozenRowCount ?? 0, + scrollTop: 0, + tableY: 0, + options: { + columns: options.columns || [{ field: 'name' }], + viewBox: options.viewBox + }, + getFrozenRowsHeight: jest.fn(() => options.frozenRowsHeight ?? 0), + getBottomFrozenRowsHeight: jest.fn(() => options.bottomFrozenRowsHeight ?? 0), + scrollToCell: jest.fn() + }; + + if (options.isMasterDetail) { + table.pluginManager = { + getPluginByName: jest.fn(name => (name === 'Master Detail Plugin' ? {} : undefined)) + }; + } + + if (options.rowHierarchyType) { + table.dataSource = { + rowHierarchyType: options.rowHierarchyType, + getTableIndex: jest.fn(index => (Array.isArray(index) ? index[0] : index)) + }; + } + + return { table, customCellStylePlugin, registeredStyles }; +} + +function attachRealCustomStylePlugin( + target: ReturnType, + customCellStyle: any[] = [], + customCellStyleArrangement: any[] = [] +) { + const plugin = new CustomCellStylePlugin( + target.table as any, + customCellStyle as any, + customCellStyleArrangement as any + ); + target.table.customCellStylePlugin = plugin; + target.table.registerCustomCellStyle = jest.fn(plugin.registerCustomCellStyle.bind(plugin)); + target.table.hasCustomCellStyle = jest.fn(plugin.hasCustomCellStyle.bind(plugin)); + target.table.arrangeCustomCellStyle = jest.fn(plugin.arrangeCustomCellStyle.bind(plugin)); + return plugin; +} + +function createTreeTable() { + const records = [{ name: 'Main' }]; + const main = createCellTable([], { + columns: [{ field: 'name', tree: true }], + records, + visibleRows: { rowStart: 1, rowEnd: 3 }, + rowHierarchyType: 'tree' + }); + main.table.rowCount = 2; + main.table.colCount = 1; + main.table.isHeader = jest.fn((_col, row) => row === 0); + main.table.getCellValue = jest.fn((_col, row) => (row === 0 ? 'Name' : records[row - 1].name)); + main.table.dataSource = { + getTableIndex: jest.fn(() => 0) + }; + main.table.internalProps = { + layoutMap: { + getHeaderCellAddressByField: jest.fn(() => ({ col: 0, row: 0 })) + }, + subTableInstances: new Map() + }; + main.table.getHierarchyState = jest.fn(() => 'expand'); + main.table.toggleHierarchyState = jest.fn(); + return main; +} + +test('clear keeps custom styles that do not belong to search', () => { + const main = createCellTable([['Alice']], { + initialArrangements: [{ col: 0, row: 1, style: 'user-style' }] + }); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('i'); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-style' }, + expect.objectContaining({ customStyleId: '__search_component_highlight' }) + ]) + ); + + search.next(); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([{ cellPosition: { col: 0, row: 1 }, customStyleId: 'user-style' }]) + ); + + search.clear(); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-style' } + ]); +}); + +test('merged search results keep their full range while navigating and clearing', () => { + const main = createCellTable([['Alice', 'Alice']], { + initialArrangements: [{ col: 0, row: 1, style: 'user-style' }] + }); + main.table.colCount = 2; + main.table.getCellRange = jest.fn((col, row) => + row === 1 ? { start: { col: 0, row: 1 }, end: { col: 1, row: 1 } } : { start: { col, row }, end: { col, row } } + ); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('i'); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([ + { + cellPosition: { range: { start: { col: 0, row: 1 }, end: { col: 1, row: 1 } } }, + customStyleId: '__search_component_highlight' + } + ]) + ); + + search.next(); + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([ + { + cellPosition: { range: { start: { col: 0, row: 1 }, end: { col: 1, row: 1 } } }, + customStyleId: '__search_component_focus' + } + ]) + ); + + search.clear(); + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-style' } + ]); +}); + +test('released detail tables are removed from search state safely', () => { + const main = createCellTable([['Alice']]); + const detail = createCellTable([['Widget']]); + main.table.internalProps = { subTableInstances: new Map([[0, detail.table]]) }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('i'); + expect(search.queryResult).toEqual( + expect.arrayContaining([expect.objectContaining({ table: detail.table, value: 'Widget' })]) + ); + detail.table.isReleased = true; + detail.table.scenegraph = null; + main.table.internalProps.subTableInstances.clear(); + + expect(() => search.clear()).not.toThrow(); + expect(search.queryResult).toHaveLength(0); +}); + +test('search skips all tables while the master table is entering release', () => { + const main = createCellTable([['Alice']], { isMasterDetail: true }); + const detail = createCellTable([['Widget']]); + main.table.internalProps = { subTableInstances: new Map([[0, detail.table]]) }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + main.table.internalProps._isReleasing = true; + + const result = search.search('i'); + + expect(result.results).toHaveLength(0); + expect(main.table.getCellValue).not.toHaveBeenCalled(); + expect(detail.table.getCellValue).not.toHaveBeenCalled(); + + main.table.internalProps.subTableInstances.clear(); + main.table.pluginManager.getPluginByName.mockReturnValue(undefined); + + const resumedResult = search.search('i'); + + expect(resumedResult.results).toHaveLength(1); + expect(resumedResult.results[0]).toMatchObject({ table: main.table, value: 'Alice' }); +}); + +test('search excludes a detail table while it is entering release', () => { + const main = createCellTable([['Alice']], { isMasterDetail: true }); + const detail = createCellTable([['Widget']]); + main.table.internalProps = { subTableInstances: new Map([[0, detail.table]]) }; + detail.table.internalProps = { _isReleasing: true }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + const result = search.search('i'); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ table: main.table, value: 'Alice' }); + expect(detail.table.getCellValue).not.toHaveBeenCalled(); +}); + +test('clearing stale results during master release does not touch table scenegraphs', () => { + const main = createCellTable([['Alice']], { isMasterDetail: true }); + const detail = createCellTable([['Widget']]); + main.table.internalProps = { subTableInstances: new Map([[0, detail.table]]) }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('i'); + main.table.scenegraph.updateCellContent.mockClear(); + main.table.scenegraph.updateNextFrame.mockClear(); + detail.table.scenegraph.updateCellContent.mockClear(); + detail.table.scenegraph.updateNextFrame.mockClear(); + main.table.internalProps._isReleasing = true; + + search.clear(); + + expect(main.table.scenegraph.updateCellContent).not.toHaveBeenCalled(); + expect(main.table.scenegraph.updateNextFrame).not.toHaveBeenCalled(); + expect(detail.table.scenegraph.updateCellContent).not.toHaveBeenCalled(); + expect(detail.table.scenegraph.updateNextFrame).not.toHaveBeenCalled(); + expect(search.queryResult).toHaveLength(0); +}); + +test('tree master tables still search expanded detail tables', () => { + const main = createTreeTable(); + const detail = createCellTable([['Widget']]); + main.table.internalProps.subTableInstances.set(0, detail.table); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + const result = search.search('i'); + + expect(result.results).toHaveLength(2); + expect(detail.customCellStylePlugin.customCellStyleArrangement).toEqual([ + { + cellPosition: { col: 0, row: 1 }, + customStyleId: '__search_component_highlight' + } + ]); + expect(detail.registeredStyles).toEqual(new Set(['__search_component_highlight', '__search_component_focus'])); +}); + +test('detail result navigation scrolls the master row into view first', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 2 } + }); + const detail = createCellTable([['Widget']], { visibleRows: { rowStart: 2, rowEnd: 2 } }); + main.table.internalProps = { subTableInstances: new Map([[5, detail.table]]) }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('i'); + search.next(); + + expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 6 }); + expect(detail.table.scrollToCell).toHaveBeenCalled(); + expect(main.table.scrollToCell.mock.invocationCallOrder[0]).toBeLessThan( + detail.table.scrollToCell.mock.invocationCallOrder[0] + ); +}); + +test('master-detail search does not recurse child records as tree results', () => { + const main = createCellTable([['Parent']], { + columns: [{ field: 'name', tree: true }], + records: [{ name: 'Parent', children: [{ name: 'Widget' }] }], + rowHierarchyType: 'grid', + isMasterDetail: true + }); + const detail = createCellTable([['Widget']]); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + main.table.getHierarchyState = jest.fn(() => 'expand'); + main.table.toggleHierarchyState = jest.fn(); + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + const result = search.search('Widget'); + + expect(result.results).toHaveLength(1); + expect(result.results[0]).toMatchObject({ + col: 0, + row: 1, + value: 'Widget', + table: detail.table, + parentRow: 0 + }); + expect(result.results[0].indexNumber).toBeUndefined(); +}); + +test('master-detail results follow their visual parent-row order', () => { + const main = createCellTable([['Target main 1'], ['Other'], ['Target main 3']], { + isMasterDetail: true + }); + const firstDetail = createCellTable([['Target detail 1'], ['Target nested 1']]); + const thirdDetail = createCellTable([['Target detail 3']]); + main.table.internalProps = { + subTableInstances: new Map([ + [2, thirdDetail.table], + [0, firstDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + + const result = search.search('Target'); + + expect(result.results.map(item => item.value)).toEqual([ + 'Target main 1', + 'Target detail 1', + 'Target nested 1', + 'Target main 3', + 'Target detail 3' + ]); +}); + +test('next includes a detail table expanded after the initial search', () => { + const main = createCellTable([['Target main 1'], ['Target main 2']], { + isMasterDetail: true + }); + main.table.internalProps = { + subTableInstances: new Map() + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Target'); + search.next(); + + const detail = createCellTable([['Target detail 1']]); + main.table.internalProps.subTableInstances.set(0, detail.table); + + const result = search.next(); + + expect(result.index).toBe(1); + expect(result.results.map(item => item.value)).toEqual(['Target main 1', 'Target detail 1', 'Target main 2']); + expect(result.results[result.index]).toMatchObject({ + table: detail.table, + parentRow: 0, + value: 'Target detail 1' + }); +}); + +test('detail navigation scrolls the master when its expanded viewBox is clipped', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 1 }, + tableNoFrameHeight: 200, + rowHierarchyType: 'grid', + isMasterDetail: true + }); + const detail = createCellTable([['Widget']], { + viewBox: { x1: 0, y1: 180, x2: 100, y2: 360 }, + cellRangeRelativeRect: () => ({ left: 0, top: 200, width: 100, height: 20 }), + visibleRows: { rowStart: 2, rowEnd: 2 } + }); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.next(); + + expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 1 }); + expect(detail.table.scrollToCell).toHaveBeenCalled(); +}); + +test('detail navigation offsets the master scroll when the target cell remains below the viewport', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 1 }, + tableNoFrameHeight: 200, + rowHierarchyType: 'grid', + isMasterDetail: true + }); + const detail = createCellTable([['Widget']], { + cellRangeRelativeRect: () => ({ left: 0, top: 200, width: 100, height: 20 }), + visibleRows: { rowStart: 2, rowEnd: 2 } + }); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.next(); + + expect(main.table.scrollTop).toBe(20); +}); + +test('detail navigation accounts for the master viewBox offset', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 1 }, + tableNoFrameHeight: 200, + viewBox: { x1: 0, y1: 40, x2: 800, y2: 240 }, + rowHierarchyType: 'grid', + isMasterDetail: true + }); + const detail = createCellTable([['Widget']], { + cellRangeRelativeRect: () => ({ left: 0, top: 230, width: 100, height: 20 }), + visibleRows: { rowStart: 2, rowEnd: 2 } + }); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.next(); + + expect(main.table.scrollTop).toBe(10); +}); + +test('detail navigation respects the master clipping area for frozen rows', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 1 }, + tableNoFrameHeight: 200, + frozenRowsHeight: 40, + bottomFrozenRowsHeight: 30, + frozenRowCount: 2, + rowHierarchyType: 'grid', + isMasterDetail: true + }); + main.table.rowCount = 10; + const detail = createCellTable([['Widget']], { + cellRangeRelativeRect: () => ({ left: 0, top: 160, width: 100, height: 20 }), + visibleRows: { rowStart: 2, rowEnd: 2 } + }); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.next(); + + expect(main.table.scrollTop).toBe(10); +}); + +test('detail navigation does not scroll the master for a visible viewBox after master scrolling', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 1 }, + tableNoFrameHeight: 200, + rowHierarchyType: 'grid', + isMasterDetail: true + }); + main.table.scrollTop = 400; + main.table.getVisibleRect = jest.fn(() => ({ + top: 400, + bottom: 600, + left: 0, + right: 800, + height: 200, + width: 800 + })); + const detail = createCellTable([['Widget']], { + viewBox: { x1: 0, y1: 20, x2: 100, y2: 100 }, + visibleRows: { rowStart: 1, rowEnd: 1 } + }); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.next(); + + expect(main.table.scrollToCell).not.toHaveBeenCalled(); +}); + +test('detail navigation checks the target cell when the detail viewBox is taller than the master viewport', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 1 }, + tableNoFrameHeight: 200, + rowHierarchyType: 'grid', + isMasterDetail: true + }); + const detail = createCellTable([['Widget']], { + viewBox: { x1: 0, y1: 20, x2: 100, y2: 520 }, + cellRangeRelativeRect: () => ({ left: 0, top: 40, width: 100, height: 20 }), + visibleRows: { rowStart: 2, rowEnd: 2 } + }); + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.next(); + + expect(main.table.scrollToCell).not.toHaveBeenCalled(); + expect(detail.table.scrollToCell).toHaveBeenCalled(); +}); + +test('pruning unavailable results preserves the current result identity', () => { + const main = createCellTable([['Parent']], { rowHierarchyType: 'grid', isMasterDetail: true }); + const first = createCellTable([['First']]); + const second = createCellTable([['Middle']]); + const third = createCellTable([['Third']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, first.table], + [1, second.table], + [2, third.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + search.currentIndex = 1; + const currentResult = search.queryResult[1]; + + first.table.isReleased = true; + first.table.scenegraph = null; + main.table.internalProps.subTableInstances.delete(0); + search.updateCellStyle(); + + expect(search.queryResult[search.currentIndex]).toBe(currentResult); + expect(search.currentIndex).toBe(0); +}); + +test('search styles do not replace a user arrangement at the same cell', () => { + const main = createCellTable([['Alice']], { + initialArrangements: [{ col: 0, row: 1, style: 'user-style' }] + }); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + + main.table.arrangeCustomCellStyle({ col: 0, row: 1 }, 'user-updated'); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-updated' }, + expect.objectContaining({ customStyleId: '__search_component_highlight' }) + ]) + ); +}); + +test('search styles remain separate when a user adds a style to an unstyled cell', () => { + const main = createCellTable([['Alice']]); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + + main.table.arrangeCustomCellStyle({ col: 0, row: 1 }, 'user-updated'); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([ + expect.objectContaining({ customStyleId: 'user-updated' }), + expect.objectContaining({ customStyleId: '__search_component_highlight' }) + ]) + ); +}); + +test('search styles do not replace a user range arrangement at the same cell', () => { + const main = createCellTable([['Alice']]); + const userRange = { + start: { col: 0, row: 1 }, + end: { col: 0, row: 1 } + }; + main.customCellStylePlugin.customCellStyleArrangement.push({ + cellPosition: { range: userRange }, + customStyleId: 'user-range-style' + }); + main.customCellStylePlugin._rebuildCustomCellStyleArrangementIndex(); + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('i'); + + main.table.arrangeCustomCellStyle({ range: userRange }, 'user-range-updated'); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( + expect.arrayContaining([ + { cellPosition: { range: userRange }, customStyleId: 'user-range-updated' }, + expect.objectContaining({ customStyleId: '__search_component_highlight' }) + ]) + ); + + search.clear(); + + expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual([ + { cellPosition: { range: userRange }, customStyleId: 'user-range-updated' } + ]); +}); + +test('real style plugin keeps multiple user styles separate from search overlays', () => { + const main = createCellTable([['Alice']]); + const plugin = attachRealCustomStylePlugin( + main, + [ + { id: 'user-a', style: { color: 'red' } }, + { id: 'user-b', style: { fontWeight: 'bold' } }, + { id: 'user-updated', style: { color: 'blue' } } + ], + [ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-a' }, + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-b' } + ] + ); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('Ali'); + main.table.arrangeCustomCellStyle({ col: 0, row: 1 }, 'user-updated'); + + expect(plugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-a' }, + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-updated' } + ]); + expect(plugin.getCustomCellStyleIds(0, 1)).toEqual(['user-a', 'user-updated', '__search_component_highlight']); + + search.clear(); + + expect(plugin.customCellStyleArrangement).toHaveLength(2); + expect(plugin.getCustomCellStyleIds(0, 1)).toEqual(['user-a', 'user-updated']); +}); + +test('real style plugin indexes merged search overlays without growing the public arrangement list', () => { + const values = Array.from({ length: 80 }, (_, index) => `Hit ${Math.floor(index / 2)}`); + const main = createCellTable([values]); + main.table.colCount = values.length; + main.table.getCellRange = jest.fn((col, row) => { + if (row !== 1) { + return { start: { col, row }, end: { col, row } }; + } + const startCol = col - (col % 2); + return { start: { col: startCol, row }, end: { col: startCol + 1, row } }; + }); + let arrangementReads = 0; + const publicArrangements = new Proxy([], { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + arrangementReads++; + } + return Reflect.get(target, property, receiver); + } + }); + const plugin = attachRealCustomStylePlugin(main, [], publicArrangements); + main.table.scenegraph.updateCellContent.mockImplementation((col, row) => { + plugin.getCustomCellStyleIds(col, row); + }); + const collectStyleIdsSpy = jest.spyOn(plugin as any, '_collectCustomCellStyleIds'); + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + arrangementReads = 0; + + const result = search.search('Hit'); + + expect(result.results).toHaveLength(40); + expect(plugin.customCellStyleArrangement).toHaveLength(0); + expect((plugin as any)._customCellStyleOverlays.get('__search_component_overlay').positions.size).toBe(40); + expect(main.table.scenegraph.updateCellContent).toHaveBeenCalledTimes(80); + expect(main.table.scenegraph.updateCellContent.mock.calls.map(([col]) => col)).toEqual( + Array.from({ length: 80 }, (_, col) => col) + ); + expect(collectStyleIdsSpy).toHaveBeenCalledTimes(40); + expect(arrangementReads).toBe(0); +}); + +test('real plugin navigation updates only the previous and current overlays', () => { + const main = createCellTable([['Alice', 'Alina']]); + main.table.colCount = 2; + const plugin = attachRealCustomStylePlugin(main); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('Ali'); + const setOverlaySpy = jest.spyOn(plugin, 'setCustomCellStyleOverlay'); + + search.next(); + search.next(); + + expect(setOverlaySpy).toHaveBeenCalledTimes(3); + expect(plugin.customCellStyleArrangement).toHaveLength(0); +}); + +test('visible range boundaries are treated as inclusive', () => { + const main = createCellTable( + [ + ['One', 'Two'], + ['Three', 'Four'] + ], + { + visibleRows: { rowStart: 1, rowEnd: 2 }, + visibleCols: { colStart: 0, colEnd: 1 } + } + ); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.jumpToCell({ col: 1, row: 2 }); + + expect(main.table.scrollToCell).not.toHaveBeenCalled(); +}); + +test('detail results expose their source table and parent body row', () => { + const main = createCellTable([['Parent']], { rowHierarchyType: 'grid', isMasterDetail: true }); + const first = createCellTable([['Widget']]); + const second = createCellTable([['Widget']]); + main.table.internalProps = { + subTableInstances: new Map([ + [3, first.table], + [7, second.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + const result = search.search('i'); + + expect(result.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ table: first.table, parentRow: 3, row: 1, col: 0 }), + expect.objectContaining({ table: second.table, parentRow: 7, row: 1, col: 0 }) + ]) + ); +}); + +test('tree detail search includes collapsed descendants from raw records', () => { + const main = createCellTable([['Parent']], { rowHierarchyType: 'grid', isMasterDetail: true }); + const detail = createCellTable([['Parent']], { + columns: [{ field: 'name', tree: true }], + records: [{ name: 'Parent', children: [{ name: 'HiddenWidget' }] }], + rowHierarchyType: 'tree' + }); + detail.table.rowCount = 2; + detail.table.dataSource = { + rowHierarchyType: 'tree', + getTableIndex: jest.fn(index => (Array.isArray(index) && index.length > 1 ? -1 : 0)) + }; + detail.table.internalProps = { + layoutMap: { + getHeaderCellAddressByField: jest.fn(() => ({ col: 0, row: 0 })) + } + }; + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + const result = search.search('Hidden'); + + expect(result.results).toEqual([ + expect.objectContaining({ + indexNumber: [0, 0], + table: detail.table, + parentRow: 0, + value: 'HiddenWidget' + }) + ]); +}); + +test('page scrolling includes a detail table viewBox offset', () => { + const main = createCellTable([['Parent']], { rowHierarchyType: 'grid', isMasterDetail: true }); + const detail = createCellTable([['Widget']], { + viewBox: { x1: 0, y1: 500, x2: 100, y2: 700 }, + cellRangeRelativeRect: () => ({ left: 0, top: 500, width: 100, height: 20 }) + }); + const scrollContainer = document.createElement('div'); + const root = document.createElement('div'); + scrollContainer.style.overflowY = 'auto'; + Object.defineProperty(scrollContainer, 'clientHeight', { configurable: true, value: 100 }); + Object.defineProperty(scrollContainer, 'scrollHeight', { configurable: true, value: 1000 }); + Object.defineProperty(scrollContainer, 'scrollTop', { configurable: true, writable: true, value: 0 }); + Object.defineProperty(scrollContainer, 'getBoundingClientRect', { + configurable: true, + value: () => ({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) + }); + Object.defineProperty(root, 'getBoundingClientRect', { + configurable: true, + value: () => ({ top: 0, bottom: 100, left: 0, right: 100, width: 100, height: 100 }) + }); + scrollContainer.appendChild(root); + document.body.appendChild(scrollContainer); + detail.table.getElement = () => root; + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + + const search = new SearchComponent({ table: main.table as any, autoJump: false, enableViewportScroll: true }); + search.jumpToCell({ col: 0, row: 1 }, detail.table as any); + + expect(scrollContainer.scrollTop).toBe(420); +}); + +test('scrolling a detail result keeps the master horizontal position', () => { + const main = createCellTable([['Parent']], { + rowHierarchyType: 'grid', + isMasterDetail: true, + visibleRows: { rowStart: 2, rowEnd: 2 } + }); + const detail = createCellTable([['Widget']]); + main.table.scrollLeft = 120; + main.table.internalProps = { + subTableInstances: new Map([[0, detail.table]]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('i'); + search.next(); + + expect(main.table.scrollLeft).toBe(120); + expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 1 }); +}); + +test('normal navigation does not check every result for availability', () => { + const main = createCellTable([['Alice', 'Alina', 'Alicia']], { rowHierarchyType: 'grid' }); + main.table.colCount = 3; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('Ali'); + + const availabilitySpy = jest.spyOn(search as any, 'isResultAvailable'); + search.next(); + search.next(); + search.prev(); + + expect(availabilitySpy).not.toHaveBeenCalled(); +}); + +test('tree-configured pivot tables search rendered cells instead of raw records', () => { + const pivot = createCellTable([['North', 'Hit total']], { rowHierarchyType: 'tree' }); + pivot.table.isPivotTable.mockReturnValue(true); + const search = new SearchComponent({ table: pivot.table as any, autoJump: false, skipHeader: true }); + + const result = search.search('Hit'); + + expect(result.results).toEqual([expect.objectContaining({ table: pivot.table, col: 1, row: 1, value: 'Hit total' })]); + expect(result.results[0].indexNumber).toBeUndefined(); +}); + +test('bulk highlighting creates every result and arrangement', () => { + const main = createCellTable([Array.from({ length: 100 }, (_, index) => `Hit ${index}`)]); + main.table.colCount = 100; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + + const result = search.search('Hit'); + + expect(result.results).toHaveLength(100); + expect(main.customCellStylePlugin.customCellStyleArrangement).toHaveLength(100); +}); + +test('navigation rebuilds a middle style cache entry mutated in place', () => { + const main = createCellTable([['Alice', 'Alina', 'Alicia']]); + main.table.colCount = 3; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Ali'); + const arrangements = main.customCellStylePlugin.customCellStyleArrangement; + const firstArrangement = arrangements[0]; + const detachedSearchStyle = arrangements[1]; + const lastArrangement = arrangements[2]; + arrangements.splice(1, 1, { + cellPosition: { col: 1, row: 1 }, + customStyleId: 'user-style' + }); + + search.next(); + search.next(); + + expect(detachedSearchStyle.customStyleId).toBe('__search_component_highlight'); + expect(arrangements[0]).toBe(firstArrangement); + expect(arrangements[2]).toBe(lastArrangement); + expect(arrangements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ customStyleId: 'user-style' }), + expect.objectContaining({ customStyleId: '__search_component_focus' }) + ]) + ); +}); + +test('expanding a tree result rebuilds highlights with current row coordinates', () => { + const records = [ + { name: 'Hit before' }, + { name: 'Parent', children: [{ name: 'Hit child' }] }, + { name: 'Hit after' } + ]; + const main = createCellTable([], { + columns: [{ field: 'name', tree: true }], + records, + rowHierarchyType: 'tree' + }); + let expanded = false; + main.table.rowCount = 4; + main.table.dataSource.getTableIndex = jest.fn(index => { + const path = Array.isArray(index) ? index : [index]; + if (path[0] === 0) { + return 0; + } + if (path[0] === 1 && path.length === 1) { + return 1; + } + if (path[0] === 1 && path[1] === 0) { + return expanded ? 2 : -1; + } + if (path[0] === 2) { + return expanded ? 3 : 2; + } + return -1; + }); + main.table.getHierarchyState = jest.fn(() => (expanded ? 'expand' : 'collapse')); + main.table.toggleHierarchyState = jest.fn(() => { + expanded = true; + main.table.rowCount = 5; + }); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('Hit'); + search.next(); + + const searchArrangements = main.customCellStylePlugin.customCellStyleArrangement.filter(item => + item.customStyleId?.startsWith('__search_component_') + ); + expect(searchArrangements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cellPosition: { col: 0, row: 3 }, + customStyleId: '__search_component_focus' + }), + expect.objectContaining({ cellPosition: { col: 0, row: 4 }, customStyleId: '__search_component_highlight' }) + ]) + ); + expect(searchArrangements).not.toEqual( + expect.arrayContaining([expect.objectContaining({ cellPosition: { col: 0, row: 2 } })]) + ); +}); + +test('next keeps its direction when the current detail result is released', () => { + const main = createCellTable([['Match']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Match first']]); + const secondDetail = createCellTable([['Match second']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Match'); + search.next(); + search.next(); + firstDetail.table.isReleased = true; + firstDetail.table.scenegraph = null; + + const result = search.next(); + + expect(result.index).toBe(1); + expect(result.results[1].table).toBe(secondDetail.table); +}); + +test('next keeps its direction when multiple results before and at the current index are released', () => { + const main = createCellTable([['Match']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Match first']]); + const secondDetail = createCellTable([['Match second']]); + const thirdDetail = createCellTable([['Match third']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table], + [2, thirdDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Match'); + search.next(); + search.next(); + search.next(); + firstDetail.table.isReleased = true; + firstDetail.table.scenegraph = null; + secondDetail.table.isReleased = true; + secondDetail.table.scenegraph = null; + + const result = search.next(); + + expect(result.index).toBe(1); + expect(result.results[1].table).toBe(thirdDetail.table); +}); + +test('prev keeps its direction when multiple results including the current detail are released', () => { + const main = createCellTable([['Match']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Match first']]); + const secondDetail = createCellTable([['Match second']]); + const thirdDetail = createCellTable([['Match third']]); + const fourthDetail = createCellTable([['Match fourth']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table], + [2, thirdDetail.table], + [3, fourthDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Match'); + search.next(); + search.next(); + search.next(); + search.next(); + firstDetail.table.isReleased = true; + firstDetail.table.scenegraph = null; + thirdDetail.table.isReleased = true; + thirdDetail.table.scenegraph = null; + + const result = search.prev(); + + expect(result.index).toBe(1); + expect(result.results[1].table).toBe(secondDetail.table); +}); + +test('result navigation uses the recorded detail parent row without a reverse scan', () => { + const main = createCellTable([['Parent']], { isMasterDetail: true }); + const detail = createCellTable([['Widget']]); + const subTableInstances = new Map([[7, detail.table]]); + main.table.internalProps = { subTableInstances }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + const result = search.search('Widget'); + const reverseLookupSpy = jest.spyOn(search as any, 'getSubTableBodyRowIndex'); + + search.next(); + + expect(result.results[0].parentRow).toBe(7); + expect(reverseLookupSpy).not.toHaveBeenCalled(); + expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 8 }); +}); + +test('tree detail navigation uses synchronous local scrolling before reading its target geometry', () => { + const main = createCellTable([['Parent']], { isMasterDetail: true }); + let detailScrolled = false; + const geometryReads: boolean[] = []; + const detail = createCellTable([['Widget']], { + columns: [{ field: 'name', tree: true }], + records: [{ name: 'Widget' }], + rowHierarchyType: 'tree', + cellRangeRelativeRect: () => { + geometryReads.push(detailScrolled); + return { left: 0, top: detailScrolled ? 50 : 500, width: 100, height: 20 }; + } + }); + detail.table.scrollToCell.mockImplementation((_cell, option) => { + if (option === false) { + detailScrolled = true; + } + }); + main.table.internalProps = { subTableInstances: new Map([[0, detail.table]]) }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.jumpToCell({ IndexNumber: [0], col: 0 }, detail.table as any, 0); + + expect(detail.table.scrollToCell).toHaveBeenCalledWith({ row: 1, col: 0 }, false); + expect(geometryReads.length).toBeGreaterThan(0); + expect(geometryReads.every(Boolean)).toBe(true); + expect(main.table.scrollTop).toBe(0); +}); + +test('search result table IDs remain unique when VTable timestamp IDs collide', () => { + const main = createCellTable([['Parent']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Widget one']]); + const secondDetail = createCellTable([['Widget two']]); + firstDetail.table.id = 'VTable123'; + secondDetail.table.id = 'VTable123'; + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + const result = search.search('Widget'); + + expect(result.results.map(item => item.tableId)).toEqual(['VTable123', 'VTable123-1']); +}); diff --git a/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts b/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts new file mode 100644 index 0000000000..c7ca059f23 --- /dev/null +++ b/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts @@ -0,0 +1,96 @@ +import * as VTable from '@visactor/vtable'; +import { MasterDetailPlugin } from '@visactor/vtable-plugins'; +import { SearchComponent } from '../../src'; + +const CONTAINER_ID = 'vTable'; +const demoWindow = window as typeof window & { + tableInstance?: VTable.ListTable; + search?: SearchComponent; + masterDetailPlugin?: MasterDetailPlugin; +}; + +const records = Array.from({ length: 8 }, (_, index) => ({ + orderId: `ORDER-${String(index + 1).padStart(3, '0')}`, + customer: index % 2 === 0 ? `Target customer ${index + 1}` : `Customer ${index + 1}`, + status: index % 3 === 0 ? 'Review' : 'Ready', + children: [ + { + task: `Target task ${index + 1}`, + owner: `Owner ${index + 1}`, + children: [ + { + task: `Target nested task ${index + 1}`, + owner: `Reviewer ${index + 1}` + } + ] + }, + { + task: `Packaging ${index + 1}`, + owner: `Operator ${index + 1}` + } + ] +})); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + + const masterDetailPlugin = new MasterDetailPlugin({ + detailTableOptions: { + columns: [ + { field: 'task', title: 'Detail task', tree: true, width: 260 }, + { field: 'owner', title: 'Owner', width: 180 } + ], + hierarchyExpandLevel: 1, + defaultRowHeight: 34, + defaultHeaderRowHeight: 36, + style: { + margin: [8, 16], + height: 150 + }, + theme: VTable.themes.BRIGHT + } + }); + + const tableInstance = new VTable.ListTable({ + container, + records, + columns: [ + { field: 'orderId', title: 'Order', width: 150 }, + { field: 'customer', title: 'Customer', width: 220 }, + { field: 'status', title: 'Status', width: 120 } + ], + defaultRowHeight: 40, + heightMode: 'standard', + plugins: [masterDetailPlugin] + }); + + demoWindow.tableInstance = tableInstance; + demoWindow.masterDetailPlugin = masterDetailPlugin; + demoWindow.search = new SearchComponent({ + table: tableInstance, + autoJump: true, + scrollOption: { + duration: 500 + } + }); + + const input = document.getElementById('search-component-input') as HTMLInputElement | null; + if (input) { + input.value = 'Target'; + } + + requestAnimationFrame(() => { + if (tableInstance.isReleased) { + return; + } + records.forEach((_record, recordIndex) => { + const row = tableInstance.getTableIndexByRecordIndex(recordIndex); + if (typeof row === 'number' && tableInstance.getHierarchyState(0, row) !== 'expand') { + tableInstance.toggleHierarchyState(0, row); + } + }); + }); +} diff --git a/packages/vtable-search/demo/main.ts b/packages/vtable-search/demo/main.ts index 334ab9ec9e..ffb02f4e56 100644 --- a/packages/vtable-search/demo/main.ts +++ b/packages/vtable-search/demo/main.ts @@ -109,7 +109,8 @@ const createSidebar = (node: HTMLDivElement) => { const run = () => { const sidebarNode = document.querySelector('#sidebar')!; - const prevActivePath = localStorage.getItem(LOCAL_STORAGE_KEY); + const prevActivePath = + new URLSearchParams(window.location.search).get('demo') || localStorage.getItem(LOCAL_STORAGE_KEY); createSidebar(sidebarNode); initSidebarEvent(sidebarNode); diff --git a/packages/vtable-search/demo/menu.ts b/packages/vtable-search/demo/menu.ts index b55c42bf66..313836ac50 100644 --- a/packages/vtable-search/demo/menu.ts +++ b/packages/vtable-search/demo/menu.ts @@ -9,6 +9,10 @@ export const menus = [ { path: 'list', name: 'list-tree' + }, + { + path: 'list', + name: 'issue-5036-master-detail-search' } ] }, diff --git a/packages/vtable-search/demo/vite.config.js b/packages/vtable-search/demo/vite.config.js index 55080a87d1..c9b5c15adf 100644 --- a/packages/vtable-search/demo/vite.config.js +++ b/packages/vtable-search/demo/vite.config.js @@ -13,6 +13,8 @@ module.exports = { }, resolve: { alias: { + '@visactor/vtable-plugins': path.resolve(__dirname, '../../vtable-plugins/src/index.ts'), + '@visactor/vtable/es': path.resolve(__dirname, '../../vtable/src'), '@visactor/vtable': path.resolve(__dirname, '../../vtable/src/index.ts'), '@src': path.resolve(__dirname, '../../vtable/src/'), '@vutils-extension': path.resolve(__dirname, '../../vtable/src/vutil-extension-temp') diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 98003b3fe8..1ac89f83f2 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -24,6 +24,7 @@ }, "scripts": { "start": "vite ./demo", + "test": "jest --runInBand", "build": "npm run fix-memory-limit && bundle --clean", "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, @@ -43,6 +44,7 @@ "cross-env": "^7.0.3", "increase-memory-limit": "^1.0.7", "@visactor/vchart": "2.1.3", + "@visactor/vtable-plugins": "workspace:1.26.8", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", "@internal/ts-config": "workspace:*", diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index 1f9ccde422..9b72d7ee02 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -4,14 +4,23 @@ import type { EasingType } from '@visactor/vtable/src/vrender'; import { isValid } from '@visactor/vutils'; type IVTable = VTable.ListTable | VTable.PivotTable | VTable.PivotChart; +export type QueryResultItem = { + col?: number; + row?: number; + range?: VTable.TYPES.CellRange; + value?: string; + indexNumber?: number[]; + /** The table that owns this match. */ + table?: IVTable; + /** A stable table instance identifier when the table exposes one. */ + tableId?: string; + /** The owning master body row for a master-detail result. */ + parentRow?: number; +}; + export type QueryResult = { queryStr: string; - results: { - col?: number; - row?: number; - value?: string; - indexNumber?: number[]; - }[]; + results: QueryResultItem[]; }; export type SearchComponentOption = { @@ -38,6 +47,18 @@ export type SearchComponentOption = { const HighlightStyleId = '__search_component_highlight'; const FocusHighlightStyleId = '__search_component_focus'; +const SearchStyleOverlayId = '__search_component_overlay'; +const searchStyleIds = new Set([HighlightStyleId, FocusHighlightStyleId]); +type SearchCellPosition = + | { col: number; row: number } + | { + range: VTable.TYPES.CellRange; + }; + +type SearchTableEntry = { + table: IVTable; + parentRow?: number; +}; const defaultHighlightCellStyle: Partial = { bgColor: 'rgba(255, 255, 0, 0.2)' @@ -74,18 +95,21 @@ export class SearchComponent { callback?: (queryResult: QueryResult, table: IVTable) => void; queryStr: string; - queryResult: { - col?: number; - row?: number; - range?: VTable.TYPES.CellRange; - value?: string; - indexNumber?: number[]; - }[]; + queryResult: QueryResultItem[]; currentIndex: number; isTree: boolean; treeIndex: number; scrollOption: ITableAnimationOption; + private resultTableMap = new WeakMap(); + private resultTreeMap = new WeakMap(); + private resultParentRowMap = new WeakMap(); + private resultTables = new Set(); + private searchedTables = new Set(); + private tableIdMap = new WeakMap(); + private usedTableIds = new Set(); + private searchStylePositions = new WeakMap>(); + private nextTableId = 1; constructor(option: SearchComponentOption) { this.table = option.table; @@ -108,66 +132,696 @@ export class SearchComponent { this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } - private getHeaderOffset(): number { + private getSearchTableEntries(): SearchTableEntry[] { + if (!this.isTableAvailable(this.table) || this.isMasterTableReleasing()) { + return []; + } + const entries: SearchTableEntry[] = [{ table: this.table }]; + const seenTables = new Set(entries.map(entry => entry.table)); + const subTableInstances = (this.table as any).internalProps?.subTableInstances; + if (subTableInstances && typeof subTableInstances.forEach === 'function') { + subTableInstances.forEach((subTable: IVTable, parentRow: number) => { + if (subTable && !seenTables.has(subTable) && this.isTableAvailable(subTable)) { + entries.push({ table: subTable, parentRow }); + seenTables.add(subTable); + } + }); + } + return entries; + } + + private getSearchTables(): IVTable[] { + return this.getSearchTableEntries().map(entry => entry.table); + } + + private isTableAvailable(table: IVTable | undefined): table is IVTable { + return !!table && !(table as any).isReleased && !this.isMasterTableReleasing(table) && !!(table as any).scenegraph; + } + + private isMasterTableReleasing(table: IVTable = this.table): boolean { + const internalProps = (table as any).internalProps; + if (internalProps?._isReleasing !== true) { + return false; + } + + if (table !== this.table) { + return true; + } + + const pluginManager = (table as any).pluginManager; + // The root table can retain this flag after its plugin is removed, so only honor it while the plugin is registered. + return ( + typeof pluginManager?.getPluginByName === 'function' && !!pluginManager.getPluginByName('Master Detail Plugin') + ); + } + + private getTableHierarchyType(table: IVTable): string | undefined { + return ( + (table as any).rowHierarchyType ?? + (table as any).dataSource?.rowHierarchyType ?? + (table as any).options?.rowHierarchyType ?? + (table as any).internalProps?.layoutMap?.rowHierarchyType + ); + } + + private isMasterDetailTable(table: IVTable = this.table): boolean { + if ((table as any).options?.masterDetail === true || (table as any).internalProps?.masterDetail === true) { + return true; + } + const pluginManager = (table as any).pluginManager; + if (pluginManager?.getPluginByName?.('Master Detail Plugin')) { + return true; + } + const subTableInstances = (table as any).internalProps?.subTableInstances; + if (!subTableInstances || typeof subTableInstances.forEach !== 'function') { + return false; + } + if (typeof subTableInstances.size === 'number' && subTableInstances.size > 0) { + return true; + } + if ( + this.getTableHierarchyType(table) === 'grid' && + (typeof (table as any).getSubTableByRowIndex === 'function' || + typeof (table as any).getAllSubTableInstances === 'function') + ) { + return true; + } + return ( + typeof (table as any).getSubTableByRowIndex === 'function' || + typeof (table as any).getAllSubTableInstances === 'function' + ); + } + + private isTreeTable(table: IVTable): boolean { + if (table.isPivotTable?.()) { + return false; + } + if (this.isMasterDetailTable(table)) { + return false; + } + const hierarchyType = this.getTableHierarchyType(table); + if (hierarchyType) { + return hierarchyType === 'tree' || hierarchyType === 'grid-tree'; + } + return !!(table as any).options?.columns?.some((item: any) => item?.tree); + } + + private getResultTable(resultItem: typeof this.queryResult[number]): IVTable | undefined { + const table = resultItem.table ?? this.resultTableMap.get(resultItem as object); + if (table) { + return this.isTableAvailable(table) ? table : undefined; + } + return this.isTableAvailable(this.table) ? this.table : undefined; + } + + private getResultParentRow(resultItem: typeof this.queryResult[number]): number | undefined { + if (typeof resultItem.parentRow === 'number') { + return resultItem.parentRow; + } + return this.resultParentRowMap.get(resultItem as object); + } + + private getResultTables(): IVTable[] { + const activeTables = new Set(this.getSearchTables()); + activeTables.add(this.table); + const tables = new Set(); + activeTables.forEach(table => { + if (this.isTableAvailable(table)) { + tables.add(table); + } + }); + this.resultTables.forEach(table => { + if (this.isTableAvailable(table)) { + tables.add(table); + } + }); + return Array.from(tables); + } + + private getActiveSearchTableSet(): Set { + const activeTables = new Set(this.getSearchTables()); + activeTables.add(this.table); + return activeTables; + } + + private isResultAvailable(resultItem: typeof this.queryResult[number], activeTables?: Set): boolean { + const table = this.getResultTable(resultItem); + const tables = activeTables || this.getActiveSearchTableSet(); + return !!table && tables.has(table) && this.isTableAvailable(table); + } + + private pruneUnavailableResults(direction: -1 | 0 | 1 = 0): void { + if (!this.queryResult?.length) { + return; + } + const activeTables = this.getActiveSearchTableSet(); + let hasUnavailableTable = false; + for (const table of this.resultTables) { + if (!activeTables.has(table) || !this.isTableAvailable(table)) { + hasUnavailableTable = true; + break; + } + } + if (!hasUnavailableTable) { + return; + } + + const previousIndex = this.currentIndex; + const currentResult = previousIndex >= 0 ? this.queryResult[previousIndex] : undefined; + const availableResults: QueryResultItem[] = []; + let availableBeforeCurrent = 0; + for (let index = 0; index < this.queryResult.length; index++) { + const resultItem = this.queryResult[index]; + if (this.isResultAvailable(resultItem, activeTables)) { + availableResults.push(resultItem); + if (index < previousIndex) { + availableBeforeCurrent++; + } + } + } + if (availableResults.length === this.queryResult.length) { + return; + } + this.queryResult = availableResults; + if (!this.queryResult.length) { + this.currentIndex = -1; + } else if (currentResult) { + const currentResultIndex = this.queryResult.indexOf(currentResult); + if (currentResultIndex >= 0) { + this.currentIndex = currentResultIndex; + } else if (direction > 0) { + this.currentIndex = availableBeforeCurrent - 1; + } else if (direction < 0) { + this.currentIndex = availableBeforeCurrent; + } else { + this.currentIndex = Math.min(availableBeforeCurrent, this.queryResult.length - 1); + } + } else if (this.currentIndex >= this.queryResult.length) { + this.currentIndex = this.queryResult.length - 1; + } + this.resultTables = new Set(); + for (const resultItem of this.queryResult) { + const table = this.getResultTable(resultItem); + if (table) { + this.resultTables.add(table); + } + } + } + + private isTreeResult(resultItem: typeof this.queryResult[number]): boolean { + return this.resultTreeMap.get(resultItem as object) ?? Array.isArray(resultItem.indexNumber); + } + + private getTableId(table: IVTable): string { + const existingId = this.tableIdMap.get(table as object); + if (existingId) { + return existingId; + } + const explicitId = (table as any).id; + const baseId = typeof explicitId === 'string' && explicitId ? explicitId : 'search-table'; + let tableId = baseId; + while (this.usedTableIds.has(tableId)) { + tableId = `${baseId}-${this.nextTableId++}`; + } + this.tableIdMap.set(table as object, tableId); + this.usedTableIds.add(tableId); + return tableId; + } + + private addQueryResult( + resultItem: typeof this.queryResult[number], + table: IVTable, + isTree = false, + parentRow?: number + ): void { + resultItem.table = table; + resultItem.tableId = this.getTableId(table); + if (typeof parentRow === 'number') { + resultItem.parentRow = parentRow; + this.resultParentRowMap.set(resultItem as object, parentRow); + } + this.queryResult.push(resultItem); + this.resultTableMap.set(resultItem as object, table); + this.resultTreeMap.set(resultItem as object, isTree); + this.resultTables.add(table); + } + + private getResultCellPosition(resultItem: typeof this.queryResult[number]): SearchCellPosition | undefined { + if (this.isTreeResult(resultItem)) { + return this.getVisibleTreeCell(resultItem); + } + if (resultItem.range) { + return { + range: resultItem.range + }; + } + if (typeof resultItem.col === 'number' && typeof resultItem.row === 'number') { + return { + col: resultItem.col, + row: resultItem.row + }; + } + return undefined; + } + + private getResultCell(resultItem: typeof this.queryResult[number]): { col: number; row: number } | undefined { + const position = this.getResultCellPosition(resultItem); + if (!position) { + return undefined; + } + return 'range' in position ? position.range.start : position; + } + + private getCellPositionRange(position: any): VTable.TYPES.CellRange | undefined { + if (position?.range) { + return position.range; + } + if (typeof position?.col === 'number' && typeof position?.row === 'number') { + return { + start: { col: position.col, row: position.row }, + end: { col: position.col, row: position.row } + }; + } + return undefined; + } + + private getCellPositionKey(position: any): string | undefined { + const range = this.getCellPositionRange(position); + if (!range) { + return undefined; + } + return `${range.start.col}:${range.start.row}:${range.end.col}:${range.end.row}`; + } + + private refreshCellStyle(table: IVTable, position: SearchCellPosition | any): void { + const range = this.getCellPositionRange(position); + if (!range) { + return; + } + const plugin = (table as any).customCellStylePlugin; + if (typeof plugin?.refreshCustomCellStyleRange === 'function') { + plugin.refreshCustomCellStyleRange(position, true); + return; + } + for (let col = range.start.col; col <= range.end.col; col++) { + for (let row = range.start.row; row <= range.end.row; row++) { + table.scenegraph.updateCellContent(col, row, true); + } + } + } + + private arrangeSearchCellStyle(table: IVTable, position: SearchCellPosition, customStyleId: string): void { + const plugin = (table as any).customCellStylePlugin; + const positionKey = this.getCellPositionKey(position); + if (plugin && positionKey && typeof plugin.setCustomCellStyleOverlay === 'function') { + plugin.setCustomCellStyleOverlay(SearchStyleOverlayId, position, customStyleId); + let positions = this.searchStylePositions.get(table as object); + if (!positions) { + positions = new Map(); + this.searchStylePositions.set(table as object, positions); + } + positions.set(positionKey, position); + return; + } + + const arrangements = plugin?.customCellStyleArrangement; + if (plugin && Array.isArray(arrangements) && positionKey) { + const existing = arrangements.find( + item => searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item.cellPosition) === positionKey + ); + if (existing && (existing.customStyleId == null || searchStyleIds.has(existing.customStyleId))) { + existing.customStyleId = customStyleId; + return; + } + if (typeof plugin.addCustomCellStyleArrangement === 'function') { + plugin.addCustomCellStyleArrangement(position as any, customStyleId); + return; + } + arrangements.push({ cellPosition: position, customStyleId }); + return; + } + const arrange = (table as any).arrangeCustomCellStyle; + if (typeof arrange === 'function') { + arrange.call(table, position as any, customStyleId as any, true); + } else if (typeof plugin?.arrangeCustomCellStyle === 'function') { + plugin.arrangeCustomCellStyle(position as any, customStyleId as any, true); + } + } + + private clearSearchCellStyleAtPosition(table: IVTable, position: SearchCellPosition): void { + const plugin = (table as any).customCellStylePlugin; + const positionKey = this.getCellPositionKey(position); + if (plugin && positionKey && typeof plugin.setCustomCellStyleOverlay === 'function') { + plugin.setCustomCellStyleOverlay(SearchStyleOverlayId, position, undefined); + this.searchStylePositions.get(table as object)?.delete(positionKey); + return; + } + + const arrangements = plugin?.customCellStyleArrangement; + if (!Array.isArray(arrangements) || !positionKey) { + return; + } + for (const item of arrangements) { + if (searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item.cellPosition) === positionKey) { + item.customStyleId = null; + } + } + } + + private clearSearchCellStyles(table: IVTable): Map { + const plugin = (table as any).customCellStylePlugin; + const positionsToRefresh = new Map(); + const overlayPositions = this.searchStylePositions.get(table as object); + if (plugin && overlayPositions && typeof plugin.clearCustomCellStyleOverlay === 'function') { + overlayPositions.forEach((position, key) => positionsToRefresh.set(key, position)); + plugin.clearCustomCellStyleOverlay(SearchStyleOverlayId); + this.searchStylePositions.delete(table as object); + return positionsToRefresh; + } + + const arrangements = plugin?.customCellStyleArrangement; + if (!Array.isArray(arrangements)) { + return positionsToRefresh; + } + const retainedArrangements: any[] = []; + let hasSearchArrangement = false; + for (const item of arrangements) { + if (!searchStyleIds.has(item?.customStyleId)) { + retainedArrangements.push(item); + continue; + } + hasSearchArrangement = true; + const position = item.cellPosition as SearchCellPosition; + const key = this.getCellPositionKey(position); + if (key) { + positionsToRefresh.set(key, position); + } + } + if (!hasSearchArrangement) { + return positionsToRefresh; + } + + arrangements.splice(0, arrangements.length, ...retainedArrangements); + plugin?._rebuildCustomCellStyleArrangementIndex?.call(plugin); + return positionsToRefresh; + } + + private setSearchCellStyle( + resultItem: typeof this.queryResult[number], + customStyleId: string | undefined = HighlightStyleId + ): void { + const table = this.getResultTable(resultItem); + if (!this.isTableAvailable(table)) { + return; + } + const position = this.getResultCellPosition(resultItem); + if (!position) { + return; + } + const plugin = (table as any).customCellStylePlugin; + if (!plugin) { + return; + } + if (customStyleId) { + this.arrangeSearchCellStyle(table, position, customStyleId); + } else { + this.clearSearchCellStyleAtPosition(table, position); + } + this.refreshCellStyle(table, position); + table.scenegraph.updateNextFrame(); + } + + private searchTable(table: IVTable, parentRow?: number): void { + if (this.isTreeTable(table)) { + this.searchTreeTable(table, parentRow); + return; + } + + const seenPositions = new Set(); + const rowCount = typeof (table as any).rowCount === 'number' ? (table as any).rowCount : 0; + const colCount = typeof (table as any).colCount === 'number' ? (table as any).colCount : 0; + for (let row = 0; row < rowCount; row++) { + for (let col = 0; col < colCount; col++) { + if (this.skipHeader && table.isHeader(col, row)) { + continue; + } + const value = table.getCellValue(col, row); + if (!this.queryMethod(this.queryStr, value, { col, row, table })) { + continue; + } + const mergeCell = table.getCellRange(col, row); + const isMerged = mergeCell.start.col !== mergeCell.end.col || mergeCell.start.row !== mergeCell.end.row; + const position = isMerged ? { range: mergeCell } : { col, row }; + const positionKey = this.getCellPositionKey(position); + if (positionKey && seenPositions.has(positionKey)) { + continue; + } + if (positionKey) { + seenPositions.add(positionKey); + } + this.addQueryResult( + isMerged + ? { + col: mergeCell.start.col, + row: mergeCell.start.row, + range: mergeCell, + value + } + : { col, row, value }, + table, + false, + parentRow + ); + } + } + } + + private getTableRecords(table: IVTable): any[] { + const records = + (table as any).records ?? (table as any).dataSource?.records ?? (table as any).internalProps?.records; + return Array.isArray(records) ? records : []; + } + + private searchTreeTable(table: IVTable, parentRow?: number): void { + const records = this.getTableRecords(table); + const treeCol = this.getTreeCol(table); + const childrenKey = (table as any).options?.childrenKey || 'children'; + const seenResults = new Set(); + + const addTreeResult = (path: number[], col: number, value?: unknown) => { + const key = `${path.join('.')}:${col}`; + if (seenResults.has(key)) { + return; + } + seenResults.add(key); + this.addQueryResult( + { + indexNumber: path, + col, + value: isValid(value) ? value?.toString?.() ?? String(value) : undefined + }, + table, + true, + parentRow + ); + }; + + const walk = (nodes: any[], path: number[]) => { + nodes.forEach((item: any, idx: number) => { + if (!item || typeof item !== 'object') { + return; + } + const currentPath = [...path, idx]; + const searchFields = + Array.isArray(this.fieldsToSearch) && this.fieldsToSearch.length > 0 + ? this.fieldsToSearch + : Object.keys(item); + let hitAnyField = false; + searchFields.forEach(field => { + const value = item[field]; + if (!isValid(value)) { + return; + } + const col = this.getHeaderCellAddressByField(table, field)?.col ?? treeCol; + if (this.queryMethod(this.queryStr, value, { col, row: 0, table })) { + hitAnyField = true; + addTreeResult(currentPath, col, value); + } + }); + + if ( + !hitAnyField && + this.treeQueryMethod && + this.treeQueryMethod(this.queryStr, item, this.fieldsToSearch, { table }) + ) { + addTreeResult(currentPath, treeCol); + } + + const children = item[childrenKey]; + if (Array.isArray(children) && children.length > 0) { + walk(children, currentPath); + } + }); + }; + + walk(records, []); + } + + private getHeaderOffset(table: IVTable): number { + const configuredOffset = (table as any).columnHeaderLevelCount; + if (typeof configuredOffset === 'number' && configuredOffset >= 0) { + return configuredOffset; + } let offset = 0; - while (this.table.isHeader(0, offset)) { + const rowCount = typeof (table as any).rowCount === 'number' ? (table as any).rowCount : Number.MAX_SAFE_INTEGER; + while (offset < rowCount && table.isHeader(0, offset)) { offset++; } return offset; } - private getHeaderCellAddressByField(field: string): { col: number; row: number } | undefined { - // PivotTable/ListTable share internal layoutMap API but it's not exposed on the public type. - const layoutMap = (this.table as any).internalProps?.layoutMap; - return layoutMap?.getHeaderCellAddressByField?.(field); + private getHeaderCellAddressByField(table: IVTable, field: string): { col: number; row: number } | undefined { + const layoutMap = (table as any).internalProps?.layoutMap; + const address = layoutMap?.getHeaderCellAddressByField?.(field); + if (address && typeof address.col === 'number') { + return address; + } + + let leafCol = 0; + let found: { col: number; row: number } | undefined; + const visitColumns = (columns: any[], depth: number) => { + columns.forEach(column => { + if (found) { + return; + } + if (Array.isArray(column?.columns) && column.columns.length > 0) { + visitColumns(column.columns, depth + 1); + } else { + if (column?.field === field) { + found = { col: leafCol, row: depth }; + } + leafCol++; + } + }); + }; + const columns = (table as any).options?.columns; + if (Array.isArray(columns)) { + visitColumns(columns, 0); + } + return found; } - private getTreeCol(): number { - const treeColumn = (this.table as any)?.options?.columns?.find((c: any) => c?.tree); + private getTreeCol(table: IVTable): number { + const columns = (table as any)?.options?.columns; + let treeColumn: any; + let leafCol = 0; + let treeLeafCol = 0; + const visitColumns = (items: any[]) => { + items.forEach(item => { + if (Array.isArray(item?.columns) && item.columns.length > 0) { + visitColumns(item.columns); + } else { + if (!treeColumn && item?.tree) { + treeColumn = item; + treeLeafCol = leafCol; + } + leafCol++; + } + }); + }; + if (Array.isArray(columns)) { + visitColumns(columns); + } const field = treeColumn?.field; if (typeof field === 'string' && field) { - const addr = this.getHeaderCellAddressByField(field); - if (addr && typeof addr.col === 'number') { - return addr.col; + const address = this.getHeaderCellAddressByField(table, field); + if (address && typeof address.col === 'number') { + return address.col; } } - // Fallback to previous behavior. - return this.treeIndex; + return treeColumn ? treeLeafCol : 0; } private getVisibleTreeCell(resultItem: typeof this.queryResult[number]): { col: number; row: number } | undefined { if (!resultItem.indexNumber) { return undefined; } - const rawIndex = this.getBodyRowIndexByRecordIndex(resultItem.indexNumber); + const table = this.getResultTable(resultItem); + if (!table) { + return undefined; + } + const rawIndex = this.getBodyRowIndexByRecordIndex(resultItem.indexNumber, table); if (rawIndex < 0) { return undefined; } return { - col: typeof resultItem.col === 'number' ? resultItem.col : this.getTreeCol(), - row: rawIndex + this.getHeaderOffset() + col: typeof resultItem.col === 'number' ? resultItem.col : this.getTreeCol(table), + row: rawIndex + this.getHeaderOffset(table) }; } - private clearRenderedCellStyles() { - const plugin = this.table.customCellStylePlugin; - const cellsToRefresh: { col: number; row: number }[] = []; - const arrangements = Array.from((plugin as any)?.customCellStyleArrangement || []); + private clearRenderedCellStyles(targetTable: IVTable = this.table) { + if (!this.isTableAvailable(targetTable)) { + return; + } + const positionsToRefresh = this.clearSearchCellStyles(targetTable); + positionsToRefresh.forEach(position => this.refreshCellStyle(targetTable, position)); + } - arrangements.forEach((item: any) => { - const cellPosition = item?.cellPosition; - if (typeof cellPosition?.col === 'number' && typeof cellPosition?.row === 'number') { - cellsToRefresh.push({ - col: cellPosition.col, - row: cellPosition.row - }); - } - }); + private sortMasterDetailResultsByVisualOrder(): void { + if (!this.isMasterDetailTable() || this.queryResult.length < 2) { + return; + } + const headerOffset = this.getHeaderOffset(this.table); + this.queryResult = this.queryResult + .map((result, index) => { + const table = this.getResultTable(result); + const isMasterResult = table === this.table; + const cell = isMasterResult ? this.getResultCell(result) : undefined; + const parentRow = isMasterResult ? (cell?.row ?? headerOffset) - headerOffset : this.getResultParentRow(result); + return { + result, + index, + parentRow: typeof parentRow === 'number' ? parentRow : Number.MAX_SAFE_INTEGER, + tableOrder: isMasterResult ? 0 : 1 + }; + }) + .sort((a, b) => a.parentRow - b.parentRow || a.tableOrder - b.tableOrder || a.index - b.index) + .map(item => item.result); + } - plugin.clearCustomCellStyleArrangement(); - cellsToRefresh.forEach(({ col, row }) => { - this.table.scenegraph.updateCellContent(col, row, true); - }); + private syncNewSearchTables(): void { + if (!this.queryStr || !this.isMasterDetailTable()) { + return; + } + const newEntries = this.getSearchTableEntries().filter(entry => !this.searchedTables.has(entry.table)); + if (!newEntries.length) { + return; + } + + const currentResult = + this.currentIndex >= 0 && this.currentIndex < this.queryResult.length + ? this.queryResult[this.currentIndex] + : undefined; + const previousResultCount = this.queryResult.length; + for (const entry of newEntries) { + this.searchedTables.add(entry.table); + this.searchTable(entry.table, entry.parentRow); + } + if (this.queryResult.length === previousResultCount) { + return; + } + + this.sortMasterDetailResultsByVisualOrder(); + this.currentIndex = currentResult ? this.queryResult.indexOf(currentResult) : -1; + this.updateCellStyle(); + this.callback?.( + { + queryStr: this.queryStr, + results: this.queryResult + }, + this.table + ); } search(str: string) { @@ -180,73 +834,23 @@ export class SearchComponent { results: this.queryResult }; } - this.isTree = this.table.options.columns.some((item: any) => item.tree); - this.treeIndex = this.isTree ? this.table.options.columns.findIndex((item: any) => item.tree) : 0; + this.isTree = this.isTreeTable(this.table); + this.treeIndex = this.isTree ? this.getTreeCol(this.table) : 0; if (this.isTree) { - // 如果传入单一节点也能处理 - const treeCol = this.getTreeCol(); - const walk = (nodes: any[], path: number[]) => { - nodes.forEach((item: any, idx: number) => { - const currentPath = [...path, idx]; // 当前节点的完整路径 - - // 为了做到“单元格级别高亮”,优先按字段匹配并映射到具体列。 - const searchFields = - Array.isArray(this.fieldsToSearch) && this.fieldsToSearch.length > 0 - ? this.fieldsToSearch - : Object.keys(item); - - let hitAnyField = false; - searchFields.forEach(field => { - const value = item?.[field]; - if (!isValid(value)) { - return; - } - const col = this.getHeaderCellAddressByField(field)?.col ?? treeCol; - // row 在树形场景下要在展开后才能准确计算,这里传 0 仅用于自定义 queryMethod 的兼容参数。 - if (this.queryMethod(this.queryStr, value, { col, row: 0, table: this.table })) { - hitAnyField = true; - this.queryResult.push({ - indexNumber: currentPath, - col, - value: value?.toString?.() ?? String(value) - }); - } - }); - - // 兼容旧用法:如果用户自定义 treeQueryMethod 命中但字段级别未命中,则至少高亮树列。 - if ( - !hitAnyField && - this.treeQueryMethod && - this.treeQueryMethod(this.queryStr, item, this.fieldsToSearch, { table: this.table }) - ) { - this.queryResult.push({ - indexNumber: currentPath, - col: treeCol - }); - } - - if (item.children && Array.isArray(item.children) && item.children.length > 0) { - walk(item.children, currentPath); - } - }); - }; - - walk(this.table.records, []); - // 同一节点同一列可能被多次命中(例如 fieldsToSearch 未限制且字段值重复),做一次简单去重 - const dedup = new Set(); - this.queryResult = this.queryResult.filter(r => { - const key = `${(r.indexNumber || []).join('.')}:${r.col ?? ''}`; - if (dedup.has(key)) { - return false; + this.searchTreeTable(this.table); + const entries = this.getSearchTableEntries(); + entries.forEach(entry => this.searchedTables.add(entry.table)); + for (const entry of entries) { + if (entry.table !== this.table) { + this.searchTable(entry.table, entry.parentRow); } - dedup.add(key); - return true; - }); + } + this.sortMasterDetailResultsByVisualOrder(); - this.currentIndex = this.queryResult.length > 0 ? 0 : -1; + this.currentIndex = this.queryResult.length > 0 && this.isTreeResult(this.queryResult[0]) ? 0 : -1; - if (this.queryResult.length > 0) { - this.jumpToCell({ IndexNumber: this.queryResult[0].indexNumber, col: this.queryResult[0].col ?? treeCol }); + if (this.currentIndex === 0) { + this.jumpToResult(this.queryResult[0]); } if (this.callback) { @@ -260,47 +864,21 @@ export class SearchComponent { } this.updateCellStyle(); + if (this.autoJump && this.currentIndex === -1 && this.queryResult.length > 0) { + return this.next(); + } + return { index: this.currentIndex >= 0 ? this.currentIndex : 0, results: this.queryResult }; } - for (let row = 0; row < this.table.rowCount; row++) { - for (let col = 0; col < this.table.colCount; col++) { - if (this.skipHeader && this.table.isHeader(col, row)) { - continue; - } - const value = this.table.getCellValue(col, row); - if (this.queryMethod(this.queryStr, value, { col, row, table: this.table })) { - // deal merge cell - const mergeCell = this.table.getCellRange(col, row); - if (mergeCell.start.col !== mergeCell.end.col || mergeCell.start.row !== mergeCell.end.row) { - // find is cell already in queryResult - let isIn = false; - for (let i = this.queryResult.length - 1; i >= 0; i--) { - if (this.queryResult[i].col === mergeCell.start.col && this.queryResult[i].row === mergeCell.start.row) { - isIn = true; - break; - } - } - if (!isIn) { - this.queryResult.push({ - col: mergeCell.start.col, - row: mergeCell.start.row, - range: mergeCell, - value - }); - } - } else { - this.queryResult.push({ - col, - row, - value - }); - } - } - } + const entries = this.getSearchTableEntries(); + entries.forEach(entry => this.searchedTables.add(entry.table)); + for (const entry of entries) { + this.searchTable(entry.table, entry.parentRow); } + this.sortMasterDetailResultsByVisualOrder(); this.updateCellStyle(); if (this.callback) { @@ -333,120 +911,98 @@ export class SearchComponent { highlight: boolean = true, customStyleId: string = HighlightStyleId ) { - const { col, row, range } = resultItem; - this.table.arrangeCustomCellStyle( - range - ? { range } - : { - row, - col - }, - highlight ? customStyleId : null - ); + this.setSearchCellStyle(resultItem, highlight ? customStyleId : undefined); } updateCellStyle(highlight: boolean = true) { + this.pruneUnavailableResults(); if (!highlight) { - this.clearRenderedCellStyles(); - this.table.scenegraph.updateNextFrame(); + this.getResultTables().forEach(table => { + const positionsToRefresh = this.clearSearchCellStyles(table); + positionsToRefresh.forEach(position => this.refreshCellStyle(table, position)); + table.scenegraph.updateNextFrame(); + }); return; } if (!this.queryResult) { return; } - if (!this.table.hasCustomCellStyle(HighlightStyleId)) { - this.table.registerCustomCellStyle(HighlightStyleId, this.highlightCellStyle as any); - } - if (!this.table.hasCustomCellStyle(FocusHighlightStyleId)) { - this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); - } - - this.clearRenderedCellStyles(); + const resultTables = this.getResultTables(); + resultTables.forEach(table => { + if (!table.hasCustomCellStyle(HighlightStyleId)) { + table.registerCustomCellStyle(HighlightStyleId, this.highlightCellStyle as any); + } + if (!table.hasCustomCellStyle(FocusHighlightStyleId)) { + table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); + } + const positionsToRefresh = this.clearSearchCellStyles(table); + positionsToRefresh.forEach(position => this.refreshCellStyle(table, position)); + }); - if (this.isTree) { - if (!this.queryResult.length) { - this.table.scenegraph.updateNextFrame(); - return; + for (let i = 0; i < this.queryResult.length; i++) { + const resultItem = this.queryResult[i]; + const table = this.getResultTable(resultItem); + const position = this.getResultCellPosition(resultItem); + if (!table || !position || !(table as any).customCellStylePlugin) { + continue; } + this.arrangeSearchCellStyle(table, position, HighlightStyleId); + this.refreshCellStyle(table, position); + } - // 先为所有命中节点打普通高亮 - for (let i = 0; i < this.queryResult.length; i++) { - const cell = this.getVisibleTreeCell(this.queryResult[i]); - if (!cell) { - continue; - } - this.table.customCellStylePlugin.addCustomCellStyleArrangement( - { - col: cell.col, - row: cell.row - }, - HighlightStyleId - ); - this.table.scenegraph.updateCellContent(cell.col, cell.row, true); - } - - // 再为当前索引打焦点高亮 - if (this.currentIndex >= 0 && this.currentIndex < this.queryResult.length) { - const cell = this.getVisibleTreeCell(this.queryResult[this.currentIndex]); - if (cell) { - this.table.customCellStylePlugin.addCustomCellStyleArrangement( - { - col: cell.col, - row: cell.row - }, - FocusHighlightStyleId - ); - this.table.scenegraph.updateCellContent(cell.col, cell.row, true); - } + if (this.currentIndex >= 0 && this.currentIndex < this.queryResult.length) { + const resultItem = this.queryResult[this.currentIndex]; + const table = this.getResultTable(resultItem); + const position = this.getResultCellPosition(resultItem); + if (table && position && (table as any).customCellStylePlugin) { + this.arrangeSearchCellStyle(table, position, FocusHighlightStyleId); + this.refreshCellStyle(table, position); } + } + resultTables.forEach(table => { + table.scenegraph.updateNextFrame(); + }); + } - this.table.scenegraph.updateNextFrame(); + private jumpToResult(resultItem: typeof this.queryResult[number]): void { + const table = this.getResultTable(resultItem); + if (!table) { + return; + } + if (this.isTreeResult(resultItem)) { + this.jumpToCell( + { IndexNumber: resultItem.indexNumber, col: resultItem.col }, + table, + this.getResultParentRow(resultItem) + ); } else { - for (let i = 0; i < this.queryResult.length; i++) { - this.table.customCellStylePlugin.addCustomCellStyleArrangement( - { - col: this.queryResult[i].col, - row: this.queryResult[i].row - }, - HighlightStyleId - ); - this.table.scenegraph.updateCellContent(this.queryResult[i].col, this.queryResult[i].row, true); - } - this.table.scenegraph.updateNextFrame(); + this.jumpToCell({ col: resultItem.col, row: resultItem.row }, table, this.getResultParentRow(resultItem)); } } next() { + this.syncNewSearchTables(); + this.pruneUnavailableResults(1); if (!this.queryResult.length) { return { index: 0, results: this.queryResult }; } - if (this.isTree) { - this.currentIndex++; - if (this.currentIndex >= this.queryResult.length) { - this.currentIndex = 0; - } - const { indexNumber, col } = this.queryResult[this.currentIndex]; - this.jumpToCell({ IndexNumber: indexNumber, col }); - this.updateCellStyle(); - } else { - if (this.currentIndex !== -1) { - // reset last focus - this.arrangeCustomCellStyle(this.queryResult[this.currentIndex]); - } - this.currentIndex++; - if (this.currentIndex >= this.queryResult.length) { - this.currentIndex = 0; - } - const { col, row } = this.queryResult[this.currentIndex]; - - this.arrangeCustomCellStyle(this.queryResult[this.currentIndex], true, FocusHighlightStyleId); + const previousIndex = this.currentIndex; + this.currentIndex++; + if (this.currentIndex >= this.queryResult.length) { + this.currentIndex = 0; + } + const previousResult = previousIndex >= 0 ? this.queryResult[previousIndex] : undefined; + const currentResult = this.queryResult[this.currentIndex]; - this.jumpToCell({ col, row }); + this.jumpToResult(currentResult); + if (previousResult) { + this.arrangeCustomCellStyle(previousResult, true, HighlightStyleId); } + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); return { index: this.currentIndex, @@ -455,6 +1011,8 @@ export class SearchComponent { } prev() { + this.syncNewSearchTables(); + this.pruneUnavailableResults(-1); if (!this.queryResult.length) { return { index: 0, @@ -462,30 +1020,19 @@ export class SearchComponent { }; } - if (this.isTree) { - this.currentIndex--; - if (this.currentIndex < 0) { - this.currentIndex = this.queryResult.length - 1; - } - - const { indexNumber, col } = this.queryResult[this.currentIndex]; - this.jumpToCell({ IndexNumber: indexNumber, col }); - this.updateCellStyle(); - } else { - // 普通表格处理 - if (this.currentIndex !== -1) { - this.arrangeCustomCellStyle(this.queryResult[this.currentIndex]); - } - - this.currentIndex--; - if (this.currentIndex < 0) { - this.currentIndex = this.queryResult.length - 1; - } + const previousIndex = this.currentIndex; + this.currentIndex--; + if (this.currentIndex < 0) { + this.currentIndex = this.queryResult.length - 1; + } + const previousResult = previousIndex >= 0 ? this.queryResult[previousIndex] : undefined; + const currentResult = this.queryResult[this.currentIndex]; - const { col, row } = this.queryResult[this.currentIndex]; - this.arrangeCustomCellStyle(this.queryResult[this.currentIndex], true, FocusHighlightStyleId); - this.jumpToCell({ col, row }); + this.jumpToResult(currentResult); + if (previousResult) { + this.arrangeCustomCellStyle(previousResult, true, HighlightStyleId); } + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); return { index: this.currentIndex, @@ -493,72 +1040,300 @@ export class SearchComponent { }; } - jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }) { - if (this.isTree) { - const { IndexNumber } = params; - const indexNumbers = [...IndexNumber]; + private getSubTableBodyRowIndex(targetTable: IVTable): number | undefined { + const subTableInstances = (this.table as any).internalProps?.subTableInstances; + if (!subTableInstances || typeof subTableInstances.forEach !== 'function') { + return undefined; + } + let bodyRowIndex: number | undefined; + subTableInstances.forEach((subTable: IVTable, rowIndex: number) => { + if (subTable === targetTable) { + bodyRowIndex = rowIndex; + } + }); + return bodyRowIndex; + } - const tmp = [...indexNumbers]; - let tmpNumber = 0; - let i = 0; + private getMasterViewport( + targetTable?: IVTable, + subTableBodyRowIndex?: number + ): { top: number; bottom: number } | undefined { + const masterTable = this.table as any; + const tableY = typeof masterTable.tableY === 'number' ? masterTable.tableY : 0; + const viewBoxY = typeof masterTable.options?.viewBox?.y1 === 'number' ? masterTable.options.viewBox.y1 : 0; + let top = tableY + viewBoxY; + const height = + typeof masterTable.tableNoFrameHeight === 'number' + ? masterTable.tableNoFrameHeight + : typeof masterTable.getVisibleRect === 'function' + ? masterTable.getVisibleRect()?.height + : undefined; + if (typeof height !== 'number') { + return undefined; + } - // 展开树形结构的父节点 - while (tmpNumber < tmp.length - 1) { - tmpNumber++; - const indexNumber = indexNumbers.slice(0, tmpNumber); + let bottom = top + height; + if (targetTable && targetTable !== this.table) { + const bodyRowIndex = subTableBodyRowIndex ?? this.getSubTableBodyRowIndex(targetTable); + if (bodyRowIndex !== undefined) { + const headerOffset = this.getHeaderOffset(this.table); + const rowIndex = bodyRowIndex + headerOffset; + const frozenRowCount = + typeof masterTable.frozenRowCount === 'number' ? masterTable.frozenRowCount : headerOffset; + const bottomFrozenRowCount = + typeof masterTable.bottomFrozenRowCount === 'number' ? masterTable.bottomFrozenRowCount : 0; + const rowCount = typeof masterTable.rowCount === 'number' ? masterTable.rowCount : 0; + const frozenRowsHeight = + typeof masterTable.getFrozenRowsHeight === 'function' ? masterTable.getFrozenRowsHeight() : 0; + const bottomFrozenRowsHeight = + typeof masterTable.getBottomFrozenRowsHeight === 'function' ? masterTable.getBottomFrozenRowsHeight() : 0; + const isFrozenDataRow = rowIndex >= headerOffset && rowIndex < frozenRowCount; + const isBottomFrozenDataRow = bottomFrozenRowCount > 0 && rowIndex >= rowCount - bottomFrozenRowCount; + + if (isFrozenDataRow) { + bottom -= bottomFrozenRowsHeight; + } else if (!isBottomFrozenDataRow) { + top += frozenRowsHeight; + bottom -= bottomFrozenRowsHeight; + } + } + } + return { top, bottom }; + } - // 跳过表头行 - while (this.table.isHeader(0, i)) { - i++; + private getSubTableTargetRect( + targetTable: IVTable, + position?: SearchCellPosition + ): { top: number; bottom: number } | undefined { + if (position && typeof (targetTable as any).getCellRangeRelativeRect === 'function') { + const range = this.getCellPositionRange(position); + if (range) { + const rect = (targetTable as any).getCellRangeRelativeRect(range); + if (rect && typeof rect.top === 'number') { + const bottom = + typeof rect.bottom === 'number' + ? rect.bottom + : typeof rect.height === 'number' + ? rect.top + rect.height + : undefined; + if (typeof bottom === 'number') { + return { top: rect.top, bottom }; + } } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; + } + } + const viewBox = (targetTable as any).options?.viewBox; + if (viewBox && typeof viewBox.y1 === 'number' && typeof viewBox.y2 === 'number') { + return { top: viewBox.y1, bottom: viewBox.y2 }; + } + return undefined; + } + + private isSubTableTargetVisible( + targetTable: IVTable, + position?: SearchCellPosition, + parentBodyRowIndex?: number + ): boolean { + const viewport = this.getMasterViewport(targetTable, parentBodyRowIndex); + const targetRect = this.getSubTableTargetRect(targetTable, position); + if (!viewport || !targetRect) { + return true; + } + const targetHeight = targetRect.bottom - targetRect.top; + if (targetHeight >= viewport.bottom - viewport.top) { + return targetRect.bottom > viewport.top && targetRect.top < viewport.bottom; + } + return targetRect.top >= viewport.top && targetRect.bottom <= viewport.bottom; + } + + private ensureSubTableParentVisible( + targetTable: IVTable, + position?: SearchCellPosition, + parentBodyRowIndex?: number + ): void { + if (targetTable === this.table) { + return; + } + const bodyRowIndex = parentBodyRowIndex ?? this.getSubTableBodyRowIndex(targetTable); + if (bodyRowIndex === undefined) { + return; + } + const parentRow = bodyRowIndex + this.getHeaderOffset(this.table); + const { rowStart, rowEnd } = this.table.getBodyVisibleRowRange(); + const isParentRowVisible = parentRow >= rowStart && parentRow <= rowEnd; + if (!isParentRowVisible || !this.isSubTableTargetVisible(targetTable, position, bodyRowIndex)) { + this.table.scrollToCell({ row: parentRow }); + this.scrollSubTableTargetIntoMasterViewport(targetTable, position, bodyRowIndex); + } + } + + private scrollSubTableTargetIntoMasterViewport( + targetTable: IVTable, + position?: SearchCellPosition, + parentBodyRowIndex?: number + ): void { + const viewport = this.getMasterViewport(targetTable, parentBodyRowIndex); + const targetRect = this.getSubTableTargetRect(targetTable, position); + const masterTable = this.table as any; + if (!viewport || !targetRect || typeof masterTable.scrollTop !== 'number') { + return; + } + + let scrollOffset = 0; + if (targetRect.top < viewport.top) { + scrollOffset = targetRect.top - viewport.top; + } else if (targetRect.bottom > viewport.bottom) { + scrollOffset = targetRect.bottom - viewport.bottom; + } + if (scrollOffset === 0) { + return; + } + + masterTable.scrollTop = Math.max(0, masterTable.scrollTop + scrollOffset); + masterTable.render?.(); + } - const hierarchyState = this.table.getHierarchyState(this.treeIndex, row); - if (hierarchyState !== 'expand') { - this.table.toggleHierarchyState(this.treeIndex, row); + private findVisibleTreeBodyIndex(table: IVTable, targetPath: number[]): number { + const records = this.getTableRecords(table); + const treeCol = this.getTreeCol(table); + const headerOffset = this.getHeaderOffset(table); + let bodyIndex = 0; + let foundIndex = -1; + const walk = (nodes: any[], parentPath: number[]) => { + nodes.forEach((node: any, index: number) => { + if (foundIndex !== -1) { + return; } + const path = [...parentPath, index]; + if (path.length === targetPath.length && path.every((value, pathIndex) => value === targetPath[pathIndex])) { + foundIndex = bodyIndex; + return; + } + const row = bodyIndex + headerOffset; + bodyIndex++; + const children = node?.[(table as any).options?.childrenKey || 'children']; + const hierarchyState = table.getHierarchyState?.(treeCol, row); + if (Array.isArray(children) && (hierarchyState === 'expand' || hierarchyState === undefined)) { + walk(children, path); + } + }); + }; + walk(records, []); + return foundIndex; + } + + private getTreeBodyIndex(table: IVTable, indexNumbers: number[]): { bodyIndex: number; hierarchyChanged: boolean } { + let bodyIndex = this.getBodyRowIndexByRecordIndex(indexNumbers, table); + const headerOffset = this.getHeaderOffset(table); + const treeCol = this.getTreeCol(table); + let hierarchyChanged = false; + + for (let depth = 1; depth < indexNumbers.length; depth++) { + const parentPath = indexNumbers.slice(0, depth); + bodyIndex = this.getBodyRowIndexByRecordIndex(parentPath, table); + if (bodyIndex < 0) { + bodyIndex = this.findVisibleTreeBodyIndex(table, parentPath); } + if (bodyIndex < 0) { + continue; + } + const row = bodyIndex + headerOffset; + const hierarchyState = table.getHierarchyState?.(treeCol, row); + if (hierarchyState !== 'expand' && typeof table.toggleHierarchyState === 'function') { + table.toggleHierarchyState(treeCol, row); + hierarchyChanged = true; + } + } + + bodyIndex = this.getBodyRowIndexByRecordIndex(indexNumbers, table); + if (bodyIndex < 0) { + bodyIndex = this.findVisibleTreeBodyIndex(table, indexNumbers); + } + return { bodyIndex, hierarchyChanged }; + } - const finalRow = this.getBodyRowIndexByRecordIndex(indexNumbers) + i; + jumpToCell( + params: { col?: number; row?: number; IndexNumber?: number[] }, + targetTable: IVTable = this.table, + parentBodyRowIndex?: number + ) { + if (Array.isArray(params.IndexNumber)) { + const indexNumbers = [...params.IndexNumber]; + const { bodyIndex: finalBodyIndex, hierarchyChanged } = this.getTreeBodyIndex(targetTable, indexNumbers); + if (finalBodyIndex < 0) { + return; + } + if (hierarchyChanged) { + this.updateCellStyle(); + } + const finalRow = finalBodyIndex + this.getHeaderOffset(targetTable); // 根据配置决定是否滚动表格 - const targetCol = typeof params.col === 'number' ? params.col : this.getTreeCol(); - this.table.scrollToCell({ row: finalRow, col: targetCol }, this.scrollOption); + const targetCol = typeof params.col === 'number' ? params.col : this.getTreeCol(targetTable); + const isDetailTable = targetTable !== this.table; + if (isDetailTable) { + this.ensureSubTableParentVisible(targetTable, undefined, parentBodyRowIndex); + } + const scrollOption = isDetailTable ? false : this.scrollOption; + targetTable.scrollToCell({ row: finalRow, col: targetCol }, scrollOption); + this.ensureSubTableParentVisible(targetTable, { col: targetCol, row: finalRow }, parentBodyRowIndex); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { - scrollVTableCellIntoView(this.table, { row: finalRow, col: targetCol }); + scrollVTableCellIntoView(targetTable, { row: finalRow, col: targetCol }); } } else { const { col, row } = params; - const { rowStart, rowEnd } = this.table.getBodyVisibleRowRange(); - const { colStart, colEnd } = this.table.getBodyVisibleColRange(); + if (typeof col !== 'number' || typeof row !== 'number') { + return; + } + const { rowStart, rowEnd } = targetTable.getBodyVisibleRowRange(); + const { colStart, colEnd } = targetTable.getBodyVisibleColRange(); + const isDetailTable = targetTable !== this.table; + if (isDetailTable) { + this.ensureSubTableParentVisible(targetTable, undefined, parentBodyRowIndex); + } // 检查单元格是否在表格可视范围内 - const isInTableView = !(row <= rowStart || row >= rowEnd || col <= colStart || col >= colEnd); + const isInTableView = row >= rowStart && row <= rowEnd && col >= colStart && col <= colEnd; // 根据配置决定是否滚动表格 if (!isInTableView) { - this.table.scrollToCell({ col, row }); + targetTable.scrollToCell({ col, row }); } + this.ensureSubTableParentVisible(targetTable, { col, row }, parentBodyRowIndex); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { - scrollVTableCellIntoView(this.table, { row, col }); + scrollVTableCellIntoView(targetTable, { row, col }); } } } - getBodyRowIndexByRecordIndex(index: number | number[]): number { + getBodyRowIndexByRecordIndex(index: number | number[], targetTable: IVTable = this.table): number { if (Array.isArray(index) && index.length === 1) { index = index[0]; } - return this.table.dataSource.getTableIndex(index); + const dataSource = (targetTable as any).dataSource; + if (typeof dataSource?.getTableIndex === 'function') { + const tableIndex = dataSource.getTableIndex(index); + return typeof tableIndex === 'number' ? tableIndex : -1; + } + const tableIndex = (targetTable as any).getTableIndexByRecordIndex?.(index as number); + if (typeof tableIndex === 'number') { + return tableIndex - this.getHeaderOffset(targetTable); + } + return -1; } clear() { // reset highlight cell style this.updateCellStyle(false); this.queryStr = ''; this.queryResult = []; + this.resultTableMap = new WeakMap(); + this.resultTreeMap = new WeakMap(); + this.resultParentRowMap = new WeakMap(); + this.resultTables.clear(); + this.searchedTables.clear(); this.currentIndex = -1; } } @@ -573,8 +1348,15 @@ function scrollVTableCellIntoView(table: IVTable, cellInfo: { row: number; col: return; } - // 获取单元格在表格中的位置信息 - const cellRect = table.getCellRect(cellInfo.col, cellInfo.row); + // 获取单元格相对于表格可视区域的位置信息。该 API 同时包含 viewBox 偏移。 + const cellRange = table.getCellRange?.(cellInfo.col, cellInfo.row) || { + start: cellInfo, + end: cellInfo + }; + const cellRect = + typeof table.getCellRangeRelativeRect === 'function' + ? table.getCellRangeRelativeRect(cellRange) + : table.getCellRect(cellInfo.col, cellInfo.row); if (!cellRect) { return; } @@ -582,8 +1364,8 @@ function scrollVTableCellIntoView(table: IVTable, cellInfo: { row: number; col: // 查找最近的可滚动父容器 let scrollContainer: Element | null = tableEl.parentElement; while (scrollContainer) { - const computedStyle = getComputedStyle(scrollContainer); - const hasScroll = /(auto|scroll|overlay)/.test(computedStyle.overflowY); + const computedStyle = typeof getComputedStyle === 'function' ? getComputedStyle(scrollContainer) : undefined; + const hasScroll = !!computedStyle && /(auto|scroll|overlay)/.test(computedStyle.overflowY); const canScroll = scrollContainer.scrollHeight > scrollContainer.clientHeight; if (hasScroll && canScroll) { diff --git a/packages/vtable-search/tsconfig.test.json b/packages/vtable-search/tsconfig.test.json index 3e81f8d9a4..03da5b506c 100644 --- a/packages/vtable-search/tsconfig.test.json +++ b/packages/vtable-search/tsconfig.test.json @@ -6,7 +6,9 @@ "lib": ["DOM", "ESNext"], "baseUrl": "./", "rootDir": ".", - "paths": {} + "paths": { + "@src/*": ["../vtable/src/*"] + } }, "include": ["src", "__tests__"] } diff --git a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts index 7781a8b1a6..f941818a94 100644 --- a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts +++ b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts @@ -124,4 +124,215 @@ describe('CustomCellStylePlugin', () => { expect(lastCall[1]).toBe(4); expect(lastCall[2]).toBe(true); }); + + test('uses indexed overlays without scanning unrelated search positions', () => { + let numericReads = 0; + const arrangements = new Proxy([], { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + numericReads++; + } + return Reflect.get(target, property, receiver); + } + }); + const plugin = new CustomCellStylePlugin( + createMockTable(1000, 1) as any, + [{ id: 's', style: { bgColor: 'yellow' } }] as any, + arrangements as any + ); + for (let col = 0; col < 1000; col++) { + plugin.setCustomCellStyleOverlay('search', { range: { start: { col, row: 0 }, end: { col, row: 0 } } }, 's'); + } + numericReads = 0; + + expect(plugin.getCustomCellStyleIds(500, 0)).toEqual(['s']); + expect(numericReads).toBeLessThan(10); + }); + + test('keeps exact and range styles in arrangement order', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'range-first', style: { bgColor: 'red' } }, + { id: 'exact', style: { color: 'white' } }, + { id: 'range-last', style: { fontWeight: 'bold' } } + ] as any, + [ + { + cellPosition: { range: { start: { col: 0, row: 0 }, end: { col: 5, row: 5 } } }, + customStyleId: 'range-first' + }, + { cellPosition: { col: 2, row: 2 }, customStyleId: 'exact' }, + { + cellPosition: { range: { start: { col: 1, row: 1 }, end: { col: 3, row: 3 } } }, + customStyleId: 'range-last' + } + ] as any + ); + + expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['range-first', 'exact', 'range-last']); + }); + + test('clearing arrangements also clears lookup indexes', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [{ id: 's', style: { bgColor: 'red' } }] as any, + [{ cellPosition: { col: 1, row: 2 }, customStyleId: 's' }] as any + ); + + plugin.clearCustomCellStyleArrangement(); + + expect((plugin as any)._customCellStyleArrangementIndex.size).toBe(0); + expect(plugin.getCustomCellStyleIds(1, 2)).toEqual([]); + }); + + test('keeps search overlays separate from user arrangement updates', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'user-a', style: { color: 'red' } }, + { id: 'user-b', style: { fontWeight: 'bold' } }, + { id: 'user-updated', style: { color: 'blue' } }, + { id: 'search', style: { bgColor: 'yellow' } } + ] as any, + [ + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-a' }, + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-b' } + ] as any + ); + plugin.setCustomCellStyleOverlay('search-component', { col: 1, row: 2 }, 'search'); + + plugin.arrangeCustomCellStyle({ col: 1, row: 2 }, 'user-updated'); + + expect(plugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-a' }, + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-updated' } + ]); + expect(plugin.getCustomCellStyleIds(1, 2)).toEqual(['user-a', 'user-updated', 'search']); + + plugin.clearCustomCellStyleOverlay('search-component'); + + expect(plugin.getCustomCellStyleIds(1, 2)).toEqual(['user-a', 'user-updated']); + }); + + test('reads direct public arrangement mutations without a lookup cache', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } } + ] as any, + [{ cellPosition: { col: 1, row: 1 }, customStyleId: 'first' }] as any + ); + + plugin.customCellStyleArrangement.splice(0, 1, { + cellPosition: { col: 2, row: 2 }, + customStyleId: 'second' + } as any); + + expect(plugin.getCustomCellStyleIds(1, 1)).toEqual([]); + expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['second']); + }); + + test('updates and clears positions inserted directly into the public arrangement array', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } } + ] as any, + [] as any + ); + plugin.customCellStyleArrangement.push({ + cellPosition: { col: 3, row: 4 }, + customStyleId: 'first' + } as any); + + plugin.arrangeCustomCellStyle({ col: 3, row: 4 }, 'second'); + + expect(plugin.customCellStyleArrangement).toHaveLength(1); + expect(plugin.getCustomCellStyleIds(3, 4)).toEqual(['second']); + + plugin.arrangeCustomCellStyle({ col: 3, row: 4 }, null); + + expect(plugin.getCustomCellStyleIds(3, 4)).toEqual([]); + }); + + test('updates the latest style after a same-position public append', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } }, + { id: 'updated', style: { color: 'green' } } + ] as any, + [{ cellPosition: { col: 2, row: 2 }, customStyleId: 'first' }] as any + ); + plugin.customCellStyleArrangement.push({ + cellPosition: { col: 2, row: 2 }, + customStyleId: 'second' + } as any); + + plugin.arrangeCustomCellStyle({ col: 2, row: 2 }, 'updated'); + + expect(plugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 2, row: 2 }, customStyleId: 'first' }, + { cellPosition: { col: 2, row: 2 }, customStyleId: 'updated' } + ]); + + plugin.arrangeCustomCellStyle({ col: 2, row: 2 }, null); + + expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['first']); + expect((plugin as any)._customCellStyleArrangementIndex.get('cell:2,2')).toBe(0); + }); + + test('updates the correct style after a direct public deletion shifts indexes', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } }, + { id: 'updated', style: { color: 'green' } } + ] as any, + [ + { cellPosition: { col: 1, row: 1 }, customStyleId: 'first' }, + { cellPosition: { col: 2, row: 2 }, customStyleId: 'second' } + ] as any + ); + plugin.customCellStyleArrangement.splice(0, 1); + + plugin.arrangeCustomCellStyle({ col: 2, row: 2 }, 'updated'); + + expect(plugin.customCellStyleArrangement).toEqual([{ cellPosition: { col: 2, row: 2 }, customStyleId: 'updated' }]); + expect((plugin as any)._customCellStyleArrangementIndex.get('cell:2,2')).toBe(0); + }); + + test('bulk updates arrangements with a single index rebuild', () => { + const table = createMockTable(1000, 2); + const plugin = new CustomCellStylePlugin(table as any, [] as any, [] as any); + const rebuildSpy = jest.spyOn(plugin as any, '_rebuildCustomCellStyleArrangementIndex'); + const arrangements = Array.from({ length: 1000 }, (_, col) => ({ + cellPosition: { col, row: 0 }, + customStyleId: 'bulk' + })); + arrangements.push({ + cellPosition: { col: 500, row: 0 }, + customStyleId: 'updated' + }); + + plugin.updateCustomCell( + [ + { id: 'bulk', style: { bgColor: 'yellow' } }, + { id: 'updated', style: { bgColor: 'green' } } + ] as any, + arrangements as any + ); + + expect(rebuildSpy).toHaveBeenCalledTimes(1); + expect(plugin.customCellStyleArrangement).toHaveLength(1000); + expect((plugin as any)._customCellStyleArrangementIndex.size).toBe(1000); + expect(plugin.getCustomCellStyleIds(500, 0)).toEqual(['updated']); + expect(table.scenegraph.updateCellContent).toHaveBeenCalledTimes(1000); + expect(table.scenegraph.updateNextFrame).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/vtable/examples/debug/issue-5036-master-detail-search.ts b/packages/vtable/examples/debug/issue-5036-master-detail-search.ts new file mode 100644 index 0000000000..00600459f8 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5036-master-detail-search.ts @@ -0,0 +1,157 @@ +import * as VTable from '../../src'; +import { MasterDetailPlugin } from '@visactor/vtable-plugins'; +import { SearchComponent } from '@visactor/vtable-search'; + +const CONTAINER_ID = 'vTable'; +const CONTROLS_ID = 'issue5036Controls'; + +const records = Array.from({ length: 8 }, (_, index) => ({ + orderId: `ORDER-${String(index + 1).padStart(3, '0')}`, + customer: index % 2 === 0 ? `Target customer ${index + 1}` : `Customer ${index + 1}`, + status: index % 3 === 0 ? 'Review' : 'Ready', + children: [ + { + task: `Target task ${index + 1}`, + owner: `Owner ${index + 1}`, + children: [ + { + task: `Target nested task ${index + 1}`, + owner: `Reviewer ${index + 1}` + } + ] + }, + { + task: `Packaging ${index + 1}`, + owner: `Operator ${index + 1}` + } + ] +})); + +function createControls(container: HTMLElement) { + document.getElementById(CONTROLS_ID)?.remove(); + + const controls = document.createElement('div'); + controls.id = CONTROLS_ID; + controls.style.cssText = + 'display:flex;align-items:center;gap:8px;height:52px;padding:0 12px;border-bottom:1px solid #e5e7eb;' + + 'box-sizing:border-box;font:13px/1.4 Arial,sans-serif;background:#fff;'; + + const input = document.createElement('input'); + input.value = 'Target'; + input.placeholder = 'Search master and detail tables'; + input.style.cssText = + 'width:260px;height:30px;padding:0 9px;border:1px solid #c9cdd4;border-radius:4px;box-sizing:border-box;'; + + const createButton = (label: string) => { + const button = document.createElement('button'); + button.textContent = label; + button.style.cssText = + 'height:30px;padding:0 12px;border:1px solid #c9cdd4;border-radius:4px;background:#fff;cursor:pointer;'; + return button; + }; + + const searchButton = createButton('Search'); + const previousButton = createButton('Previous'); + const nextButton = createButton('Next'); + const clearButton = createButton('Clear'); + const result = document.createElement('span'); + result.style.cssText = 'min-width:110px;color:#4e5969;'; + result.textContent = 'Ready'; + + controls.append(input, searchButton, previousButton, nextButton, clearButton, result); + container.parentElement?.insertBefore(controls, container); + return { input, searchButton, previousButton, nextButton, clearButton, result }; +} + +function updateResult( + resultNode: HTMLElement, + result: ReturnType, + emptyLabel: string = 'No matches' +) { + resultNode.textContent = + result.results.length > 0 ? `${Math.max(result.index + 1, 1)} / ${result.results.length}` : emptyLabel; +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + + document.getElementById('input-test')!.style.display = 'none'; + document.getElementById('textarea-test')!.style.display = 'none'; + container.style.height = 'calc(100% - 52px)'; + + const controls = createControls(container); + const masterDetailPlugin = new MasterDetailPlugin({ + detailTableOptions: { + columns: [ + { field: 'task', title: 'Detail task', tree: true, width: 260 }, + { field: 'owner', title: 'Owner', width: 180 } + ], + hierarchyExpandLevel: 1, + defaultRowHeight: 34, + defaultHeaderRowHeight: 36, + style: { + margin: [8, 16], + height: 150 + }, + theme: VTable.themes.BRIGHT + } + }); + + const tableInstance = new VTable.ListTable({ + container, + records, + columns: [ + { field: 'orderId', title: 'Order', width: 150 }, + { field: 'customer', title: 'Customer', width: 220 }, + { field: 'status', title: 'Status', width: 120 } + ], + defaultRowHeight: 40, + heightMode: 'standard', + plugins: [masterDetailPlugin] + }); + const search = new SearchComponent({ + table: tableInstance, + autoJump: true, + scrollOption: { + duration: 350 + } + }); + + const runSearch = () => updateResult(controls.result, search.search(controls.input.value)); + controls.searchButton.onclick = runSearch; + controls.previousButton.onclick = () => updateResult(controls.result, search.prev()); + controls.nextButton.onclick = () => updateResult(controls.result, search.next()); + controls.clearButton.onclick = () => { + search.clear(); + controls.result.textContent = 'Cleared'; + }; + controls.input.onkeydown = event => { + if (event.key === 'Enter') { + runSearch(); + } + }; + + window.tableInstance = tableInstance; + (window as any).search = search; + (window as any).masterDetailPlugin = masterDetailPlugin; + + requestAnimationFrame(() => { + if (tableInstance.isReleased) { + return; + } + records.forEach((_record, recordIndex) => { + const row = tableInstance.getTableIndexByRecordIndex(recordIndex); + if (typeof row === 'number' && tableInstance.getHierarchyState(0, row) !== 'expand') { + tableInstance.toggleHierarchyState(0, row); + } + }); + requestAnimationFrame(() => { + if (!tableInstance.isReleased) { + runSearch(); + } + }); + }); +} diff --git a/packages/vtable/examples/main.ts b/packages/vtable/examples/main.ts index 382142cdb2..532938e16f 100644 --- a/packages/vtable/examples/main.ts +++ b/packages/vtable/examples/main.ts @@ -52,6 +52,17 @@ const handleClick = (e: { target: any }, isInit?: boolean) => { localStorage.setItem(LOCAL_STORAGE_KEY, name); } + const issue5036Controls = document.getElementById('issue5036Controls'); + if (issue5036Controls) { + (window as any).search?.clear?.(); + issue5036Controls.remove(); + document.getElementById('input-test')!.style.display = ''; + document.getElementById('textarea-test')!.style.display = ''; + document.getElementById('vTable')!.style.height = '90%'; + (window as any).search = undefined; + (window as any).masterDetailPlugin = undefined; + } + if (window.tableInstance) { window.tableInstance.release(); document.getElementById('vTable').innerHTML = null; @@ -110,7 +121,8 @@ const createSidebar = (node: HTMLDivElement) => { const run = () => { const sidebarNode = document.querySelector('#sidebar')!; - const prevActivePath = localStorage.getItem(LOCAL_STORAGE_KEY); + const prevActivePath = + new URLSearchParams(window.location.search).get('demo') || localStorage.getItem(LOCAL_STORAGE_KEY); createSidebar(sidebarNode); initSidebarEvent(sidebarNode); diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index e8e5c4d953..0376eaecdf 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -78,6 +78,10 @@ export const menus = [ path: 'debug', name: 'issue-4186-nested-field-edit' }, + { + path: 'debug', + name: 'issue-5036-master-detail-search' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/examples/vite.config.js b/packages/vtable/examples/vite.config.js index 4692e061b6..ac4084fc65 100644 --- a/packages/vtable/examples/vite.config.js +++ b/packages/vtable/examples/vite.config.js @@ -15,6 +15,7 @@ module.exports = { alias: { '@visactor/vtable-editors': path.resolve(__dirname, '../../vtable-editors/src/index.ts'), '@visactor/vtable-plugins': path.resolve(__dirname, '../../vtable-plugins/src/index.ts'), + '@visactor/vtable-search': path.resolve(__dirname, '../../vtable-search/src/index.ts'), '@src': path.resolve(__dirname, '../src/'), '@vutils-extension': path.resolve(__dirname, '../src/vutil-extension-temp'), '@visactor/vtable/es': path.resolve(__dirname, '../src/') diff --git a/packages/vtable/src/plugins/custom-cell-style.ts b/packages/vtable/src/plugins/custom-cell-style.ts index 476cc304ee..d8b1085cb3 100644 --- a/packages/vtable/src/plugins/custom-cell-style.ts +++ b/packages/vtable/src/plugins/custom-cell-style.ts @@ -18,12 +18,19 @@ export interface ICustomCellStylePlugin { ): CustomCellStylePlugin; } +type CustomCellStyleOverlay = { + positions: Map; + cells: Map>; +}; + export class CustomCellStylePlugin { table: BaseTableAPI; customCellStyle: CustomCellStyle[]; customCellStyleArrangement: CustomCellStyleArrangement[]; private _customCellStyleArrangementIndex: Map; private _customCellStyleArrangementTombstoneCount: number; + private _customCellStyleOverlays: Map; + private _customCellStyleIdsCache?: Map; constructor( table: BaseTableAPI, @@ -35,6 +42,7 @@ export class CustomCellStylePlugin { this.customCellStyleArrangement = customCellStyleArrangement; this._customCellStyleArrangementIndex = new Map(); this._customCellStyleArrangementTombstoneCount = 0; + this._customCellStyleOverlays = new Map(); this._rebuildCustomCellStyleArrangementIndex(); } @@ -53,11 +61,12 @@ export class CustomCellStylePlugin { this._customCellStyleArrangementIndex.clear(); this._customCellStyleArrangementTombstoneCount = 0; for (let i = 0; i < this.customCellStyleArrangement.length; i++) { - if (!isValid((this.customCellStyleArrangement[i] as any).customStyleId)) { + const arrangement = this.customCellStyleArrangement[i]; + if (!isValid((arrangement as any).customStyleId)) { this._customCellStyleArrangementTombstoneCount++; continue; } - const key = this._getCustomCellStyleArrangementKey(this.customCellStyleArrangement[i].cellPosition); + const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); if (key) { this._customCellStyleArrangementIndex.set(key, i); } @@ -84,12 +93,14 @@ export class CustomCellStylePlugin { clearCustomCellStyleArrangement() { this.customCellStyleArrangement = []; + this._rebuildCustomCellStyleArrangementIndex(); } addCustomCellStyleArrangement( cellPosition: { - col: number; - row: number; + col?: number; + row?: number; + range?: CellRange; }, customStyleId: string | undefined | null ) { @@ -99,6 +110,63 @@ export class CustomCellStylePlugin { }); } + setCustomCellStyleOverlay( + sourceId: string, + cellPosition: { col?: number; row?: number; range?: CellRange }, + customStyleId: string | undefined | null + ) { + const positionKey = this._getCustomCellStyleArrangementKey(cellPosition); + if (!positionKey) { + return; + } + let overlay = this._customCellStyleOverlays.get(sourceId); + if (!overlay) { + overlay = { + positions: new Map(), + cells: new Map() + }; + this._customCellStyleOverlays.set(sourceId, overlay); + } + const previousCellKeys = overlay.positions.get(positionKey) ?? []; + if (!customStyleId) { + previousCellKeys.forEach(cellKey => { + const cellStyles = overlay?.cells.get(cellKey); + cellStyles?.delete(positionKey); + if (cellStyles?.size === 0) { + overlay?.cells.delete(cellKey); + } + }); + overlay.positions.delete(positionKey); + if (overlay.positions.size === 0) { + this._customCellStyleOverlays.delete(sourceId); + } + return; + } + + const range = cellPosition.range ?? { + start: { col: cellPosition.col as number, row: cellPosition.row as number }, + end: { col: cellPosition.col as number, row: cellPosition.row as number } + }; + const cellKeys: string[] = []; + for (let col = range.start.col; col <= range.end.col; col++) { + for (let row = range.start.row; row <= range.end.row; row++) { + const cellKey = `${col}:${row}`; + cellKeys.push(cellKey); + let cellStyles = overlay.cells.get(cellKey); + if (!cellStyles) { + cellStyles = new Map(); + overlay.cells.set(cellKey, cellStyles); + } + cellStyles.set(positionKey, customStyleId); + } + } + overlay.positions.set(positionKey, cellKeys); + } + + clearCustomCellStyleOverlay(sourceId: string) { + this._customCellStyleOverlays.delete(sourceId); + } + getCustomCellStyle(col: number, row: number) { const customStyleIds = this.getCustomCellStyleIds(col, row); if (customStyleIds.length) { @@ -132,15 +200,28 @@ export class CustomCellStylePlugin { } getCustomCellStyleIds(col: number, row: number) { - // let customStyleId; + const range = this.table.getCellRange(col, row); + const rangeKey = `${range.start.col}:${range.start.row}:${range.end.col}:${range.end.row}`; + const cachedStyleIds = this._customCellStyleIdsCache?.get(rangeKey); + if (cachedStyleIds) { + return cachedStyleIds; + } + + const customStyleIds = this._collectCustomCellStyleIds(range); + this._customCellStyleIdsCache?.set(rangeKey, customStyleIds); + return customStyleIds; + } + + private _collectCustomCellStyleIds(range: CellRange) { const customStyleIds: string[] = []; + const seenArrangements = new Set(); + const seenOverlayPositions = new Set(); - const range = this.table.getCellRange(col, row); for (let c = range.start.col; c <= range.end.col; c++) { for (let r = range.start.row; r <= range.end.row; r++) { // eslint-disable-next-line no-loop-func - this.customCellStyleArrangement.forEach(style => { - if (!isValid(style.customStyleId)) { + this.customCellStyleArrangement.forEach((style, index) => { + if (seenArrangements.has(index) || !isValid(style.customStyleId)) { return; } if (style.cellPosition.range) { @@ -150,20 +231,53 @@ export class CustomCellStylePlugin { style.cellPosition.range.start.row <= r && style.cellPosition.range.end.row >= r ) { - // customStyleId = style.customStyleId; + seenArrangements.add(index); customStyleIds.push(style.customStyleId as string); } } else if (style.cellPosition.col === c && style.cellPosition.row === r) { - // customStyleId = style.customStyleId; + seenArrangements.add(index); customStyleIds.push(style.customStyleId as string); } }); + for (const [sourceId, overlay] of this._customCellStyleOverlays) { + const overlayStyles = overlay.cells.get(`${c}:${r}`); + if (overlayStyles) { + for (const [positionKey, customStyleId] of overlayStyles) { + const overlayPositionKey = `${sourceId}:${positionKey}`; + if (!seenOverlayPositions.has(overlayPositionKey)) { + seenOverlayPositions.add(overlayPositionKey); + customStyleIds.push(customStyleId); + } + } + } + } } } return customStyleIds; } + refreshCustomCellStyleRange( + cellPosition: { col?: number; row?: number; range?: CellRange }, + forceFastUpdate: boolean = false + ) { + const range = cellPosition.range ?? { + start: { col: cellPosition.col as number, row: cellPosition.row as number }, + end: { col: cellPosition.col as number, row: cellPosition.row as number } + }; + const previousCache = this._customCellStyleIdsCache; + this._customCellStyleIdsCache = new Map(); + try { + for (let col = Math.max(0, range.start.col); col <= Math.min(this.table.colCount - 1, range.end.col); col++) { + for (let row = Math.max(0, range.start.row); row <= Math.min(this.table.rowCount - 1, range.end.row); row++) { + this.table.scenegraph.updateCellContent(col, row, forceFastUpdate); + } + } + } finally { + this._customCellStyleIdsCache = previousCache; + } + } + getCustomCellStyleOption(customStyleId: string) { return this.customCellStyle.find(style => style.id === customStyleId); } @@ -220,40 +334,8 @@ export class CustomCellStylePlugin { forceFastUpdate?: boolean ) { const inputKey = this._getCustomCellStyleArrangementKey(cellPos); - let index = inputKey ? this._customCellStyleArrangementIndex.get(inputKey) ?? -1 : -1; - if (inputKey && index !== -1) { - const item = this.customCellStyleArrangement[index]; - const itemKey = item ? this._getCustomCellStyleArrangementKey(item.cellPosition) : undefined; - if (!item || !isValid((item as any).customStyleId) || itemKey !== inputKey) { - index = this.customCellStyleArrangement.findIndex(style => { - if (!isValid((style as any).customStyleId)) { - return false; - } - return this._getCustomCellStyleArrangementKey(style.cellPosition) === inputKey; - }); - if (index !== -1) { - this._customCellStyleArrangementIndex.set(inputKey, index); - } else { - this._customCellStyleArrangementIndex.delete(inputKey); - } - } - } - if (index === -1 && !inputKey) { - index = this.customCellStyleArrangement.findIndex(style => { - if (!isValid((style as any).customStyleId)) { - return false; - } - if (style.cellPosition.range && cellPos.range) { - return ( - style.cellPosition.range.start.col === cellPos.range.start.col && - style.cellPosition.range.start.row === cellPos.range.start.row && - style.cellPosition.range.end.col === cellPos.range.end.col && - style.cellPosition.range.end.row === cellPos.range.end.row - ); - } - return style.cellPosition.col === cellPos.col && style.cellPosition.row === cellPos.row; - }); - } + this._rebuildCustomCellStyleArrangementIndex(); + const index = inputKey ? this._customCellStyleArrangementIndex.get(inputKey) ?? -1 : -1; if (index === -1 && !customStyleId) { // do nothing @@ -283,14 +365,8 @@ export class CustomCellStylePlugin { this.customCellStyleArrangement[index].customStyleId = customStyleId; } else { // delete useless style - const existedKey = this._getCustomCellStyleArrangementKey(this.customCellStyleArrangement[index].cellPosition); - if (isValid((this.customCellStyleArrangement[index] as any).customStyleId)) { - this._customCellStyleArrangementTombstoneCount++; - } (this.customCellStyleArrangement[index] as any).customStyleId = null; - if (existedKey) { - this._customCellStyleArrangementIndex.delete(existedKey); - } + this._rebuildCustomCellStyleArrangementIndex(); this._compactCustomCellStyleArrangementIfNeeded(); } @@ -335,16 +411,59 @@ export class CustomCellStylePlugin { } updateCustomCell(customCellStyle: CustomCellStyle[], customCellStyleArrangement: CustomCellStyleArrangement[]) { + const positionsToRefresh = new Map(); + this.customCellStyleArrangement.forEach(arrangement => { + const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); + if (key) { + positionsToRefresh.set(key, arrangement.cellPosition); + } + }); + this.customCellStyle.length = 0; - this.customCellStyleArrangement.length = 0; - this._customCellStyleArrangementIndex.clear(); - this._customCellStyleArrangementTombstoneCount = 0; - customCellStyle.forEach((cellStyle: CustomCellStyle) => { - this.registerCustomCellStyle(cellStyle.id, cellStyle.style); + const styleIndexes = new Map(); + customCellStyle.forEach(cellStyle => { + const index = styleIndexes.get(cellStyle.id); + const nextCellStyle = { + id: cellStyle.id, + style: cellStyle.style + }; + if (index === undefined) { + styleIndexes.set(cellStyle.id, this.customCellStyle.length); + this.customCellStyle.push(nextCellStyle); + } else { + this.customCellStyle[index] = nextCellStyle; + } }); - customCellStyleArrangement.forEach((cellStyle: CustomCellStyleArrangement) => { - this.arrangeCustomCellStyle(cellStyle.cellPosition, cellStyle.customStyleId); + + this.customCellStyleArrangement.length = 0; + const arrangementIndexes = new Map(); + customCellStyleArrangement.forEach(arrangement => { + const nextArrangement = { + cellPosition: { + col: arrangement.cellPosition.col, + row: arrangement.cellPosition.row, + range: arrangement.cellPosition.range + }, + customStyleId: arrangement.customStyleId + }; + const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); + if (key) { + const index = arrangementIndexes.get(key); + if (index === undefined) { + arrangementIndexes.set(key, this.customCellStyleArrangement.length); + this.customCellStyleArrangement.push(nextArrangement); + } else { + this.customCellStyleArrangement[index] = nextArrangement; + } + positionsToRefresh.set(key, arrangement.cellPosition); + } else { + this.customCellStyleArrangement.push(nextArrangement); + } }); + this._rebuildCustomCellStyleArrangementIndex(); + + positionsToRefresh.forEach(position => this.refreshCustomCellStyleRange(position)); + this.table.scenegraph.updateNextFrame(); } hasCustomCellStyle(customStyleId: string) {