snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
# @univerjs/sheets-find-replace
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-find-replace?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-find-replace)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-find-replace?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-find-replace)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-find-replace?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-find-replace)
`@univerjs/sheets-find-replace` extends shared find and replace capabilities to worksheets in Univer Sheets.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-find-replace` | `UniverSheetsFindReplace` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-find-replace
# or
npm install @univerjs/sheets-find-replace
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverSheetsFindReplacePlugin } from '@univerjs/sheets-find-replace';
univer.registerPlugin(UniverSheetsFindReplacePlugin);
```
## Integration Notes
Use this package with `@univerjs/find-replace` to share find and replace infrastructure across Univer surfaces.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-find-replace)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,93 @@
{
"name": "@univerjs/sheets-find-replace",
"version": "1.0.0-rc.0",
"private": false,
"description": "Find and replace integration for Univer Sheets.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"sheets",
"find-replace",
"search",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/find-replace": "workspace:*",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,95 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ICommandService, Injector, IUndoRedoService } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { SheetReplaceCommand } from '../sheet-replace.command';
class SequencedCommandService {
readonly commands: Array<{ id: string; params?: unknown }> = [];
results: boolean[] = [];
executeCommand(id: string, params?: unknown): Promise<boolean> {
this.commands.push({ id, params });
return Promise.resolve(this.results.shift() ?? true);
}
}
class RecordingUndoRedoService {
readonly batchingUnits: string[] = [];
disposeCount = 0;
__tempBatchingUndoRedo(unitId: string) {
this.batchingUnits.push(unitId);
return {
dispose: () => {
this.disposeCount++;
},
};
}
}
describe('SheetReplaceCommand', () => {
it('should batch undo/redo and execute SetRangeValues for replacements', async () => {
const injector = new Injector();
injector.add([ICommandService, { useClass: SequencedCommandService as never }]);
injector.add([IUndoRedoService, { useClass: RecordingUndoRedoService as never }]);
const commandService = injector.get(ICommandService) as unknown as SequencedCommandService;
const undoRedoService = injector.get(IUndoRedoService) as unknown as RecordingUndoRedoService;
commandService.results = [true, true];
const res = await SheetReplaceCommand.handler(injector, {
unitId: 'u1',
replacements: [
{ subUnitId: 's1', count: 2, value: { 0: { 0: { v: 'a' } } } as any },
{ subUnitId: 's2', count: 1, value: { 1: { 1: { v: 'b' } } } as any },
],
});
expect(commandService.commands.map(({ params }) => params)).toMatchObject([
{ unitId: 'u1', subUnitId: 's1' },
{ unitId: 'u1', subUnitId: 's2' },
]);
expect(undoRedoService.batchingUnits).toEqual(['u1']);
expect(undoRedoService.disposeCount).toBe(1);
expect(res).toEqual({ success: 3, failure: 0 });
});
it('should report failed replacement counts when a range update fails', async () => {
const injector = new Injector();
injector.add([ICommandService, { useClass: SequencedCommandService as never }]);
injector.add([IUndoRedoService, { useClass: RecordingUndoRedoService as never }]);
const commandService = injector.get(ICommandService) as unknown as SequencedCommandService;
const undoRedoService = injector.get(IUndoRedoService) as unknown as RecordingUndoRedoService;
commandService.results = [true, false];
const res = await SheetReplaceCommand.handler(injector, {
unitId: 'u1',
replacements: [
{ subUnitId: 's1', count: 2, value: { 0: { 0: { v: 'a' } } } as any },
{ subUnitId: 's2', count: 3, value: { 1: { 1: { v: 'b' } } } as any },
],
});
expect(commandService.commands.map(({ params }) => params)).toMatchObject([
{ unitId: 'u1', subUnitId: 's1' },
{ unitId: 'u1', subUnitId: 's2' },
]);
expect(undoRedoService.batchingUnits).toEqual(['u1']);
expect(undoRedoService.disposeCount).toBe(1);
expect(res).toEqual({ success: 2, failure: 3 });
});
});

View File

@@ -0,0 +1,74 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICellData, ICommand, IObjectArrayPrimitiveType } from '@univerjs/core';
import type { IReplaceAllResult } from '@univerjs/find-replace';
import type { ISetRangeValuesCommandParams } from '@univerjs/sheets';
import { CommandType, ICommandService, IUndoRedoService } from '@univerjs/core';
import { SetRangeValuesCommand } from '@univerjs/sheets';
export interface ISheetReplaceCommandParams {
unitId: string;
replacements: ISheetReplacement[];
}
export interface ISheetReplacement {
count: number;
subUnitId: string;
value: IObjectArrayPrimitiveType<ICellData>;
}
/**
* This command is used for the SheetFindReplaceController to deal with replacing, including undo redo.
*
*/
export const SheetReplaceCommand: ICommand<ISheetReplaceCommandParams, IReplaceAllResult> = {
id: 'sheet.command.replace',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: ISheetReplaceCommandParams) => {
const undoRedoService = accessor.get(IUndoRedoService);
const commandService = accessor.get(ICommandService);
const { unitId, replacements } = params;
// NOTE: Note that if we are going to support "Replace All" in different units, this need to be changed.
const disposeBatchingHandler = undoRedoService.__tempBatchingUndoRedo(unitId);
const results = await Promise.all(replacements.map((replacement) => commandService.executeCommand(SetRangeValuesCommand.id, {
unitId,
subUnitId: replacement.subUnitId,
value: replacement.value,
} as ISetRangeValuesCommandParams)));
disposeBatchingHandler.dispose();
return getReplaceAllResult(results, replacements);
},
};
function getReplaceAllResult(results: boolean[], replacements: ISheetReplacement[]): IReplaceAllResult {
let success = 0;
let failure = 0;
results.forEach((r, index) => {
const count = replacements[index].count;
if (r) {
success += count;
} else {
failure += count;
}
});
return { success, failure };
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const SHEETS_FIND_REPLACE_PLUGIN_CONFIG_KEY = 'sheets-find-replace.config';
export const configSymbol = Symbol(SHEETS_FIND_REPLACE_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsFindReplaceConfig {
}
export const defaultPluginConfig: IUniverSheetsFindReplaceConfig = {};

View File

@@ -0,0 +1,118 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IFindQuery } from '@univerjs/find-replace';
import { FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
import { describe, expect, it } from 'vitest';
import { hitCell } from '../sheet-find-replace.controller';
describe('hitCell', () => {
const baseQuery: IFindQuery = {
replaceRevealed: false,
findString: 'abc',
caseSensitive: false,
matchesTheWholeCell: false,
matchesTheWholeWord: false,
findDirection: FindDirection.ROW,
findScope: FindScope.SUBUNIT,
findBy: FindBy.VALUE,
};
it('should mark formula matches replaceable only in formula mode', () => {
const worksheet = {
getCellRaw: () => ({ f: '=ABC()' }),
};
const formulaQuery: IFindQuery = { ...baseQuery, findBy: FindBy.FORMULA, findString: '=abc' };
const res1 = hitCell(worksheet as any, 0, 0, formulaQuery, { v: 'xxx' } as any);
expect(res1.hit).toBe(true);
expect(res1.replaceable).toBe(true);
expect(res1.isFormula).toBe(true);
const valueQuery: IFindQuery = { ...baseQuery, findBy: FindBy.VALUE, findString: 'xxx' };
const res2 = hitCell(worksheet as any, 0, 0, valueQuery, { v: 'xxx' } as any);
expect(res2.hit).toBe(true);
expect(res2.replaceable).toBe(false);
expect(res2.isFormula).toBe(true);
});
it('should mark non-formula matches replaceable only when rawData exists', () => {
const worksheetWithRaw = {
getCellRaw: () => ({ v: 'abc' }),
};
const res1 = hitCell(worksheetWithRaw as any, 0, 0, baseQuery, { v: 'abc' } as any);
expect(res1.hit).toBe(true);
expect(res1.replaceable).toBe(true);
const worksheetNoRaw = {
getCellRaw: () => null,
};
const res2 = hitCell(worksheetNoRaw as any, 0, 0, baseQuery, { v: 'abc' } as any);
expect(res2.hit).toBe(true);
expect(res2.replaceable).toBe(false);
});
it('should support whole-cell matching with edge spaces', () => {
const worksheet = {
getCellRaw: () => ({ v: ' abc ' }),
};
const res = hitCell(worksheet as any, 0, 0, {
...baseQuery,
matchesTheWholeCell: true,
findString: 'abc',
}, { v: ' abc ' } as any);
expect(res.hit).toBe(true);
expect(res.replaceable).toBe(true);
});
it('should extract number and boolean cell values for matching', () => {
const worksheet = {
getCellRaw: (_row: number, col: number) => (col === 0 ? { v: 123 } : { v: true }),
};
expect(hitCell(worksheet as any, 0, 0, { ...baseQuery, findString: '23' }, { v: 123 } as any).hit).toBe(true);
expect(hitCell(worksheet as any, 0, 1, { ...baseQuery, findString: '1' }, { v: true } as any).hit).toBe(true);
});
it('should report misses for empty values and unmatched formula display values', () => {
const formulaWorksheet = {
getCellRaw: () => ({ f: '=SUM(1)' }),
};
const formulaRawMiss = hitCell(formulaWorksheet as any, 0, 0, {
...baseQuery,
findBy: FindBy.FORMULA,
findString: '=missing',
}, { v: 'alpha' } as any);
expect(formulaRawMiss.hit).toBe(false);
expect(formulaRawMiss.replaceable).toBe(false);
const formulaMiss = hitCell(formulaWorksheet as any, 0, 0, { ...baseQuery, findString: 'missing' }, { v: 'alpha' } as any);
expect(formulaMiss.hit).toBe(false);
expect(formulaMiss.replaceable).toBe(false);
expect(formulaMiss.isFormula).toBe(true);
const emptyWorksheet = {
getCellRaw: () => ({ v: '' }),
};
const emptyMiss = hitCell(emptyWorksheet as any, 0, 0, baseQuery, { v: '' } as any);
expect(emptyMiss.hit).toBe(false);
expect(emptyMiss.replaceable).toBe(false);
});
});

View File

@@ -0,0 +1,116 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IFindQuery } from '@univerjs/find-replace';
import { CloseFindDialogOperation, FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetFindModel, SheetsFindReplaceController } from '../sheet-find-replace.controller';
describe('SheetFindModel navigation', () => {
it('should move to next match based on current selection (no focus)', () => {
const activeSheet = { getSheetId: () => 's1', getRowCount: () => 10, getColumnCount: () => 10, iterateByRow: () => [], iterateByColumn: () => [], isSheetHidden: () => false };
const workbook = {
getUnitId: () => 'u1',
getActiveSheet: () => activeSheet,
activeSheet$: new Subject<any>(),
};
const skeletonService = {
currentSkeleton$: new Subject<any>(),
getCurrent: () => null,
};
const renderManagerService = { getRenderUnitById: vi.fn(() => null) };
const commandService = { executeCommand: vi.fn(), onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })) };
const contextService = { setContextValue: vi.fn() };
const themeService = { getColorFromTheme: vi.fn(() => '#fff') };
const selections = [{ range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 } }];
const workbookSelections = {
getCurrentSelections: () => selections,
getCurrentLastSelection: () => selections[0],
};
const selectionService = {
getWorkbookSelections: vi.fn(() => workbookSelections),
};
const model = new SheetFindModel(
workbook as any,
skeletonService as any,
{} as any,
renderManagerService as any,
commandService as any,
contextService as any,
themeService as any,
selectionService as any
);
(model as any)._matches = [
{ unitId: 'u1', provider: 'sheets-find-replace-provider', isFormula: false, replaceable: true, range: { subUnitId: 's1', range: { startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 } } },
{ unitId: 'u1', provider: 'sheets-find-replace-provider', isFormula: false, replaceable: true, range: { subUnitId: 's1', range: { startRow: 2, startColumn: 1, endRow: 2, endColumn: 1 } } },
];
(model as any)._query = {
replaceRevealed: false,
findString: 'a',
caseSensitive: false,
matchesTheWholeCell: false,
matchesTheWholeWord: false,
findDirection: FindDirection.ROW,
findScope: FindScope.SUBUNIT,
findBy: FindBy.VALUE,
} satisfies IFindQuery;
const match = model.moveToNextMatch({ noFocus: true });
expect(match?.range.range.startRow).toBe(1);
expect(commandService.executeCommand).not.toHaveBeenCalled();
model.dispose();
});
});
describe('SheetsFindReplaceController', () => {
it('should register provider and close on editor activated', () => {
const injector = { createInstance: vi.fn(() => ({ dispose: vi.fn() })) };
const subscribeContextValue$ = vi.fn(() => ({ pipe: () => ({ subscribe: (fn: any) => {
fn(true);
return { unsubscribe: vi.fn() };
} }) }));
const contextService = { subscribeContextValue$ };
const findReplaceService = {
registerFindReplaceProvider: vi.fn(() => ({ dispose: vi.fn() })),
terminate: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(() => Promise.resolve(true)),
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
};
new SheetsFindReplaceController(
injector as any,
contextService as any,
findReplaceService as any,
commandService as any
);
expect(commandService.executeCommand).toHaveBeenCalledWith(CloseFindDialogOperation.id);
// Do not call `dispose` here: the controller stores rx subscriptions in a DisposableCollection,
// and our simplified stubs don't fully implement the IDisposable contract.
expect(findReplaceService.registerFindReplaceProvider).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,550 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICellData, IRange } from '@univerjs/core';
import type { IFindQuery } from '@univerjs/find-replace';
import { UniverInstanceType } from '@univerjs/core';
import { RENDER_RAW_FORMULA_KEY } from '@univerjs/engine-render';
import { CloseFindDialogOperation, FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
import { SelectRangeCommand, SetRangeValuesCommand, SetWorksheetActivateCommand } from '@univerjs/sheets';
import { ScrollToCellCommand } from '@univerjs/sheets-ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetReplaceCommand } from '../../commands/commands/sheet-replace.command';
import { SheetFindModel, SheetsFindReplaceController } from '../sheet-find-replace.controller';
vi.mock('@univerjs/sheets-ui', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/sheets-ui')>();
return {
...actual,
getCoordByCell: vi.fn((row: number, col: number) => ({
startX: col * 10,
startY: row * 20,
endX: (col + 1) * 10,
endY: (row + 1) * 20,
})),
};
});
const baseQuery: IFindQuery = {
replaceRevealed: false,
findString: 'alpha',
caseSensitive: false,
matchesTheWholeCell: false,
matchesTheWholeWord: false,
findDirection: FindDirection.ROW,
findScope: FindScope.SUBUNIT,
findBy: FindBy.VALUE,
};
function createWorksheet(sheetId: string, cells: Record<string, ICellData>, options?: { hidden?: boolean; filteredRows?: number[] }) {
const getCellData = (row: number, col: number) => cells[`${row},${col}`];
return {
getSheetId: () => sheetId,
getRowCount: () => 4,
getColumnCount: () => 4,
isSheetHidden: () => !!options?.hidden,
getMergedCell: () => null,
getRowFiltered: (row: number) => options?.filteredRows?.includes(row) ?? false,
getRowRawVisible: () => true,
getColVisible: () => true,
getCellRaw: vi.fn((row: number, col: number) => getCellData(row, col) ?? null),
* iterateByRow(range: IRange) {
for (let row = range.startRow; row <= range.endRow; row++) {
for (let col = range.startColumn; col <= range.endColumn; col++) {
const value = getCellData(row, col);
if (value) {
yield { row, col, value };
}
}
}
},
* iterateByColumn(range: IRange) {
for (let col = range.startColumn; col <= range.endColumn; col++) {
for (let row = range.startRow; row <= range.endRow; row++) {
const value = getCellData(row, col);
if (value) {
yield { row, col, value };
}
}
}
},
} as any;
}
function createWorkbook(activeSheet: any, sheets = [activeSheet]) {
return {
getUnitId: () => 'unit-1',
type: UniverInstanceType.UNIVER_SHEET,
getActiveSheet: vi.fn(() => activeSheet),
getSheets: vi.fn(() => sheets),
getSheetOrders: vi.fn(() => sheets.map((sheet) => sheet.getSheetId())),
getSheetBySheetId: vi.fn((sheetId: string) => sheets.find((sheet) => sheet.getSheetId() === sheetId)),
activeSheet$: new Subject<any>(),
} as any;
}
function createModel(
workbook: any,
selections: any[] = [{ range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 } }],
overrides: {
skeletonService?: any;
renderManagerService?: any;
themeService?: any;
} = {}
) {
const skeletonService = overrides.skeletonService ?? {
currentSkeleton$: new Subject<any>(),
getCurrentSkeleton: vi.fn(() => null),
};
const commandCallbacks: Array<(command: any, options?: any) => void> = [];
const commandService = {
executeCommand: vi.fn(() => Promise.resolve(true)),
onCommandExecuted: vi.fn((callback: (command: any, options?: any) => void) => {
commandCallbacks.push(callback);
return { dispose: vi.fn() };
}),
emitCommand(command: any, options?: any) {
commandCallbacks.forEach((callback) => callback(command, options));
},
};
const contextService = { setContextValue: vi.fn() };
const selectionService = {
getWorkbookSelections: vi.fn(() => ({
getCurrentSelections: vi.fn(() => selections),
getCurrentLastSelection: vi.fn(() => selections.at(-1)),
})),
};
const model = new SheetFindModel(
workbook,
skeletonService as any,
{} as any,
(overrides.renderManagerService ?? { getRenderUnitById: vi.fn(() => null) }) as any,
commandService as any,
contextService as any,
(overrides.themeService ?? { getColorFromTheme: vi.fn(() => '#ff0') }) as any,
selectionService as any
);
return { model, commandService, contextService, skeletonService };
}
describe('SheetsFindReplaceController integration', () => {
it('registers a real sheets provider, preprocesses queries and closes on editor activation', async () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { v: 'Alpha' },
'0,1': { v: 'beta' },
'1,0': { f: '=SUM(1)', v: 'alpha result' },
'2,0': { v: 'alpha filtered' },
}, { filteredRows: [2] });
const workbook = createWorkbook(activeSheet);
const skeletonService = {
currentSkeleton$: new Subject<any>(),
getCurrentSkeleton: vi.fn(() => null),
};
const renderManagerService = {
getRenderUnitById: vi.fn(() => ({ with: vi.fn(() => skeletonService) })),
};
const commandService = {
registerCommand: vi.fn(() => ({ dispose: vi.fn() })),
executeCommand: vi.fn(() => Promise.resolve(true)),
onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })),
};
const contextValue$ = new Subject<boolean>();
const contextService = {
subscribeContextValue$: vi.fn(() => contextValue$),
setContextValue: vi.fn(),
};
const selectionService = {
getWorkbookSelections: vi.fn(() => ({
getCurrentSelections: vi.fn(() => [{ range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 } }]),
getCurrentLastSelection: vi.fn(() => ({ range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 } })),
})),
};
const univerInstanceService = {
getCurrentUnitOfType: vi.fn(() => workbook),
};
const injector = {
createInstance: vi.fn((Ctor: any, ...args: any[]) => {
if (Ctor.name === 'SheetsFindReplaceProvider') {
return new Ctor(univerInstanceService, renderManagerService, injector);
}
return new Ctor(
args[0],
args[1],
univerInstanceService,
renderManagerService,
commandService,
contextService,
{ getColorFromTheme: vi.fn(() => '#ff0') },
selectionService
);
}),
};
let provider: any;
const terminate = vi.fn();
const findReplaceService = {
registerFindReplaceProvider: vi.fn((registeredProvider) => {
provider = registeredProvider;
return { dispose: vi.fn() };
}),
terminate,
};
const controller = new SheetsFindReplaceController(
injector as any,
contextService as any,
findReplaceService as any,
commandService as any
);
contextValue$.next(true);
const [model] = await provider.find({ ...baseQuery, findString: ' ALPHA ' });
expect(commandService.registerCommand).toHaveBeenCalledWith(SheetReplaceCommand);
expect(commandService.executeCommand).toHaveBeenCalledWith(CloseFindDialogOperation.id);
expect(model).toBeInstanceOf(SheetFindModel);
expect(model.matchesCount).toBe(2);
expect(model.getMatches().map((match: any) => match.range.range.startRow)).toEqual([0, 1]);
expect(model.getMatches().map((match: any) => match.replaceable)).toEqual([true, false]);
controller.dispose();
expect(terminate).toHaveBeenCalledOnce();
});
it('returns no models when the provider has no active workbook', async () => {
const injector = {
createInstance: vi.fn((Ctor: any) => new Ctor(
{ getCurrentUnitOfType: vi.fn(() => null) },
{ getRenderUnitById: vi.fn() },
injector
)),
};
let provider: any;
const controller = new SheetsFindReplaceController(
injector as any,
{ subscribeContextValue$: vi.fn(() => new Subject<boolean>()) } as any,
{
registerFindReplaceProvider: vi.fn((registeredProvider) => {
provider = registeredProvider;
return { dispose: vi.fn() };
}),
terminate: vi.fn(),
} as any,
{ registerCommand: vi.fn(() => ({ dispose: vi.fn() })) } as any
);
await expect(provider.find(baseQuery)).resolves.toEqual([]);
provider.terminate();
controller.dispose();
});
});
describe('SheetFindModel search and replace', () => {
it('finds in selected ranges with deduplication and toggles raw formula mode', () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { v: 'alpha' },
'0,1': { v: 'alpha again' },
'1,0': { v: 'beta' },
});
const workbook = createWorkbook(activeSheet);
const { model, contextService } = createModel(workbook, [
{ range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 } },
{ range: { startRow: 0, startColumn: 1, endRow: 1, endColumn: 1 } },
]);
const complete = model.findInActiveWorksheet({ ...baseQuery, findBy: FindBy.FORMULA });
expect(contextService.setContextValue).not.toHaveBeenCalled();
expect(complete.results).toHaveLength(2);
expect(complete.results.map((match: any) => match.range.range.startColumn)).toEqual([0, 1]);
expect(model.matchesCount).toBe(2);
model.start({ ...baseQuery, findBy: FindBy.FORMULA, findString: '=sum' });
expect(contextService.setContextValue).toHaveBeenCalledWith(RENDER_RAW_FORMULA_KEY, true);
model.dispose();
expect(contextService.setContextValue).toHaveBeenLastCalledWith(RENDER_RAW_FORMULA_KEY, false);
});
it('finds across visible workbook sheets and skips hidden sheets', () => {
const sheet1 = createWorksheet('sheet-1', { '0,0': { v: 'alpha' } });
const sheet2 = createWorksheet('sheet-2', { '0,0': { v: 'alpha second' } });
const hiddenSheet = createWorksheet('hidden', { '0,0': { v: 'alpha hidden' } }, { hidden: true });
const workbook = createWorkbook(sheet1, [sheet1, sheet2, hiddenSheet]);
const { model } = createModel(workbook);
const complete = model.findInWorkbook({ ...baseQuery, findScope: FindScope.UNIT });
expect(complete.results.map((match: any) => match.range.subUnitId)).toEqual(['sheet-1', 'sheet-2']);
expect(model.matchesCount).toBe(2);
model.dispose();
});
it('starts workbook-scope searches from the public start entry and no-ops focus without a match', () => {
const sheet1 = createWorksheet('sheet-1', { '0,0': { v: 'alpha' } });
const sheet2 = createWorksheet('sheet-2', { '0,0': { v: 'alpha second' } });
const workbook = createWorkbook(sheet1, [sheet1, sheet2]);
const { model, commandService } = createModel(workbook);
model.focusSelection();
expect(commandService.executeCommand).not.toHaveBeenCalled();
model.start({ ...baseQuery, findScope: FindScope.UNIT });
expect(model.matchesCount).toBe(2);
model.dispose();
});
it('returns no active worksheet matches when the workbook has no active sheet', () => {
const workbook = {
getUnitId: () => 'unit-1',
getActiveSheet: vi.fn(() => null),
activeSheet$: new Subject<any>(),
};
const { model } = createModel(workbook);
expect(model.findInActiveWorksheet(baseQuery)).toBeUndefined();
model.dispose();
});
it('moves between matches, focuses selections and executes focus commands', async () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { v: 'alpha' },
'1,1': { v: 'alpha next' },
});
const workbook = createWorkbook(activeSheet);
const { model, commandService } = createModel(workbook);
expect(model.moveToNextMatch()).toBeNull();
model.start(baseQuery);
const first = model.moveToNextMatch({ ignoreSelection: true });
expect(first?.range.range).toMatchObject({ startRow: 0, startColumn: 0 });
expect(model.matchesPosition).toBe(1);
expect(commandService.executeCommand).toHaveBeenLastCalledWith(ScrollToCellCommand.id, {
range: first!.range.range,
}, {
fromFindReplace: true,
});
model.focusSelection();
expect(commandService.executeCommand).toHaveBeenLastCalledWith(SelectRangeCommand.id, {
unitId: 'unit-1',
subUnit: 'sheet-1',
range: first!.range.range,
});
const stayed = model.moveToNextMatch({ stayIfOnMatch: true, noFocus: true });
expect(stayed).toBe(first);
const second = model.moveToNextMatch({ noFocus: true });
expect(second?.range.range).toMatchObject({ startRow: 1, startColumn: 1 });
expect(model.moveToNextMatch({ noFocus: true })).toBeNull();
expect(model.matchesPosition).toBe(0);
const previous = model.moveToPreviousMatch({ noFocus: true, ignoreSelection: true });
expect(previous?.range.range).toMatchObject({ startRow: 1, startColumn: 1 });
expect(model.moveToPreviousMatch({ stayIfOnMatch: true, noFocus: true })).toBe(previous);
model.dispose();
});
it('focuses a match on another sheet through activation and scroll commands', async () => {
const sheet1 = createWorksheet('sheet-1', { '0,0': { v: 'alpha' } });
const sheet2 = createWorksheet('sheet-2', { '1,1': { v: 'alpha' } });
const workbook = createWorkbook(sheet1, [sheet1, sheet2]);
const { model, commandService } = createModel(workbook);
const match = {
unitId: 'unit-1',
provider: 'sheets-find-replace-provider',
replaceable: true,
isFormula: false,
range: {
subUnitId: 'sheet-2',
range: { startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 },
},
};
await (model as any)._focusMatch(match);
expect(commandService.executeCommand).toHaveBeenNthCalledWith(1, SetWorksheetActivateCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-2',
}, {
fromFindReplace: true,
});
expect(commandService.executeCommand).toHaveBeenNthCalledWith(2, ScrollToCellCommand.id, {
range: match.range.range,
}, {
fromFindReplace: true,
});
model.dispose();
});
it('renders find highlights for visible matches and updates the current highlight', () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { v: 'alpha' },
'1,1': { v: 'alpha next' },
});
const workbook = createWorkbook(activeSheet);
const skeletonSubject = new Subject<any>();
const skeletonService = {
currentSkeleton$: skeletonSubject,
getCurrentSkeleton: vi.fn(() => ({ rowColumnSegment: {} })),
};
const scene = {
addObjects: vi.fn(),
makeDirty: vi.fn(),
};
const { model } = createModel(workbook, undefined, {
skeletonService,
renderManagerService: { getRenderUnitById: vi.fn(() => ({ scene })) },
themeService: { getColorFromTheme: vi.fn(() => '#ff0') },
});
model.start(baseQuery);
let highlightShapes = scene.addObjects.mock.calls.at(-1)![0] as any[];
expect(highlightShapes).toHaveLength(2);
expect(highlightShapes[0].width).toBe(10);
expect(highlightShapes[0].height).toBe(20);
expect(scene.makeDirty).toHaveBeenCalled();
model.moveToNextMatch({ noFocus: true, ignoreSelection: true });
highlightShapes = scene.addObjects.mock.calls.at(-1)![0] as any[];
expect(highlightShapes[0]._activated).toBe(true);
skeletonSubject.next({});
expect(scene.addObjects).toHaveBeenCalledTimes(2);
model.dispose();
});
it('replaces the current match and replaces all replaceable matches by sheet', async () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { v: 'alpha plain' },
'0,1': { f: '=ALPHA()', v: 'alpha formula result' },
'1,0': { p: { body: { dataStream: 'alpha rich\r\n' } } as any },
});
const workbook = createWorkbook(activeSheet);
const { model, commandService } = createModel(workbook);
model.start(baseQuery);
model.moveToNextMatch({ noFocus: true, ignoreSelection: true });
await expect(model.replace('omega')).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenCalledWith(SetRangeValuesCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
value: {
0: {
0: { v: 'omega plain' },
},
},
});
await model.replaceAll('omega');
expect(commandService.executeCommand).toHaveBeenLastCalledWith(SheetReplaceCommand.id, {
unitId: 'unit-1',
replacements: [
{
count: 2,
subUnitId: 'sheet-1',
value: expect.objectContaining({
0: expect.objectContaining({ 0: { v: 'omega plain' } }),
1: expect.objectContaining({ 0: expect.objectContaining({ p: expect.any(Object) }) }),
}),
},
],
});
model.dispose();
});
it('replaces formulas only in formula search mode', async () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { f: '=ALPHA()', v: 'alpha result' },
});
const workbook = createWorkbook(activeSheet);
const { model, commandService } = createModel(workbook);
model.start({ ...baseQuery, findBy: FindBy.FORMULA, findString: '=alpha' });
model.moveToNextMatch({ noFocus: true, ignoreSelection: true });
await expect(model.replace('=OMEGA')).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenLastCalledWith(SetRangeValuesCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
value: {
0: {
0: { f: '=OMEGA()', v: null },
},
},
});
model.dispose();
});
it('treats find strings as literal text when replacing plain cells', async () => {
const activeSheet = createWorksheet('sheet-1', {
'0,0': { v: 'a.b aXb a.b' },
});
const workbook = createWorkbook(activeSheet);
const { model, commandService } = createModel(workbook);
model.start({ ...baseQuery, findString: 'a.b' });
await expect(model.replaceAll('hit')).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenLastCalledWith(SheetReplaceCommand.id, {
unitId: 'unit-1',
replacements: [
{
count: 1,
subUnitId: 'sheet-1',
value: {
0: {
0: { v: 'hit aXb hit' },
},
},
},
],
});
model.dispose();
});
it('returns empty replacement results when nothing is searchable', async () => {
const activeSheet = createWorksheet('sheet-1', {});
const workbook = createWorkbook(activeSheet);
const { model, commandService } = createModel(workbook);
expect(model.findInActiveWorksheet(baseQuery).results).toEqual([]);
await expect(model.replace('omega')).resolves.toBe(false);
await expect(model.replaceAll('omega')).resolves.toEqual({ success: 0, failure: 0 });
expect(commandService.executeCommand).not.toHaveBeenCalled();
model.dispose();
});
});

