From 42f1e9512448f02c775cefae3d72484db202e1a4 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Sat, 29 Aug 2026 10:10:28 +0800 Subject: [PATCH 1/6] fix(vtable-search): search expanded master-detail tables (#5036) --- .../master-detail-search-highlight.test.ts | 96 +++++++++ .../src/search-component/search-component.ts | 185 ++++++++++++------ 2 files changed, 217 insertions(+), 64 deletions(-) create mode 100644 packages/vtable-search/__tests__/master-detail-search-highlight.test.ts 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..dea70b1af9 --- /dev/null +++ b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts @@ -0,0 +1,96 @@ +/* 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 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 }); + } + }), + customCellStylePlugin: { + customCellStyleArrangement: arrangements, + addCustomCellStyleArrangement: jest.fn((position: { col: number; row: number }, style: string) => { + arrangements.push({ col: position.col, row: position.row, style }); + }), + clearCustomCellStyleArrangement: jest.fn(() => { + arrangements.splice(0, arrangements.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.arrangeCustomCellStyle).toHaveBeenCalledWith({ col: 0, row: 1 }, '__search_component_focus'); + + search.clear(); + + expect(detail.table.customCellStylePlugin.clearCustomCellStyleArrangement).toHaveBeenCalled(); + expect(detail.arrangements).toHaveLength(0); +}); diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index 1f9ccde422..2b9f55eb24 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -86,6 +86,7 @@ export class SearchComponent { isTree: boolean; treeIndex: number; scrollOption: ITableAnimationOption; + private resultTableMap = new WeakMap(); constructor(option: SearchComponentOption) { this.table = option.table; @@ -108,6 +109,34 @@ export class SearchComponent { this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } + private getSearchTables(): IVTable[] { + const tables: IVTable[] = [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 && !(subTable as any).isReleased) { + tables.push(subTable); + } + }); + } + return tables; + } + + private getResultTable(resultItem: typeof this.queryResult[number]): IVTable { + return this.resultTableMap.get(resultItem as object) || this.table; + } + + private getResultTables(): IVTable[] { + const tables = new Set(this.getSearchTables()); + this.queryResult?.forEach(resultItem => tables.add(this.getResultTable(resultItem))); + return Array.from(tables); + } + + private addQueryResult(resultItem: typeof this.queryResult[number], table: IVTable): void { + this.queryResult.push(resultItem); + this.resultTableMap.set(resultItem as object, table); + } + private getHeaderOffset(): number { let offset = 0; while (this.table.isHeader(0, offset)) { @@ -149,8 +178,11 @@ export class SearchComponent { }; } - private clearRenderedCellStyles() { - const plugin = this.table.customCellStylePlugin; + private clearRenderedCellStyles(targetTable: IVTable = this.table) { + const plugin = (targetTable as any).customCellStylePlugin; + if (!plugin) { + return; + } const cellsToRefresh: { col: number; row: number }[] = []; const arrangements = Array.from((plugin as any)?.customCellStyleArrangement || []); @@ -166,7 +198,7 @@ export class SearchComponent { plugin.clearCustomCellStyleArrangement(); cellsToRefresh.forEach(({ col, row }) => { - this.table.scenegraph.updateCellContent(col, row, true); + targetTable.scenegraph.updateCellContent(col, row, true); }); } @@ -205,11 +237,14 @@ 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 + ); } }); @@ -219,10 +254,13 @@ 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 + ); } if (item.children && Array.isArray(item.children) && item.children.length > 0) { @@ -265,42 +303,55 @@ export class SearchComponent { 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; + this.getSearchTables().forEach(table => { + 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 })) { + // deal merge cell + const mergeCell = 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--) { + const resultTable = this.getResultTable(this.queryResult[i]); + if ( + resultTable === table && + 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 + ); } - if (!isIn) { - this.queryResult.push({ - col: mergeCell.start.col, - row: mergeCell.start.row, - range: mergeCell, - value - }); - } - } else { - this.queryResult.push({ - col, - row, - value - }); } } } - } + }); this.updateCellStyle(); if (this.callback) { @@ -334,7 +385,7 @@ export class SearchComponent { customStyleId: string = HighlightStyleId ) { const { col, row, range } = resultItem; - this.table.arrangeCustomCellStyle( + this.getResultTable(resultItem).arrangeCustomCellStyle( range ? { range } : { @@ -347,22 +398,26 @@ export class SearchComponent { updateCellStyle(highlight: boolean = true) { 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(); + 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); + }); if (this.isTree) { if (!this.queryResult.length) { @@ -404,16 +459,17 @@ export class SearchComponent { this.table.scenegraph.updateNextFrame(); } else { for (let i = 0; i < this.queryResult.length; i++) { - this.table.customCellStylePlugin.addCustomCellStyleArrangement( + const table = this.getResultTable(this.queryResult[i]); + 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); + table.scenegraph.updateCellContent(this.queryResult[i].col, this.queryResult[i].row, true); } - this.table.scenegraph.updateNextFrame(); + resultTables.forEach(table => table.scenegraph.updateNextFrame()); } } @@ -445,7 +501,7 @@ export class SearchComponent { this.arrangeCustomCellStyle(this.queryResult[this.currentIndex], true, FocusHighlightStyleId); - this.jumpToCell({ col, row }); + this.jumpToCell({ col, row }, this.getResultTable(this.queryResult[this.currentIndex])); } return { @@ -484,7 +540,7 @@ export class SearchComponent { const { col, row } = this.queryResult[this.currentIndex]; this.arrangeCustomCellStyle(this.queryResult[this.currentIndex], true, FocusHighlightStyleId); - this.jumpToCell({ col, row }); + this.jumpToCell({ col, row }, this.getResultTable(this.queryResult[this.currentIndex])); } return { @@ -493,7 +549,7 @@ export class SearchComponent { }; } - jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }) { + jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }, targetTable: IVTable = this.table) { if (this.isTree) { const { IndexNumber } = params; const indexNumbers = [...IndexNumber]; @@ -531,20 +587,20 @@ export class SearchComponent { } } else { const { col, row } = params; - const { rowStart, rowEnd } = this.table.getBodyVisibleRowRange(); - const { colStart, colEnd } = this.table.getBodyVisibleColRange(); + 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 +615,7 @@ export class SearchComponent { this.updateCellStyle(false); this.queryStr = ''; this.queryResult = []; + this.resultTableMap = new WeakMap(); this.currentIndex = -1; } } From 7c0210c0e2a1ad45a645823e5983f79753a8974d Mon Sep 17 00:00:00 2001 From: biubiukam Date: Sat, 29 Aug 2026 10:12:57 +0800 Subject: [PATCH 2/6] docs: update changlog of rush --- ...ue-5036-master-detail-search_2026-08-29-02-12.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@visactor/vtable-search/fix-issue-5036-master-detail-search_2026-08-29-02-12.json 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" +} From 5e44de6922e3148a99d7c3ebbc99b32365b4acc3 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Mon, 31 Aug 2026 22:26:45 +0800 Subject: [PATCH 3/6] fix(vtable-search): preserve custom styles during navigation --- .../master-detail-search-highlight.test.ts | 13 +- .../__tests__/review-regressions.test.ts | 220 ++++++++ .../src/search-component/search-component.ts | 510 ++++++++++++------ 3 files changed, 576 insertions(+), 167 deletions(-) create mode 100644 packages/vtable-search/__tests__/review-regressions.test.ts diff --git a/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts index dea70b1af9..c5ecc029eb 100644 --- a/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts +++ b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts @@ -6,6 +6,7 @@ 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' }] @@ -24,15 +25,18 @@ function createTable(values: string[][]) { 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: arrangements, + 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: { @@ -87,7 +91,12 @@ test('focus navigation and clear operate on the matching detail table', () => { search.next(); search.next(); - expect(detail.table.arrangeCustomCellStyle).toHaveBeenCalledWith({ col: 0, row: 1 }, '__search_component_focus'); + expect(detail.table.customCellStylePlugin.customCellStyleArrangement).toEqual([ + { + cellPosition: { col: 0, row: 1 }, + customStyleId: '__search_component_focus' + } + ]); search.clear(); 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 2b9f55eb24..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)' @@ -87,6 +92,7 @@ export class SearchComponent { treeIndex: number; scrollOption: ITableAnimationOption; private resultTableMap = new WeakMap(); + private resultTreeMap = new WeakMap(); constructor(option: SearchComponentOption) { this.table = option.table; @@ -110,11 +116,11 @@ export class SearchComponent { } private getSearchTables(): IVTable[] { - const tables: IVTable[] = [this.table]; + 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 && !(subTable as any).isReleased) { + if (subTable && subTable !== this.table && this.isTableAvailable(subTable)) { tables.push(subTable); } }); @@ -122,19 +128,223 @@ export class SearchComponent { return tables; } - private getResultTable(resultItem: typeof this.queryResult[number]): IVTable { - return this.resultTableMap.get(resultItem as object) || this.table; + 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 tables = new Set(this.getSearchTables()); - this.queryResult?.forEach(resultItem => tables.add(this.getResultTable(resultItem))); + 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 addQueryResult(resultItem: typeof this.queryResult[number], table: IVTable): void { + 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 { @@ -180,26 +390,35 @@ export class SearchComponent { private clearRenderedCellStyles(targetTable: IVTable = this.table) { const plugin = (targetTable as any).customCellStylePlugin; - if (!plugin) { + if (!plugin || !this.isTableAvailable(targetTable)) { return; } - const cellsToRefresh: { col: number; row: number }[] = []; + 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 }) => { - targetTable.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) { @@ -243,7 +462,8 @@ export class SearchComponent { col, value: value?.toString?.() ?? String(value) }, - this.table + this.table, + true ); } }); @@ -259,7 +479,8 @@ export class SearchComponent { indexNumber: currentPath, col: treeCol }, - this.table + this.table, + true ); } @@ -273,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; @@ -281,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)); + + this.currentIndex = this.queryResult.length > 0 && this.isTreeResult(this.queryResult[0]) ? 0 : -1; - if (this.queryResult.length > 0) { + if (this.currentIndex === 0) { this.jumpToCell({ IndexNumber: this.queryResult[0].indexNumber, col: this.queryResult[0].col ?? treeCol }); } @@ -298,60 +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 }; } - this.getSearchTables().forEach(table => { - 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 })) { - // deal merge cell - const mergeCell = 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--) { - const resultTable = this.getResultTable(this.queryResult[i]); - if ( - resultTable === table && - 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 - ); - } - } - } - } - }); + this.getSearchTables().forEach(table => this.searchTable(table)); this.updateCellStyle(); if (this.callback) { @@ -384,19 +568,11 @@ export class SearchComponent { highlight: boolean = true, customStyleId: string = HighlightStyleId ) { - const { col, row, range } = resultItem; - this.getResultTable(resultItem).arrangeCustomCellStyle( - range - ? { range } - : { - row, - col - }, - highlight ? customStyleId : null - ); + this.setSearchCellStyle(resultItem, highlight ? customStyleId : undefined); } updateCellStyle(highlight: boolean = true) { + this.pruneUnavailableResults(); if (!highlight) { this.getResultTables().forEach(table => { this.clearRenderedCellStyles(table); @@ -419,89 +595,72 @@ export class SearchComponent { this.clearRenderedCellStyles(table); }); - if (this.isTree) { - if (!this.queryResult.length) { - this.table.scenegraph.updateNextFrame(); - return; - } - - // 先为所有命中节点打普通高亮 - 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++) { - const table = this.getResultTable(this.queryResult[i]); - table.customCellStylePlugin.addCustomCellStyleArrangement( - { - col: this.queryResult[i].col, - row: this.queryResult[i].row - }, - HighlightStyleId - ); - 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); } - resultTables.forEach(table => 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.getResultTable(this.queryResult[this.currentIndex])); + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); + this.jumpToResult(currentResult); } return { @@ -511,6 +670,7 @@ export class SearchComponent { } prev() { + this.pruneUnavailableResults(); if (!this.queryResult.length) { return { index: 0, @@ -518,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.getResultTable(this.queryResult[this.currentIndex])); + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); + this.jumpToResult(currentResult); } return { @@ -549,8 +703,22 @@ export class SearchComponent { }; } + 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 (this.isTree) { + if (Array.isArray(params.IndexNumber)) { const { IndexNumber } = params; const indexNumbers = [...IndexNumber]; @@ -587,6 +755,17 @@ export class SearchComponent { } } else { const { col, row } = params; + 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(); @@ -616,6 +795,7 @@ export class SearchComponent { this.queryStr = ''; this.queryResult = []; this.resultTableMap = new WeakMap(); + this.resultTreeMap = new WeakMap(); this.currentIndex = -1; } } From 2310957041d9a6f1e3b4f578e774e65940488d8e Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 16:30:11 +0800 Subject: [PATCH 4/6] fix(vtable-search): address master-detail review feedback --- .../__tests__/review-regressions.test.ts | 497 ++++++++- .../src/search-component/search-component.ts | 965 ++++++++++++------ 2 files changed, 1153 insertions(+), 309 deletions(-) diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index c81c6cefb1..9c7e1590dd 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -10,16 +10,40 @@ function createCellTable( 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'; + viewBox?: { x1: number; y1: number; x2: number; y2: number }; + tableNoFrameHeight?: 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) => [`${item.cellPosition.col}:${item.cellPosition.row}`, index]) + arrangements + .map((item, index) => [getArrangementKey(item.cellPosition), index]) + .filter(([key]) => key !== undefined) ); const customCellStylePlugin = { customCellStyleArrangement: arrangements, @@ -32,21 +56,26 @@ function createCellTable( _rebuildCustomCellStyleArrangementIndex: jest.fn(() => { arrangementIndex.clear(); customCellStylePlugin.customCellStyleArrangement.forEach((item, index) => { - arrangementIndex.set(`${item.cellPosition.col}:${item.cellPosition.row}`, index); + if (!item.customStyleId) { + return; + } + const key = getArrangementKey(item.cellPosition); + if (key) { + arrangementIndex.set(key, index); + } }); }) }; const table = { - options: { - columns: options.columns || [{ field: 'name' }] - }, + 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])), + getCellValue: jest.fn((col, row) => (row === 0 ? 'Name' : values[row - 1]?.[col])), getCellRange: jest.fn((col, row) => ({ start: { col, row }, end: { col, row } @@ -55,15 +84,16 @@ function createCellTable( hasCustomCellStyle: jest.fn(() => true), arrangeCustomCellStyle: jest.fn((position, style) => { if (style) { - const cellPosition = position.range || position; - const key = `${cellPosition.col}:${cellPosition.row}`; + const key = getArrangementKey(position); const index = arrangementIndex.get(key); if (index === undefined) { customCellStylePlugin.customCellStyleArrangement.push({ - cellPosition, + cellPosition: position, customStyleId: style }); - arrangementIndex.set(key, customCellStylePlugin.customCellStyleArrangement.length - 1); + if (key) { + arrangementIndex.set(key, customCellStylePlugin.customCellStyleArrangement.length - 1); + } } else { customCellStylePlugin.customCellStyleArrangement[index].customStyleId = style; } @@ -75,10 +105,49 @@ function createCellTable( updateNextFrame: jest.fn() }, getBodyVisibleRowRange: jest.fn(() => options.visibleRows || { rowStart: 1, rowEnd: values.length + 1 }), - getBodyVisibleColRange: jest.fn(() => ({ colStart: 0, colEnd: values[0]?.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, + scrollTop: 0, + tableY: 0, + options: { + columns: options.columns || [{ field: 'name' }], + viewBox: options.viewBox + }, 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 }; } @@ -87,7 +156,8 @@ function createTreeTable() { const main = createCellTable([], { columns: [{ field: 'name', tree: true }], records, - visibleRows: { rowStart: 1, rowEnd: 3 } + visibleRows: { rowStart: 1, rowEnd: 3 }, + rowHierarchyType: 'tree' }); main.table.rowCount = 2; main.table.colCount = 1; @@ -118,7 +188,7 @@ test('clear keeps custom styles that do not belong to search', () => { expect(main.customCellStylePlugin.customCellStyleArrangement).toEqual( expect.arrayContaining([ { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-style' }, - { cellPosition: { col: 0, row: 1 }, customStyleId: '__search_component_highlight' } + expect.objectContaining({ customStyleId: '__search_component_highlight' }) ]) ); @@ -208,13 +278,410 @@ test('detail result navigation scrolls the master row into view first', () => { const main = createCellTable([['Parent']], { visibleRows: { rowStart: 1, rowEnd: 2 } }); - const detail = createCellTable([['Widget']]); + 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({ col: 0, row: 6 }); + expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 6 }); + expect(detail.table.scrollToCell).toHaveBeenCalled(); +}); + +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('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 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('navigation does not rebuild the custom style index for search entries', () => { + const main = createCellTable([['Alice', 'Alina']]); + main.table.colCount = 2; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('Ali'); + const rebuildIndex = main.customCellStylePlugin._rebuildCustomCellStyleArrangementIndex; + rebuildIndex.mockClear(); + + search.next(); + search.next(); + + expect(rebuildIndex).not.toHaveBeenCalled(); +}); + +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 filter the entire result list', () => { + 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 filterSpy = jest.spyOn(Array.prototype, 'filter'); + search.next(); + search.next(); + search.prev(); + + expect(filterSpy).not.toHaveBeenCalled(); + filterSpy.mockRestore(); +}); diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index ceaf6c0ef9..eb1115f336 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,12 +47,18 @@ export type SearchComponentOption = { const HighlightStyleId = '__search_component_highlight'; const FocusHighlightStyleId = '__search_component_focus'; +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)' }; @@ -79,13 +94,7 @@ 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; @@ -93,6 +102,11 @@ export class SearchComponent { scrollOption: ITableAnimationOption; private resultTableMap = new WeakMap(); private resultTreeMap = new WeakMap(); + private resultParentRowMap = new WeakMap(); + private resultTables = new Set(); + private tableIdMap = new WeakMap(); + private searchStyleArrangementMap = new WeakMap>(); + private nextTableId = 1; constructor(option: SearchComponentOption) { this.table = option.table; @@ -115,31 +129,92 @@ export class SearchComponent { this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } - private getSearchTables(): IVTable[] { - const tables: IVTable[] = this.isTableAvailable(this.table) ? [this.table] : []; + private getSearchTableEntries(): SearchTableEntry[] { + const entries: SearchTableEntry[] = this.isTableAvailable(this.table) ? [{ 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) => { - if (subTable && subTable !== this.table && this.isTableAvailable(subTable)) { - tables.push(subTable); + subTableInstances.forEach((subTable: IVTable, parentRow: number) => { + if (subTable && !seenTables.has(subTable) && this.isTableAvailable(subTable)) { + entries.push({ table: subTable, parentRow }); + seenTables.add(subTable); } }); } - return tables; + 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 && !!(table as any).scenegraph; } - private getResultTable(resultItem: typeof this.queryResult[number]): IVTable | undefined { - const table = this.resultTableMap.get(resultItem as object); + 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 (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); @@ -149,47 +224,110 @@ export class SearchComponent { tables.add(table); } }); - this.queryResult?.forEach(resultItem => { - const table = this.resultTableMap.get(resultItem as object); - if (table && activeTables.has(table) && this.isTableAvailable(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(): 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); - }); + 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 currentResult = this.currentIndex >= 0 ? this.queryResult[this.currentIndex] : undefined; + const availableResults: QueryResultItem[] = []; + for (const resultItem of this.queryResult) { + if (this.isResultAvailable(resultItem, activeTables)) { + availableResults.push(resultItem); + } + } 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); + this.currentIndex = + currentResultIndex >= 0 + ? currentResultIndex + : Math.min(Math.max(this.currentIndex, -1), 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 { + 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 { + private getTableId(table: IVTable): string { + const explicitId = (table as any).id; + if (typeof explicitId === 'string' && explicitId) { + return explicitId; + } + const existingId = this.tableIdMap.get(table as object); + if (existingId) { + return existingId; + } + const generatedId = `search-table-${this.nextTableId++}`; + this.tableIdMap.set(table as object, generatedId); + return generatedId; + } + + 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 { + private getResultCellPosition(resultItem: (typeof this.queryResult)[number]): SearchCellPosition | undefined { if (this.isTreeResult(resultItem)) { return this.getVisibleTreeCell(resultItem); } @@ -207,7 +345,7 @@ export class SearchComponent { return undefined; } - private getResultCell(resultItem: typeof this.queryResult[number]): { col: number; row: number } | undefined { + private getResultCell(resultItem: (typeof this.queryResult)[number]): { col: number; row: number } | undefined { const position = this.getResultCellPosition(resultItem); if (!position) { return undefined; @@ -248,25 +386,111 @@ export class SearchComponent { } } - private rebuildCustomCellStyleArrangement(plugin: any, arrangements: any[]): void { - if (!plugin) { + private arrangeSearchCellStyle(table: IVTable, position: SearchCellPosition, customStyleId: string): void { + const plugin = (table as any).customCellStylePlugin; + const arrangements = plugin?.customCellStyleArrangement; + const positionKey = this.getCellPositionKey(position); + if (plugin && Array.isArray(arrangements) && positionKey) { + let tableStyles = this.searchStyleArrangementMap.get(table as object); + if (!tableStyles) { + tableStyles = new Map(); + this.searchStyleArrangementMap.set(table as object, tableStyles); + } + const existing = tableStyles.get(positionKey); + if (existing && arrangements.includes(existing)) { + existing.customStyleId = customStyleId; + return; + } + const existingSearchArrangement = arrangements.find( + (item: any) => + searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item.cellPosition) === positionKey + ); + if (existingSearchArrangement) { + existingSearchArrangement.customStyleId = customStyleId; + tableStyles.set(positionKey, existingSearchArrangement); + return; + } + if (typeof plugin.addCustomCellStyleArrangement === 'function') { + plugin.addCustomCellStyleArrangement(position as any, customStyleId); + const currentArrangements = plugin.customCellStyleArrangement; + const addedArrangement = Array.isArray(currentArrangements) + ? [...currentArrangements] + .reverse() + .find( + (item: any) => + searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item.cellPosition) === positionKey + ) + : undefined; + if (addedArrangement) { + tableStyles.set(positionKey, addedArrangement); + } + return; + } + const addedArrangement = { cellPosition: position, customStyleId }; + arrangements.push(addedArrangement); + tableStyles.set(positionKey, addedArrangement); return; } - const currentArrangements = plugin.customCellStyleArrangement; - if (Array.isArray(currentArrangements)) { - currentArrangements.length = 0; - currentArrangements.push(...arrangements); - } else { - plugin.customCellStyleArrangement = arrangements; + 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); } - const rebuildIndex = plugin._rebuildCustomCellStyleArrangementIndex; - if (typeof rebuildIndex === 'function') { - rebuildIndex.call(plugin); + } + + private clearSearchCellStyleAtPosition(table: IVTable, position: SearchCellPosition): void { + const plugin = (table as any).customCellStylePlugin; + const arrangements = plugin?.customCellStyleArrangement; + const positionKey = this.getCellPositionKey(position); + 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 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; + } + + if (retainedArrangements.length === 0 && typeof plugin.clearCustomCellStyleArrangement === 'function') { + plugin.clearCustomCellStyleArrangement(); + } else if (typeof plugin.updateCustomCell === 'function' && Array.isArray(plugin.customCellStyle)) { + plugin.updateCustomCell([...plugin.customCellStyle], retainedArrangements); + } else { + arrangements.splice(0, arrangements.length, ...retainedArrangements); + plugin?._rebuildCustomCellStyleArrangementIndex?.call(plugin); + } + return positionsToRefresh; + } + private setSearchCellStyle( - resultItem: typeof this.queryResult[number], + resultItem: (typeof this.queryResult)[number], customStyleId: string | undefined = HighlightStyleId ): void { const table = this.getResultTable(resultItem); @@ -281,143 +505,229 @@ export class SearchComponent { 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.arrangeSearchCellStyle(table, position, customStyleId); + } else { + this.clearSearchCellStyleAtPosition(table, position); } - 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++) { + 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 })) { - 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 - ); - } + 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 getHeaderOffset(): number { + 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 { + 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(targetTable: IVTable = this.table) { - const plugin = (targetTable as any).customCellStylePlugin; - if (!plugin || !this.isTableAvailable(targetTable)) { + if (!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)); - - if (!searchArrangements.length) { - return; - } - - searchArrangements.forEach((item: any) => { - const cellPosition = item?.cellPosition; - const key = this.getCellPositionKey(cellPosition); - if (key) { - positionsToRefresh.set(key, cellPosition); - } - }); - - const retainedArrangements = arrangements.filter((item: any) => !searchStyleIds.has(item?.customStyleId)); - if (retainedArrangements.length === 0) { - plugin.clearCustomCellStyleArrangement(); - this.rebuildCustomCellStyleArrangement(plugin, []); - } else { - this.rebuildCustomCellStyleArrangement(plugin, retainedArrangements); - } - + const positionsToRefresh = this.clearSearchCellStyles(targetTable); positionsToRefresh.forEach(position => this.refreshCellStyle(targetTable, position)); } @@ -431,88 +741,20 @@ 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.addQueryResult( - { - indexNumber: currentPath, - col, - value: value?.toString?.() ?? String(value) - }, - this.table, - true - ); - } - }); - - // 兼容旧用法:如果用户自定义 treeQueryMethod 命中但字段级别未命中,则至少高亮树列。 - if ( - !hitAnyField && - this.treeQueryMethod && - this.treeQueryMethod(this.queryStr, item, this.fieldsToSearch, { table: this.table }) - ) { - this.addQueryResult( - { - indexNumber: currentPath, - col: treeCol - }, - this.table, - true - ); - } - - 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 => { - if (!this.isTreeResult(r)) { - return true; - } - const key = `${(r.indexNumber || []).join('.')}:${r.col ?? ''}`; - if (dedup.has(key)) { - return false; + this.searchTreeTable(this.table); + for (const entry of this.getSearchTableEntries()) { + if (entry.table !== this.table) { + this.searchTable(entry.table, entry.parentRow); } - dedup.add(key); - return true; - }); - - this.getSearchTables() - .filter(table => table !== this.table) - .forEach(table => this.searchTable(table)); + } 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 }); + this.jumpToResult(this.queryResult[0]); } if (this.callback) { @@ -535,7 +777,9 @@ export class SearchComponent { results: this.queryResult }; } - this.getSearchTables().forEach(table => this.searchTable(table)); + for (const entry of this.getSearchTableEntries()) { + this.searchTable(entry.table, entry.parentRow); + } this.updateCellStyle(); if (this.callback) { @@ -564,7 +808,7 @@ export class SearchComponent { * @param {string} customStyleId 自定义样式ID */ arrangeCustomCellStyle( - resultItem: typeof this.queryResult[number], + resultItem: (typeof this.queryResult)[number], highlight: boolean = true, customStyleId: string = HighlightStyleId ) { @@ -575,7 +819,8 @@ export class SearchComponent { this.pruneUnavailableResults(); if (!highlight) { this.getResultTables().forEach(table => { - this.clearRenderedCellStyles(table); + const positionsToRefresh = this.clearSearchCellStyles(table); + positionsToRefresh.forEach(position => this.refreshCellStyle(table, position)); table.scenegraph.updateNextFrame(); }); return; @@ -592,17 +837,18 @@ export class SearchComponent { if (!table.hasCustomCellStyle(FocusHighlightStyleId)) { table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } - this.clearRenderedCellStyles(table); + const positionsToRefresh = this.clearSearchCellStyles(table); + positionsToRefresh.forEach(position => this.refreshCellStyle(table, position)); }); 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) { + if (!table || !position || !(table as any).customCellStylePlugin) { continue; } - table.customCellStylePlugin.addCustomCellStyleArrangement(position as any, HighlightStyleId); + this.arrangeSearchCellStyle(table, position, HighlightStyleId); this.refreshCellStyle(table, position); } @@ -610,28 +856,25 @@ export class SearchComponent { 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); + if (table && position && (table as any).customCellStylePlugin) { + this.arrangeSearchCellStyle(table, position, FocusHighlightStyleId); this.refreshCellStyle(table, position); } } resultTables.forEach(table => { - this.rebuildCustomCellStyleArrangement( - (table as any).customCellStylePlugin, - Array.from((table as any).customCellStylePlugin?.customCellStyleArrangement || []) - ); table.scenegraph.updateNextFrame(); }); } - private jumpToResult(resultItem: typeof this.queryResult[number]): void { + 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 }); + this.jumpToCell({ IndexNumber: resultItem.indexNumber, col: resultItem.col }, table); } else { - const table = this.getResultTable(resultItem); - if (table) { - this.jumpToCell({ col: resultItem.col, row: resultItem.row }, table); - } + this.jumpToCell({ col: resultItem.col, row: resultItem.row }, table); } } @@ -651,17 +894,11 @@ export class SearchComponent { 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 (previousResult) { - // reset last focus - this.arrangeCustomCellStyle(previousResult); - } - this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); - this.jumpToResult(currentResult); + this.jumpToResult(currentResult); + if (previousResult) { + this.arrangeCustomCellStyle(previousResult, true, HighlightStyleId); } + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); return { index: this.currentIndex, @@ -686,16 +923,11 @@ export class SearchComponent { 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 (previousResult) { - this.arrangeCustomCellStyle(previousResult); - } - this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); - this.jumpToResult(currentResult); + this.jumpToResult(currentResult); + if (previousResult) { + this.arrangeCustomCellStyle(previousResult, true, HighlightStyleId); } + this.arrangeCustomCellStyle(currentResult, true, FocusHighlightStyleId); return { index: this.currentIndex, @@ -717,65 +949,192 @@ export class SearchComponent { 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]; + private getMasterViewport(): { 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; + const top = tableY + viewBoxY; + const height = + typeof masterTable.tableNoFrameHeight === 'number' + ? masterTable.tableNoFrameHeight + : typeof masterTable.getVisibleRect === 'function' + ? masterTable.getVisibleRect()?.height + : undefined; + return typeof height === 'number' ? { top, bottom: top + height } : undefined; + } - const tmp = [...indexNumbers]; - let tmpNumber = 0; - let i = 0; + 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 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; + } - // 展开树形结构的父节点 - while (tmpNumber < tmp.length - 1) { - tmpNumber++; - const indexNumber = indexNumbers.slice(0, tmpNumber); + private isSubTableTargetVisible(targetTable: IVTable, position?: SearchCellPosition): boolean { + const viewport = this.getMasterViewport(); + 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; + } - // 跳过表头行 - while (this.table.isHeader(0, i)) { - i++; - } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; + private ensureSubTableParentVisible(targetTable: IVTable, position?: SearchCellPosition): void { + if (targetTable === this.table) { + return; + } + const bodyRowIndex = 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)) { + this.table.scrollToCell({ row: parentRow }); + this.scrollSubTableTargetIntoMasterViewport(targetTable, position); + } + } - const hierarchyState = this.table.getHierarchyState(this.treeIndex, row); - if (hierarchyState !== 'expand') { - this.table.toggleHierarchyState(this.treeIndex, row); + private scrollSubTableTargetIntoMasterViewport(targetTable: IVTable, position?: SearchCellPosition): void { + const viewport = this.getMasterViewport(); + 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?.(); + } + + 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[]): number { + let bodyIndex = this.getBodyRowIndexByRecordIndex(indexNumbers, table); + const headerOffset = this.getHeaderOffset(table); + const treeCol = this.getTreeCol(table); + + 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') { + table.toggleHierarchyState?.(treeCol, row); + } + } + + bodyIndex = this.getBodyRowIndexByRecordIndex(indexNumbers, table); + if (bodyIndex < 0) { + bodyIndex = this.findVisibleTreeBodyIndex(table, indexNumbers); + } + return bodyIndex; + } - const finalRow = this.getBodyRowIndexByRecordIndex(indexNumbers) + i; + jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }, targetTable: IVTable = this.table) { + if (Array.isArray(params.IndexNumber)) { + const indexNumbers = [...params.IndexNumber]; + const finalBodyIndex = this.getTreeBodyIndex(targetTable, indexNumbers); + if (finalBodyIndex < 0) { + return; + } + 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); + targetTable.scrollToCell({ row: finalRow, col: targetCol }, this.scrollOption); + this.ensureSubTableParentVisible(targetTable, { col: targetCol, row: finalRow }); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { - scrollVTableCellIntoView(this.table, { row: finalRow, col: targetCol }); + scrollVTableCellIntoView(targetTable, { row: finalRow, col: targetCol }); } } else { const { col, row } = params; - 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 }); - } - } + if (typeof col !== 'number' || typeof row !== 'number') { + return; } const { rowStart, rowEnd } = targetTable.getBodyVisibleRowRange(); const { colStart, colEnd } = targetTable.getBodyVisibleColRange(); // 检查单元格是否在表格可视范围内 - const isInTableView = !(row <= rowStart || row >= rowEnd || col <= colStart || col >= colEnd); + const isInTableView = row >= rowStart && row <= rowEnd && col >= colStart && col <= colEnd; // 根据配置决定是否滚动表格 if (!isInTableView) { targetTable.scrollToCell({ col, row }); } + this.ensureSubTableParentVisible(targetTable, { col, row }); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { @@ -783,11 +1142,20 @@ export class SearchComponent { } } } - 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 @@ -796,6 +1164,8 @@ export class SearchComponent { this.queryResult = []; this.resultTableMap = new WeakMap(); this.resultTreeMap = new WeakMap(); + this.resultParentRowMap = new WeakMap(); + this.resultTables.clear(); this.currentIndex = -1; } } @@ -810,8 +1180,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; } @@ -819,8 +1196,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) { From 1bc611829f2792da2e690b0474555d3c38bdceac Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 16:49:02 +0800 Subject: [PATCH 5/6] fix(vtable-search): tighten detail viewport handling --- .../__tests__/review-regressions.test.ts | 48 +++++++++++++ .../src/search-component/search-component.ts | 72 +++++++++++++++---- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index 9c7e1590dd..3158c32849 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -16,6 +16,10 @@ function createCellTable( rowHierarchyType?: '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; @@ -126,12 +130,16 @@ function createCellTable( 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() }; @@ -386,6 +394,32 @@ test('detail navigation accounts for the master viewBox offset', () => { 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 }, @@ -544,6 +578,20 @@ test('navigation does not rebuild the custom style index for search entries', () expect(rebuildIndex).not.toHaveBeenCalled(); }); +test('navigation does not scan the arrangement list for cached search styles', () => { + const main = createCellTable([['Alice', 'Alina']]); + main.table.colCount = 2; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('Ali'); + const includesSpy = jest.spyOn(Array.prototype, 'includes'); + + search.next(); + search.next(); + + expect(includesSpy).not.toHaveBeenCalled(); + includesSpy.mockRestore(); +}); + test('visible range boundaries are treated as inclusive', () => { const main = createCellTable( [ diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index eb1115f336..36caee4695 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -106,6 +106,7 @@ export class SearchComponent { private resultTables = new Set(); private tableIdMap = new WeakMap(); private searchStyleArrangementMap = new WeakMap>(); + private searchStyleArrangementArrays = new WeakMap(); private nextTableId = 1; constructor(option: SearchComponentOption) { @@ -374,6 +375,11 @@ export class SearchComponent { return `${range.start.col}:${range.start.row}:${range.end.col}:${range.end.row}`; } + private resetSearchStyleArrangementCache(table: IVTable): void { + this.searchStyleArrangementMap.delete(table as object); + this.searchStyleArrangementArrays.delete(table as object); + } + private refreshCellStyle(table: IVTable, position: SearchCellPosition | any): void { const range = this.getCellPositionRange(position); if (!range) { @@ -392,12 +398,17 @@ export class SearchComponent { const positionKey = this.getCellPositionKey(position); if (plugin && Array.isArray(arrangements) && positionKey) { let tableStyles = this.searchStyleArrangementMap.get(table as object); + const cachedArrangements = this.searchStyleArrangementArrays.get(table as object); + if (cachedArrangements !== arrangements) { + tableStyles?.clear(); + this.searchStyleArrangementArrays.set(table as object, arrangements); + } if (!tableStyles) { tableStyles = new Map(); this.searchStyleArrangementMap.set(table as object, tableStyles); } const existing = tableStyles.get(positionKey); - if (existing && arrangements.includes(existing)) { + if (existing && (existing.customStyleId == null || searchStyleIds.has(existing.customStyleId))) { existing.customStyleId = customStyleId; return; } @@ -413,14 +424,15 @@ export class SearchComponent { if (typeof plugin.addCustomCellStyleArrangement === 'function') { plugin.addCustomCellStyleArrangement(position as any, customStyleId); const currentArrangements = plugin.customCellStyleArrangement; - const addedArrangement = Array.isArray(currentArrangements) - ? [...currentArrangements] - .reverse() - .find( - (item: any) => - searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item.cellPosition) === positionKey - ) + const lastArrangement = Array.isArray(currentArrangements) + ? currentArrangements[currentArrangements.length - 1] : undefined; + const addedArrangement = + lastArrangement && + searchStyleIds.has(lastArrangement.customStyleId) && + this.getCellPositionKey(lastArrangement.cellPosition) === positionKey + ? lastArrangement + : undefined; if (addedArrangement) { tableStyles.set(positionKey, addedArrangement); } @@ -456,6 +468,7 @@ export class SearchComponent { private clearSearchCellStyles(table: IVTable): Map { const plugin = (table as any).customCellStylePlugin; const positionsToRefresh = new Map(); + this.resetSearchStyleArrangementCache(table); const arrangements = plugin?.customCellStyleArrangement; if (!Array.isArray(arrangements)) { return positionsToRefresh; @@ -949,19 +962,48 @@ export class SearchComponent { return bodyRowIndex; } - private getMasterViewport(): { top: number; bottom: number } | undefined { + private getMasterViewport(targetTable?: IVTable): { 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; - const top = tableY + viewBoxY; + 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; - return typeof height === 'number' ? { top, bottom: top + height } : undefined; + if (typeof height !== 'number') { + return undefined; + } + + let bottom = top + height; + if (targetTable && targetTable !== this.table) { + const bodyRowIndex = 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 }; } private getSubTableTargetRect( @@ -993,7 +1035,7 @@ export class SearchComponent { } private isSubTableTargetVisible(targetTable: IVTable, position?: SearchCellPosition): boolean { - const viewport = this.getMasterViewport(); + const viewport = this.getMasterViewport(targetTable); const targetRect = this.getSubTableTargetRect(targetTable, position); if (!viewport || !targetRect) { return true; @@ -1023,7 +1065,7 @@ export class SearchComponent { } private scrollSubTableTargetIntoMasterViewport(targetTable: IVTable, position?: SearchCellPosition): void { - const viewport = this.getMasterViewport(); + const viewport = this.getMasterViewport(targetTable); const targetRect = this.getSubTableTargetRect(targetTable, position); const masterTable = this.table as any; if (!viewport || !targetRect || typeof masterTable.scrollTop !== 'number') { From 7d92029265906ea48cdab35bf86ce2c0008b0cec Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 19:28:14 +0800 Subject: [PATCH 6/6] fix(vtable-search): ignore tables during release --- .../__tests__/review-regressions.test.ts | 54 +++++++++++++++++++ .../src/search-component/search-component.ts | 24 ++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index 3158c32849..e3375b2fb9 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -265,6 +265,60 @@ test('released detail tables are removed from search state safely', () => { 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(); + detail.table.scenegraph.updateCellContent.mockClear(); + main.table.internalProps._isReleasing = true; + + search.clear(); + + expect(main.table.scenegraph.updateCellContent).not.toHaveBeenCalled(); + expect(detail.table.scenegraph.updateCellContent).not.toHaveBeenCalled(); + expect(search.queryResult).toHaveLength(0); +}); + test('tree master tables still search expanded detail tables', () => { const main = createTreeTable(); const detail = createCellTable([['Widget']]); diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index 36caee4695..c7f6e22cad 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -131,7 +131,10 @@ export class SearchComponent { } private getSearchTableEntries(): SearchTableEntry[] { - const entries: SearchTableEntry[] = this.isTableAvailable(this.table) ? [{ table: this.table }] : []; + 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') { @@ -150,7 +153,24 @@ export class SearchComponent { } private isTableAvailable(table: IVTable | undefined): table is IVTable { - return !!table && !(table as any).isReleased && !!(table as any).scenegraph; + 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 {