From 292db08b91d5b2ed80ebb8730d604c2ea2fb44f6 Mon Sep 17 00:00:00 2001 From: jay77721 <164177721+jay77721@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:22:02 +0800 Subject: [PATCH] fix(preview): highlight all spreadsheet chunk ranges (#21511) --- .../ingestion/component/chunker/general.go | 54 ++--- .../component/chunker/general_test.go | 70 +++++- .../excel-source-locate.test.ts | 104 +++++++++ .../components/document-preview/hooks.test.ts | 210 +++++++++++++++++- web/src/components/document-preview/hooks.ts | 89 ++++---- 5 files changed, 445 insertions(+), 82 deletions(-) create mode 100644 web/src/components/document-preview/excel-source-locate.test.ts diff --git a/internal/ingestion/component/chunker/general.go b/internal/ingestion/component/chunker/general.go index fb4028d8f8..f393f01328 100644 --- a/internal/ingestion/component/chunker/general.go +++ b/internal/ingestion/component/chunker/general.go @@ -1084,6 +1084,19 @@ func (c *GeneralChunkerComponent) splitSpreadsheetTable(unit schema.ChunkDoc, do if td == nil { return []schema.ChunkDoc{cloneChunkDoc(unit)} } + // Parser positions align with the structured grid until splitting is done. + // Emitted chunks use preview order: first data row, then remaining ranges. + var matrix [][]float64 + aligned := false + if unit.SheetIndex != nil && json.Unmarshal(unit.Positions, &matrix) == nil && len(matrix) == len(td.Grid) { + aligned = true + for _, tuple := range matrix { + if len(tuple) != 5 { + aligned = false + break + } + } + } parts, ranges, headerRows := splitLargeTable(td, c.param.ChunkTokenSize, tokenizeStr, docID) if ranges == nil { // The whole table fits one chunk: render it as HTML on Text so the @@ -1092,30 +1105,11 @@ func (c *GeneralChunkerComponent) splitSpreadsheetTable(unit schema.ChunkDoc, do out := cloneChunkDoc(unit) out.Text = tableutil.RenderTableHTML(td) out.TableData = nil + if aligned { + out.Positions = spreadsheetPreviewPositions(matrix, td.HeaderRows) + } return []schema.ChunkDoc{out} } - var matrix [][]float64 - // Spreadsheet items only: PDF items write layout boxes into the same - // field, and those must never be sliced into sub-tables as row tuples. - if unit.SheetIndex != nil && len(unit.Positions) > 0 { - if err := json.Unmarshal(unit.Positions, &matrix); err != nil { - matrix = nil - } - } - // The matrix carries one five-field tuple per row; the splitter cut those - // same rows, so its last range end is the total data-row count. - aligned := false - if len(ranges) > 0 { - if expected := headerRows + ranges[len(ranges)-1][1]; expected > 0 && len(matrix) == expected { - aligned = true - for _, tuple := range matrix { - if len(tuple) != 5 { - aligned = false - break - } - } - } - } out := make([]schema.ChunkDoc, 0, len(parts)) for i, part := range parts { piece := cloneChunkDoc(unit) @@ -1132,15 +1126,25 @@ func (c *GeneralChunkerComponent) splitSpreadsheetTable(unit schema.ChunkDoc, do sub := make([][]float64, 0, headerRows+hi-lo) sub = append(sub, matrix[:headerRows]...) sub = append(sub, matrix[lo:hi]...) - if encoded, err := json.Marshal(sub); err == nil { - piece.Positions = encoded - } + piece.Positions = spreadsheetPreviewPositions(sub, headerRows) } out = append(out, piece) } return out } +// spreadsheetPreviewPositions chooses the first data row as the scroll target +// after row-aligned slicing is complete. Header-only chunks keep their order. +func spreadsheetPreviewPositions(matrix [][]float64, headerRows int) json.RawMessage { + if headerRows > 0 && headerRows < len(matrix) { + first := matrix[headerRows] + copy(matrix[1:headerRows+1], matrix[:headerRows]) + matrix[0] = first + } + encoded, _ := json.Marshal(matrix) + return encoded +} + func (c *GeneralChunkerComponent) chunkGeneral(ctx context.Context, upstream schema.ChunkerFromUpstream) (map[string]any, error) { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("GeneralChunker: %w", err) diff --git a/internal/ingestion/component/chunker/general_test.go b/internal/ingestion/component/chunker/general_test.go index c28f82460e..d185c025bb 100644 --- a/internal/ingestion/component/chunker/general_test.go +++ b/internal/ingestion/component/chunker/general_test.go @@ -85,6 +85,63 @@ func spreadsheetSegmentItem(sheet string, header []string, rows [][]string, shee } } +func TestSpreadsheetIndexPositionsStartWithDataRow(t *testing.T) { + component, err := NewGeneralChunker(map[string]any{"chunk_token_size": 1000}) + if err != nil { + t.Fatal(err) + } + item := spreadsheetSegmentItem("Sheet1", []string{"Name"}, [][]string{{"Alice"}, {"Bob"}}, 1, 20) + out, err := component.Invoke(t.Context(), nil, map[string]any{"name": "test.xlsx", "file_type": "xlsx", "output_format": "json", "json": []map[string]any{item}}) + if err != nil { + t.Fatal(err) + } + chunks := outputChunks(t, out) + if len(chunks) != 1 { + t.Fatalf("got %d chunks", len(chunks)) + } + want := [][]int{{1, 20, 20, 1, 1}, {1, 19, 19, 1, 1}, {1, 21, 21, 1, 1}} + var previewPositions [][]int + raw, err := json.Marshal(chunks[0]["positions"]) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &previewPositions); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(previewPositions, want) { + t.Fatalf("chunk preview positions = %v, want data row first", previewPositions) + } + if _, err := indexdoc.ProcessChunksForPipeline(chunks, "doc", "test.xlsx", time.Now()); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(chunks[0]["position_int"], want) { + t.Fatalf("positions = %v, want %v", chunks[0]["position_int"], want) + } +} + +func TestSpreadsheetIndexPositionsKeepHeaderWhenNoData(t *testing.T) { + component, err := NewGeneralChunker(map[string]any{"chunk_token_size": 1000}) + if err != nil { + t.Fatal(err) + } + item := spreadsheetSegmentItem("Sheet1", []string{"Name"}, nil, 1, 20) + out, err := component.Invoke(t.Context(), nil, map[string]any{"name": "test.xlsx", "file_type": "xlsx", "output_format": "json", "json": []map[string]any{item}}) + if err != nil { + t.Fatal(err) + } + chunks := outputChunks(t, out) + if len(chunks) != 1 { + t.Fatalf("got %d chunks", len(chunks)) + } + if _, err := indexdoc.ProcessChunksForPipeline(chunks, "doc", "test.xlsx", time.Now()); err != nil { + t.Fatal(err) + } + want := [][]int{{1, 19, 19, 1, 1}} + if !reflect.DeepEqual(chunks[0]["position_int"], want) { + t.Fatalf("positions = %v, want %v", chunks[0]["position_int"], want) + } +} + func TestGeneralChunkerRegistered(t *testing.T) { factory, category, metadata, ok := runtime.DefaultRegistry.Lookup(ComponentNameGeneralChunker) if !ok { @@ -1150,19 +1207,26 @@ func TestGeneralChunkerSpreadsheetSplitsLargeSegmentIntoBoundedChunks(t *testing var matrix [][]float64 raw, _ := json.Marshal(chunk["positions"]) if err := json.Unmarshal(raw, &matrix); err != nil { - t.Fatalf("chunk %d positions = %v, want a row-aligned matrix", i, chunk["positions"]) + t.Fatalf("chunk %d positions = %v, want a position matrix", i, chunk["positions"]) } // One header tuple + this sub-table's own data tuples, never the // full segment matrix. if len(matrix) < 2 { t.Fatalf("chunk %d matrix = %v, want header plus data rows", i, matrix) } - if matrix[0][1] != 1 { - t.Errorf("chunk %d header tuple = %v, want sheet 1 row 1", i, matrix[0]) + if matrix[1][1] != 1 { + t.Errorf("chunk %d header tuple = %v, want sheet 1 row 1", i, matrix[1]) } if dataRows != len(matrix)-1 { t.Errorf("chunk %d data rows %d != position tuples %d", i, dataRows, len(matrix)-1) } + if _, err := indexdoc.ProcessChunksForPipeline([]map[string]any{chunk}, "doc", "long.xlsx", time.Now()); err != nil { + t.Fatal(err) + } + indexed := chunk["position_int"].([][]int) + if indexed[0][1] != covered+2 || indexed[1][1] != 1 { + t.Fatalf("chunk %d indexed positions = %v, want first data row %d then header", i, indexed, covered+2) + } covered += dataRows } if covered != len(rows) { diff --git a/web/src/components/document-preview/excel-source-locate.test.ts b/web/src/components/document-preview/excel-source-locate.test.ts new file mode 100644 index 0000000000..81158c4414 --- /dev/null +++ b/web/src/components/document-preview/excel-source-locate.test.ts @@ -0,0 +1,104 @@ +import jsPreviewExcel from '@js-preview/excel'; +import * as XLSX from 'xlsx'; +import { applyExcelSourceLocate } from './hooks'; + +// Exercise the bundled previewer and workbook importer in jsdom. +jest.mock('@js-preview/excel', () => ({ + __esModule: true, + default: require('@js-preview/excel/lib/index.umd.js'), +})); +jest.mock('@/hooks/route-hook', () => ({ + useGetKnowledgeSearchParams: () => ({}), +})); +jest.mock('@/pages/dataflow-result/hooks', () => ({ + useGetPipelineResultSearchParams: () => ({}), +})); +jest.mock('@/utils/api', () => ({ default: {}, restAPIv1: '' })); +jest.mock('@/utils/authorization-util', () => ({ getAuthorization: () => '' })); + +type PreviewData = { + rows: { + len: number; + getCell: (row: number, col: number) => { style: number } | null; + }; + cols: { len: number }; + styles: { bgcolor?: string }[]; +}; +type PreviewInternals = { + xs: { + datas: PreviewData[]; + sheet: { + verticalScrollbar: { + move: (offset: { top: number }) => void; + scroll: () => { top: number }; + }; + }; + }; +}; + +it('highlights and scrolls to imported cells beyond the default grid', async () => { + const workbook = XLSX.utils.book_new(); + const worksheet = XLSX.utils.aoa_to_sheet([['header']]); + XLSX.utils.sheet_add_aoa(worksheet, [['wide']], { origin: 'AD2' }); + XLSX.utils.sheet_add_aoa(worksheet, [['late']], { origin: 'A150' }); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1'); + const bytes = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); + + const context = new Proxy( + { measureText: () => ({ width: 10 }) }, + { + get: (target, key) => + key in target ? target[key as keyof typeof target] : () => {}, + }, + ); + jest + .spyOn(HTMLCanvasElement.prototype, 'getContext') + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + jest.spyOn(console, 'log').mockImplementation(() => {}); + const originalXhr = globalThis.XMLHttpRequest; + globalThis.XMLHttpRequest = class { + status = 200; + response = bytes; + onload?: () => void; + open() {} + setRequestHeader() {} + send() { + this.onload?.(); + } + } as unknown as typeof XMLHttpRequest; + + const container = document.createElement('div'); + document.body.appendChild(container); + const previewer = jsPreviewExcel.init(container); + try { + await previewer.preview('file.xlsx'); + const internal = previewer as unknown as PreviewInternals; + const sheet = internal.xs.datas[0]; + expect(sheet.rows.len).toBeGreaterThanOrEqual(150); + expect(sheet.cols.len).toBeGreaterThanOrEqual(30); + const scrollbar = internal.xs.sheet.verticalScrollbar; + const scroll = jest.spyOn(scrollbar, 'move'); + applyExcelSourceLocate(previewer, [ + [1, 150, 150, 1, 1], + [1, 2, 2, 30, 30], + ]); + for (const [row, col] of [ + [149, 0], + [1, 29], + ]) { + const cell = sheet.rows.getCell(row, col); + expect(cell).not.toBeNull(); + expect(sheet.styles[cell!.style].bgcolor).toBe('#ffe58f'); + } + expect(scroll).toHaveBeenCalledWith({ top: expect.any(Number) }); + expect(scroll.mock.calls[0][0].top).toBeGreaterThan(0); + expect(scrollbar.scroll().top).toBe(scroll.mock.calls[0][0].top); + // The previewer schedules its own rendering after load. + await new Promise((resolve) => setTimeout(resolve, 30)); + } finally { + previewer.destroy(); + container.remove(); + globalThis.XMLHttpRequest = originalXhr; + jest.restoreAllMocks(); + } +}); diff --git a/web/src/components/document-preview/hooks.test.ts b/web/src/components/document-preview/hooks.test.ts index b791288e2f..f302374db9 100644 --- a/web/src/components/document-preview/hooks.test.ts +++ b/web/src/components/document-preview/hooks.test.ts @@ -1,11 +1,16 @@ -import { applyExcelSourceLocate } from './hooks'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import jsPreviewExcel from '@js-preview/excel'; +import axios from 'axios'; +import { applyExcelSourceLocate, useFetchExcel } from './hooks'; jest.mock('@js-preview/excel', () => ({ __esModule: true, default: { init: jest.fn() }, })); jest.mock('ahooks', () => ({ - useDebounceFn: () => ({ run: jest.fn() }), + useDebounceFn: (fn: () => void) => ({ + run: require('react').useCallback(fn, [fn]), + }), useSize: () => ({ width: 800, height: 600 }), })); jest.mock('axios', () => ({ get: jest.fn() })); @@ -30,12 +35,14 @@ jest.mock('@/constants/authorization', () => ({ function mockPreviewer(sheetCount = 2) { const makeData = () => ({ - addStyle: jest.fn(() => 7), + styles: [] as Record[], rows: { setCell: jest.fn(), - getCellOrNew: jest.fn(), + getCellOrNew: jest.fn<{ style?: number }, [number, number]>(() => ({})), getHeight: jest.fn(() => 20), + len: 100, }, + cols: { len: 10 }, scroll: { x: 0, y: 0 }, }); const datas = Array.from({ length: sheetCount }, makeData); @@ -65,14 +72,60 @@ describe('applyExcelSourceLocate', () => { jest.restoreAllMocks(); }); + it('paints every range while scrolling to the first position', () => { + const previewer = mockPreviewer(); + applyExcelSourceLocate(previewer as never, [ + [1, 20, 20, 1, 1], + [1, 1, 1, 1, 1], + ]); + expect(previewer.xs.datas[0].rows.setCell).toHaveBeenCalledWith( + 19, + 0, + { style: 0 }, + 'format', + ); + expect(previewer.xs.datas[0].rows.setCell).toHaveBeenCalledWith( + 0, + 0, + { style: 0 }, + 'format', + ); + expect(previewer.xs.sheet.verticalScrollbar.move).toHaveBeenCalledWith({ + top: 379, + }); + }); + + it('paints ranges on multiple sheets and opens the target sheet', () => { + const previewer = mockPreviewer(); + applyExcelSourceLocate(previewer as never, [ + [2, 4, 4, 1, 1], + [1, 2, 2, 1, 1], + ]); + expect(previewer.xs.datas[1].rows.setCell).toHaveBeenCalledWith( + 3, + 0, + { style: 0 }, + 'format', + ); + expect(previewer.xs.datas[0].rows.setCell).toHaveBeenCalledWith( + 1, + 0, + { style: 0 }, + 'format', + ); + expect(previewer.xs.bottombar.clickSwap2).toHaveBeenCalledWith( + previewer.xs.bottombar.items[1], + ); + }); + it('selects the 1-based sheet, paints the range, and scrolls via the scrollbar', () => { const previewer = mockPreviewer(); - applyExcelSourceLocate(previewer as never, [2, 3, 4, 1, 2]); + applyExcelSourceLocate(previewer as never, [[2, 3, 4, 1, 2]]); expect(previewer.xs.bottombar.clickSwap2).toHaveBeenCalledWith( previewer.xs.bottombar.items[1], ); - expect(previewer.xs.datas[1].addStyle).toHaveBeenCalledWith({ + expect(previewer.xs.datas[1].styles[0]).toEqual({ bgcolor: '#ffe58f', color: '#1a1a1a', }); @@ -87,27 +140,107 @@ describe('applyExcelSourceLocate', () => { expect(previewer.xs.datas[1].rows.setCell).toHaveBeenCalledWith( 2, 0, - { style: 7 }, + { style: 0 }, 'format', ); expect(previewer.xs.datas[1].rows.setCell).toHaveBeenCalledWith( 3, 1, - { style: 7 }, + { style: 0 }, 'format', ); }); it('is a no-op when the position tuple is short', () => { const previewer = mockPreviewer(); - applyExcelSourceLocate(previewer as never, [1, 2]); - expect(previewer.xs.datas[0].addStyle).not.toHaveBeenCalled(); + applyExcelSourceLocate(previewer as never, [[1, 2]]); + expect(previewer.xs.datas[0].styles).toHaveLength(0); + }); + + it('skips a range outside the sheet when choosing the scroll target', () => { + const previewer = mockPreviewer(); + applyExcelSourceLocate(previewer as never, [ + [1, 101, 101, 1, 1], + [2, 3, 3, 1, 1], + ]); + expect(previewer.xs.datas[0].rows.setCell).not.toHaveBeenCalled(); + expect(previewer.xs.bottombar.clickSwap2).toHaveBeenCalledWith( + previewer.xs.bottombar.items[1], + ); + expect(previewer.xs.sheet.verticalScrollbar.move).toHaveBeenCalledWith({ + top: 39, + }); + }); + + it('keeps cell formatting and reuses a highlight style for identical styles', () => { + const previewer = mockPreviewer(); + const sheet = previewer.xs.datas[0]; + const original = { + font: { bold: true }, + align: 'center', + border: { bottom: ['thin', '#000000'] }, + }; + Object.assign(sheet, { styles: [original, { ...original }] }); + sheet.rows.getCellOrNew.mockImplementation((row: number) => ({ + style: row % 2, + })); + applyExcelSourceLocate(previewer as never, [[1, 1, 2, 1, 1]]); + const styles = (sheet as unknown as { styles: Record[] }) + .styles; + expect(styles).toHaveLength(3); + expect(styles[2]).toEqual({ + ...original, + bgcolor: '#ffe58f', + color: '#1a1a1a', + }); + expect(sheet.rows.setCell).toHaveBeenCalledWith( + 0, + 0, + { style: 2 }, + 'format', + ); + expect(sheet.rows.setCell).toHaveBeenCalledWith( + 1, + 0, + { style: 2 }, + 'format', + ); + }); + + it('ignores invalid position entries while highlighting valid ranges', () => { + const previewer = mockPreviewer(); + expect(() => + applyExcelSourceLocate( + previewer as never, + [[1, 2, 2, 1, 1], null] as never, + ), + ).not.toThrow(); + expect(previewer.xs.datas[0].rows.setCell).toHaveBeenCalled(); + }); + + it('does not add more styles when the same cells are highlighted twice', () => { + const previewer = mockPreviewer(); + const sheet = previewer.xs.datas[0]; + const cells = [{ style: 0 }, { style: 0 }]; + sheet.styles.push({ font: { bold: true } }); + sheet.rows.getCellOrNew.mockImplementation((row: number) => cells[row]); + sheet.rows.setCell.mockImplementation((row, _col, cell) => { + cells[row].style = cell.style; + }); + applyExcelSourceLocate(previewer as never, [[1, 1, 2, 1, 1]]); + applyExcelSourceLocate(previewer as never, [[1, 1, 2, 1, 1]]); + expect(sheet.styles).toHaveLength(2); + expect(sheet.styles[1]).toEqual({ + font: { bold: true }, + bgcolor: '#ffe58f', + color: '#1a1a1a', + }); }); it('falls back to resetData when clickSwap2 is missing', () => { const previewer = mockPreviewer(); (previewer.xs.bottombar as { clickSwap2?: unknown }).clickSwap2 = undefined; - applyExcelSourceLocate(previewer as never, [1, 2, 2, 1, 1]); + applyExcelSourceLocate(previewer as never, [[1, 2, 2, 1, 1]]); expect(previewer.xs.sheet.resetData).toHaveBeenCalledWith( previewer.xs.datas[0], ); @@ -118,7 +251,7 @@ describe('applyExcelSourceLocate', () => { const sheet = previewer.xs.sheet as Record; sheet.verticalScrollbar = undefined; sheet.horizontalScrollbar = undefined; - applyExcelSourceLocate(previewer as never, [1, 3, 3, 1, 1]); + applyExcelSourceLocate(previewer as never, [[1, 3, 3, 1, 1]]); // r1 = 2 rows above, each 20px high expect(previewer.xs.datas[0].scroll).toEqual({ x: 0, y: 40 }); @@ -126,3 +259,56 @@ describe('applyExcelSourceLocate', () => { expect(previewer.xs.sheet.table.render).toHaveBeenCalled(); }); }); + +describe('useFetchExcel', () => { + it('updates the preview when only a later position changes', async () => { + jest.mocked(axios.get).mockResolvedValue({ data: new ArrayBuffer(0) }); + const previews: ReturnType[] = []; + jest.mocked(jsPreviewExcel.init).mockImplementation(() => { + const preview = { + ...mockPreviewer(), + preview: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + }; + previews.push(preview); + return preview as never; + }); + const raf = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((fn) => { + fn(0); + return 1; + }); + const { result, rerender, unmount } = renderHook( + ({ positions }) => useFetchExcel('/file.xlsx', positions), + { + initialProps: { + positions: [ + [1, 2, 2, 1, 1], + [1, 3, 3, 1, 1], + ], + }, + }, + ); + act(() => result.current.containerRef(document.createElement('div'))); + await waitFor(() => + expect(previews[0]?.xs.datas[0].rows.setCell).toHaveBeenCalledTimes(2), + ); + rerender({ + positions: [ + [1, 2, 2, 1, 1], + [1, 4, 4, 1, 1], + ], + }); + await waitFor(() => + expect(previews[1]?.xs.datas[0].rows.setCell).toHaveBeenCalledWith( + 3, + 0, + { style: 0 }, + 'format', + ), + ); + unmount(); + raf.mockRestore(); + }); +}); diff --git a/web/src/components/document-preview/hooks.ts b/web/src/components/document-preview/hooks.ts index 3b82b0b51c..da2c6992a0 100644 --- a/web/src/components/document-preview/hooks.ts +++ b/web/src/components/document-preview/hooks.ts @@ -215,11 +215,9 @@ async function sanitizeSheetNames(data: ArrayBuffer): Promise { }); } -type ExcelLocatePos = number[]; - /** x-spreadsheet Data instance used by @js-preview/excel (not a plain object). */ type XsData = { - addStyle: (style: Record) => number; + styles: Record[]; rows: { setCell: ( ri: number, @@ -259,49 +257,56 @@ type XsInstance = { /** Locate inside @js-preview/excel: switch sheet, paint range via Data API, scroll. */ export function applyExcelSourceLocate( previewer: ReturnType, - pos: ExcelLocatePos, + positions: number[][], ) { - if (!pos || pos.length < 5) return; const xs = (previewer as { xs?: XsInstance }).xs; if (!xs?.datas?.length) return; - - const sheetIdx = Math.min( - xs.datas.length - 1, - Math.max(0, (pos[0] || 1) - 1), - ); - let r1 = Math.max(0, (pos[1] || 1) - 1); - let r2 = Math.max(r1, (pos[2] || pos[1] || 1) - 1); - let c1 = (pos[3] || 1) - 1; - let c2 = (pos[4] || pos[3] || 1) - 1; - if (c1 < 0) c1 = 0; - if (c2 < c1) c2 = c1; - - const data = xs.datas[sheetIdx]; - if (!data?.addStyle || !data.rows?.setCell) return; - - if (typeof data.rows.len === 'number') { - const lastRow = Math.max(0, data.rows.len - 1); - r1 = Math.min(r1, lastRow); - r2 = Math.min(r2, lastRow); - } - if (typeof data.cols?.len === 'number') { - const lastCol = Math.max(0, data.cols.len - 1); - c1 = Math.min(c1, lastCol); - c2 = Math.min(c2, lastCol); - } + const ranges = positions.flatMap((pos) => { + if ( + !Array.isArray(pos) || + pos.length < 5 || + !pos.slice(0, 5).every((v) => Number.isInteger(v) && v > 0) + ) + return []; + const sheetIdx = pos[0] - 1; + const data = xs.datas[sheetIdx]; + if (!data?.styles || !data.rows?.setCell) return []; + const r1 = pos[1] - 1; + const r2 = Math.min(pos[2] - 1, (data.rows.len ?? Infinity) - 1); + const c1 = pos[3] - 1; + const c2 = Math.min(pos[4] - 1, (data.cols?.len ?? Infinity) - 1); + if (r2 < r1 || c2 < c1) return []; + return [{ sheetIdx, data, r1, r2, c1, c2 }]; + }); + const first = ranges[0]; + if (!first) return; + const { sheetIdx, data, r1 } = first; // The spreadsheet canvas is always rendered on a light sheet background // regardless of app theme, so the locate highlight must use fixed light // colors rather than theme tokens (which resolve to near-white text in dark // mode and become invisible). Matches the yellow used by the PDF highlighter. - if (r2 >= r1 && c2 >= c1) { - const styleIdx = data.addStyle({ - bgcolor: '#ffe58f', - color: '#1a1a1a', - }); - for (let r = r1; r <= r2; r++) { - for (let c = c1; c <= c2; c++) { - data.rows.setCell(r, c, { style: styleIdx }, 'format'); + const stylesBySheet = new Map>(); + for (const range of ranges) { + let styles = stylesBySheet.get(range.data); + if (!styles) { + styles = new Map(); + stylesBySheet.set(range.data, styles); + } + for (let r = range.r1; r <= range.r2; r++) { + for (let c = range.c1; c <= range.c2; c++) { + const cell = range.data.rows.getCellOrNew(r, c); + const original = range.data.styles[cell.style ?? -1] ?? {}; + if (original.bgcolor === '#ffe58f' && original.color === '#1a1a1a') + continue; + const highlight = { ...original, bgcolor: '#ffe58f', color: '#1a1a1a' }; + const key = JSON.stringify(highlight); + let styleIdx = styles.get(key); + if (styleIdx === undefined) { + styleIdx = range.data.styles.push(highlight) - 1; + styles.set(key, styleIdx); + } + range.data.rows.setCell(r, c, { style: styleIdx }, 'format'); } } } @@ -362,7 +367,7 @@ export const useFetchExcel = (filePath: string, positions?: number[][]) => { const [dataReady, setDataReady] = useState(false); const positionsRef = useRef(positions); positionsRef.current = positions; - const locateKey = positions?.[0]?.join(',') ?? ''; + const locateKey = JSON.stringify(positions ?? []); const locateTimerRef = useRef | null>(null); // @js-preview/excel reads the container's width/height at init time and @@ -385,12 +390,12 @@ export const useFetchExcel = (filePath: string, positions?: number[][]) => { try { await previewer.preview(dataRef.current); - const pos = positionsRef.current?.[0]; - if (pos) { + const currentPositions = positionsRef.current; + if (currentPositions?.length) { // bottombar tabs are created during loadData; apply after paint. const run = () => { if (previewerRef.current !== previewer) return; - applyExcelSourceLocate(previewer, pos); + applyExcelSourceLocate(previewer, currentPositions); }; requestAnimationFrame(() => { run();