View File

@@ -0,0 +1,339 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency, IWorkbookData, Workbook, Worksheet } from '@univerjs/core';
import type { IFindQuery } from '@univerjs/find-replace';
import { ILogService, IUniverInstanceService, LocaleType, LogLevel, Univer, UniverInstanceType } from '@univerjs/core';
import { FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { hitCell } from '../sheet-find-replace.controller';
import {
isBeforePositionWithColumnPriority,
isBeforePositionWithRowPriority,
isBehindPositionWithColumnPriority,
isBehindPositionWithRowPriority,
isSamePosition,
isSelectionSingleCell,
} from '../utils';
describe('Test sheet find replace utils', () => {
it('Should "isSamePosition" work as expected', () => {
expect(
isSamePosition(
{ startRow: 12, endColumn: 24, startColumn: 22, endRow: 25 },
{ startRow: 12, endRow: 23, startColumn: 22, endColumn: 28 }
)
).toBeTruthy();
expect(
isSamePosition(
{ startRow: 13, endColumn: 24, startColumn: 22, endRow: 25 },
{ startRow: 12, endRow: 23, startColumn: 22, endColumn: 28 }
)
).toBeFalsy();
expect(
isSamePosition(
{ startRow: 12, endColumn: 24, startColumn: 24, endRow: 25 },
{ startRow: 12, endRow: 23, startColumn: 22, endColumn: 28 }
)
).toBeFalsy();
});
it('Should "isBehindPositionWithRowPriority" work as expected', () => {
expect(
isBehindPositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
expect(
isBehindPositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 }
)
).toBeTruthy();
expect(
isBehindPositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 0 }
)
).toBeTruthy();
expect(
isBehindPositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }
)
).toBeFalsy();
expect(
isBehindPositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 }
)
).toBeFalsy();
});
it('Should "isBehindPositionWithColumnPriority" work as expected', () => {
expect(
isBehindPositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
expect(
isBehindPositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 }
)
).toBeTruthy();
expect(
isBehindPositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 0 }
)
).toBeFalsy();
expect(
isBehindPositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }
)
).toBeFalsy();
expect(
isBehindPositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 2, endRow: 2, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
});
it('Should "isBeforePositionWithRowPriority" work as expected', () => {
expect(
isBeforePositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
expect(
isBeforePositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 }
)
).toBeFalsy();
expect(
isBeforePositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 0 }
)
).toBeFalsy();
expect(
isBeforePositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
expect(
isBeforePositionWithRowPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 }
)
).toBeTruthy();
});
it('Should "isBeforePositionWithColumnPriority" work as expected', () => {
expect(
isBeforePositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
expect(
isBeforePositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 }
)
).toBeFalsy();
expect(
isBeforePositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 0 }
)
).toBeTruthy();
expect(
isBeforePositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }
)
).toBeTruthy();
expect(
isBeforePositionWithColumnPriority(
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
{ startRow: 2, endRow: 2, startColumn: 1, endColumn: 1 }
)
).toBeFalsy();
});
it('Should "isSelectionSingleCell" treat a full merged range as one cell', () => {
const mergedRange = { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 };
const worksheet = {
getMergedCell: () => mergedRange,
} as any;
expect(isSelectionSingleCell({ range: mergedRange } as any, worksheet)).toBe(true);
expect(isSelectionSingleCell({
range: { startRow: 1, endRow: 1, startColumn: 1, endColumn: 1 },
} as any, worksheet)).toBe(false);
});
});
describe('test "hitCell" method', () => {
let univer: Univer;
let worksheet: Worksheet;
beforeEach(() => {
const testBed = createTestBed();
univer = testBed.univer;
worksheet = testBed.sheet.getActiveSheet()!;
});
afterEach(() => {
univer.dispose();
});
describe('hitting formulas', () => {
describe('replaceable if only searching for raw formulas', () => {
it('should be replaceable when searching for formulas and formula string matches', () => {
const query: IFindQuery = {
findString: '6',
caseSensitive: false,
findBy: FindBy.FORMULA,
findDirection: FindDirection.COLUMN,
findScope: FindScope.SUBUNIT,
matchesTheWholeCell: false,
matchesTheWholeWord: false,
replaceRevealed: false,
};
// If search by formula, when the formula string failed to match, the cell should not be hit.
const result1 = hitCell(worksheet, 0, 0, query, TEST_WORKBOOK_DATA.sheets.sheet1.cellData![0][0]);
expect(result1.hit).toBeFalsy();
expect(result1.replaceable).toBeFalsy();
const result2 = hitCell(worksheet, 0, 1, query, TEST_WORKBOOK_DATA.sheets.sheet1.cellData![0][1]);
expect(result2.hit).toBeTruthy();
expect(result2.replaceable).toBeTruthy();
});
});
it('formula string should support "caseSensitive" option', () => {
const query: IFindQuery = {
findString: 'sum',
caseSensitive: true,
findBy: FindBy.FORMULA,
findDirection: FindDirection.COLUMN,
findScope: FindScope.SUBUNIT,
matchesTheWholeCell: false,
matchesTheWholeWord: false,
replaceRevealed: false,
};
const result1 = hitCell(worksheet, 0, 1, query, TEST_WORKBOOK_DATA.sheets.sheet1.cellData![0][1]);
expect(result1.hit).toBeFalsy();
expect(result1.replaceable).toBeFalsy();
const result2 = hitCell(worksheet, 0, 2, query, TEST_WORKBOOK_DATA.sheets.sheet1.cellData![0][2]);
expect(result2.hit).toBeTruthy();
expect(result2.replaceable).toBeTruthy();
// If we unset 'caseSensitive' and [0][1] should match!
query.caseSensitive = false;
const result3 = hitCell(worksheet, 0, 1, query, TEST_WORKBOOK_DATA.sheets.sheet1.cellData![0][1]);
expect(result3.hit).toBeTruthy();
expect(result3.replaceable).toBeTruthy();
});
});
});
const TEST_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 6,
f: '=SUM(3, 3)',
},
1: {
v: 9,
f: '=SUM(6, 3)',
},
2: {
v: 9,
f: '=sum(6, 3)', // test case sensitive
},
3: {
v: 66,
},
},
},
name: 'Sheet-001',
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
function createTestBed(dependencies?: Dependency[]) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
dependencies?.forEach((d) => injector.add(d));
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, TEST_WORKBOOK_DATA);
const univerInstanceService = get(IUniverInstanceService);
univerInstanceService.focusUnit(TEST_WORKBOOK_DATA.id);
const logService = get(ILogService);
logService.setLogLevel(LogLevel.SILENT);
return {
univer,
get,
sheet,
};
}

