From 17d5d46733855a6997d7974f6a7afebc9bd9571c Mon Sep 17 00:00:00 2001 From: biubiukam Date: Sat, 29 Aug 2026 10:10:28 +0800 Subject: [PATCH 01/12] 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 43027a05c58d83fcc64adbee9c12565a166dd156 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Sat, 29 Aug 2026 10:12:57 +0800 Subject: [PATCH 02/12] 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 b4a4c8fd0beb8ce703b849c7a5c92f41bec7cc6c Mon Sep 17 00:00:00 2001 From: biubiukam Date: Mon, 31 Aug 2026 22:26:45 +0800 Subject: [PATCH 03/12] 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 1d138938d08a3e9f22872128ded7fdba75c36ff1 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 16:30:11 +0800 Subject: [PATCH 04/12] 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 fd80a7acfe06fd499ee185961574c7da938c279d Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 16:49:02 +0800 Subject: [PATCH 05/12] 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 81af76c7d29a67ec40e82948e63311a1e9a0f2f1 Mon Sep 17 00:00:00 2001 From: biubiukam Date: Fri, 4 Sep 2026 19:28:14 +0800 Subject: [PATCH 06/12] 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 { From bf6fb233ed3967b35435a778b6a8a150ea3313be Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 14 Sep 2026 14:41:58 +0800 Subject: [PATCH 07/12] fix(vtable-search): harden search navigation and highlighting Prevent stale tree/detail coordinates and repeated style scans. Ensure search regressions run in CI. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .github/workflows/unit-test.yml | 6 +- .../__tests__/review-regressions.test.ts | 169 ++++++++++++- packages/vtable-search/package.json | 1 + .../src/search-component/search-component.ts | 234 ++++++++++++------ 4 files changed, 331 insertions(+), 79 deletions(-) diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index a1a93e3e2d..e5705096ec 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -32,7 +32,7 @@ jobs: run: node common/scripts/install-run-rush.js install --bypass-policy - name: Build env: - NODE_OPTIONS: "--max_old_space_size=4096" - run: node common/scripts/install-run-rush.js build -t @visactor/vtable + NODE_OPTIONS: '--max_old_space_size=4096' + run: node common/scripts/install-run-rush.js build -t @visactor/vtable-search - name: Test - run: node common/scripts/install-run-rush.js test -t @visactor/vtable + run: node common/scripts/install-run-rush.js test -t @visactor/vtable-search diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index e3375b2fb9..4e367e7dc2 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -4,6 +4,10 @@ import { SearchComponent } from '../src'; +afterEach(() => { + jest.restoreAllMocks(); +}); + function createCellTable( values: string[][], options: { @@ -13,7 +17,7 @@ function createCellTable( visibleCols?: { colStart: number; colEnd: number }; initialArrangements?: { col: number; row: number; style: string }[]; columnHeaderLevelCount?: number; - rowHierarchyType?: 'grid' | 'tree'; + rowHierarchyType?: 'grid' | 'tree' | 'grid-tree'; viewBox?: { x1: number; y1: number; x2: number; y2: number }; tableNoFrameHeight?: number; frozenRowsHeight?: number; @@ -71,6 +75,7 @@ function createCellTable( }) }; const table = { + isPivotTable: jest.fn(() => false), id: `table-${Math.random()}`, rowHierarchyType: options.rowHierarchyType, records: options.records, @@ -787,3 +792,165 @@ test('normal navigation does not filter the entire result list', () => { expect(filterSpy).not.toHaveBeenCalled(); filterSpy.mockRestore(); }); + +test('tree-configured pivot tables search rendered cells instead of raw records', () => { + const pivot = createCellTable([['North', 'Hit total']], { rowHierarchyType: 'tree' }); + pivot.table.isPivotTable.mockReturnValue(true); + const search = new SearchComponent({ table: pivot.table as any, autoJump: false, skipHeader: true }); + + const result = search.search('Hit'); + + expect(result.results).toEqual([expect.objectContaining({ table: pivot.table, col: 1, row: 1, value: 'Hit total' })]); + expect(result.results[0].indexNumber).toBeUndefined(); +}); + +test('bulk highlighting does not scan the growing arrangement list for every result', () => { + const main = createCellTable([Array.from({ length: 100 }, (_, index) => `Hit ${index}`)]); + main.table.colCount = 100; + const findSpy = jest.fn(() => undefined); + main.customCellStylePlugin.customCellStyleArrangement.find = findSpy; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + + search.search('Hit'); + + expect(findSpy).not.toHaveBeenCalled(); +}); + +test('navigation rebuilds a style cache mutated in place', () => { + const main = createCellTable([['Alice']]); + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Ali'); + const arrangements = main.customCellStylePlugin.customCellStyleArrangement; + const detachedSearchStyle = arrangements[0]; + arrangements.splice(0, arrangements.length, { + cellPosition: { col: 0, row: 1 }, + customStyleId: 'user-style' + }); + + search.next(); + + expect(detachedSearchStyle.customStyleId).toBe('__search_component_highlight'); + expect(arrangements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ customStyleId: 'user-style' }), + expect.objectContaining({ customStyleId: '__search_component_focus' }) + ]) + ); +}); + +test('expanding a tree result rebuilds highlights with current row coordinates', () => { + const records = [ + { name: 'Hit before' }, + { name: 'Parent', children: [{ name: 'Hit child' }] }, + { name: 'Hit after' } + ]; + const main = createCellTable([], { + columns: [{ field: 'name', tree: true }], + records, + rowHierarchyType: 'tree' + }); + let expanded = false; + main.table.rowCount = 4; + main.table.dataSource.getTableIndex = jest.fn(index => { + const path = Array.isArray(index) ? index : [index]; + if (path[0] === 0) { + return 0; + } + if (path[0] === 1 && path.length === 1) { + return 1; + } + if (path[0] === 1 && path[1] === 0) { + return expanded ? 2 : -1; + } + if (path[0] === 2) { + return expanded ? 3 : 2; + } + return -1; + }); + main.table.getHierarchyState = jest.fn(() => (expanded ? 'expand' : 'collapse')); + main.table.toggleHierarchyState = jest.fn(() => { + expanded = true; + main.table.rowCount = 5; + }); + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.search('Hit'); + search.next(); + + const searchArrangements = main.customCellStylePlugin.customCellStyleArrangement.filter(item => + item.customStyleId?.startsWith('__search_component_') + ); + expect(searchArrangements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cellPosition: { col: 0, row: 3 }, + customStyleId: '__search_component_focus' + }), + expect.objectContaining({ cellPosition: { col: 0, row: 4 }, customStyleId: '__search_component_highlight' }) + ]) + ); + expect(searchArrangements).not.toEqual( + expect.arrayContaining([expect.objectContaining({ cellPosition: { col: 0, row: 2 } })]) + ); +}); + +test('next keeps its direction when the current detail result is released', () => { + const main = createCellTable([['Match']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Match first']]); + const secondDetail = createCellTable([['Match second']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Match'); + search.next(); + search.next(); + firstDetail.table.isReleased = true; + firstDetail.table.scenegraph = null; + + const result = search.next(); + + expect(result.index).toBe(1); + expect(result.results[1].table).toBe(secondDetail.table); +}); + +test('result navigation uses the recorded detail parent row without a reverse scan', () => { + const main = createCellTable([['Parent']], { isMasterDetail: true }); + const detail = createCellTable([['Widget']]); + const subTableInstances = new Map([[7, detail.table]]); + const forEachSpy = jest.spyOn(subTableInstances, 'forEach'); + main.table.internalProps = { subTableInstances }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + forEachSpy.mockClear(); + + search.jumpToCell({ col: 0, row: 1 }, detail.table as any, 7); + + expect(forEachSpy).not.toHaveBeenCalled(); + expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 8 }); +}); + +test('tree detail navigation completes its local scroll before adjusting the master viewport', () => { + const main = createCellTable([['Parent']], { isMasterDetail: true }); + let detailScrolled = false; + const detail = createCellTable([['Widget']], { + columns: [{ field: 'name', tree: true }], + records: [{ name: 'Widget' }], + rowHierarchyType: 'tree', + cellRangeRelativeRect: () => ({ left: 0, top: detailScrolled ? 50 : 500, width: 100, height: 20 }) + }); + detail.table.scrollToCell.mockImplementation((_cell, option) => { + if (option?.duration === 0) { + detailScrolled = true; + } + }); + main.table.internalProps = { subTableInstances: new Map([[0, detail.table]]) }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + search.jumpToCell({ IndexNumber: [0], col: 0 }, detail.table as any, 0); + + expect(detail.table.scrollToCell).toHaveBeenCalledWith({ row: 1, col: 0 }, expect.objectContaining({ duration: 0 })); + expect(main.table.scrollTop).toBe(0); +}); diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 98003b3fe8..291e4be171 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -24,6 +24,7 @@ }, "scripts": { "start": "vite ./demo", + "test": "jest --runInBand", "build": "npm run fix-memory-limit && bundle --clean", "fix-memory-limit": "cross-env LIMIT=10240 increase-memory-limit" }, diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index c7f6e22cad..cfcb558fcd 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -105,8 +105,16 @@ export class SearchComponent { private resultParentRowMap = new WeakMap(); private resultTables = new Set(); private tableIdMap = new WeakMap(); - private searchStyleArrangementMap = new WeakMap>(); - private searchStyleArrangementArrays = new WeakMap(); + private searchStyleArrangementMap = new WeakMap>(); + private searchStyleArrangementStates = new WeakMap< + object, + { + arrangements: any[]; + length: number; + first: any; + last: any; + } + >(); private nextTableId = 1; constructor(option: SearchComponentOption) { @@ -211,6 +219,9 @@ export class SearchComponent { } private isTreeTable(table: IVTable): boolean { + if (table.isPivotTable?.()) { + return false; + } if (this.isMasterDetailTable(table)) { return false; } @@ -221,7 +232,7 @@ export class SearchComponent { return !!(table as any).options?.columns?.some((item: any) => item?.tree); } - private getResultTable(resultItem: (typeof this.queryResult)[number]): IVTable | undefined { + 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; @@ -229,7 +240,7 @@ export class SearchComponent { return this.isTableAvailable(this.table) ? this.table : undefined; } - private getResultParentRow(resultItem: (typeof this.queryResult)[number]): number | undefined { + private getResultParentRow(resultItem: typeof this.queryResult[number]): number | undefined { if (typeof resultItem.parentRow === 'number') { return resultItem.parentRow; } @@ -259,13 +270,13 @@ export class SearchComponent { return activeTables; } - private isResultAvailable(resultItem: (typeof this.queryResult)[number], activeTables?: Set): boolean { + 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 { + private pruneUnavailableResults(direction: -1 | 0 | 1 = 0): void { if (!this.queryResult?.length) { return; } @@ -296,10 +307,15 @@ export class SearchComponent { 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); + if (currentResultIndex >= 0) { + this.currentIndex = currentResultIndex; + } else if (direction > 0) { + this.currentIndex = Math.min(this.currentIndex - 1, this.queryResult.length - 1); + } else if (direction < 0) { + this.currentIndex = Math.min(this.currentIndex, this.queryResult.length); + } else { + this.currentIndex = Math.min(Math.max(this.currentIndex, -1), this.queryResult.length - 1); + } } else if (this.currentIndex >= this.queryResult.length) { this.currentIndex = this.queryResult.length - 1; } @@ -312,7 +328,7 @@ export class SearchComponent { } } - 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); } @@ -331,7 +347,7 @@ export class SearchComponent { } private addQueryResult( - resultItem: (typeof this.queryResult)[number], + resultItem: typeof this.queryResult[number], table: IVTable, isTree = false, parentRow?: number @@ -348,7 +364,7 @@ export class SearchComponent { 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); } @@ -366,7 +382,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; @@ -397,7 +413,49 @@ export class SearchComponent { private resetSearchStyleArrangementCache(table: IVTable): void { this.searchStyleArrangementMap.delete(table as object); - this.searchStyleArrangementArrays.delete(table as object); + this.searchStyleArrangementStates.delete(table as object); + } + + private rebuildSearchStyleArrangementCache( + table: IVTable, + arrangements: any[] + ): Map { + const tableStyles = new Map(); + arrangements.forEach((arrangement, index) => { + if (!searchStyleIds.has(arrangement?.customStyleId)) { + return; + } + const key = this.getCellPositionKey(arrangement.cellPosition); + if (key) { + tableStyles.set(key, { arrangement, index }); + } + }); + this.searchStyleArrangementMap.set(table as object, tableStyles); + this.updateSearchStyleArrangementState(table, arrangements); + return tableStyles; + } + + private getSearchStyleArrangementCache( + table: IVTable, + arrangements: any[] + ): Map { + const state = this.searchStyleArrangementStates.get(table as object); + const tableStyles = this.searchStyleArrangementMap.get(table as object); + const isFresh = + state?.arrangements === arrangements && + state.length === arrangements.length && + state.first === arrangements[0] && + state.last === arrangements[arrangements.length - 1]; + return tableStyles && isFresh ? tableStyles : this.rebuildSearchStyleArrangementCache(table, arrangements); + } + + private updateSearchStyleArrangementState(table: IVTable, arrangements: any[]): void { + this.searchStyleArrangementStates.set(table as object, { + arrangements, + length: arrangements.length, + first: arrangements[0], + last: arrangements[arrangements.length - 1] + }); } private refreshCellStyle(table: IVTable, position: SearchCellPosition | any): void { @@ -417,50 +475,43 @@ export class SearchComponent { const arrangements = plugin?.customCellStyleArrangement; 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); + let tableStyles = this.getSearchStyleArrangementCache(table, arrangements); + let existing = tableStyles.get(positionKey); + if (existing && arrangements[existing.index] !== existing.arrangement) { + tableStyles = this.rebuildSearchStyleArrangementCache(table, arrangements); + existing = tableStyles.get(positionKey); } - const existing = tableStyles.get(positionKey); - if (existing && (existing.customStyleId == null || searchStyleIds.has(existing.customStyleId))) { - 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); + if ( + existing && + (existing.arrangement.customStyleId == null || searchStyleIds.has(existing.arrangement.customStyleId)) + ) { + existing.arrangement.customStyleId = customStyleId; return; } if (typeof plugin.addCustomCellStyleArrangement === 'function') { plugin.addCustomCellStyleArrangement(position as any, customStyleId); const currentArrangements = plugin.customCellStyleArrangement; - 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); + if (Array.isArray(currentArrangements)) { + if (currentArrangements !== arrangements) { + tableStyles = this.getSearchStyleArrangementCache(table, currentArrangements); + } + const index = currentArrangements.length - 1; + const addedArrangement = currentArrangements[index]; + if ( + addedArrangement && + searchStyleIds.has(addedArrangement.customStyleId) && + this.getCellPositionKey(addedArrangement.cellPosition) === positionKey + ) { + tableStyles.set(positionKey, { arrangement: addedArrangement, index }); + } + this.updateSearchStyleArrangementState(table, currentArrangements); } return; } const addedArrangement = { cellPosition: position, customStyleId }; arrangements.push(addedArrangement); - tableStyles.set(positionKey, addedArrangement); + tableStyles.set(positionKey, { arrangement: addedArrangement, index: arrangements.length - 1 }); + this.updateSearchStyleArrangementState(table, arrangements); return; } const arrange = (table as any).arrangeCustomCellStyle; @@ -523,7 +574,7 @@ export class SearchComponent { } private setSearchCellStyle( - resultItem: (typeof this.queryResult)[number], + resultItem: typeof this.queryResult[number], customStyleId: string | undefined = HighlightStyleId ): void { const table = this.getResultTable(resultItem); @@ -738,7 +789,7 @@ export class SearchComponent { 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; } @@ -841,7 +892,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 ) { @@ -899,20 +950,24 @@ export class SearchComponent { }); } - 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 }, table); + this.jumpToCell( + { IndexNumber: resultItem.indexNumber, col: resultItem.col }, + table, + this.getResultParentRow(resultItem) + ); } else { - this.jumpToCell({ col: resultItem.col, row: resultItem.row }, table); + this.jumpToCell({ col: resultItem.col, row: resultItem.row }, table, this.getResultParentRow(resultItem)); } } next() { - this.pruneUnavailableResults(); + this.pruneUnavailableResults(1); if (!this.queryResult.length) { return { index: 0, @@ -940,7 +995,7 @@ export class SearchComponent { } prev() { - this.pruneUnavailableResults(); + this.pruneUnavailableResults(-1); if (!this.queryResult.length) { return { index: 0, @@ -982,7 +1037,10 @@ export class SearchComponent { return bodyRowIndex; } - private getMasterViewport(targetTable?: IVTable): { top: number; bottom: number } | undefined { + private getMasterViewport( + targetTable?: IVTable, + subTableBodyRowIndex?: number + ): { top: number; bottom: number } | undefined { const masterTable = this.table as any; const tableY = typeof masterTable.tableY === 'number' ? masterTable.tableY : 0; const viewBoxY = typeof masterTable.options?.viewBox?.y1 === 'number' ? masterTable.options.viewBox.y1 : 0; @@ -999,7 +1057,7 @@ export class SearchComponent { let bottom = top + height; if (targetTable && targetTable !== this.table) { - const bodyRowIndex = this.getSubTableBodyRowIndex(targetTable); + const bodyRowIndex = subTableBodyRowIndex ?? this.getSubTableBodyRowIndex(targetTable); if (bodyRowIndex !== undefined) { const headerOffset = this.getHeaderOffset(this.table); const rowIndex = bodyRowIndex + headerOffset; @@ -1054,8 +1112,12 @@ export class SearchComponent { return undefined; } - private isSubTableTargetVisible(targetTable: IVTable, position?: SearchCellPosition): boolean { - const viewport = this.getMasterViewport(targetTable); + private isSubTableTargetVisible( + targetTable: IVTable, + position?: SearchCellPosition, + parentBodyRowIndex?: number + ): boolean { + const viewport = this.getMasterViewport(targetTable, parentBodyRowIndex); const targetRect = this.getSubTableTargetRect(targetTable, position); if (!viewport || !targetRect) { return true; @@ -1067,25 +1129,33 @@ export class SearchComponent { return targetRect.top >= viewport.top && targetRect.bottom <= viewport.bottom; } - private ensureSubTableParentVisible(targetTable: IVTable, position?: SearchCellPosition): void { + private ensureSubTableParentVisible( + targetTable: IVTable, + position?: SearchCellPosition, + parentBodyRowIndex?: number + ): void { if (targetTable === this.table) { return; } - const bodyRowIndex = this.getSubTableBodyRowIndex(targetTable); + const bodyRowIndex = parentBodyRowIndex ?? this.getSubTableBodyRowIndex(targetTable); if (bodyRowIndex === undefined) { return; } const parentRow = bodyRowIndex + this.getHeaderOffset(this.table); const { rowStart, rowEnd } = this.table.getBodyVisibleRowRange(); const isParentRowVisible = parentRow >= rowStart && parentRow <= rowEnd; - if (!isParentRowVisible || !this.isSubTableTargetVisible(targetTable, position)) { + if (!isParentRowVisible || !this.isSubTableTargetVisible(targetTable, position, bodyRowIndex)) { this.table.scrollToCell({ row: parentRow }); - this.scrollSubTableTargetIntoMasterViewport(targetTable, position); + this.scrollSubTableTargetIntoMasterViewport(targetTable, position, bodyRowIndex); } } - private scrollSubTableTargetIntoMasterViewport(targetTable: IVTable, position?: SearchCellPosition): void { - const viewport = this.getMasterViewport(targetTable); + private scrollSubTableTargetIntoMasterViewport( + targetTable: IVTable, + position?: SearchCellPosition, + parentBodyRowIndex?: number + ): void { + const viewport = this.getMasterViewport(targetTable, parentBodyRowIndex); const targetRect = this.getSubTableTargetRect(targetTable, position); const masterTable = this.table as any; if (!viewport || !targetRect || typeof masterTable.scrollTop !== 'number') { @@ -1135,10 +1205,11 @@ export class SearchComponent { return foundIndex; } - private getTreeBodyIndex(table: IVTable, indexNumbers: number[]): number { + private getTreeBodyIndex(table: IVTable, indexNumbers: number[]): { bodyIndex: number; hierarchyChanged: boolean } { let bodyIndex = this.getBodyRowIndexByRecordIndex(indexNumbers, table); const headerOffset = this.getHeaderOffset(table); const treeCol = this.getTreeCol(table); + let hierarchyChanged = false; for (let depth = 1; depth < indexNumbers.length; depth++) { const parentPath = indexNumbers.slice(0, depth); @@ -1151,8 +1222,9 @@ export class SearchComponent { } const row = bodyIndex + headerOffset; const hierarchyState = table.getHierarchyState?.(treeCol, row); - if (hierarchyState !== 'expand') { - table.toggleHierarchyState?.(treeCol, row); + if (hierarchyState !== 'expand' && typeof table.toggleHierarchyState === 'function') { + table.toggleHierarchyState(treeCol, row); + hierarchyChanged = true; } } @@ -1160,22 +1232,34 @@ export class SearchComponent { if (bodyIndex < 0) { bodyIndex = this.findVisibleTreeBodyIndex(table, indexNumbers); } - return bodyIndex; + return { bodyIndex, hierarchyChanged }; } - jumpToCell(params: { col?: number; row?: number; IndexNumber?: number[] }, targetTable: IVTable = this.table) { + jumpToCell( + params: { col?: number; row?: number; IndexNumber?: number[] }, + targetTable: IVTable = this.table, + parentBodyRowIndex?: number + ) { if (Array.isArray(params.IndexNumber)) { const indexNumbers = [...params.IndexNumber]; - const finalBodyIndex = this.getTreeBodyIndex(targetTable, indexNumbers); + const { bodyIndex: finalBodyIndex, hierarchyChanged } = this.getTreeBodyIndex(targetTable, indexNumbers); if (finalBodyIndex < 0) { return; } + if (hierarchyChanged) { + this.updateCellStyle(); + } const finalRow = finalBodyIndex + this.getHeaderOffset(targetTable); // 根据配置决定是否滚动表格 const targetCol = typeof params.col === 'number' ? params.col : this.getTreeCol(targetTable); - targetTable.scrollToCell({ row: finalRow, col: targetCol }, this.scrollOption); - this.ensureSubTableParentVisible(targetTable, { col: targetCol, row: finalRow }); + const isDetailTable = targetTable !== this.table; + if (isDetailTable) { + this.ensureSubTableParentVisible(targetTable, undefined, parentBodyRowIndex); + } + const scrollOption = isDetailTable ? { ...this.scrollOption, duration: 0 } : this.scrollOption; + targetTable.scrollToCell({ row: finalRow, col: targetCol }, scrollOption); + this.ensureSubTableParentVisible(targetTable, { col: targetCol, row: finalRow }, parentBodyRowIndex); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { @@ -1196,7 +1280,7 @@ export class SearchComponent { if (!isInTableView) { targetTable.scrollToCell({ col, row }); } - this.ensureSubTableParentVisible(targetTable, { col, row }); + this.ensureSubTableParentVisible(targetTable, { col, row }, parentBodyRowIndex); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { From 3f8f91cf84075c582491c8f4b2df41c90115c758 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 14 Sep 2026 16:58:49 +0800 Subject: [PATCH 08/12] fix(vtable-search): resolve remaining review issues Eliminate repeated style scans and stabilize detail navigation. Add a focused master-detail search demo and stronger regressions. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...-custom-style-lookup_2026-09-14-16-00.json | 11 ++ common/config/rush/pnpm-lock.yaml | 3 + .../__tests__/review-regressions.test.ts | 150 +++++++++++++++--- .../list/issue-5036-master-detail-search.ts | 93 +++++++++++ packages/vtable-search/demo/main.ts | 3 +- packages/vtable-search/demo/menu.ts | 4 + packages/vtable-search/demo/vite.config.js | 1 + packages/vtable-search/package.json | 1 + .../src/search-component/search-component.ts | 39 +++-- .../plugins/custom-cell-style.test.ts | 65 ++++++++ .../vtable/src/plugins/custom-cell-style.ts | 95 ++++++++--- 11 files changed, 402 insertions(+), 63 deletions(-) create mode 100644 common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json create mode 100644 packages/vtable-search/demo/list/issue-5036-master-detail-search.ts diff --git a/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json b/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json new file mode 100644 index 0000000000..95dd0fa7b6 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable): use indexed exact-cell custom style lookups", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index d37d1fc8c5..7ec0174aa7 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1415,6 +1415,9 @@ importers: '@visactor/vchart': specifier: 2.1.3 version: 2.1.3 + '@visactor/vtable-plugins': + specifier: workspace:1.26.8 + version: link:../vtable-plugins '@vitejs/plugin-react': specifier: 3.1.0 version: 3.1.0(vite@3.2.6(@types/node@25.5.0)(less@4.1.3)(sass@1.43.5)(terser@5.17.1)) diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index 4e367e7dc2..53a58ede9b 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -6,6 +6,7 @@ import { SearchComponent } from '../src'; afterEach(() => { jest.restoreAllMocks(); + document.body.innerHTML = ''; }); function createCellTable( @@ -53,6 +54,7 @@ function createCellTable( .map((item, index) => [getArrangementKey(item.cellPosition), index]) .filter(([key]) => key !== undefined) ); + const registeredStyles = new Set(); const customCellStylePlugin = { customCellStyleArrangement: arrangements, addCustomCellStyleArrangement: jest.fn((cellPosition, customStyleId) => { @@ -89,8 +91,10 @@ function createCellTable( start: { col, row }, end: { col, row } })), - registerCustomCellStyle: jest.fn(), - hasCustomCellStyle: jest.fn(() => true), + registerCustomCellStyle: jest.fn(styleId => { + registeredStyles.add(styleId); + }), + hasCustomCellStyle: jest.fn(styleId => registeredStyles.has(styleId)), arrangeCustomCellStyle: jest.fn((position, style) => { if (style) { const key = getArrangementKey(position); @@ -161,7 +165,7 @@ function createCellTable( }; } - return { table, customCellStylePlugin }; + return { table, customCellStylePlugin, registeredStyles }; } function createTreeTable() { @@ -314,13 +318,17 @@ test('clearing stale results during master release does not touch table scenegra search.search('i'); main.table.scenegraph.updateCellContent.mockClear(); + main.table.scenegraph.updateNextFrame.mockClear(); detail.table.scenegraph.updateCellContent.mockClear(); + detail.table.scenegraph.updateNextFrame.mockClear(); main.table.internalProps._isReleasing = true; search.clear(); expect(main.table.scenegraph.updateCellContent).not.toHaveBeenCalled(); + expect(main.table.scenegraph.updateNextFrame).not.toHaveBeenCalled(); expect(detail.table.scenegraph.updateCellContent).not.toHaveBeenCalled(); + expect(detail.table.scenegraph.updateNextFrame).not.toHaveBeenCalled(); expect(search.queryResult).toHaveLength(0); }); @@ -339,6 +347,7 @@ test('tree master tables still search expanded detail tables', () => { customStyleId: '__search_component_highlight' } ]); + expect(detail.registeredStyles).toEqual(new Set(['__search_component_highlight', '__search_component_focus'])); }); test('detail result navigation scrolls the master row into view first', () => { @@ -354,6 +363,9 @@ test('detail result navigation scrolls the master row into view first', () => { expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 6 }); expect(detail.table.scrollToCell).toHaveBeenCalled(); + expect(main.table.scrollToCell.mock.invocationCallOrder[0]).toBeLessThan( + detail.table.scrollToCell.mock.invocationCallOrder[0] + ); }); test('master-detail search does not recurse child records as tree results', () => { @@ -778,19 +790,18 @@ test('scrolling a detail result keeps the master horizontal position', () => { expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 1 }); }); -test('normal navigation does not filter the entire result list', () => { +test('normal navigation does not check every result for availability', () => { const main = createCellTable([['Alice', 'Alina', 'Alicia']], { rowHierarchyType: 'grid' }); main.table.colCount = 3; const search = new SearchComponent({ table: main.table as any, autoJump: false }); search.search('Ali'); - const filterSpy = jest.spyOn(Array.prototype, 'filter'); + const availabilitySpy = jest.spyOn(search as any, 'isResultAvailable'); search.next(); search.next(); search.prev(); - expect(filterSpy).not.toHaveBeenCalled(); - filterSpy.mockRestore(); + expect(availabilitySpy).not.toHaveBeenCalled(); }); test('tree-configured pivot tables search rendered cells instead of raw records', () => { @@ -804,32 +815,37 @@ test('tree-configured pivot tables search rendered cells instead of raw records' expect(result.results[0].indexNumber).toBeUndefined(); }); -test('bulk highlighting does not scan the growing arrangement list for every result', () => { +test('bulk highlighting creates every result and arrangement', () => { const main = createCellTable([Array.from({ length: 100 }, (_, index) => `Hit ${index}`)]); main.table.colCount = 100; - const findSpy = jest.fn(() => undefined); - main.customCellStylePlugin.customCellStyleArrangement.find = findSpy; const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); - search.search('Hit'); + const result = search.search('Hit'); - expect(findSpy).not.toHaveBeenCalled(); + expect(result.results).toHaveLength(100); + expect(main.customCellStylePlugin.customCellStyleArrangement).toHaveLength(100); }); -test('navigation rebuilds a style cache mutated in place', () => { - const main = createCellTable([['Alice']]); +test('navigation rebuilds a middle style cache entry mutated in place', () => { + const main = createCellTable([['Alice', 'Alina', 'Alicia']]); + main.table.colCount = 3; const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); search.search('Ali'); const arrangements = main.customCellStylePlugin.customCellStyleArrangement; - const detachedSearchStyle = arrangements[0]; - arrangements.splice(0, arrangements.length, { - cellPosition: { col: 0, row: 1 }, + const firstArrangement = arrangements[0]; + const detachedSearchStyle = arrangements[1]; + const lastArrangement = arrangements[2]; + arrangements.splice(1, 1, { + cellPosition: { col: 1, row: 1 }, customStyleId: 'user-style' }); + search.next(); search.next(); expect(detachedSearchStyle.customStyleId).toBe('__search_component_highlight'); + expect(arrangements[0]).toBe(firstArrangement); + expect(arrangements[2]).toBe(lastArrangement); expect(arrangements).toEqual( expect.arrayContaining([ expect.objectContaining({ customStyleId: 'user-style' }), @@ -917,32 +933,96 @@ test('next keeps its direction when the current detail result is released', () = expect(result.results[1].table).toBe(secondDetail.table); }); +test('next keeps its direction when multiple results before and at the current index are released', () => { + const main = createCellTable([['Match']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Match first']]); + const secondDetail = createCellTable([['Match second']]); + const thirdDetail = createCellTable([['Match third']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table], + [2, thirdDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Match'); + search.next(); + search.next(); + search.next(); + firstDetail.table.isReleased = true; + firstDetail.table.scenegraph = null; + secondDetail.table.isReleased = true; + secondDetail.table.scenegraph = null; + + const result = search.next(); + + expect(result.index).toBe(1); + expect(result.results[1].table).toBe(thirdDetail.table); +}); + +test('prev keeps its direction when multiple results including the current detail are released', () => { + const main = createCellTable([['Match']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Match first']]); + const secondDetail = createCellTable([['Match second']]); + const thirdDetail = createCellTable([['Match third']]); + const fourthDetail = createCellTable([['Match fourth']]); + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table], + [2, thirdDetail.table], + [3, fourthDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Match'); + search.next(); + search.next(); + search.next(); + search.next(); + firstDetail.table.isReleased = true; + firstDetail.table.scenegraph = null; + thirdDetail.table.isReleased = true; + thirdDetail.table.scenegraph = null; + + const result = search.prev(); + + expect(result.index).toBe(1); + expect(result.results[1].table).toBe(secondDetail.table); +}); + test('result navigation uses the recorded detail parent row without a reverse scan', () => { const main = createCellTable([['Parent']], { isMasterDetail: true }); const detail = createCellTable([['Widget']]); const subTableInstances = new Map([[7, detail.table]]); - const forEachSpy = jest.spyOn(subTableInstances, 'forEach'); main.table.internalProps = { subTableInstances }; const search = new SearchComponent({ table: main.table as any, autoJump: false }); - forEachSpy.mockClear(); + const result = search.search('Widget'); + const reverseLookupSpy = jest.spyOn(search as any, 'getSubTableBodyRowIndex'); - search.jumpToCell({ col: 0, row: 1 }, detail.table as any, 7); + search.next(); - expect(forEachSpy).not.toHaveBeenCalled(); + expect(result.results[0].parentRow).toBe(7); + expect(reverseLookupSpy).not.toHaveBeenCalled(); expect(main.table.scrollToCell).toHaveBeenCalledWith({ row: 8 }); }); -test('tree detail navigation completes its local scroll before adjusting the master viewport', () => { +test('tree detail navigation uses synchronous local scrolling before reading its target geometry', () => { const main = createCellTable([['Parent']], { isMasterDetail: true }); let detailScrolled = false; + const geometryReads: boolean[] = []; const detail = createCellTable([['Widget']], { columns: [{ field: 'name', tree: true }], records: [{ name: 'Widget' }], rowHierarchyType: 'tree', - cellRangeRelativeRect: () => ({ left: 0, top: detailScrolled ? 50 : 500, width: 100, height: 20 }) + cellRangeRelativeRect: () => { + geometryReads.push(detailScrolled); + return { left: 0, top: detailScrolled ? 50 : 500, width: 100, height: 20 }; + } }); detail.table.scrollToCell.mockImplementation((_cell, option) => { - if (option?.duration === 0) { + if (option === false) { detailScrolled = true; } }); @@ -951,6 +1031,26 @@ test('tree detail navigation completes its local scroll before adjusting the mas search.jumpToCell({ IndexNumber: [0], col: 0 }, detail.table as any, 0); - expect(detail.table.scrollToCell).toHaveBeenCalledWith({ row: 1, col: 0 }, expect.objectContaining({ duration: 0 })); + expect(detail.table.scrollToCell).toHaveBeenCalledWith({ row: 1, col: 0 }, false); + expect(geometryReads[geometryReads.length - 1]).toBe(true); expect(main.table.scrollTop).toBe(0); }); + +test('search result table IDs remain unique when VTable timestamp IDs collide', () => { + const main = createCellTable([['Parent']], { isMasterDetail: true }); + const firstDetail = createCellTable([['Widget one']]); + const secondDetail = createCellTable([['Widget two']]); + firstDetail.table.id = 'VTable123'; + secondDetail.table.id = 'VTable123'; + main.table.internalProps = { + subTableInstances: new Map([ + [0, firstDetail.table], + [1, secondDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false }); + + const result = search.search('Widget'); + + expect(result.results.map(item => item.tableId)).toEqual(['VTable123', 'VTable123-1']); +}); diff --git a/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts b/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts new file mode 100644 index 0000000000..add1b51564 --- /dev/null +++ b/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts @@ -0,0 +1,93 @@ +import * as VTable from '@visactor/vtable'; +import { MasterDetailPlugin } from '@visactor/vtable-plugins'; +import { SearchComponent } from '../../src'; + +const CONTAINER_ID = 'vTable'; +const demoWindow = window as typeof window & { + tableInstance?: VTable.ListTable; + search?: SearchComponent; + masterDetailPlugin?: MasterDetailPlugin; +}; + +const records = Array.from({ length: 8 }, (_, index) => ({ + orderId: `ORDER-${String(index + 1).padStart(3, '0')}`, + customer: index % 2 === 0 ? `Target customer ${index + 1}` : `Customer ${index + 1}`, + status: index % 3 === 0 ? 'Review' : 'Ready', + children: [ + { + task: `Target task ${index + 1}`, + owner: `Owner ${index + 1}`, + children: [ + { + task: `Target nested task ${index + 1}`, + owner: `Reviewer ${index + 1}` + } + ] + }, + { + task: `Packaging ${index + 1}`, + owner: `Operator ${index + 1}` + } + ] +})); + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + + const masterDetailPlugin = new MasterDetailPlugin({ + detailTableOptions: { + columns: [ + { field: 'task', title: 'Detail task', tree: true, width: 260 }, + { field: 'owner', title: 'Owner', width: 180 } + ], + hierarchyExpandLevel: 1, + defaultRowHeight: 34, + defaultHeaderRowHeight: 36, + style: { + margin: [8, 16], + height: 150 + }, + theme: VTable.themes.BRIGHT + } + }); + + const tableInstance = new VTable.ListTable({ + container, + records, + columns: [ + { field: 'orderId', title: 'Order', width: 150 }, + { field: 'customer', title: 'Customer', width: 220 }, + { field: 'status', title: 'Status', width: 120 } + ], + defaultRowHeight: 40, + heightMode: 'standard', + plugins: [masterDetailPlugin] + }); + + demoWindow.tableInstance = tableInstance; + demoWindow.masterDetailPlugin = masterDetailPlugin; + demoWindow.search = new SearchComponent({ + table: tableInstance, + autoJump: true, + scrollOption: { + duration: 500 + } + }); + + const input = document.getElementById('search-component-input') as HTMLInputElement | null; + if (input) { + input.value = 'Target'; + } + + requestAnimationFrame(() => { + records.forEach((_record, recordIndex) => { + const row = tableInstance.getTableIndexByRecordIndex(recordIndex); + if (typeof row === 'number' && tableInstance.getHierarchyState(0, row) !== 'expand') { + tableInstance.toggleHierarchyState(0, row); + } + }); + }); +} diff --git a/packages/vtable-search/demo/main.ts b/packages/vtable-search/demo/main.ts index 334ab9ec9e..ffb02f4e56 100644 --- a/packages/vtable-search/demo/main.ts +++ b/packages/vtable-search/demo/main.ts @@ -109,7 +109,8 @@ const createSidebar = (node: HTMLDivElement) => { const run = () => { const sidebarNode = document.querySelector('#sidebar')!; - const prevActivePath = localStorage.getItem(LOCAL_STORAGE_KEY); + const prevActivePath = + new URLSearchParams(window.location.search).get('demo') || localStorage.getItem(LOCAL_STORAGE_KEY); createSidebar(sidebarNode); initSidebarEvent(sidebarNode); diff --git a/packages/vtable-search/demo/menu.ts b/packages/vtable-search/demo/menu.ts index b55c42bf66..313836ac50 100644 --- a/packages/vtable-search/demo/menu.ts +++ b/packages/vtable-search/demo/menu.ts @@ -9,6 +9,10 @@ export const menus = [ { path: 'list', name: 'list-tree' + }, + { + path: 'list', + name: 'issue-5036-master-detail-search' } ] }, diff --git a/packages/vtable-search/demo/vite.config.js b/packages/vtable-search/demo/vite.config.js index 55080a87d1..bddd21d88b 100644 --- a/packages/vtable-search/demo/vite.config.js +++ b/packages/vtable-search/demo/vite.config.js @@ -13,6 +13,7 @@ module.exports = { }, resolve: { alias: { + '@visactor/vtable/es': path.resolve(__dirname, '../../vtable/src'), '@visactor/vtable': path.resolve(__dirname, '../../vtable/src/index.ts'), '@src': path.resolve(__dirname, '../../vtable/src/'), '@vutils-extension': path.resolve(__dirname, '../../vtable/src/vutil-extension-temp') diff --git a/packages/vtable-search/package.json b/packages/vtable-search/package.json index 291e4be171..1ac89f83f2 100644 --- a/packages/vtable-search/package.json +++ b/packages/vtable-search/package.json @@ -44,6 +44,7 @@ "cross-env": "^7.0.3", "increase-memory-limit": "^1.0.7", "@visactor/vchart": "2.1.3", + "@visactor/vtable-plugins": "workspace:1.26.8", "@internal/bundler": "workspace:*", "@internal/eslint-config": "workspace:*", "@internal/ts-config": "workspace:*", diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index cfcb558fcd..cf78fe9591 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -105,6 +105,7 @@ export class SearchComponent { private resultParentRowMap = new WeakMap(); private resultTables = new Set(); private tableIdMap = new WeakMap(); + private tableIdOwners = new Map(); private searchStyleArrangementMap = new WeakMap>(); private searchStyleArrangementStates = new WeakMap< object, @@ -292,11 +293,17 @@ export class SearchComponent { return; } - const currentResult = this.currentIndex >= 0 ? this.queryResult[this.currentIndex] : undefined; + const previousIndex = this.currentIndex; + const currentResult = previousIndex >= 0 ? this.queryResult[previousIndex] : undefined; const availableResults: QueryResultItem[] = []; - for (const resultItem of this.queryResult) { + let availableBeforeCurrent = 0; + for (let index = 0; index < this.queryResult.length; index++) { + const resultItem = this.queryResult[index]; if (this.isResultAvailable(resultItem, activeTables)) { availableResults.push(resultItem); + if (index < previousIndex) { + availableBeforeCurrent++; + } } } if (availableResults.length === this.queryResult.length) { @@ -310,11 +317,11 @@ export class SearchComponent { if (currentResultIndex >= 0) { this.currentIndex = currentResultIndex; } else if (direction > 0) { - this.currentIndex = Math.min(this.currentIndex - 1, this.queryResult.length - 1); + this.currentIndex = availableBeforeCurrent - 1; } else if (direction < 0) { - this.currentIndex = Math.min(this.currentIndex, this.queryResult.length); + this.currentIndex = availableBeforeCurrent; } else { - this.currentIndex = Math.min(Math.max(this.currentIndex, -1), this.queryResult.length - 1); + this.currentIndex = Math.min(availableBeforeCurrent, this.queryResult.length - 1); } } else if (this.currentIndex >= this.queryResult.length) { this.currentIndex = this.queryResult.length - 1; @@ -333,17 +340,19 @@ export class SearchComponent { } 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; + const explicitId = (table as any).id; + const baseId = typeof explicitId === 'string' && explicitId ? explicitId : 'search-table'; + let tableId = baseId; + while (this.tableIdOwners.has(tableId)) { + tableId = `${baseId}-${this.nextTableId++}`; + } + this.tableIdMap.set(table as object, tableId); + this.tableIdOwners.set(tableId, table as object); + return tableId; } private addQueryResult( @@ -1257,7 +1266,7 @@ export class SearchComponent { if (isDetailTable) { this.ensureSubTableParentVisible(targetTable, undefined, parentBodyRowIndex); } - const scrollOption = isDetailTable ? { ...this.scrollOption, duration: 0 } : this.scrollOption; + const scrollOption = isDetailTable ? false : this.scrollOption; targetTable.scrollToCell({ row: finalRow, col: targetCol }, scrollOption); this.ensureSubTableParentVisible(targetTable, { col: targetCol, row: finalRow }, parentBodyRowIndex); @@ -1272,6 +1281,10 @@ export class SearchComponent { } const { rowStart, rowEnd } = targetTable.getBodyVisibleRowRange(); const { colStart, colEnd } = targetTable.getBodyVisibleColRange(); + const isDetailTable = targetTable !== this.table; + if (isDetailTable) { + this.ensureSubTableParentVisible(targetTable, undefined, parentBodyRowIndex); + } // 检查单元格是否在表格可视范围内 const isInTableView = row >= rowStart && row <= rowEnd && col >= colStart && col <= colEnd; diff --git a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts index 7781a8b1a6..1510a5f378 100644 --- a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts +++ b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts @@ -124,4 +124,69 @@ describe('CustomCellStylePlugin', () => { expect(lastCall[1]).toBe(4); expect(lastCall[2]).toBe(true); }); + + test('uses the exact-cell index instead of scanning unrelated arrangements', () => { + let numericReads = 0; + const arrangements = new Proxy( + Array.from({ length: 1000 }, (_, col) => ({ + cellPosition: { col, row: 0 }, + customStyleId: 's' + })), + { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + numericReads++; + } + return Reflect.get(target, property, receiver); + } + } + ); + const plugin = new CustomCellStylePlugin( + createMockTable(1000, 1) as any, + [{ id: 's', style: { bgColor: 'yellow' } }] as any, + arrangements as any + ); + numericReads = 0; + + expect(plugin.getCustomCellStyleIds(500, 0)).toEqual(['s']); + expect(numericReads).toBeLessThan(10); + }); + + test('keeps exact and range styles in arrangement order', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'range-first', style: { bgColor: 'red' } }, + { id: 'exact', style: { color: 'white' } }, + { id: 'range-last', style: { fontWeight: 'bold' } } + ] as any, + [ + { + cellPosition: { range: { start: { col: 0, row: 0 }, end: { col: 5, row: 5 } } }, + customStyleId: 'range-first' + }, + { cellPosition: { col: 2, row: 2 }, customStyleId: 'exact' }, + { + cellPosition: { range: { start: { col: 1, row: 1 }, end: { col: 3, row: 3 } } }, + customStyleId: 'range-last' + } + ] as any + ); + + expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['range-first', 'exact', 'range-last']); + }); + + test('clearing arrangements also clears lookup indexes', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [{ id: 's', style: { bgColor: 'red' } }] as any, + [{ cellPosition: { col: 1, row: 2 }, customStyleId: 's' }] as any + ); + + plugin.clearCustomCellStyleArrangement(); + + expect((plugin as any)._customCellStyleArrangementIndex.size).toBe(0); + expect((plugin as any)._customCellStyleArrangementIndexes.size).toBe(0); + expect(plugin.getCustomCellStyleIds(1, 2)).toEqual([]); + }); }); diff --git a/packages/vtable/src/plugins/custom-cell-style.ts b/packages/vtable/src/plugins/custom-cell-style.ts index 476cc304ee..01b1409061 100644 --- a/packages/vtable/src/plugins/custom-cell-style.ts +++ b/packages/vtable/src/plugins/custom-cell-style.ts @@ -23,6 +23,8 @@ export class CustomCellStylePlugin { customCellStyle: CustomCellStyle[]; customCellStyleArrangement: CustomCellStyleArrangement[]; private _customCellStyleArrangementIndex: Map; + private _customCellStyleArrangementIndexes: Map; + private _customCellStyleRangeArrangementIndexes: number[]; private _customCellStyleArrangementTombstoneCount: number; constructor( @@ -34,6 +36,8 @@ export class CustomCellStylePlugin { this.customCellStyle = customCellStyle; this.customCellStyleArrangement = customCellStyleArrangement; this._customCellStyleArrangementIndex = new Map(); + this._customCellStyleArrangementIndexes = new Map(); + this._customCellStyleRangeArrangementIndexes = []; this._customCellStyleArrangementTombstoneCount = 0; this._rebuildCustomCellStyleArrangementIndex(); } @@ -51,15 +55,27 @@ export class CustomCellStylePlugin { private _rebuildCustomCellStyleArrangementIndex() { this._customCellStyleArrangementIndex.clear(); + this._customCellStyleArrangementIndexes.clear(); + this._customCellStyleRangeArrangementIndexes.length = 0; this._customCellStyleArrangementTombstoneCount = 0; for (let i = 0; i < this.customCellStyleArrangement.length; i++) { - if (!isValid((this.customCellStyleArrangement[i] as any).customStyleId)) { + const arrangement = this.customCellStyleArrangement[i]; + if (!isValid((arrangement as any).customStyleId)) { this._customCellStyleArrangementTombstoneCount++; continue; } - const key = this._getCustomCellStyleArrangementKey(this.customCellStyleArrangement[i].cellPosition); + const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); if (key) { this._customCellStyleArrangementIndex.set(key, i); + const indexes = this._customCellStyleArrangementIndexes.get(key); + if (indexes) { + indexes.push(i); + } else { + this._customCellStyleArrangementIndexes.set(key, [i]); + } + } + if (arrangement.cellPosition.range) { + this._customCellStyleRangeArrangementIndexes.push(i); } } } @@ -84,19 +100,36 @@ export class CustomCellStylePlugin { clearCustomCellStyleArrangement() { this.customCellStyleArrangement = []; + this._rebuildCustomCellStyleArrangementIndex(); } addCustomCellStyleArrangement( cellPosition: { - col: number; - row: number; + col?: number; + row?: number; + range?: CellRange; }, customStyleId: string | undefined | null ) { - this.customCellStyleArrangement.push({ + const arrangement = { cellPosition, customStyleId - }); + }; + this.customCellStyleArrangement.push(arrangement); + const index = this.customCellStyleArrangement.length - 1; + const key = this._getCustomCellStyleArrangementKey(cellPosition); + if (key) { + this._customCellStyleArrangementIndex.set(key, index); + const indexes = this._customCellStyleArrangementIndexes.get(key); + if (indexes) { + indexes.push(index); + } else { + this._customCellStyleArrangementIndexes.set(key, [index]); + } + } + if (cellPosition.range) { + this._customCellStyleRangeArrangementIndexes.push(index); + } } getCustomCellStyle(col: number, row: number) { @@ -138,12 +171,24 @@ export class CustomCellStylePlugin { const range = this.table.getCellRange(col, row); for (let c = range.start.col; c <= range.end.col; c++) { for (let r = range.start.row; r <= range.end.row; r++) { - // eslint-disable-next-line no-loop-func - this.customCellStyleArrangement.forEach(style => { - if (!isValid(style.customStyleId)) { - return; + const exactIndexes = this._customCellStyleArrangementIndexes.get(`cell:${c},${r}`) ?? []; + let exactIndex = 0; + let rangeIndex = 0; + while (exactIndex < exactIndexes.length || rangeIndex < this._customCellStyleRangeArrangementIndexes.length) { + const nextExactIndex = exactIndexes[exactIndex] ?? Number.POSITIVE_INFINITY; + const nextRangeIndex = this._customCellStyleRangeArrangementIndexes[rangeIndex] ?? Number.POSITIVE_INFINITY; + const arrangementIndex = Math.min(nextExactIndex, nextRangeIndex); + const isRangeArrangement = nextRangeIndex <= nextExactIndex; + if (isRangeArrangement) { + rangeIndex++; + } else { + exactIndex++; + } + const style = this.customCellStyleArrangement[arrangementIndex]; + if (!style || !isValid(style.customStyleId)) { + continue; } - if (style.cellPosition.range) { + if (isRangeArrangement && style.cellPosition.range) { if ( style.cellPosition.range.start.col <= c && style.cellPosition.range.end.col >= c && @@ -153,11 +198,11 @@ export class CustomCellStylePlugin { // customStyleId = style.customStyleId; customStyleIds.push(style.customStyleId as string); } - } else if (style.cellPosition.col === c && style.cellPosition.row === r) { + } else if (!isRangeArrangement && style.cellPosition.col === c && style.cellPosition.row === r) { // customStyleId = style.customStyleId; customStyleIds.push(style.customStyleId as string); } - }); + } } } @@ -225,17 +270,8 @@ export class CustomCellStylePlugin { const item = this.customCellStyleArrangement[index]; const itemKey = item ? this._getCustomCellStyleArrangementKey(item.cellPosition) : undefined; if (!item || !isValid((item as any).customStyleId) || itemKey !== inputKey) { - index = this.customCellStyleArrangement.findIndex(style => { - if (!isValid((style as any).customStyleId)) { - return false; - } - return this._getCustomCellStyleArrangementKey(style.cellPosition) === inputKey; - }); - if (index !== -1) { - this._customCellStyleArrangementIndex.set(inputKey, index); - } else { - this._customCellStyleArrangementIndex.delete(inputKey); - } + this._rebuildCustomCellStyleArrangementIndex(); + index = this._customCellStyleArrangementIndex.get(inputKey) ?? -1; } } if (index === -1 && !inputKey) { @@ -274,6 +310,15 @@ export class CustomCellStylePlugin { ); if (pushedKey) { this._customCellStyleArrangementIndex.set(pushedKey, pushedIndex); + const indexes = this._customCellStyleArrangementIndexes.get(pushedKey); + if (indexes) { + indexes.push(pushedIndex); + } else { + this._customCellStyleArrangementIndexes.set(pushedKey, [pushedIndex]); + } + } + if (this.customCellStyleArrangement[pushedIndex].cellPosition.range) { + this._customCellStyleRangeArrangementIndexes.push(pushedIndex); } } else if (this.customCellStyleArrangement[index].customStyleId === customStyleId) { // same style @@ -338,6 +383,8 @@ export class CustomCellStylePlugin { this.customCellStyle.length = 0; this.customCellStyleArrangement.length = 0; this._customCellStyleArrangementIndex.clear(); + this._customCellStyleArrangementIndexes.clear(); + this._customCellStyleRangeArrangementIndexes.length = 0; this._customCellStyleArrangementTombstoneCount = 0; customCellStyle.forEach((cellStyle: CustomCellStyle) => { this.registerCustomCellStyle(cellStyle.id, cellStyle.style); From 23e60fcc8570547f23f24049bfec85468efd03e7 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Mon, 14 Sep 2026 19:06:26 +0800 Subject: [PATCH 09/12] fix(vtable-search): isolate search style overlays Keep search highlights separate from user style arrangements to preserve public mutation semantics and avoid merged-cell lookup scans. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- ...-custom-style-lookup_2026-09-14-16-00.json | 2 +- .../master-detail-search-highlight.test.ts | 3 +- .../__tests__/review-regressions.test.ts | 103 +++++++++++-- packages/vtable-search/demo/vite.config.js | 1 + .../src/search-component/search-component.ts | 142 +++++------------- packages/vtable-search/tsconfig.test.json | 4 +- .../plugins/custom-cell-style.test.ts | 72 +++++++-- .../vtable/src/plugins/custom-cell-style.ts | 133 +++++++++------- 8 files changed, 269 insertions(+), 191 deletions(-) diff --git a/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json b/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json index 95dd0fa7b6..95357570ef 100644 --- a/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json +++ b/common/changes/@visactor/vtable/fix-custom-style-lookup_2026-09-14-16-00.json @@ -1,7 +1,7 @@ { "changes": [ { - "comment": "fix(vtable): use indexed exact-cell custom style lookups", + "comment": "fix(vtable): support indexed custom style overlays", "type": "patch", "packageName": "@visactor/vtable" } 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 c5ecc029eb..e487c55d55 100644 --- a/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts +++ b/packages/vtable-search/__tests__/master-detail-search-highlight.test.ts @@ -100,6 +100,5 @@ test('focus navigation and clear operate on the matching detail table', () => { search.clear(); - expect(detail.table.customCellStylePlugin.clearCustomCellStyleArrangement).toHaveBeenCalled(); - expect(detail.arrangements).toHaveLength(0); + expect(detail.table.customCellStylePlugin.customCellStyleArrangement).toHaveLength(0); }); diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index 53a58ede9b..d7c120223a 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -3,6 +3,7 @@ // @ts-nocheck import { SearchComponent } from '../src'; +import { CustomCellStylePlugin } from '../../vtable/src/plugins/custom-cell-style'; afterEach(() => { jest.restoreAllMocks(); @@ -87,6 +88,8 @@ function createCellTable( isReleased: false, isHeader: jest.fn((_col, row) => row === 0), getCellValue: jest.fn((col, row) => (row === 0 ? 'Name' : values[row - 1]?.[col])), + getCellOriginValue: jest.fn((col, row) => (row === 0 ? 'Name' : values[row - 1]?.[col])), + getCellHeaderPaths: jest.fn(() => undefined), getCellRange: jest.fn((col, row) => ({ start: { col, row }, end: { col, row } @@ -168,6 +171,23 @@ function createCellTable( return { table, customCellStylePlugin, registeredStyles }; } +function attachRealCustomStylePlugin( + target: ReturnType, + customCellStyle: any[] = [], + customCellStyleArrangement: any[] = [] +) { + const plugin = new CustomCellStylePlugin( + target.table as any, + customCellStyle as any, + customCellStyleArrangement as any + ); + target.table.customCellStylePlugin = plugin; + target.table.registerCustomCellStyle = jest.fn(plugin.registerCustomCellStyle.bind(plugin)); + target.table.hasCustomCellStyle = jest.fn(plugin.hasCustomCellStyle.bind(plugin)); + target.table.arrangeCustomCellStyle = jest.fn(plugin.arrangeCustomCellStyle.bind(plugin)); + return plugin; +} + function createTreeTable() { const records = [{ name: 'Main' }]; const main = createCellTable([], { @@ -266,6 +286,9 @@ test('released detail tables are removed from search state safely', () => { const search = new SearchComponent({ table: main.table as any, autoJump: false }); search.search('i'); + expect(search.queryResult).toEqual( + expect.arrayContaining([expect.objectContaining({ table: detail.table, value: 'Widget' })]) + ); detail.table.isReleased = true; detail.table.scenegraph = null; main.table.internalProps.subTableInstances.clear(); @@ -635,32 +658,85 @@ test('search styles do not replace a user range arrangement at the same cell', ( ]); }); -test('navigation does not rebuild the custom style index for search entries', () => { - const main = createCellTable([['Alice', 'Alina']]); - main.table.colCount = 2; +test('real style plugin keeps multiple user styles separate from search overlays', () => { + const main = createCellTable([['Alice']]); + const plugin = attachRealCustomStylePlugin( + main, + [ + { id: 'user-a', style: { color: 'red' } }, + { id: 'user-b', style: { fontWeight: 'bold' } }, + { id: 'user-updated', style: { color: 'blue' } } + ], + [ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-a' }, + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-b' } + ] + ); const search = new SearchComponent({ table: main.table as any, autoJump: false }); + search.search('Ali'); - const rebuildIndex = main.customCellStylePlugin._rebuildCustomCellStyleArrangementIndex; - rebuildIndex.mockClear(); + main.table.arrangeCustomCellStyle({ col: 0, row: 1 }, 'user-updated'); - search.next(); - search.next(); + expect(plugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-a' }, + { cellPosition: { col: 0, row: 1 }, customStyleId: 'user-updated' } + ]); + expect(plugin.getCustomCellStyleIds(0, 1)).toEqual(['user-a', 'user-updated', '__search_component_highlight']); + + search.clear(); + + expect(plugin.customCellStyleArrangement).toHaveLength(2); + expect(plugin.getCustomCellStyleIds(0, 1)).toEqual(['user-a', 'user-updated']); +}); + +test('real style plugin indexes merged search overlays without growing the public arrangement list', () => { + const values = Array.from({ length: 80 }, (_, index) => `Hit ${Math.floor(index / 2)}`); + const main = createCellTable([values]); + main.table.colCount = values.length; + main.table.getCellRange = jest.fn((col, row) => { + if (row !== 1) { + return { start: { col, row }, end: { col, row } }; + } + const startCol = col - (col % 2); + return { start: { col: startCol, row }, end: { col: startCol + 1, row } }; + }); + let arrangementReads = 0; + const publicArrangements = new Proxy([], { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + arrangementReads++; + } + return Reflect.get(target, property, receiver); + } + }); + const plugin = attachRealCustomStylePlugin(main, [], publicArrangements); + main.table.scenegraph.updateCellContent.mockImplementation((col, row) => { + plugin.getCustomCellStyleIds(col, row); + }); + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + arrangementReads = 0; + + const result = search.search('Hit'); - expect(rebuildIndex).not.toHaveBeenCalled(); + expect(result.results).toHaveLength(40); + expect(plugin.customCellStyleArrangement).toHaveLength(0); + expect((plugin as any)._customCellStyleOverlays.get('__search_component_overlay').positions.size).toBe(40); + expect(arrangementReads).toBe(0); }); -test('navigation does not scan the arrangement list for cached search styles', () => { +test('real plugin navigation updates only the previous and current overlays', () => { const main = createCellTable([['Alice', 'Alina']]); main.table.colCount = 2; + const plugin = attachRealCustomStylePlugin(main); const search = new SearchComponent({ table: main.table as any, autoJump: false }); search.search('Ali'); - const includesSpy = jest.spyOn(Array.prototype, 'includes'); + const setOverlaySpy = jest.spyOn(plugin, 'setCustomCellStyleOverlay'); search.next(); search.next(); - expect(includesSpy).not.toHaveBeenCalled(); - includesSpy.mockRestore(); + expect(setOverlaySpy).toHaveBeenCalledTimes(3); + expect(plugin.customCellStyleArrangement).toHaveLength(0); }); test('visible range boundaries are treated as inclusive', () => { @@ -1032,7 +1108,8 @@ test('tree detail navigation uses synchronous local scrolling before reading its search.jumpToCell({ IndexNumber: [0], col: 0 }, detail.table as any, 0); expect(detail.table.scrollToCell).toHaveBeenCalledWith({ row: 1, col: 0 }, false); - expect(geometryReads[geometryReads.length - 1]).toBe(true); + expect(geometryReads.length).toBeGreaterThan(0); + expect(geometryReads.every(Boolean)).toBe(true); expect(main.table.scrollTop).toBe(0); }); diff --git a/packages/vtable-search/demo/vite.config.js b/packages/vtable-search/demo/vite.config.js index bddd21d88b..c9b5c15adf 100644 --- a/packages/vtable-search/demo/vite.config.js +++ b/packages/vtable-search/demo/vite.config.js @@ -13,6 +13,7 @@ module.exports = { }, resolve: { alias: { + '@visactor/vtable-plugins': path.resolve(__dirname, '../../vtable-plugins/src/index.ts'), '@visactor/vtable/es': path.resolve(__dirname, '../../vtable/src'), '@visactor/vtable': path.resolve(__dirname, '../../vtable/src/index.ts'), '@src': path.resolve(__dirname, '../../vtable/src/'), diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index cf78fe9591..743390bdb7 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -47,6 +47,7 @@ export type SearchComponentOption = { const HighlightStyleId = '__search_component_highlight'; const FocusHighlightStyleId = '__search_component_focus'; +const SearchStyleOverlayId = '__search_component_overlay'; const searchStyleIds = new Set([HighlightStyleId, FocusHighlightStyleId]); type SearchCellPosition = | { col: number; row: number } @@ -105,17 +106,8 @@ export class SearchComponent { private resultParentRowMap = new WeakMap(); private resultTables = new Set(); private tableIdMap = new WeakMap(); - private tableIdOwners = new Map(); - private searchStyleArrangementMap = new WeakMap>(); - private searchStyleArrangementStates = new WeakMap< - object, - { - arrangements: any[]; - length: number; - first: any; - last: any; - } - >(); + private usedTableIds = new Set(); + private searchStylePositions = new WeakMap>(); private nextTableId = 1; constructor(option: SearchComponentOption) { @@ -347,11 +339,11 @@ export class SearchComponent { const explicitId = (table as any).id; const baseId = typeof explicitId === 'string' && explicitId ? explicitId : 'search-table'; let tableId = baseId; - while (this.tableIdOwners.has(tableId)) { + while (this.usedTableIds.has(tableId)) { tableId = `${baseId}-${this.nextTableId++}`; } this.tableIdMap.set(table as object, tableId); - this.tableIdOwners.set(tableId, table as object); + this.usedTableIds.add(tableId); return tableId; } @@ -420,53 +412,6 @@ 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.searchStyleArrangementStates.delete(table as object); - } - - private rebuildSearchStyleArrangementCache( - table: IVTable, - arrangements: any[] - ): Map { - const tableStyles = new Map(); - arrangements.forEach((arrangement, index) => { - if (!searchStyleIds.has(arrangement?.customStyleId)) { - return; - } - const key = this.getCellPositionKey(arrangement.cellPosition); - if (key) { - tableStyles.set(key, { arrangement, index }); - } - }); - this.searchStyleArrangementMap.set(table as object, tableStyles); - this.updateSearchStyleArrangementState(table, arrangements); - return tableStyles; - } - - private getSearchStyleArrangementCache( - table: IVTable, - arrangements: any[] - ): Map { - const state = this.searchStyleArrangementStates.get(table as object); - const tableStyles = this.searchStyleArrangementMap.get(table as object); - const isFresh = - state?.arrangements === arrangements && - state.length === arrangements.length && - state.first === arrangements[0] && - state.last === arrangements[arrangements.length - 1]; - return tableStyles && isFresh ? tableStyles : this.rebuildSearchStyleArrangementCache(table, arrangements); - } - - private updateSearchStyleArrangementState(table: IVTable, arrangements: any[]): void { - this.searchStyleArrangementStates.set(table as object, { - arrangements, - length: arrangements.length, - first: arrangements[0], - last: arrangements[arrangements.length - 1] - }); - } - private refreshCellStyle(table: IVTable, position: SearchCellPosition | any): void { const range = this.getCellPositionRange(position); if (!range) { @@ -481,46 +426,32 @@ export class SearchComponent { 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.getSearchStyleArrangementCache(table, arrangements); - let existing = tableStyles.get(positionKey); - if (existing && arrangements[existing.index] !== existing.arrangement) { - tableStyles = this.rebuildSearchStyleArrangementCache(table, arrangements); - existing = tableStyles.get(positionKey); + if (plugin && positionKey && typeof plugin.setCustomCellStyleOverlay === 'function') { + plugin.setCustomCellStyleOverlay(SearchStyleOverlayId, position, customStyleId); + let positions = this.searchStylePositions.get(table as object); + if (!positions) { + positions = new Map(); + this.searchStylePositions.set(table as object, positions); } - if ( - existing && - (existing.arrangement.customStyleId == null || searchStyleIds.has(existing.arrangement.customStyleId)) - ) { - existing.arrangement.customStyleId = customStyleId; + positions.set(positionKey, position); + return; + } + + const arrangements = plugin?.customCellStyleArrangement; + if (plugin && Array.isArray(arrangements) && positionKey) { + const existing = arrangements.find( + item => searchStyleIds.has(item?.customStyleId) && this.getCellPositionKey(item.cellPosition) === positionKey + ); + if (existing && (existing.customStyleId == null || searchStyleIds.has(existing.customStyleId))) { + existing.customStyleId = customStyleId; return; } if (typeof plugin.addCustomCellStyleArrangement === 'function') { plugin.addCustomCellStyleArrangement(position as any, customStyleId); - const currentArrangements = plugin.customCellStyleArrangement; - if (Array.isArray(currentArrangements)) { - if (currentArrangements !== arrangements) { - tableStyles = this.getSearchStyleArrangementCache(table, currentArrangements); - } - const index = currentArrangements.length - 1; - const addedArrangement = currentArrangements[index]; - if ( - addedArrangement && - searchStyleIds.has(addedArrangement.customStyleId) && - this.getCellPositionKey(addedArrangement.cellPosition) === positionKey - ) { - tableStyles.set(positionKey, { arrangement: addedArrangement, index }); - } - this.updateSearchStyleArrangementState(table, currentArrangements); - } return; } - const addedArrangement = { cellPosition: position, customStyleId }; - arrangements.push(addedArrangement); - tableStyles.set(positionKey, { arrangement: addedArrangement, index: arrangements.length - 1 }); - this.updateSearchStyleArrangementState(table, arrangements); + arrangements.push({ cellPosition: position, customStyleId }); return; } const arrange = (table as any).arrangeCustomCellStyle; @@ -533,8 +464,14 @@ export class SearchComponent { private clearSearchCellStyleAtPosition(table: IVTable, position: SearchCellPosition): void { const plugin = (table as any).customCellStylePlugin; - const arrangements = plugin?.customCellStyleArrangement; const positionKey = this.getCellPositionKey(position); + if (plugin && positionKey && typeof plugin.setCustomCellStyleOverlay === 'function') { + plugin.setCustomCellStyleOverlay(SearchStyleOverlayId, position, undefined); + this.searchStylePositions.get(table as object)?.delete(positionKey); + return; + } + + const arrangements = plugin?.customCellStyleArrangement; if (!Array.isArray(arrangements) || !positionKey) { return; } @@ -548,7 +485,14 @@ export class SearchComponent { private clearSearchCellStyles(table: IVTable): Map { const plugin = (table as any).customCellStylePlugin; const positionsToRefresh = new Map(); - this.resetSearchStyleArrangementCache(table); + const overlayPositions = this.searchStylePositions.get(table as object); + if (plugin && overlayPositions && typeof plugin.clearCustomCellStyleOverlay === 'function') { + overlayPositions.forEach((position, key) => positionsToRefresh.set(key, position)); + plugin.clearCustomCellStyleOverlay(SearchStyleOverlayId); + this.searchStylePositions.delete(table as object); + return positionsToRefresh; + } + const arrangements = plugin?.customCellStyleArrangement; if (!Array.isArray(arrangements)) { return positionsToRefresh; @@ -571,14 +515,8 @@ export class SearchComponent { 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); - } + arrangements.splice(0, arrangements.length, ...retainedArrangements); + plugin?._rebuildCustomCellStyleArrangementIndex?.call(plugin); return positionsToRefresh; } diff --git a/packages/vtable-search/tsconfig.test.json b/packages/vtable-search/tsconfig.test.json index 3e81f8d9a4..03da5b506c 100644 --- a/packages/vtable-search/tsconfig.test.json +++ b/packages/vtable-search/tsconfig.test.json @@ -6,7 +6,9 @@ "lib": ["DOM", "ESNext"], "baseUrl": "./", "rootDir": ".", - "paths": {} + "paths": { + "@src/*": ["../vtable/src/*"] + } }, "include": ["src", "__tests__"] } diff --git a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts index 1510a5f378..6c5b8ae788 100644 --- a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts +++ b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts @@ -125,27 +125,24 @@ describe('CustomCellStylePlugin', () => { expect(lastCall[2]).toBe(true); }); - test('uses the exact-cell index instead of scanning unrelated arrangements', () => { + test('uses indexed overlays without scanning unrelated search positions', () => { let numericReads = 0; - const arrangements = new Proxy( - Array.from({ length: 1000 }, (_, col) => ({ - cellPosition: { col, row: 0 }, - customStyleId: 's' - })), - { - get(target, property, receiver) { - if (typeof property === 'string' && /^\d+$/.test(property)) { - numericReads++; - } - return Reflect.get(target, property, receiver); + const arrangements = new Proxy([], { + get(target, property, receiver) { + if (typeof property === 'string' && /^\d+$/.test(property)) { + numericReads++; } + return Reflect.get(target, property, receiver); } - ); + }); const plugin = new CustomCellStylePlugin( createMockTable(1000, 1) as any, [{ id: 's', style: { bgColor: 'yellow' } }] as any, arrangements as any ); + for (let col = 0; col < 1000; col++) { + plugin.setCustomCellStyleOverlay('search', { range: { start: { col, row: 0 }, end: { col, row: 0 } } }, 's'); + } numericReads = 0; expect(plugin.getCustomCellStyleIds(500, 0)).toEqual(['s']); @@ -186,7 +183,54 @@ describe('CustomCellStylePlugin', () => { plugin.clearCustomCellStyleArrangement(); expect((plugin as any)._customCellStyleArrangementIndex.size).toBe(0); - expect((plugin as any)._customCellStyleArrangementIndexes.size).toBe(0); expect(plugin.getCustomCellStyleIds(1, 2)).toEqual([]); }); + + test('keeps search overlays separate from user arrangement updates', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'user-a', style: { color: 'red' } }, + { id: 'user-b', style: { fontWeight: 'bold' } }, + { id: 'user-updated', style: { color: 'blue' } }, + { id: 'search', style: { bgColor: 'yellow' } } + ] as any, + [ + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-a' }, + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-b' } + ] as any + ); + plugin.setCustomCellStyleOverlay('search-component', { col: 1, row: 2 }, 'search'); + + plugin.arrangeCustomCellStyle({ col: 1, row: 2 }, 'user-updated'); + + expect(plugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-a' }, + { cellPosition: { col: 1, row: 2 }, customStyleId: 'user-updated' } + ]); + expect(plugin.getCustomCellStyleIds(1, 2)).toEqual(['user-a', 'user-updated', 'search']); + + plugin.clearCustomCellStyleOverlay('search-component'); + + expect(plugin.getCustomCellStyleIds(1, 2)).toEqual(['user-a', 'user-updated']); + }); + + test('reads direct public arrangement mutations without a lookup cache', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } } + ] as any, + [{ cellPosition: { col: 1, row: 1 }, customStyleId: 'first' }] as any + ); + + plugin.customCellStyleArrangement.splice(0, 1, { + cellPosition: { col: 2, row: 2 }, + customStyleId: 'second' + } as any); + + expect(plugin.getCustomCellStyleIds(1, 1)).toEqual([]); + expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['second']); + }); }); diff --git a/packages/vtable/src/plugins/custom-cell-style.ts b/packages/vtable/src/plugins/custom-cell-style.ts index 01b1409061..cf049c9fa1 100644 --- a/packages/vtable/src/plugins/custom-cell-style.ts +++ b/packages/vtable/src/plugins/custom-cell-style.ts @@ -18,14 +18,18 @@ export interface ICustomCellStylePlugin { ): CustomCellStylePlugin; } +type CustomCellStyleOverlay = { + positions: Map; + cells: Map>; +}; + export class CustomCellStylePlugin { table: BaseTableAPI; customCellStyle: CustomCellStyle[]; customCellStyleArrangement: CustomCellStyleArrangement[]; private _customCellStyleArrangementIndex: Map; - private _customCellStyleArrangementIndexes: Map; - private _customCellStyleRangeArrangementIndexes: number[]; private _customCellStyleArrangementTombstoneCount: number; + private _customCellStyleOverlays: Map; constructor( table: BaseTableAPI, @@ -36,9 +40,8 @@ export class CustomCellStylePlugin { this.customCellStyle = customCellStyle; this.customCellStyleArrangement = customCellStyleArrangement; this._customCellStyleArrangementIndex = new Map(); - this._customCellStyleArrangementIndexes = new Map(); - this._customCellStyleRangeArrangementIndexes = []; this._customCellStyleArrangementTombstoneCount = 0; + this._customCellStyleOverlays = new Map(); this._rebuildCustomCellStyleArrangementIndex(); } @@ -55,8 +58,6 @@ export class CustomCellStylePlugin { private _rebuildCustomCellStyleArrangementIndex() { this._customCellStyleArrangementIndex.clear(); - this._customCellStyleArrangementIndexes.clear(); - this._customCellStyleRangeArrangementIndexes.length = 0; this._customCellStyleArrangementTombstoneCount = 0; for (let i = 0; i < this.customCellStyleArrangement.length; i++) { const arrangement = this.customCellStyleArrangement[i]; @@ -67,15 +68,6 @@ export class CustomCellStylePlugin { const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); if (key) { this._customCellStyleArrangementIndex.set(key, i); - const indexes = this._customCellStyleArrangementIndexes.get(key); - if (indexes) { - indexes.push(i); - } else { - this._customCellStyleArrangementIndexes.set(key, [i]); - } - } - if (arrangement.cellPosition.range) { - this._customCellStyleRangeArrangementIndexes.push(i); } } } @@ -111,25 +103,67 @@ export class CustomCellStylePlugin { }, customStyleId: string | undefined | null ) { - const arrangement = { + this.customCellStyleArrangement.push({ cellPosition, customStyleId - }; - this.customCellStyleArrangement.push(arrangement); - const index = this.customCellStyleArrangement.length - 1; - const key = this._getCustomCellStyleArrangementKey(cellPosition); - if (key) { - this._customCellStyleArrangementIndex.set(key, index); - const indexes = this._customCellStyleArrangementIndexes.get(key); - if (indexes) { - indexes.push(index); - } else { - this._customCellStyleArrangementIndexes.set(key, [index]); + }); + } + + setCustomCellStyleOverlay( + sourceId: string, + cellPosition: { col?: number; row?: number; range?: CellRange }, + customStyleId: string | undefined | null + ) { + const positionKey = this._getCustomCellStyleArrangementKey(cellPosition); + if (!positionKey) { + return; + } + let overlay = this._customCellStyleOverlays.get(sourceId); + if (!overlay) { + overlay = { + positions: new Map(), + cells: new Map() + }; + this._customCellStyleOverlays.set(sourceId, overlay); + } + const previousCellKeys = overlay.positions.get(positionKey) ?? []; + if (!customStyleId) { + previousCellKeys.forEach(cellKey => { + const cellStyles = overlay?.cells.get(cellKey); + cellStyles?.delete(positionKey); + if (cellStyles?.size === 0) { + overlay?.cells.delete(cellKey); + } + }); + overlay.positions.delete(positionKey); + if (overlay.positions.size === 0) { + this._customCellStyleOverlays.delete(sourceId); } + return; } - if (cellPosition.range) { - this._customCellStyleRangeArrangementIndexes.push(index); + + const range = cellPosition.range ?? { + start: { col: cellPosition.col as number, row: cellPosition.row as number }, + end: { col: cellPosition.col as number, row: cellPosition.row as number } + }; + const cellKeys: string[] = []; + for (let col = range.start.col; col <= range.end.col; col++) { + for (let row = range.start.row; row <= range.end.row; row++) { + const cellKey = `${col}:${row}`; + cellKeys.push(cellKey); + let cellStyles = overlay.cells.get(cellKey); + if (!cellStyles) { + cellStyles = new Map(); + overlay.cells.set(cellKey, cellStyles); + } + cellStyles.set(positionKey, customStyleId); + } } + overlay.positions.set(positionKey, cellKeys); + } + + clearCustomCellStyleOverlay(sourceId: string) { + this._customCellStyleOverlays.delete(sourceId); } getCustomCellStyle(col: number, row: number) { @@ -171,24 +205,12 @@ export class CustomCellStylePlugin { const range = this.table.getCellRange(col, row); for (let c = range.start.col; c <= range.end.col; c++) { for (let r = range.start.row; r <= range.end.row; r++) { - const exactIndexes = this._customCellStyleArrangementIndexes.get(`cell:${c},${r}`) ?? []; - let exactIndex = 0; - let rangeIndex = 0; - while (exactIndex < exactIndexes.length || rangeIndex < this._customCellStyleRangeArrangementIndexes.length) { - const nextExactIndex = exactIndexes[exactIndex] ?? Number.POSITIVE_INFINITY; - const nextRangeIndex = this._customCellStyleRangeArrangementIndexes[rangeIndex] ?? Number.POSITIVE_INFINITY; - const arrangementIndex = Math.min(nextExactIndex, nextRangeIndex); - const isRangeArrangement = nextRangeIndex <= nextExactIndex; - if (isRangeArrangement) { - rangeIndex++; - } else { - exactIndex++; + // eslint-disable-next-line no-loop-func + this.customCellStyleArrangement.forEach(style => { + if (!isValid(style.customStyleId)) { + return; } - const style = this.customCellStyleArrangement[arrangementIndex]; - if (!style || !isValid(style.customStyleId)) { - continue; - } - if (isRangeArrangement && style.cellPosition.range) { + if (style.cellPosition.range) { if ( style.cellPosition.range.start.col <= c && style.cellPosition.range.end.col >= c && @@ -198,10 +220,16 @@ export class CustomCellStylePlugin { // customStyleId = style.customStyleId; customStyleIds.push(style.customStyleId as string); } - } else if (!isRangeArrangement && style.cellPosition.col === c && style.cellPosition.row === r) { + } else if (style.cellPosition.col === c && style.cellPosition.row === r) { // customStyleId = style.customStyleId; customStyleIds.push(style.customStyleId as string); } + }); + for (const overlay of this._customCellStyleOverlays.values()) { + const overlayStyles = overlay.cells.get(`${c}:${r}`); + if (overlayStyles) { + customStyleIds.push(...overlayStyles.values()); + } } } } @@ -310,15 +338,6 @@ export class CustomCellStylePlugin { ); if (pushedKey) { this._customCellStyleArrangementIndex.set(pushedKey, pushedIndex); - const indexes = this._customCellStyleArrangementIndexes.get(pushedKey); - if (indexes) { - indexes.push(pushedIndex); - } else { - this._customCellStyleArrangementIndexes.set(pushedKey, [pushedIndex]); - } - } - if (this.customCellStyleArrangement[pushedIndex].cellPosition.range) { - this._customCellStyleRangeArrangementIndexes.push(pushedIndex); } } else if (this.customCellStyleArrangement[index].customStyleId === customStyleId) { // same style @@ -383,8 +402,6 @@ export class CustomCellStylePlugin { this.customCellStyle.length = 0; this.customCellStyleArrangement.length = 0; this._customCellStyleArrangementIndex.clear(); - this._customCellStyleArrangementIndexes.clear(); - this._customCellStyleRangeArrangementIndexes.length = 0; this._customCellStyleArrangementTombstoneCount = 0; customCellStyle.forEach((cellStyle: CustomCellStyle) => { this.registerCustomCellStyle(cellStyle.id, cellStyle.style); From 068c193c2a5c41908a9b91074aae04eae52103a7 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 15 Sep 2026 11:22:32 +0800 Subject: [PATCH 10/12] fix(vtable-search): address latest review blockers Avoid repeated merged-cell refreshes and keep custom style indexes aligned with direct public arrangement mutations. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/review-regressions.test.ts | 2 + .../list/issue-5036-master-detail-search.ts | 3 + .../src/search-component/search-component.ts | 6 +- .../plugins/custom-cell-style.test.ts | 73 +++++++++++++++++++ .../vtable/src/plugins/custom-cell-style.ts | 35 +-------- 5 files changed, 82 insertions(+), 37 deletions(-) diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index d7c120223a..fea5efb156 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -721,6 +721,8 @@ test('real style plugin indexes merged search overlays without growing the publi expect(result.results).toHaveLength(40); expect(plugin.customCellStyleArrangement).toHaveLength(0); expect((plugin as any)._customCellStyleOverlays.get('__search_component_overlay').positions.size).toBe(40); + expect(main.table.scenegraph.updateCellContent).toHaveBeenCalledTimes(40); + expect(main.table.scenegraph.updateCellContent.mock.calls.every(([col]) => col % 2 === 0)).toBe(true); expect(arrangementReads).toBe(0); }); diff --git a/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts b/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts index add1b51564..c7ca059f23 100644 --- a/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts +++ b/packages/vtable-search/demo/list/issue-5036-master-detail-search.ts @@ -83,6 +83,9 @@ export function createTable() { } requestAnimationFrame(() => { + if (tableInstance.isReleased) { + return; + } records.forEach((_record, recordIndex) => { const row = tableInstance.getTableIndexByRecordIndex(recordIndex); if (typeof row === 'number' && tableInstance.getHierarchyState(0, row) !== 'expand') { diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index 743390bdb7..b4a1bfacb7 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -417,11 +417,7 @@ export class SearchComponent { 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); - } - } + table.scenegraph.updateCellContent(range.start.col, range.start.row, true); } private arrangeSearchCellStyle(table: IVTable, position: SearchCellPosition, customStyleId: string): void { diff --git a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts index 6c5b8ae788..785b140894 100644 --- a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts +++ b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts @@ -233,4 +233,77 @@ describe('CustomCellStylePlugin', () => { expect(plugin.getCustomCellStyleIds(1, 1)).toEqual([]); expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['second']); }); + + test('updates and clears positions inserted directly into the public arrangement array', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } } + ] as any, + [] as any + ); + plugin.customCellStyleArrangement.push({ + cellPosition: { col: 3, row: 4 }, + customStyleId: 'first' + } as any); + + plugin.arrangeCustomCellStyle({ col: 3, row: 4 }, 'second'); + + expect(plugin.customCellStyleArrangement).toHaveLength(1); + expect(plugin.getCustomCellStyleIds(3, 4)).toEqual(['second']); + + plugin.arrangeCustomCellStyle({ col: 3, row: 4 }, null); + + expect(plugin.getCustomCellStyleIds(3, 4)).toEqual([]); + }); + + test('updates the latest style after a same-position public append', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } }, + { id: 'updated', style: { color: 'green' } } + ] as any, + [{ cellPosition: { col: 2, row: 2 }, customStyleId: 'first' }] as any + ); + plugin.customCellStyleArrangement.push({ + cellPosition: { col: 2, row: 2 }, + customStyleId: 'second' + } as any); + + plugin.arrangeCustomCellStyle({ col: 2, row: 2 }, 'updated'); + + expect(plugin.customCellStyleArrangement).toEqual([ + { cellPosition: { col: 2, row: 2 }, customStyleId: 'first' }, + { cellPosition: { col: 2, row: 2 }, customStyleId: 'updated' } + ]); + + plugin.arrangeCustomCellStyle({ col: 2, row: 2 }, null); + + expect(plugin.getCustomCellStyleIds(2, 2)).toEqual(['first']); + expect((plugin as any)._customCellStyleArrangementIndex.get('cell:2,2')).toBe(0); + }); + + test('updates the correct style after a direct public deletion shifts indexes', () => { + const plugin = new CustomCellStylePlugin( + createMockTable() as any, + [ + { id: 'first', style: { color: 'red' } }, + { id: 'second', style: { color: 'blue' } }, + { id: 'updated', style: { color: 'green' } } + ] as any, + [ + { cellPosition: { col: 1, row: 1 }, customStyleId: 'first' }, + { cellPosition: { col: 2, row: 2 }, customStyleId: 'second' } + ] as any + ); + plugin.customCellStyleArrangement.splice(0, 1); + + plugin.arrangeCustomCellStyle({ col: 2, row: 2 }, 'updated'); + + expect(plugin.customCellStyleArrangement).toEqual([{ cellPosition: { col: 2, row: 2 }, customStyleId: 'updated' }]); + expect((plugin as any)._customCellStyleArrangementIndex.get('cell:2,2')).toBe(0); + }); }); diff --git a/packages/vtable/src/plugins/custom-cell-style.ts b/packages/vtable/src/plugins/custom-cell-style.ts index cf049c9fa1..f1844b8031 100644 --- a/packages/vtable/src/plugins/custom-cell-style.ts +++ b/packages/vtable/src/plugins/custom-cell-style.ts @@ -293,31 +293,8 @@ export class CustomCellStylePlugin { forceFastUpdate?: boolean ) { const inputKey = this._getCustomCellStyleArrangementKey(cellPos); - let index = inputKey ? this._customCellStyleArrangementIndex.get(inputKey) ?? -1 : -1; - if (inputKey && index !== -1) { - const item = this.customCellStyleArrangement[index]; - const itemKey = item ? this._getCustomCellStyleArrangementKey(item.cellPosition) : undefined; - if (!item || !isValid((item as any).customStyleId) || itemKey !== inputKey) { - this._rebuildCustomCellStyleArrangementIndex(); - index = this._customCellStyleArrangementIndex.get(inputKey) ?? -1; - } - } - if (index === -1 && !inputKey) { - index = this.customCellStyleArrangement.findIndex(style => { - if (!isValid((style as any).customStyleId)) { - return false; - } - if (style.cellPosition.range && cellPos.range) { - return ( - style.cellPosition.range.start.col === cellPos.range.start.col && - style.cellPosition.range.start.row === cellPos.range.start.row && - style.cellPosition.range.end.col === cellPos.range.end.col && - style.cellPosition.range.end.row === cellPos.range.end.row - ); - } - return style.cellPosition.col === cellPos.col && style.cellPosition.row === cellPos.row; - }); - } + this._rebuildCustomCellStyleArrangementIndex(); + const index = inputKey ? this._customCellStyleArrangementIndex.get(inputKey) ?? -1 : -1; if (index === -1 && !customStyleId) { // do nothing @@ -347,14 +324,8 @@ export class CustomCellStylePlugin { this.customCellStyleArrangement[index].customStyleId = customStyleId; } else { // delete useless style - const existedKey = this._getCustomCellStyleArrangementKey(this.customCellStyleArrangement[index].cellPosition); - if (isValid((this.customCellStyleArrangement[index] as any).customStyleId)) { - this._customCellStyleArrangementTombstoneCount++; - } (this.customCellStyleArrangement[index] as any).customStyleId = null; - if (existedKey) { - this._customCellStyleArrangementIndex.delete(existedKey); - } + this._rebuildCustomCellStyleArrangementIndex(); this._compactCustomCellStyleArrangementIfNeeded(); } From 1efded5e55ad5e15603d6d8da3cb7dd26e28c6f4 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Tue, 15 Sep 2026 16:34:59 +0800 Subject: [PATCH 11/12] fix(vtable-search): complete merged style refreshes Refresh every merged-cell fragment with cached style resolution and batch custom style restoration without repeated index rebuilds. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/review-regressions.test.ts | 8 +- .../src/search-component/search-component.ts | 11 +- .../plugins/custom-cell-style.test.ts | 29 +++++ .../vtable/src/plugins/custom-cell-style.ts | 114 +++++++++++++++--- 4 files changed, 144 insertions(+), 18 deletions(-) diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index fea5efb156..bdc433c2f7 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -713,6 +713,7 @@ test('real style plugin indexes merged search overlays without growing the publi main.table.scenegraph.updateCellContent.mockImplementation((col, row) => { plugin.getCustomCellStyleIds(col, row); }); + const collectStyleIdsSpy = jest.spyOn(plugin as any, '_collectCustomCellStyleIds'); const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); arrangementReads = 0; @@ -721,8 +722,11 @@ test('real style plugin indexes merged search overlays without growing the publi expect(result.results).toHaveLength(40); expect(plugin.customCellStyleArrangement).toHaveLength(0); expect((plugin as any)._customCellStyleOverlays.get('__search_component_overlay').positions.size).toBe(40); - expect(main.table.scenegraph.updateCellContent).toHaveBeenCalledTimes(40); - expect(main.table.scenegraph.updateCellContent.mock.calls.every(([col]) => col % 2 === 0)).toBe(true); + expect(main.table.scenegraph.updateCellContent).toHaveBeenCalledTimes(80); + expect(main.table.scenegraph.updateCellContent.mock.calls.map(([col]) => col)).toEqual( + Array.from({ length: 80 }, (_, col) => col) + ); + expect(collectStyleIdsSpy).toHaveBeenCalledTimes(40); expect(arrangementReads).toBe(0); }); diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index b4a1bfacb7..2180b8beaf 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -417,7 +417,16 @@ export class SearchComponent { if (!range) { return; } - table.scenegraph.updateCellContent(range.start.col, range.start.row, true); + const plugin = (table as any).customCellStylePlugin; + if (typeof plugin?.refreshCustomCellStyleRange === 'function') { + plugin.refreshCustomCellStyleRange(position, true); + return; + } + for (let col = range.start.col; col <= range.end.col; col++) { + for (let row = range.start.row; row <= range.end.row; row++) { + table.scenegraph.updateCellContent(col, row, true); + } + } } private arrangeSearchCellStyle(table: IVTable, position: SearchCellPosition, customStyleId: string): void { diff --git a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts index 785b140894..f941818a94 100644 --- a/packages/vtable/__tests__/plugins/custom-cell-style.test.ts +++ b/packages/vtable/__tests__/plugins/custom-cell-style.test.ts @@ -306,4 +306,33 @@ describe('CustomCellStylePlugin', () => { expect(plugin.customCellStyleArrangement).toEqual([{ cellPosition: { col: 2, row: 2 }, customStyleId: 'updated' }]); expect((plugin as any)._customCellStyleArrangementIndex.get('cell:2,2')).toBe(0); }); + + test('bulk updates arrangements with a single index rebuild', () => { + const table = createMockTable(1000, 2); + const plugin = new CustomCellStylePlugin(table as any, [] as any, [] as any); + const rebuildSpy = jest.spyOn(plugin as any, '_rebuildCustomCellStyleArrangementIndex'); + const arrangements = Array.from({ length: 1000 }, (_, col) => ({ + cellPosition: { col, row: 0 }, + customStyleId: 'bulk' + })); + arrangements.push({ + cellPosition: { col: 500, row: 0 }, + customStyleId: 'updated' + }); + + plugin.updateCustomCell( + [ + { id: 'bulk', style: { bgColor: 'yellow' } }, + { id: 'updated', style: { bgColor: 'green' } } + ] as any, + arrangements as any + ); + + expect(rebuildSpy).toHaveBeenCalledTimes(1); + expect(plugin.customCellStyleArrangement).toHaveLength(1000); + expect((plugin as any)._customCellStyleArrangementIndex.size).toBe(1000); + expect(plugin.getCustomCellStyleIds(500, 0)).toEqual(['updated']); + expect(table.scenegraph.updateCellContent).toHaveBeenCalledTimes(1000); + expect(table.scenegraph.updateNextFrame).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/vtable/src/plugins/custom-cell-style.ts b/packages/vtable/src/plugins/custom-cell-style.ts index f1844b8031..d8b1085cb3 100644 --- a/packages/vtable/src/plugins/custom-cell-style.ts +++ b/packages/vtable/src/plugins/custom-cell-style.ts @@ -30,6 +30,7 @@ export class CustomCellStylePlugin { private _customCellStyleArrangementIndex: Map; private _customCellStyleArrangementTombstoneCount: number; private _customCellStyleOverlays: Map; + private _customCellStyleIdsCache?: Map; constructor( table: BaseTableAPI, @@ -199,15 +200,28 @@ export class CustomCellStylePlugin { } getCustomCellStyleIds(col: number, row: number) { - // let customStyleId; + const range = this.table.getCellRange(col, row); + const rangeKey = `${range.start.col}:${range.start.row}:${range.end.col}:${range.end.row}`; + const cachedStyleIds = this._customCellStyleIdsCache?.get(rangeKey); + if (cachedStyleIds) { + return cachedStyleIds; + } + + const customStyleIds = this._collectCustomCellStyleIds(range); + this._customCellStyleIdsCache?.set(rangeKey, customStyleIds); + return customStyleIds; + } + + private _collectCustomCellStyleIds(range: CellRange) { const customStyleIds: string[] = []; + const seenArrangements = new Set(); + const seenOverlayPositions = new Set(); - const range = this.table.getCellRange(col, row); for (let c = range.start.col; c <= range.end.col; c++) { for (let r = range.start.row; r <= range.end.row; r++) { // eslint-disable-next-line no-loop-func - this.customCellStyleArrangement.forEach(style => { - if (!isValid(style.customStyleId)) { + this.customCellStyleArrangement.forEach((style, index) => { + if (seenArrangements.has(index) || !isValid(style.customStyleId)) { return; } if (style.cellPosition.range) { @@ -217,18 +231,24 @@ export class CustomCellStylePlugin { style.cellPosition.range.start.row <= r && style.cellPosition.range.end.row >= r ) { - // customStyleId = style.customStyleId; + seenArrangements.add(index); customStyleIds.push(style.customStyleId as string); } } else if (style.cellPosition.col === c && style.cellPosition.row === r) { - // customStyleId = style.customStyleId; + seenArrangements.add(index); customStyleIds.push(style.customStyleId as string); } }); - for (const overlay of this._customCellStyleOverlays.values()) { + for (const [sourceId, overlay] of this._customCellStyleOverlays) { const overlayStyles = overlay.cells.get(`${c}:${r}`); if (overlayStyles) { - customStyleIds.push(...overlayStyles.values()); + for (const [positionKey, customStyleId] of overlayStyles) { + const overlayPositionKey = `${sourceId}:${positionKey}`; + if (!seenOverlayPositions.has(overlayPositionKey)) { + seenOverlayPositions.add(overlayPositionKey); + customStyleIds.push(customStyleId); + } + } } } } @@ -237,6 +257,27 @@ export class CustomCellStylePlugin { return customStyleIds; } + refreshCustomCellStyleRange( + cellPosition: { col?: number; row?: number; range?: CellRange }, + forceFastUpdate: boolean = false + ) { + const range = cellPosition.range ?? { + start: { col: cellPosition.col as number, row: cellPosition.row as number }, + end: { col: cellPosition.col as number, row: cellPosition.row as number } + }; + const previousCache = this._customCellStyleIdsCache; + this._customCellStyleIdsCache = new Map(); + try { + for (let col = Math.max(0, range.start.col); col <= Math.min(this.table.colCount - 1, range.end.col); col++) { + for (let row = Math.max(0, range.start.row); row <= Math.min(this.table.rowCount - 1, range.end.row); row++) { + this.table.scenegraph.updateCellContent(col, row, forceFastUpdate); + } + } + } finally { + this._customCellStyleIdsCache = previousCache; + } + } + getCustomCellStyleOption(customStyleId: string) { return this.customCellStyle.find(style => style.id === customStyleId); } @@ -370,16 +411,59 @@ export class CustomCellStylePlugin { } updateCustomCell(customCellStyle: CustomCellStyle[], customCellStyleArrangement: CustomCellStyleArrangement[]) { + const positionsToRefresh = new Map(); + this.customCellStyleArrangement.forEach(arrangement => { + const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); + if (key) { + positionsToRefresh.set(key, arrangement.cellPosition); + } + }); + this.customCellStyle.length = 0; - this.customCellStyleArrangement.length = 0; - this._customCellStyleArrangementIndex.clear(); - this._customCellStyleArrangementTombstoneCount = 0; - customCellStyle.forEach((cellStyle: CustomCellStyle) => { - this.registerCustomCellStyle(cellStyle.id, cellStyle.style); + const styleIndexes = new Map(); + customCellStyle.forEach(cellStyle => { + const index = styleIndexes.get(cellStyle.id); + const nextCellStyle = { + id: cellStyle.id, + style: cellStyle.style + }; + if (index === undefined) { + styleIndexes.set(cellStyle.id, this.customCellStyle.length); + this.customCellStyle.push(nextCellStyle); + } else { + this.customCellStyle[index] = nextCellStyle; + } }); - customCellStyleArrangement.forEach((cellStyle: CustomCellStyleArrangement) => { - this.arrangeCustomCellStyle(cellStyle.cellPosition, cellStyle.customStyleId); + + this.customCellStyleArrangement.length = 0; + const arrangementIndexes = new Map(); + customCellStyleArrangement.forEach(arrangement => { + const nextArrangement = { + cellPosition: { + col: arrangement.cellPosition.col, + row: arrangement.cellPosition.row, + range: arrangement.cellPosition.range + }, + customStyleId: arrangement.customStyleId + }; + const key = this._getCustomCellStyleArrangementKey(arrangement.cellPosition); + if (key) { + const index = arrangementIndexes.get(key); + if (index === undefined) { + arrangementIndexes.set(key, this.customCellStyleArrangement.length); + this.customCellStyleArrangement.push(nextArrangement); + } else { + this.customCellStyleArrangement[index] = nextArrangement; + } + positionsToRefresh.set(key, arrangement.cellPosition); + } else { + this.customCellStyleArrangement.push(nextArrangement); + } }); + this._rebuildCustomCellStyleArrangementIndex(); + + positionsToRefresh.forEach(position => this.refreshCustomCellStyleRange(position)); + this.table.scenegraph.updateNextFrame(); } hasCustomCellStyle(customStyleId: string) { From 2d09aa64dc0294b09a4f4b4a89ea4882854df325 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Wed, 16 Sep 2026 17:57:27 +0800 Subject: [PATCH 12/12] fix(vtable-search): sync master-detail navigation Order matches by parent row and include newly expanded detail tables during navigation. Add a dedicated VTable example for the workflow. Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com --- .../__tests__/review-regressions.test.ts | 50 ++++++ .../src/search-component/search-component.ts | 70 +++++++- .../debug/issue-5036-master-detail-search.ts | 157 ++++++++++++++++++ packages/vtable/examples/main.ts | 14 +- packages/vtable/examples/menu.ts | 4 + packages/vtable/examples/vite.config.js | 1 + 6 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 packages/vtable/examples/debug/issue-5036-master-detail-search.ts diff --git a/packages/vtable-search/__tests__/review-regressions.test.ts b/packages/vtable-search/__tests__/review-regressions.test.ts index bdc433c2f7..32bdc90319 100644 --- a/packages/vtable-search/__tests__/review-regressions.test.ts +++ b/packages/vtable-search/__tests__/review-regressions.test.ts @@ -419,6 +419,56 @@ test('master-detail search does not recurse child records as tree results', () = expect(result.results[0].indexNumber).toBeUndefined(); }); +test('master-detail results follow their visual parent-row order', () => { + const main = createCellTable([['Target main 1'], ['Other'], ['Target main 3']], { + isMasterDetail: true + }); + const firstDetail = createCellTable([['Target detail 1'], ['Target nested 1']]); + const thirdDetail = createCellTable([['Target detail 3']]); + main.table.internalProps = { + subTableInstances: new Map([ + [2, thirdDetail.table], + [0, firstDetail.table] + ]) + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + + const result = search.search('Target'); + + expect(result.results.map(item => item.value)).toEqual([ + 'Target main 1', + 'Target detail 1', + 'Target nested 1', + 'Target main 3', + 'Target detail 3' + ]); +}); + +test('next includes a detail table expanded after the initial search', () => { + const main = createCellTable([['Target main 1'], ['Target main 2']], { + isMasterDetail: true + }); + main.table.internalProps = { + subTableInstances: new Map() + }; + const search = new SearchComponent({ table: main.table as any, autoJump: false, skipHeader: true }); + search.search('Target'); + search.next(); + + const detail = createCellTable([['Target detail 1']]); + main.table.internalProps.subTableInstances.set(0, detail.table); + + const result = search.next(); + + expect(result.index).toBe(1); + expect(result.results.map(item => item.value)).toEqual(['Target main 1', 'Target detail 1', 'Target main 2']); + expect(result.results[result.index]).toMatchObject({ + table: detail.table, + parentRow: 0, + value: 'Target detail 1' + }); +}); + test('detail navigation scrolls the master when its expanded viewBox is clipped', () => { const main = createCellTable([['Parent']], { visibleRows: { rowStart: 1, rowEnd: 1 }, diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index 2180b8beaf..9b72d7ee02 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -105,6 +105,7 @@ export class SearchComponent { private resultTreeMap = new WeakMap(); private resultParentRowMap = new WeakMap(); private resultTables = new Set(); + private searchedTables = new Set(); private tableIdMap = new WeakMap(); private usedTableIds = new Set(); private searchStylePositions = new WeakMap>(); @@ -767,6 +768,62 @@ export class SearchComponent { positionsToRefresh.forEach(position => this.refreshCellStyle(targetTable, position)); } + private sortMasterDetailResultsByVisualOrder(): void { + if (!this.isMasterDetailTable() || this.queryResult.length < 2) { + return; + } + const headerOffset = this.getHeaderOffset(this.table); + this.queryResult = this.queryResult + .map((result, index) => { + const table = this.getResultTable(result); + const isMasterResult = table === this.table; + const cell = isMasterResult ? this.getResultCell(result) : undefined; + const parentRow = isMasterResult ? (cell?.row ?? headerOffset) - headerOffset : this.getResultParentRow(result); + return { + result, + index, + parentRow: typeof parentRow === 'number' ? parentRow : Number.MAX_SAFE_INTEGER, + tableOrder: isMasterResult ? 0 : 1 + }; + }) + .sort((a, b) => a.parentRow - b.parentRow || a.tableOrder - b.tableOrder || a.index - b.index) + .map(item => item.result); + } + + private syncNewSearchTables(): void { + if (!this.queryStr || !this.isMasterDetailTable()) { + return; + } + const newEntries = this.getSearchTableEntries().filter(entry => !this.searchedTables.has(entry.table)); + if (!newEntries.length) { + return; + } + + const currentResult = + this.currentIndex >= 0 && this.currentIndex < this.queryResult.length + ? this.queryResult[this.currentIndex] + : undefined; + const previousResultCount = this.queryResult.length; + for (const entry of newEntries) { + this.searchedTables.add(entry.table); + this.searchTable(entry.table, entry.parentRow); + } + if (this.queryResult.length === previousResultCount) { + return; + } + + this.sortMasterDetailResultsByVisualOrder(); + this.currentIndex = currentResult ? this.queryResult.indexOf(currentResult) : -1; + this.updateCellStyle(); + this.callback?.( + { + queryStr: this.queryStr, + results: this.queryResult + }, + this.table + ); + } + search(str: string) { this.clear(); this.queryStr = str; @@ -781,11 +838,14 @@ export class SearchComponent { this.treeIndex = this.isTree ? this.getTreeCol(this.table) : 0; if (this.isTree) { this.searchTreeTable(this.table); - for (const entry of this.getSearchTableEntries()) { + const entries = this.getSearchTableEntries(); + entries.forEach(entry => this.searchedTables.add(entry.table)); + for (const entry of entries) { if (entry.table !== this.table) { this.searchTable(entry.table, entry.parentRow); } } + this.sortMasterDetailResultsByVisualOrder(); this.currentIndex = this.queryResult.length > 0 && this.isTreeResult(this.queryResult[0]) ? 0 : -1; @@ -813,9 +873,12 @@ export class SearchComponent { results: this.queryResult }; } - for (const entry of this.getSearchTableEntries()) { + const entries = this.getSearchTableEntries(); + entries.forEach(entry => this.searchedTables.add(entry.table)); + for (const entry of entries) { this.searchTable(entry.table, entry.parentRow); } + this.sortMasterDetailResultsByVisualOrder(); this.updateCellStyle(); if (this.callback) { @@ -919,6 +982,7 @@ export class SearchComponent { } next() { + this.syncNewSearchTables(); this.pruneUnavailableResults(1); if (!this.queryResult.length) { return { @@ -947,6 +1011,7 @@ export class SearchComponent { } prev() { + this.syncNewSearchTables(); this.pruneUnavailableResults(-1); if (!this.queryResult.length) { return { @@ -1268,6 +1333,7 @@ export class SearchComponent { this.resultTreeMap = new WeakMap(); this.resultParentRowMap = new WeakMap(); this.resultTables.clear(); + this.searchedTables.clear(); this.currentIndex = -1; } } diff --git a/packages/vtable/examples/debug/issue-5036-master-detail-search.ts b/packages/vtable/examples/debug/issue-5036-master-detail-search.ts new file mode 100644 index 0000000000..00600459f8 --- /dev/null +++ b/packages/vtable/examples/debug/issue-5036-master-detail-search.ts @@ -0,0 +1,157 @@ +import * as VTable from '../../src'; +import { MasterDetailPlugin } from '@visactor/vtable-plugins'; +import { SearchComponent } from '@visactor/vtable-search'; + +const CONTAINER_ID = 'vTable'; +const CONTROLS_ID = 'issue5036Controls'; + +const records = Array.from({ length: 8 }, (_, index) => ({ + orderId: `ORDER-${String(index + 1).padStart(3, '0')}`, + customer: index % 2 === 0 ? `Target customer ${index + 1}` : `Customer ${index + 1}`, + status: index % 3 === 0 ? 'Review' : 'Ready', + children: [ + { + task: `Target task ${index + 1}`, + owner: `Owner ${index + 1}`, + children: [ + { + task: `Target nested task ${index + 1}`, + owner: `Reviewer ${index + 1}` + } + ] + }, + { + task: `Packaging ${index + 1}`, + owner: `Operator ${index + 1}` + } + ] +})); + +function createControls(container: HTMLElement) { + document.getElementById(CONTROLS_ID)?.remove(); + + const controls = document.createElement('div'); + controls.id = CONTROLS_ID; + controls.style.cssText = + 'display:flex;align-items:center;gap:8px;height:52px;padding:0 12px;border-bottom:1px solid #e5e7eb;' + + 'box-sizing:border-box;font:13px/1.4 Arial,sans-serif;background:#fff;'; + + const input = document.createElement('input'); + input.value = 'Target'; + input.placeholder = 'Search master and detail tables'; + input.style.cssText = + 'width:260px;height:30px;padding:0 9px;border:1px solid #c9cdd4;border-radius:4px;box-sizing:border-box;'; + + const createButton = (label: string) => { + const button = document.createElement('button'); + button.textContent = label; + button.style.cssText = + 'height:30px;padding:0 12px;border:1px solid #c9cdd4;border-radius:4px;background:#fff;cursor:pointer;'; + return button; + }; + + const searchButton = createButton('Search'); + const previousButton = createButton('Previous'); + const nextButton = createButton('Next'); + const clearButton = createButton('Clear'); + const result = document.createElement('span'); + result.style.cssText = 'min-width:110px;color:#4e5969;'; + result.textContent = 'Ready'; + + controls.append(input, searchButton, previousButton, nextButton, clearButton, result); + container.parentElement?.insertBefore(controls, container); + return { input, searchButton, previousButton, nextButton, clearButton, result }; +} + +function updateResult( + resultNode: HTMLElement, + result: ReturnType, + emptyLabel: string = 'No matches' +) { + resultNode.textContent = + result.results.length > 0 ? `${Math.max(result.index + 1, 1)} / ${result.results.length}` : emptyLabel; +} + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + + document.getElementById('input-test')!.style.display = 'none'; + document.getElementById('textarea-test')!.style.display = 'none'; + container.style.height = 'calc(100% - 52px)'; + + const controls = createControls(container); + const masterDetailPlugin = new MasterDetailPlugin({ + detailTableOptions: { + columns: [ + { field: 'task', title: 'Detail task', tree: true, width: 260 }, + { field: 'owner', title: 'Owner', width: 180 } + ], + hierarchyExpandLevel: 1, + defaultRowHeight: 34, + defaultHeaderRowHeight: 36, + style: { + margin: [8, 16], + height: 150 + }, + theme: VTable.themes.BRIGHT + } + }); + + const tableInstance = new VTable.ListTable({ + container, + records, + columns: [ + { field: 'orderId', title: 'Order', width: 150 }, + { field: 'customer', title: 'Customer', width: 220 }, + { field: 'status', title: 'Status', width: 120 } + ], + defaultRowHeight: 40, + heightMode: 'standard', + plugins: [masterDetailPlugin] + }); + const search = new SearchComponent({ + table: tableInstance, + autoJump: true, + scrollOption: { + duration: 350 + } + }); + + const runSearch = () => updateResult(controls.result, search.search(controls.input.value)); + controls.searchButton.onclick = runSearch; + controls.previousButton.onclick = () => updateResult(controls.result, search.prev()); + controls.nextButton.onclick = () => updateResult(controls.result, search.next()); + controls.clearButton.onclick = () => { + search.clear(); + controls.result.textContent = 'Cleared'; + }; + controls.input.onkeydown = event => { + if (event.key === 'Enter') { + runSearch(); + } + }; + + window.tableInstance = tableInstance; + (window as any).search = search; + (window as any).masterDetailPlugin = masterDetailPlugin; + + requestAnimationFrame(() => { + if (tableInstance.isReleased) { + return; + } + records.forEach((_record, recordIndex) => { + const row = tableInstance.getTableIndexByRecordIndex(recordIndex); + if (typeof row === 'number' && tableInstance.getHierarchyState(0, row) !== 'expand') { + tableInstance.toggleHierarchyState(0, row); + } + }); + requestAnimationFrame(() => { + if (!tableInstance.isReleased) { + runSearch(); + } + }); + }); +} diff --git a/packages/vtable/examples/main.ts b/packages/vtable/examples/main.ts index 382142cdb2..532938e16f 100644 --- a/packages/vtable/examples/main.ts +++ b/packages/vtable/examples/main.ts @@ -52,6 +52,17 @@ const handleClick = (e: { target: any }, isInit?: boolean) => { localStorage.setItem(LOCAL_STORAGE_KEY, name); } + const issue5036Controls = document.getElementById('issue5036Controls'); + if (issue5036Controls) { + (window as any).search?.clear?.(); + issue5036Controls.remove(); + document.getElementById('input-test')!.style.display = ''; + document.getElementById('textarea-test')!.style.display = ''; + document.getElementById('vTable')!.style.height = '90%'; + (window as any).search = undefined; + (window as any).masterDetailPlugin = undefined; + } + if (window.tableInstance) { window.tableInstance.release(); document.getElementById('vTable').innerHTML = null; @@ -110,7 +121,8 @@ const createSidebar = (node: HTMLDivElement) => { const run = () => { const sidebarNode = document.querySelector('#sidebar')!; - const prevActivePath = localStorage.getItem(LOCAL_STORAGE_KEY); + const prevActivePath = + new URLSearchParams(window.location.search).get('demo') || localStorage.getItem(LOCAL_STORAGE_KEY); createSidebar(sidebarNode); initSidebarEvent(sidebarNode); diff --git a/packages/vtable/examples/menu.ts b/packages/vtable/examples/menu.ts index e8e5c4d953..0376eaecdf 100644 --- a/packages/vtable/examples/menu.ts +++ b/packages/vtable/examples/menu.ts @@ -78,6 +78,10 @@ export const menus = [ path: 'debug', name: 'issue-4186-nested-field-edit' }, + { + path: 'debug', + name: 'issue-5036-master-detail-search' + }, { path: 'debug', name: 'issue-4761-update-records-edit-render' diff --git a/packages/vtable/examples/vite.config.js b/packages/vtable/examples/vite.config.js index 4692e061b6..ac4084fc65 100644 --- a/packages/vtable/examples/vite.config.js +++ b/packages/vtable/examples/vite.config.js @@ -15,6 +15,7 @@ module.exports = { alias: { '@visactor/vtable-editors': path.resolve(__dirname, '../../vtable-editors/src/index.ts'), '@visactor/vtable-plugins': path.resolve(__dirname, '../../vtable-plugins/src/index.ts'), + '@visactor/vtable-search': path.resolve(__dirname, '../../vtable-search/src/index.ts'), '@src': path.resolve(__dirname, '../src/'), '@vutils-extension': path.resolve(__dirname, '../src/vutil-extension-temp'), '@visactor/vtable/es': path.resolve(__dirname, '../src/')