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
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:
41
packages/sheets-find-replace/README.md
Normal file
41
packages/sheets-find-replace/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @univerjs/sheets-find-replace
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/sheets-find-replace)
|
||||
[](https://npmjs.com/package/@univerjs/sheets-find-replace)
|
||||
[](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)
|
||||
93
packages/sheets-find-replace/package.json
Normal file
93
packages/sheets-find-replace/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
24
packages/sheets-find-replace/src/config/config.ts
Normal file
24
packages/sheets-find-replace/src/config/config.ts
Normal 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 = {};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
81
packages/sheets-find-replace/src/controllers/utils.ts
Normal file
81
packages/sheets-find-replace/src/controllers/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
469
packages/sheets-find-replace/src/facade/f-text-finder.ts
Normal file
469
packages/sheets-find-replace/src/facade/f-text-finder.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
77
packages/sheets-find-replace/src/facade/f-univer.ts
Normal file
77
packages/sheets-find-replace/src/facade/f-univer.ts
Normal 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 {}
|
||||
}
|
||||
21
packages/sheets-find-replace/src/facade/index.ts
Normal file
21
packages/sheets-find-replace/src/facade/index.ts
Normal 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';
|
||||
21
packages/sheets-find-replace/src/index.ts
Normal file
21
packages/sheets-find-replace/src/index.ts
Normal 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';
|
||||
66
packages/sheets-find-replace/src/mobile-plugin.ts
Normal file
66
packages/sheets-find-replace/src/mobile-plugin.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
63
packages/sheets-find-replace/src/plugin.ts
Normal file
63
packages/sheets-find-replace/src/plugin.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
packages/sheets-find-replace/tsconfig.json
Normal file
8
packages/sheets-find-replace/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/sheets-find-replace/tsconfig.node.json
Normal file
18
packages/sheets-find-replace/tsconfig.node.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
packages/sheets-find-replace/vitest.config.ts
Normal file
3
packages/sheets-find-replace/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user