View File

@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Workbook } from '@univerjs/core';
import type { IScrollToCellCommandParams } from '@univerjs/sheets-ui';
import { Disposable, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { convertTransformToOffsetY, IRenderManagerService } from '@univerjs/engine-render';
import {
getCoordByCell,
getSheetObject,
getViewportByCell,
ScrollToCellCommand,
SetScrollRelativeCommand,
SheetSkeletonManagerService,
} from '@univerjs/sheets-ui';
const MOBILE_FIND_REPLACE_BAR_SELECTOR = '[data-u-comp="mobile-find-replace-bar"]';
const MOBILE_FIND_MATCH_SAFE_PADDING = 8;
export function getMobileFindMatchScrollOffset(cellBottom: number, barTop: number, safePadding: number): number {
return Math.max(0, cellBottom - barTop + safePadding);
}
export class SheetsFindReplaceMobileController extends Disposable {
private _pendingFrameId: number | null = null;
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
this.disposeWithMe(this._commandService.onCommandExecuted((command, options) => {
if (command.id !== ScrollToCellCommand.id || options?.fromFindReplace !== true) {
return;
}
const range = (command.params as IScrollToCellCommandParams | undefined)?.range;
if (!range) {
return;
}
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getActiveSheet();
if (!workbook || !worksheet) {
return;
}
this._scheduleReveal(workbook.getUnitId(), worksheet.getSheetId(), range.endRow, range.endColumn);
}));
}
override dispose(): void {
if (this._pendingFrameId !== null) {
cancelAnimationFrame(this._pendingFrameId);
this._pendingFrameId = null;
}
super.dispose();
}
private _scheduleReveal(unitId: string, subUnitId: string, row: number, column: number): void {
if (this._pendingFrameId !== null) {
cancelAnimationFrame(this._pendingFrameId);
}
this._pendingFrameId = requestAnimationFrame(() => {
this._pendingFrameId = null;
this._revealMatch(unitId, subUnitId, row, column);
});
}
private _revealMatch(unitId: string, subUnitId: string, row: number, column: number): void {
const bar = document.querySelector<HTMLElement>(MOBILE_FIND_REPLACE_BAR_SELECTOR);
if (!bar) {
return;
}
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getActiveSheet();
if (!workbook || !worksheet || workbook.getUnitId() !== unitId || worksheet.getSheetId() !== subUnitId) {
return;
}
const render = this._renderManagerService.getRenderUnitById(workbook.getUnitId());
const sheetObject = getSheetObject(workbook, this._renderManagerService);
const skeleton = render?.with(SheetSkeletonManagerService).getSkeleton(worksheet.getSheetId());
if (!sheetObject || !skeleton) {
return;
}
const { scene, engine } = sheetObject;
const viewport = getViewportByCell(row, column, scene, worksheet);
if (!viewport) {
return;
}
const { endY } = getCoordByCell(row, column, scene, skeleton);
const { scaleY } = scene.getAncestorScale();
const scrollXY = scene.getViewportScrollXY(viewport);
const canvasTop = engine.getCanvasElement().getBoundingClientRect().top;
const cellBottom = canvasTop + convertTransformToOffsetY(endY, scaleY, scrollXY);
const offsetY = getMobileFindMatchScrollOffset(
cellBottom,
bar.getBoundingClientRect().top,
MOBILE_FIND_MATCH_SAFE_PADDING
);
if (offsetY === 0) {
return;
}
this._commandService.executeCommand(SetScrollRelativeCommand.id, { offsetY }).catch(() => undefined);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRange, Worksheet } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import { Rectangle } from '@univerjs/core';
export function isSamePosition(range1: IRange, range2: IRange): boolean {
return range1.startRow === range2.startRow && range1.startColumn === range2.startColumn;
}
/**
* Tell if `range2` is after (or the same as) `range1` with row direction is at priority.
* @param range1
* @param range2
* @returns
*/
export function isBehindPositionWithRowPriority(range1: IRange, range2: IRange): boolean {
return (
range1.startRow < range2.startRow ||
(range1.startRow === range2.startRow && range1.startColumn <= range2.startColumn)
);
}
/**
* Tell if `range2` is after (or the same as) `range1` with column direction is at priority.
* @param range1
* @param range2
* @returns
*/
export function isBehindPositionWithColumnPriority(range1: IRange, range2: IRange): boolean {
return (
range1.startColumn < range2.startColumn ||
(range1.startColumn === range2.startColumn && range1.startRow <= range2.startRow)
);
}
/**
* Tell if `range2` is before (or the same as) `range1` with column direction is at priority.
* @param range1
* @param range2
* @returns
*/
export function isBeforePositionWithRowPriority(range1: IRange, range2: IRange): boolean {
return (
range1.startRow > range2.startRow ||
(range1.startRow === range2.startRow && range1.startColumn >= range2.startColumn)
);
}
export function isBeforePositionWithColumnPriority(range1: IRange, range2: IRange): boolean {
return (
range1.startColumn > range2.startColumn ||
(range1.startColumn === range2.startColumn && range1.startRow >= range2.startRow)
);
}
export function isSelectionSingleCell(selection: ISelectionWithStyle, worksheet: Worksheet): boolean {
const { range } = selection;
const { startRow, startColumn } = range;
const hasMergedCell = worksheet.getMergedCell(startRow, startColumn);
if (hasMergedCell) {
return Rectangle.equals(range, hasMergedCell);
} else {
return range.endRow === range.startRow && range.endColumn === range.startColumn;
}
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BehaviorSubject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { FTextFinder } from '../f-text-finder';
describe('FTextFinder', () => {
it('should return matches after ensureCompleteAsync and navigate next/previous', async () => {
const matches = [
{ provider: 'p', unitId: 'u1', range: { subUnitId: 's1', range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 } }, replaceable: true },
{ provider: 'p', unitId: 'u1', range: { subUnitId: 's1', range: { startRow: 1, startColumn: 0, endRow: 1, endColumn: 0 } }, replaceable: true },
];
const model = {
currentMatch$: new BehaviorSubject<any>(matches[0]),
start: vi.fn(async () => ({ results: matches })),
moveToNextMatch: vi.fn(() => matches[1]),
moveToPreviousMatch: vi.fn(() => matches[0]),
replaceAll: vi.fn(async () => ({ success: 2, failure: 0 })),
replace: vi.fn(async () => true),
};
const workbook = {
getSheetBySheetId: vi.fn(() => ({})),
};
const injector = {
createInstance: vi.fn((cls: any, ...args: any[]) => {
if (cls.name === 'FindReplaceModel') return model;
// FRange
return { cls, args };
}),
};
const univerInstanceService = {
getUnit: vi.fn(() => workbook),
};
const findReplaceService = {
getProviders: vi.fn(() => new Set()),
};
const finder = new FTextFinder({ findString: 'a' }, injector as any, univerInstanceService as any, findReplaceService as any);
await finder.ensureCompleteAsync();
// ensure internal state is marked completed
(finder as any)._state.changeState({ findCompleted: true });
expect(finder.findAll()).toHaveLength(2);
expect(finder.findNext()).toBeTruthy();
expect(finder.findPrevious()).toBeTruthy();
expect(finder.getCurrentMatch()).toBeTruthy();
const count = await finder.replaceAllWithAsync('b');
expect(count).toBe(2);
const ok = await finder.replaceWithAsync('c');
expect(ok).toBe(true);
const p1 = finder.matchCaseAsync(true);
(finder as any)._state.changeState({ findCompleted: true });
await p1;
const p2 = finder.matchEntireCellAsync(true);
(finder as any)._state.changeState({ findCompleted: true });
await p2;
const p3 = finder.matchFormulaTextAsync(true);
(finder as any)._state.changeState({ findCompleted: true });
await p3;
});
});

View File

@@ -0,0 +1,156 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IWorkbookData } from '@univerjs/core';
import type { IFindMatch, IFindQuery, IReplaceAllResult } from '@univerjs/find-replace';
import type { Observable } from 'rxjs';
import { ILogService, Inject, Injector, IUniverInstanceService, LocaleType, LogLevel, Univer, UniverInstanceType } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { FindModel, FindReplaceService, IFindReplaceService } from '@univerjs/find-replace';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { EMPTY, of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import '../f-univer';
const WORKBOOK_DATA: IWorkbookData = {
id: 'find-replace-workbook',
appVersion: '3.0.0-alpha',
name: 'Find Replace Workbook',
locale: LocaleType.EN_US,
sheetOrder: ['sheet-1'],
styles: {},
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
rowCount: 10,
columnCount: 10,
cellData: {
0: {
1: { v: 'target value' },
},
2: {
0: { v: 'other value' },
},
},
},
},
};
class TestWorkbookFindModel extends FindModel {
readonly unitId = WORKBOOK_DATA.id;
readonly matchesUpdate$: Observable<IFindMatch[]>;
readonly activelyChangingMatch$: Observable<IFindMatch> = EMPTY;
private readonly _matches: IFindMatch[];
constructor(private readonly _query: IFindQuery) {
super();
this._matches = this._query.findString === 'target'
? [
{
provider: 'test-workbook-provider',
unitId: WORKBOOK_DATA.id,
range: {
subUnitId: 'sheet-1',
range: {
startRow: 0,
endRow: 0,
startColumn: 1,
endColumn: 1,
},
},
replaceable: true,
},
]
: [];
this.matchesUpdate$ = of(this._matches);
}
getMatches(): IFindMatch[] {
return this._matches;
}
moveToNextMatch(): IFindMatch | null {
return this._matches[0] ?? null;
}
moveToPreviousMatch(): IFindMatch | null {
return this._matches[0] ?? null;
}
replace(): Promise<boolean> {
return Promise.resolve(this._matches.length > 0);
}
replaceAll(): Promise<IReplaceAllResult> {
return Promise.resolve({ success: this._matches.length, failure: 0 });
}
focusSelection(): void {}
}
class TestWorkbookFindReplaceProvider {
readonly capabilities = {
caseSensitive: true,
matchesTheWholeWord: false,
matchesTheWholeCell: true,
findDirection: true,
findScope: true,
findBy: true,
};
constructor(@Inject(Injector) private readonly _injector: Injector) {}
isSupported(): boolean {
return true;
}
find(query: IFindQuery): Promise<FindModel[]> {
return Promise.resolve([this._injector.createInstance(TestWorkbookFindModel, query)]);
}
terminate(): void {}
}
function createFindReplaceFacade() {
const univer = new Univer();
const injector = univer.__getInjector();
univer.registerPlugin(UniverSheetsPlugin);
const workbook = univer.createUnit(UniverInstanceType.UNIVER_SHEET, WORKBOOK_DATA);
injector.add([IFindReplaceService, { useClass: FindReplaceService }]);
injector.add([TestWorkbookFindReplaceProvider]);
const findReplaceService = injector.get(IFindReplaceService);
findReplaceService.registerFindReplaceProvider(injector.get(TestWorkbookFindReplaceProvider));
injector.get(IUniverInstanceService).focusUnit(workbook.getUnitId());
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
return FUniver.newAPI(univer);
}
describe('FUniverSheetsFindReplaceMixin', () => {
it('creates a text finder that searches the focused workbook', async () => {
const univerAPI = createFindReplaceFacade();
const finder = await univerAPI.createTextFinderAsync('target');
const matches = finder?.findAll() ?? [];
expect(matches.map((range) => range.getA1Notation())).toEqual(['B1']);
});
});

