fix(preview): highlight all spreadsheet chunk ranges (#21511)

This commit is contained in:
jay77721 authored and GitHub committed 2026-10-27 14:22:02 +08:00
1 parent a43cac2615
commit 292db08b91
5 files changed
+445 -82

No files matched your search

+29 -25
View File
@@ -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)
@@ -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) {
@@ -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();
}
});
+198 -12
View File
@@ -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<string, unknown>[],
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<string, unknown>[] })
.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<string, unknown>;
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<typeof mockPreviewer>[] = [];
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();
});
});
+47 -42
View File
@@ -215,11 +215,9 @@ async function sanitizeSheetNames(data: ArrayBuffer): Promise<ArrayBuffer> {
});
}
type ExcelLocatePos = number[];
/** x-spreadsheet Data instance used by @js-preview/excel (not a plain object). */
type XsData = {
addStyle: (style: Record<string, unknown>) => number;
styles: Record<string, unknown>[];
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<typeof jsPreviewExcel.init>,
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<XsData, Map<string, number>>();
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<ReturnType<typeof setTimeout> | 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();