mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
fix(preview): highlight all spreadsheet chunk ranges (#21511)
This commit is contained in:
1 parent
a43cac2615
commit
292db08b91
5 files changed
+445
-82
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user