View File

@@ -0,0 +1,469 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRange, Nullable, Workbook } from '@univerjs/core';
import type { IFindComplete, IFindMatch, IFindReplaceState } from '@univerjs/find-replace';
import { Disposable, Inject, Injector, IUniverInstanceService } from '@univerjs/core';
import { createInitFindReplaceState, FindBy, FindReplaceModel, FindReplaceState, IFindReplaceService } from '@univerjs/find-replace';
import { FRange } from '@univerjs/sheets/facade';
import { filter, firstValueFrom } from 'rxjs';
/**
* @ignore
*/
export interface IFTextFinder {
/**
* Get all the matched cells of the current sheet, the current matched cell is the last matched cell.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @returns {FRange[]} All the matched cells.
* @throws If the find operation is not completed.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13]
* ]);
*
* // Create a text-finder to find the text '5'.
* const textFinder = await univerAPI.createTextFinderAsync('5');
*
* // Find all cells that contain the text '5'.
* const matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // D2, C3, B4, A5
* });
* ```
*/
findAll(): FRange[];
/**
* Get the next matched cell of the current sheet, if exists return the next matched cell and move the current matched cell to the next matched cell.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @returns {Nullable<FRange>} The next matched cell.
* @throws If the find operation is not completed.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13]
* ]);
*
* // Create a text-finder to find the text '5'.
* const textFinder = await univerAPI.createTextFinderAsync('5');
* console.log(textFinder.getCurrentMatch().getA1Notation()); // current match cell is A5
*
* // Find the next matched range
* const nextMatch = textFinder.findNext();
* console.log(nextMatch.getA1Notation()); // D2
* console.log(textFinder.getCurrentMatch().getA1Notation()); // current match cell is D2
* ```
*/
findNext(): Nullable<FRange>;
/**
* Get the previous matched cell of the current sheet, if exists return the previous matched cell and move the current matched cell to the previous matched cell.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @returns {Nullable<FRange>} The previous matched cell.
* @throws If the find operation is not completed.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13]
* ]);
*
* // Create a text-finder to find the text '5'.
* const textFinder = await univerAPI.createTextFinderAsync('5');
* console.log(textFinder.getCurrentMatch().getA1Notation()); // current match cell is A5
*
* // Find the previous matched range.
* const previousMatch = textFinder.findPrevious();
* console.log(previousMatch.getA1Notation()); // B4
* console.log(textFinder.getCurrentMatch().getA1Notation()); // current match cell is B4
* ```
*/
findPrevious(): Nullable<FRange>;
/**
* Get the current matched cell of the current sheet.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @returns {Nullable<FRange>} The current matched cell.
* @throws If the find operation is not completed.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13]
* ]);
*
* // Create a text-finder to find the text '5'.
* const textFinder = await univerAPI.createTextFinderAsync('5');
*
* // Get the current matched range.
* const currentMatch = textFinder.getCurrentMatch();
* console.log(currentMatch.getA1Notation()); // A5
* ```
*/
getCurrentMatch(): Nullable<FRange>;
/**
* Set the match case option, if true, the find operation will match case, otherwise, the find operation will ignore case.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @param {boolean} matchCase - Whether to match case.
* @returns {Promise<IFTextFinder>} The text-finder instance.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D1');
* fRange.setValues([
* ['hello univer', 'hello UNIVER', 'HELLO UNIVER', 'HELLO univer'],
* ]);
*
* // Create a text-finder to find the text 'univer'.
* const textFinder = await univerAPI.createTextFinderAsync('univer');
* let matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // A1, B1, C1, D1
* });
*
* // Set the match case.
* await textFinder.matchCaseAsync(true);
* matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // A1, D1
* });
* ```
*/
matchCaseAsync(matchCase: boolean): Promise<IFTextFinder>;
/**
* Set the match entire cell option, if true, the find operation will match entire cell value, otherwise, the find operation will match part of the cell value.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @param {boolean} matchEntireCell - Whether to match entire cell value.
* @returns {Promise<IFTextFinder>} The text-finder instance.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D1');
* fRange.setValues([
* ['hello univer', 'hello univer 1', 'hello univer 2', 'hello univer 3'],
* ]);
*
* // Create a text-finder to find the text 'hello univer'.
* const textFinder = await univerAPI.createTextFinderAsync('hello univer');
* let matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // A1, B1, C1, D1
* });
*
* // Set the match entire cell.
* await textFinder.matchEntireCellAsync(true);
* matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // A1
* });
* ```
*/
matchEntireCellAsync(matchEntireCell: boolean): Promise<IFTextFinder>;
/**
* Set the match formula text option, if true, the find operation will match formula text, otherwise, the find operation will match value.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @param {boolean} matchFormulaText - Whether to match formula text.
* @returns {Promise<IFTextFinder>} The text-finder instance.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D1');
* fRange.setValues([
* ['sum', '1', '=SUM(2)', '3'],
* ]);
*
* // Create a text-finder to find the text 'sum'.
* const textFinder = await univerAPI.createTextFinderAsync('sum');
* let matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // A1
* });
*
* // Set the match entire cell.
* await textFinder.matchFormulaTextAsync(true);
* matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // A1, C1
* });
* ```
*/
matchFormulaTextAsync(matchFormulaText: boolean): Promise<IFTextFinder>;
/**
* Replace all the matched text with the given text.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @param {string} replaceText - The text to replace.
* @returns {Promise<number>} The count of replaced text.
* @throws If the find operation is not completed.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D1');
* fRange.setValues([
* ['hello', 'hello', 'hello', 'hello'],
* ]);
*
* // Create a text-finder to find the text 'hello'.
* const textFinder = await univerAPI.createTextFinderAsync('hello');
*
* // Replace all the matched text with 'hello univer'.
* const count = await textFinder.replaceAllWithAsync('hello univer');
* console.log(count); // 4
* console.log(fRange.getValues()); // [['hello univer', 'hello univer', 'hello univer', 'hello univer']]
* ```
*/
replaceAllWithAsync(replaceText: string): Promise<number>;
/**
* Replace the current matched text with the given text.
* If current sheet changed, use `await textFinder.ensureCompleteAsync()` to ensure the find operation is completed.
* @param {string} replaceText - The text to replace.
* @returns {Promise<boolean>} Whether the replace is successful.
* @throws If the find operation is not completed.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('B1:E1');
* fRange.setValues([
* ['hello', 'hello', 'hello', 'hello'],
* ]);
*
* // Create a text-finder to find the text 'hello'.
* const textFinder = await univerAPI.createTextFinderAsync('hello');
*
* // Replace the current matched text with 'hello univer'.
* const replaced = await textFinder.replaceWithAsync('hello univer');
* console.log(replaced); // true
* console.log(fRange.getValues()); // [['hello', 'hello', 'hello', 'hello univer']]
* ```
*/
replaceWithAsync(replaceText: string): Promise<boolean>;
/**
* Ensure the find operation is completed. Especially when the current sheet changed use this method to ensure the find operation is completed.
* @returns {Promise<Nullable<IFindComplete>>} The find complete result.
* @example
* ```typescript
* // Create a text-finder to find the text '1'.
* const textFinder = await univerAPI.createTextFinderAsync('1');
*
* // Find all cells that contain the text '1'.
* const matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation());
* });
*
* const fWorkbook = univerAPI.getActiveWorkbook();
* const sheets = fWorkbook.getSheets();
*
* // Change the current sheet to the second sheet.
* sheets[1]?.activate();
*
* // Ensure the find operation is completed of the current sheet.
* await textFinder.ensureCompleteAsync();
* const matchCells2 = textFinder.findAll();
* matchCells2.forEach((cell) => {
* console.log(cell.getA1Notation());
* });
* ```
*/
ensureCompleteAsync(): Promise<Nullable<IFindComplete>>;
}
/**
* This interface class provides methods to find and replace text in the univer.
* @hideconstructor
*/
export class FTextFinder extends Disposable implements IFTextFinder {
private readonly _state = new FindReplaceState();
private _model: Nullable<FindReplaceModel>;
private _complete: Nullable<IFindComplete>;
constructor(
_initialState: Partial<IFindReplaceState>,
@Inject(Injector) private readonly _injector: Injector,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IFindReplaceService private readonly _findReplaceService: IFindReplaceService
) {
super();
const providers = this._findReplaceService.getProviders();
this._model = this._injector.createInstance(FindReplaceModel, this._state, providers);
const newState = {
...createInitFindReplaceState(),
..._initialState,
};
this._state.changeState(newState);
}
findAll(): FRange[] {
if (!this._state.findCompleted || !this._complete) {
return [];
}
return this._complete.results.map((result) => {
return this._findMatchToFRange(result);
});
}
findNext(): Nullable<FRange> {
if (!this._state.findCompleted || !this._complete) {
return null;
}
const match = this._model?.moveToNextMatch();
if (!match) {
return null;
}
return this._findMatchToFRange(match);
}
findPrevious(): Nullable<FRange> {
const match = this._model?.moveToPreviousMatch();
if (!match) {
return null;
}
return this._findMatchToFRange(match);
}
getCurrentMatch(): Nullable<FRange> {
if (!this._state.findCompleted || !this._complete) {
throw new Error('Find operation is not completed.');
}
const match = this._model?.currentMatch$.value;
if (!match) {
return null;
}
return this._findMatchToFRange(match);
}
async matchCaseAsync(matchCase: boolean): Promise<IFTextFinder> {
this._state.changeState({ caseSensitive: matchCase, findCompleted: false });
await firstValueFrom(this._state.stateUpdates$.pipe(filter((state) => state.findCompleted === true)));
await this.ensureCompleteAsync();
return this;
}
async matchEntireCellAsync(matchEntireCell: boolean): Promise<IFTextFinder> {
this._state.changeState({ matchesTheWholeCell: matchEntireCell, findCompleted: false });
await firstValueFrom(this._state.stateUpdates$.pipe(filter((state) => state.findCompleted === true)));
await this.ensureCompleteAsync();
return this;
}
async matchFormulaTextAsync(matchFormulaText: boolean): Promise<IFTextFinder> {
this._state.changeState({ findBy: matchFormulaText ? FindBy.FORMULA : FindBy.VALUE, findCompleted: false });
await firstValueFrom(this._state.stateUpdates$.pipe(filter((state) => state.findCompleted === true)));
await this.ensureCompleteAsync();
return this;
}
async replaceAllWithAsync(replaceText: string): Promise<number> {
await this._state.changeState({ replaceRevealed: true, replaceString: replaceText });
const res = (await this._model?.replaceAll())?.success ?? 0;
this._state.changeState({ replaceRevealed: false });
return res;
}
async replaceWithAsync(replaceText: string): Promise<boolean> {
await this._state.changeState({ replaceRevealed: true, replaceString: replaceText });
await this._model?.replace();
this._state.changeState({ replaceRevealed: false });
return true;
}
async ensureCompleteAsync(): Promise<Nullable<IFindComplete>> {
this._complete = await this._model?.start();
};
private _findMatchToFRange(match: IFindMatch): FRange {
const { unitId } = match;
const { subUnitId, range } = match.range as { subUnitId: string; range: IRange };
const workbook = this._univerInstanceService.getUnit(unitId) as Workbook;
const worksheet = workbook.getSheetBySheetId(subUnitId)!;
return this._injector.createInstance(FRange, workbook, worksheet, range);
}
}

