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/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..c5ecc029eb --- /dev/null +++ b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts @@ -0,0 +1,105 @@ +/* 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.clearCustomCellStyleArrangement).toHaveBeenCalled(); + expect(detail.arrangements).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..c81c6cefb1 --- /dev/null +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -0,0 +1,220 @@ +/* eslint-env jest */ +/* eslint-disable no-undef */ +// @ts-nocheck + +import { SearchComponent } from '../src'; + +function createCellTable( + values: string[][], + options: { + columns?: any[]; + records?: any[]; + visibleRows?: { rowStart: number; rowEnd: number }; + initialArrangements?: { col: number; row: number; style: string }[]; + columnHeaderLevelCount?: number; + } = {} +) { + const arrangements = (options.initialArrangements || []).map(item => ({ + cellPosition: { col: item.col, row: item.row }, + customStyleId: item.style + })); + const arrangementIndex = new Map( + arrangements.map((item, index) => [`${item.cellPosition.col}:${item.cellPosition.row}`, index]) + ); + 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) => { + arrangementIndex.set(`${item.cellPosition.col}:${item.cellPosition.row}`, index); + }); + }) + }; + const table = { + options: { + columns: options.columns || [{ field: 'name' }] + }, + 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])), + getCellRange: jest.fn((col, row) => ({ + start: { col, row }, + end: { col, row } + })), + registerCustomCellStyle: jest.fn(), + hasCustomCellStyle: jest.fn(() => true), + arrangeCustomCellStyle: jest.fn((position, style) => { + if (style) { + const cellPosition = position.range || position; + const key = `${cellPosition.col}:${cellPosition.row}`; + const index = arrangementIndex.get(key); + if (index === undefined) { + customCellStylePlugin.customCellStyleArrangement.push({ + cellPosition, + customStyleId: style + }); + 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(() => ({ colStart: 0, colEnd: values[0]?.length ?? 1 })), + scrollToCell: jest.fn() + }; + + return { table, customCellStylePlugin }; +} + +function createTreeTable() { + const records = [{ name: 'Main' }]; + const main = createCellTable([], { + columns: [{ field: 'name', tree: true }], + records, + visibleRows: { rowStart: 1, rowEnd: 3 } + }); + 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' }, + { cellPosition: { col: 0, row: 1 }, 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'); + detail.table.isReleased = true; + detail.table.scenegraph = null; + main.table.internalProps.subTableInstances.clear(); + + expect(() => search.clear()).not.toThrow(); + 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' + } + ]); +}); + +test('detail result navigation scrolls the master row into view first', () => { + const main = createCellTable([['Parent']], { + visibleRows: { rowStart: 1, rowEnd: 2 } + }); + const detail = createCellTable([['Widget']]); + 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({ col: 0, row: 6 }); + expect(detail.table.scrollToCell).toHaveBeenCalled(); +}); diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index 1f9ccde422..ceaf6c0ef9 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -38,6 +38,11 @@ export type SearchComponentOption = { const HighlightStyleId = '__search_component_highlight'; const FocusHighlightStyleId = '__search_component_focus'; +type SearchCellPosition = + | { col: number; row: number } + | { + range: VTable.TYPES.CellRange; + }; const defaultHighlightCellStyle: Partial = { bgColor: 'rgba(255, 255, 0, 0.2)' @@ -86,6 +91,8 @@ export class SearchComponent { isTree: boolean; treeIndex: number; scrollOption: ITableAnimationOption; + private resultTableMap = new WeakMap(); + private resultTreeMap = new WeakMap(); constructor(option: SearchComponentOption) { this.table = option.table; @@ -108,6 +115,238 @@ export class SearchComponent { this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } + private getSearchTables(): IVTable[] { + const tables: IVTable[] = this.isTableAvailable(this.table) ? [this.table] : []; + const subTableInstances = (this.table as any).internalProps?.subTableInstances; + if (subTableInstances && typeof subTableInstances.forEach === 'function') { + subTableInstances.forEach((subTable: IVTable) => { + if (subTable && subTable !== this.table && this.isTableAvailable(subTable)) { + tables.push(subTable); + } + }); + } + return tables; + } + + private isTableAvailable(table: IVTable | undefined): table is IVTable { + return !!table && !(table as any).isReleased && !!(table as any).scenegraph; + } + + private getResultTable(resultItem: typeof this.queryResult[number]): IVTable | undefined { + const table = this.resultTableMap.get(resultItem as object); + if (table) { + return this.isTableAvailable(table) ? table : undefined; + } + return this.isTableAvailable(this.table) ? this.table : undefined; + } + + 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.queryResult?.forEach(resultItem => { + const table = this.resultTableMap.get(resultItem as object); + if (table && activeTables.has(table) && this.isTableAvailable(table)) { + tables.add(table); + } + }); + return Array.from(tables); + } + + private pruneUnavailableResults(): void { + if (!this.queryResult?.length) { + return; + } + const activeTables = new Set(this.getSearchTables()); + activeTables.add(this.table); + const availableResults = this.queryResult.filter(resultItem => { + const table = this.resultTableMap.get(resultItem as object) || this.table; + return activeTables.has(table) && this.isTableAvailable(table); + }); + if (availableResults.length === this.queryResult.length) { + return; + } + this.queryResult = availableResults; + if (!this.queryResult.length) { + this.currentIndex = -1; + } else if (this.currentIndex >= this.queryResult.length) { + this.currentIndex = this.queryResult.length - 1; + } + } + + private isTreeResult(resultItem: typeof this.queryResult[number]): boolean { + return this.resultTreeMap.get(resultItem as object) ?? Array.isArray(resultItem.indexNumber); + } + + private addQueryResult(resultItem: typeof this.queryResult[number], table: IVTable, isTree = false): void { + this.queryResult.push(resultItem); + this.resultTableMap.set(resultItem as object, table); + this.resultTreeMap.set(resultItem as object, isTree); + } + + 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; + } + 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 rebuildCustomCellStyleArrangement(plugin: any, arrangements: any[]): void { + if (!plugin) { + return; + } + const currentArrangements = plugin.customCellStyleArrangement; + if (Array.isArray(currentArrangements)) { + currentArrangements.length = 0; + currentArrangements.push(...arrangements); + } else { + plugin.customCellStyleArrangement = arrangements; + } + const rebuildIndex = plugin._rebuildCustomCellStyleArrangementIndex; + if (typeof rebuildIndex === 'function') { + rebuildIndex.call(plugin); + } + } + + 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; + } + const searchStyleIds = new Set([HighlightStyleId, FocusHighlightStyleId]); + const arrangements = Array.from(plugin.customCellStyleArrangement || []); + const positionKey = this.getCellPositionKey(position); + const retainedArrangements = arrangements.filter((item: any) => { + return !(searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item?.cellPosition) === positionKey); + }); + if (customStyleId) { + retainedArrangements.push({ + cellPosition: position, + customStyleId + }); + } + this.rebuildCustomCellStyleArrangement(plugin, retainedArrangements); + this.refreshCellStyle(table, position); + table.scenegraph.updateNextFrame(); + } + + private searchTable(table: IVTable): void { + for (let row = 0; row < table.rowCount; row++) { + for (let col = 0; col < table.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 })) { + const mergeCell = table.getCellRange(col, row); + if (mergeCell.start.col !== mergeCell.end.col || mergeCell.start.row !== mergeCell.end.row) { + let isIn = false; + for (let i = this.queryResult.length - 1; i >= 0; i--) { + const resultTable = this.getResultTable(this.queryResult[i]); + if ( + resultTable === table && + !this.isTreeResult(this.queryResult[i]) && + this.queryResult[i].col === mergeCell.start.col && + this.queryResult[i].row === mergeCell.start.row + ) { + isIn = true; + break; + } + } + if (!isIn) { + this.addQueryResult( + { + col: mergeCell.start.col, + row: mergeCell.start.row, + range: mergeCell, + value + }, + table + ); + } + } else { + this.addQueryResult( + { + col, + row, + value + }, + table + ); + } + } + } + } + } + private getHeaderOffset(): number { let offset = 0; while (this.table.isHeader(0, offset)) { @@ -149,25 +388,37 @@ export class SearchComponent { }; } - private clearRenderedCellStyles() { - const plugin = this.table.customCellStylePlugin; - const cellsToRefresh: { col: number; row: number }[] = []; + private clearRenderedCellStyles(targetTable: IVTable = this.table) { + const plugin = (targetTable as any).customCellStylePlugin; + if (!plugin || !this.isTableAvailable(targetTable)) { + return; + } + const positionsToRefresh = new Map(); const arrangements = Array.from((plugin as any)?.customCellStyleArrangement || []); + const searchStyleIds = new Set([HighlightStyleId, FocusHighlightStyleId]); + const searchArrangements = arrangements.filter((item: any) => searchStyleIds.has(item?.customStyleId)); - arrangements.forEach((item: any) => { + if (!searchArrangements.length) { + return; + } + + searchArrangements.forEach((item: any) => { const cellPosition = item?.cellPosition; - if (typeof cellPosition?.col === 'number' && typeof cellPosition?.row === 'number') { - cellsToRefresh.push({ - col: cellPosition.col, - row: cellPosition.row - }); + const key = this.getCellPositionKey(cellPosition); + if (key) { + positionsToRefresh.set(key, cellPosition); } }); - plugin.clearCustomCellStyleArrangement(); - cellsToRefresh.forEach(({ col, row }) => { - this.table.scenegraph.updateCellContent(col, row, true); - }); + const retainedArrangements = arrangements.filter((item: any) => !searchStyleIds.has(item?.customStyleId)); + if (retainedArrangements.length === 0) { + plugin.clearCustomCellStyleArrangement(); + this.rebuildCustomCellStyleArrangement(plugin, []); + } else { + this.rebuildCustomCellStyleArrangement(plugin, retainedArrangements); + } + + positionsToRefresh.forEach(position => this.refreshCellStyle(targetTable, position)); } search(str: string) { @@ -205,11 +456,15 @@ export class SearchComponent { // 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) - }); + this.addQueryResult( + { + indexNumber: currentPath, + col, + value: value?.toString?.() ?? String(value) + }, + this.table, + true + ); } }); @@ -219,10 +474,14 @@ export class SearchComponent { this.treeQueryMethod && this.treeQueryMethod(this.queryStr, item, this.fieldsToSearch, { table: this.table }) ) { - this.queryResult.push({ - indexNumber: currentPath, - col: treeCol - }); + this.addQueryResult( + { + indexNumber: currentPath, + col: treeCol + }, + this.table, + true + ); } if (item.children && Array.isArray(item.children) && item.children.length > 0) { @@ -235,6 +494,9 @@ export class SearchComponent { // 同一节点同一列可能被多次命中(例如 fieldsToSearch 未限制且字段值重复),做一次简单去重 const dedup = new Set(); this.queryResult = this.queryResult.filter(r => { + if (!this.isTreeResult(r)) { + return true; + } const key = `${(r.indexNumber || []).join('.')}:${r.col ?? ''}`; if (dedup.has(key)) { return false; @@ -243,9 +505,13 @@ export class SearchComponent { return true; }); - this.currentIndex = this.queryResult.length > 0 ? 0 : -1; + this.getSearchTables() + .filter(table => table !== this.table) + .forEach(table => this.searchTable(table)); - if (this.queryResult.length > 0) { + this.currentIndex = this.queryResult.length > 0 && this.isTreeResult(this.queryResult[0]) ? 0 : -1; + + if (this.currentIndex === 0) { this.jumpToCell({ IndexNumber: this.queryResult[0].indexNumber, col: this.queryResult[0].col ?? treeCol }); } @@ -260,47 +526,16 @@ 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 - }); - } - } - } - } + this.getSearchTables().forEach(table => this.searchTable(table)); this.updateCellStyle(); if (this.callback) { @@ -333,119 +568,99 @@ 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 => { + this.clearRenderedCellStyles(table); + 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(); - - if (this.isTree) { - if (!this.queryResult.length) { - this.table.scenegraph.updateNextFrame(); - return; + 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); } + this.clearRenderedCellStyles(table); + }); - // 先为所有命中节点打普通高亮 - 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); + 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) { + continue; } + table.customCellStylePlugin.addCustomCellStyleArrangement(position as any, HighlightStyleId); + this.refreshCellStyle(table, position); + } - // 再为当前索引打焦点高亮 - 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.customCellStylePlugin.addCustomCellStyleArrangement(position as any, FocusHighlightStyleId); + this.refreshCellStyle(table, position); } + } + resultTables.forEach(table => { + this.rebuildCustomCellStyleArrangement( + (table as any).customCellStylePlugin, + Array.from((table as any).customCellStylePlugin?.customCellStyleArrangement || []) + ); + table.scenegraph.updateNextFrame(); + }); + } - this.table.scenegraph.updateNextFrame(); + private jumpToResult(resultItem: typeof this.queryResult[number]): void { + if (this.isTreeResult(resultItem)) { + this.jumpToCell({ IndexNumber: resultItem.indexNumber, col: resultItem.col }); } 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); + const table = this.getResultTable(resultItem); + if (table) { + this.jumpToCell({ col: resultItem.col, row: resultItem.row }, table); } - this.table.scenegraph.updateNextFrame(); } } next() { + this.pruneUnavailableResults(); 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 }); + 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]; + + if (this.isTreeResult(currentResult) || (previousResult && this.isTreeResult(previousResult))) { + this.jumpToResult(currentResult); this.updateCellStyle(); } else { - if (this.currentIndex !== -1) { + if (previousResult) { // reset last focus - this.arrangeCustomCellStyle(this.queryResult[this.currentIndex]); + this.arrangeCustomCellStyle(previousResult); } - 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); - - this.jumpToCell({ col, row }); + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); + this.jumpToResult(currentResult); } return { @@ -455,6 +670,7 @@ export class SearchComponent { } prev() { + this.pruneUnavailableResults(); if (!this.queryResult.length) { return { index: 0, @@ -462,29 +678,23 @@ export class SearchComponent { }; } - if (this.isTree) { - 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 { indexNumber, col } = this.queryResult[this.currentIndex]; - this.jumpToCell({ IndexNumber: indexNumber, col }); + if (this.isTreeResult(currentResult) || (previousResult && this.isTreeResult(previousResult))) { + this.jumpToResult(currentResult); 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; + if (previousResult) { + this.arrangeCustomCellStyle(previousResult); } - - const { col, row } = this.queryResult[this.currentIndex]; - this.arrangeCustomCellStyle(this.queryResult[this.currentIndex], true, FocusHighlightStyleId); - this.jumpToCell({ col, row }); + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); + this.jumpToResult(currentResult); } return { @@ -493,8 +703,22 @@ export class SearchComponent { }; } - jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }) { - if (this.isTree) { + 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; + } + + jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }, targetTable: IVTable = this.table) { + if (Array.isArray(params.IndexNumber)) { const { IndexNumber } = params; const indexNumbers = [...IndexNumber]; @@ -531,20 +755,31 @@ export class SearchComponent { } } else { const { col, row } = params; - const { rowStart, rowEnd } = this.table.getBodyVisibleRowRange(); - const { colStart, colEnd } = this.table.getBodyVisibleColRange(); + if (targetTable !== this.table) { + const bodyRowIndex = this.getSubTableBodyRowIndex(targetTable); + if (bodyRowIndex !== undefined) { + const parentRow = bodyRowIndex + ((this.table as any).columnHeaderLevelCount || 0); + const { rowStart, rowEnd } = this.table.getBodyVisibleRowRange(); + const isParentRowVisible = parentRow >= rowStart && parentRow <= rowEnd; + if (!isParentRowVisible) { + this.table.scrollToCell({ col: 0, row: parentRow }); + } + } + } + const { rowStart, rowEnd } = targetTable.getBodyVisibleRowRange(); + const { colStart, colEnd } = targetTable.getBodyVisibleColRange(); // 检查单元格是否在表格可视范围内 const isInTableView = !(row <= rowStart || row >= rowEnd || col <= colStart || col >= colEnd); // 根据配置决定是否滚动表格 if (!isInTableView) { - this.table.scrollToCell({ col, row }); + targetTable.scrollToCell({ col, row }); } // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { - scrollVTableCellIntoView(this.table, { row, col }); + scrollVTableCellIntoView(targetTable, { row, col }); } } } @@ -559,6 +794,8 @@ export class SearchComponent { this.updateCellStyle(false); this.queryStr = ''; this.queryResult = []; + this.resultTableMap = new WeakMap(); + this.resultTreeMap = new WeakMap(); this.currentIndex = -1; } }