View File

@@ -0,0 +1,77 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IFindReplaceState } from '@univerjs/find-replace';
import { FUniver } from '@univerjs/core/facade';
import { FTextFinder } from './f-text-finder';
/**
* @ignore
*/
export interface IFUniverSheetsFindReplaceMixin {
/**
* Create a text-finder for the current univer.
* @param {string} text - The text to find.
* @returns {Promise<FTextFinder | null>} A promise that resolves to the text-finder instance.
* @example
* ```typescript
* // Assume the current sheet is empty sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set some values to the range A1:D10.
* const fRange = fWorksheet.getRange('A1:D10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13]
* ]);
*
* // Create a text-finder to find the text '5'.
* const textFinder = await univerAPI.createTextFinderAsync('5');
*
* // Find all cells that contain the text '5'.
* const matchCells = textFinder.findAll();
* matchCells.forEach((cell) => {
* console.log(cell.getA1Notation()); // D2, C3, B4, A5
* });
* ```
*/
createTextFinderAsync(text: string): Promise<FTextFinder | null>;
}
export class FUniverSheetsFindReplaceMixin extends FUniver implements IFUniverSheetsFindReplaceMixin {
override async createTextFinderAsync(text: string): Promise<FTextFinder | null> {
const state: Partial<IFindReplaceState> = { findString: text };
const textFinder = this._injector.createInstance(FTextFinder, state);
await textFinder.ensureCompleteAsync();
return textFinder;
}
}
FUniver.extend(FUniverSheetsFindReplaceMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FUniver extends IFUniverSheetsFindReplaceMixin {}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './f-text-finder';
import './f-univer';
export type * from './f-text-finder';
export type * from './f-univer';

View File

@@ -0,0 +1,21 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { SheetReplaceCommand } from './commands/commands/sheet-replace.command';
export type { IUniverSheetsFindReplaceConfig } from './config/config';
export { SheetsFindReplaceController } from './controllers/sheet-find-replace.controller';
export { UniverSheetsFindReplaceMobileUIPlugin } from './mobile-plugin';
export { UniverSheetsFindReplacePlugin } from './plugin';

View File

@@ -0,0 +1,66 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsFindReplaceConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverFindReplaceMobileUIPlugin } from '@univerjs/find-replace';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsMobileUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_FIND_REPLACE_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsFindReplaceMobileController } from './controllers/mobile/sheet-find-replace-mobile.controller';
import { SheetsFindReplaceController } from './controllers/sheet-find-replace.controller';
@DependentOn(
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverFindReplaceMobileUIPlugin,
UniverSheetsMobileUIPlugin
)
export class UniverSheetsFindReplaceMobileUIPlugin extends Plugin {
static override pluginName = 'SHEET_FIND_REPLACE_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsFindReplaceConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { ...rest } = merge({}, defaultPluginConfig, this._config);
this._configService.setConfig(SHEETS_FIND_REPLACE_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[SheetsFindReplaceController],
[SheetsFindReplaceMobileController],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
}
override onRendered(): void {
this._injector.get(SheetsFindReplaceMobileController);
}
override onSteady(): void {
this._injector.get(SheetsFindReplaceController);
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsFindReplaceConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverFindReplacePlugin } from '@univerjs/find-replace';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_FIND_REPLACE_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsFindReplaceController } from './controllers/sheet-find-replace.controller';
@DependentOn(
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverFindReplacePlugin,
UniverSheetsUIPlugin
)
export class UniverSheetsFindReplacePlugin extends Plugin {
static override pluginName = 'SHEET_FIND_REPLACE_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsFindReplaceConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_FIND_REPLACE_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([[SheetsFindReplaceController]] as Dependency[]).forEach((d) => this._injector.add(d));
}
override onSteady(): void {
this._injector.get(SheetsFindReplaceController);
}
}

View File

@@ -0,0 +1,312 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { SheetFindReplaceHighlightShape } from '../find-replace-highlight.shape';
class TestCanvasContext {
fillStyle = '';
strokeStyle = '';
lineWidth = 0;
lineCap = '';
lineDashOffset = 0;
lineJoin = '';
miterLimit = 0;
fillCount = 0;
strokeCount = 0;
readonly rects: Array<{ left: number; top: number; width: number; height: number }> = [];
save(): void {}
restore(): void {}
beginPath(): void {}
closePath(): void {}
setLineDash(): void {}
rect(left: number, top: number, width: number, height: number): void {
this.rects.push({ left, top, width, height });
}
fill(): void {
this.fillCount += 1;
}
stroke(): void {
this.strokeCount += 1;
}
}
function drawShape(shape: SheetFindReplaceHighlightShape, context: TestCanvasContext): void {
(shape as unknown as { _draw: (ctx: TestCanvasContext) => void })._draw(context);
}
function createViewTestBed() {
const injector = new Injector();
return {
createHighlightShape(key: string, props: ConstructorParameters<typeof SheetFindReplaceHighlightShape>[1]) {
return injector.createInstance(SheetFindReplaceHighlightShape, key, props);
},
};
}
describe('SheetFindReplaceHighlightShape', () => {
it('draws a translucent search result highlight without a border by default', () => {
const shape = new SheetFindReplaceHighlightShape('result', {
inHiddenRange: false,
color: { r: 1, g: 2, b: 3 },
width: 10,
height: 20,
});
const context = new TestCanvasContext();
drawShape(shape, context);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 10, height: 20 }]);
expect(context.fillStyle).toBe('rgba(1, 2, 3, 0.35)');
expect(context.fillCount).toBe(1);
expect(context.strokeCount).toBe(0);
});
it('draws the current search result with a solid two-pixel border', () => {
const shape = new SheetFindReplaceHighlightShape('active-result', {
inHiddenRange: false,
color: { r: 4, g: 5, b: 6 },
width: 10,
height: 20,
});
shape.setShapeProps({
activated: true,
width: 30,
height: 40,
});
const context = new TestCanvasContext();
drawShape(shape, context);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 30, height: 40 }]);
expect(context.fillStyle).toBe('rgba(4, 5, 6, 0.35)');
expect(context.strokeStyle).toBe('rgb(4, 5, 6)');
expect(context.lineWidth).toBe(2);
expect(context.strokeCount).toBe(1);
});
it('removes the current result border after the shape is deactivated', () => {
const shape = new SheetFindReplaceHighlightShape('active-result', {
inHiddenRange: false,
color: { r: 7, g: 8, b: 9 },
activated: true,
width: 10,
height: 20,
});
const activeContext = new TestCanvasContext();
drawShape(shape, activeContext);
expect(activeContext.strokeStyle).toBe('rgb(7, 8, 9)');
expect(activeContext.strokeCount).toBe(1);
shape.setShapeProps({
activated: false,
width: 10,
height: 20,
});
const inactiveContext = new TestCanvasContext();
drawShape(shape, inactiveContext);
expect(inactiveContext.fillStyle).toBe('rgba(7, 8, 9, 0.35)');
expect(inactiveContext.lineWidth).toBe(0);
expect(inactiveContext.strokeCount).toBe(0);
});
it('redraws refreshed search results with the latest bounds and color', () => {
const shape = new SheetFindReplaceHighlightShape('refreshed-result', {
inHiddenRange: false,
color: { r: 10, g: 20, b: 30 },
activated: true,
width: 12,
height: 24,
});
shape.setShapeProps({
color: { r: 40, g: 50, b: 60 },
activated: true,
width: 36,
height: 48,
});
const context = new TestCanvasContext();
drawShape(shape, context);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 36, height: 48 }]);
expect(context.fillStyle).toBe('rgba(40, 50, 60, 0.35)');
expect(context.strokeStyle).toBe('rgb(40, 50, 60)');
expect(context.lineWidth).toBe(2);
expect(context.fillCount).toBe(1);
expect(context.strokeCount).toBe(1);
});
it('marks the current match in a hidden range with a compact active highlight', () => {
const shape = new SheetFindReplaceHighlightShape('hidden-current-result', {
inHiddenRange: true,
color: { r: 70, g: 80, b: 90 },
activated: true,
width: 2,
height: 2,
});
const context = new TestCanvasContext();
drawShape(shape, context);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 2, height: 2 }]);
expect(context.fillStyle).toBe('rgba(70, 80, 90, 0.35)');
expect(context.strokeStyle).toBe('rgb(70, 80, 90)');
expect(context.lineWidth).toBe(2);
expect(context.fillCount).toBe(1);
expect(context.strokeCount).toBe(1);
});
it('keeps a hidden non-current match compact without drawing an active border', () => {
const shape = new SheetFindReplaceHighlightShape('hidden-result', {
inHiddenRange: true,
color: { r: 90, g: 100, b: 110 },
activated: false,
width: 2,
height: 2,
});
const context = new TestCanvasContext();
drawShape(shape, context);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 2, height: 2 }]);
expect(context.fillStyle).toBe('rgba(90, 100, 110, 0.35)');
expect(context.lineWidth).toBe(0);
expect(context.fillCount).toBe(1);
expect(context.strokeCount).toBe(0);
});
it('activates an existing hidden-range highlight without changing its compact marker', () => {
const shape = new SheetFindReplaceHighlightShape('hidden-result-to-activate', {
inHiddenRange: true,
color: { r: 120, g: 130, b: 140 },
activated: false,
width: 2,
height: 2,
});
shape.setShapeProps({ activated: true });
const context = new TestCanvasContext();
drawShape(shape, context);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 2, height: 2 }]);
expect(context.fillStyle).toBe('rgba(120, 130, 140, 0.35)');
expect(context.strokeStyle).toBe('rgb(120, 130, 140)');
expect(context.lineWidth).toBe(2);
expect(context.fillCount).toBe(1);
expect(context.strokeCount).toBe(1);
});
it('moves the current-match border between result highlights while preserving both fills', () => {
const previousCurrent = new SheetFindReplaceHighlightShape('previous-current-result', {
inHiddenRange: false,
color: { r: 12, g: 34, b: 56 },
activated: true,
width: 18,
height: 22,
});
const nextCurrent = new SheetFindReplaceHighlightShape('next-current-result', {
inHiddenRange: false,
color: { r: 78, g: 90, b: 123 },
activated: false,
width: 24,
height: 28,
});
previousCurrent.setShapeProps({ activated: false });
nextCurrent.setShapeProps({ activated: true });
const previousContext = new TestCanvasContext();
const nextContext = new TestCanvasContext();
drawShape(previousCurrent, previousContext);
drawShape(nextCurrent, nextContext);
expect(previousContext.rects).toEqual([{ left: 0, top: 0, width: 18, height: 22 }]);
expect(previousContext.fillStyle).toBe('rgba(12, 34, 56, 0.35)');
expect(previousContext.lineWidth).toBe(0);
expect(previousContext.fillCount).toBe(1);
expect(previousContext.strokeCount).toBe(0);
expect(nextContext.rects).toEqual([{ left: 0, top: 0, width: 24, height: 28 }]);
expect(nextContext.fillStyle).toBe('rgba(78, 90, 123, 0.35)');
expect(nextContext.strokeStyle).toBe('rgb(78, 90, 123)');
expect(nextContext.lineWidth).toBe(2);
expect(nextContext.fillCount).toBe(1);
expect(nextContext.strokeCount).toBe(1);
});
it('creates search highlights as passive overlays above the sheet content', () => {
const testBed = createViewTestBed();
const shape = testBed.createHighlightShape('visible-result-overlay', {
left: 32,
top: 48,
inHiddenRange: false,
color: { r: 230, g: 180, b: 40 },
width: 96,
height: 24,
evented: false,
zIndex: 10000,
});
expect(shape.evented).toBe(false);
expect(shape.zIndex).toBe(10000);
expect(shape.left).toBe(32);
expect(shape.top).toBe(48);
expect(shape.width).toBe(96);
expect(shape.height).toBe(24);
});
it('keeps a found cell anchored when navigation only toggles the active match state', () => {
const testBed = createViewTestBed();
const shape = testBed.createHighlightShape('anchored-result', {
left: 120,
top: 72,
inHiddenRange: false,
color: { r: 24, g: 144, b: 255 },
width: 64,
height: 22,
evented: false,
zIndex: 10000,
});
shape.setShapeProps({ activated: true });
const context = new TestCanvasContext();
drawShape(shape, context);
expect(shape.left).toBe(120);
expect(shape.top).toBe(72);
expect(context.rects).toEqual([{ left: 0, top: 0, width: 64, height: 22 }]);
expect(context.strokeStyle).toBe('rgb(24, 144, 255)');
expect(context.lineWidth).toBe(2);
expect(context.strokeCount).toBe(1);
});
});

View File

@@ -0,0 +1,71 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRgbColor, Nullable } from '@univerjs/core';
import type { IShapeProps, UniverRenderingContext } from '@univerjs/engine-render';
import { Rect, Shape } from '@univerjs/engine-render';
export interface ISheetFindReplaceHighlightShapeProps extends IShapeProps {
inHiddenRange: boolean;
color: IRgbColor;
activated?: boolean;
}
export class SheetFindReplaceHighlightShape extends Shape<ISheetFindReplaceHighlightShapeProps> {
protected _activated = false;
protected _inHiddenRange = false;
protected _color: Nullable<IRgbColor>;
constructor(key?: string, props?: ISheetFindReplaceHighlightShapeProps) {
super(key, props);
if (props) {
this.setShapeProps(props);
}
}
setShapeProps(props: Partial<ISheetFindReplaceHighlightShapeProps>): void {
this._activated = !!props.activated;
if (typeof props.inHiddenRange !== 'undefined') {
this._inHiddenRange = props.inHiddenRange;
}
if (typeof props.color !== 'undefined') {
this._color = props.color;
}
this.transformByState({
width: props.width!,
height: props.height!,
});
}
protected override _draw(ctx: CanvasRenderingContext2D): void {
const activated = this._activated;
const color = `rgba(${this._color!.r}, ${this._color!.g}, ${this._color!.b}, 0.35)`;
const borderColor = `rgb(${this._color!.r}, ${this._color!.g}, ${this._color!.b})`;
Rect.drawWith(ctx as UniverRenderingContext, {
width: this.width,
height: this.height,
fill: color,
stroke: activated ? borderColor : undefined,
strokeWidth: activated ? 2 : 0,
evented: false,
});
}
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();