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

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

View File

@@ -0,0 +1,41 @@
# @univerjs/find-replace
[![npm version](https://img.shields.io/npm/v/@univerjs/find-replace?style=flat-square)](https://npmjs.com/package/@univerjs/find-replace)
[![license](https://img.shields.io/npm/l/@univerjs/find-replace?style=flat-square)](https://npmjs.com/package/@univerjs/find-replace)
[![downloads](https://img.shields.io/npm/dm/@univerjs/find-replace?style=flat-square)](https://npmjs.com/package/@univerjs/find-replace)
`@univerjs/find-replace` provides shared find and replace services and UI infrastructure that product-specific packages can extend.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/find-replace` | `UniverFindReplace` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/find-replace
# or
npm install @univerjs/find-replace
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/find-replace/lib/index.css';
import EnUS from '@univerjs/find-replace/locale/en-US';
import { UniverFindReplacePlugin } from '@univerjs/find-replace';
univer.registerPlugin(UniverFindReplacePlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/find-replace)
- [GitHub repository](https://github.com/dream-num/univer)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
{
"name": "@univerjs/find-replace",
"version": "1.0.0-rc.0",
"private": false,
"description": "Shared find and replace services and UI infrastructure for Univer.",
"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",
"find-replace",
"search",
"replace",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.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"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.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": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,126 @@
/**
* 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 } from '@univerjs/core';
import type { IMessageProps } from '@univerjs/design';
import {
Disposable,
IConfirmService,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
TestConfirmService,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { IMessageService } from '@univerjs/ui';
import enUS from '../locale/en-US';
const TEST_WORKBOOK_DATA_DEMO: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'A2',
},
},
},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
class TestMessageService implements IMessageService {
readonly messages: IMessageProps[] = [];
show(options: IMessageProps): Disposable {
this.messages.push(options);
return new Disposable();
}
remove(_id: string): void {}
removeAll(): void {
this.messages.length = 0;
}
}
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: Workbook;
}
export function createTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'find-replace-test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.add([IConfirmService, { useClass: TestConfirmService }]);
this._injector.add([IMessageService, { useClass: TestMessageService }]);
dependencies?.forEach((d) => this._injector.add(d));
}
}
univer.registerPlugin(TestPlugin);
const snapshot = Tools.deepClone(workbookData || TEST_WORKBOOK_DATA_DEMO);
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, snapshot);
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit(sheet.getUnitId());
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT);
const localeService = injector.get(LocaleService);
localeService.load({ enUS });
return {
univer,
get: injector.get.bind(injector),
sheet,
};
}
export { TestMessageService };

View File

@@ -0,0 +1,97 @@
/**
* 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 { Injector, Univer } from '@univerjs/core';
import type { TestMessageService } from '../../../__tests__/create-test-bed';
import { ICommandService, IConfirmService } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IMessageService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestBed } from '../../../__tests__/create-test-bed';
import { FindReplaceService, IFindReplaceService } from '../../../services/find-replace.service';
import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../replace.command';
describe('replace.command', () => {
let univer: Univer;
let get: Injector['get'];
beforeEach(() => {
const testBed = createTestBed(undefined, [[IFindReplaceService, { useClass: FindReplaceService }]]);
univer = testBed.univer;
get = testBed.get;
const commandService = get(ICommandService);
commandService.registerCommand(ReplaceCurrentMatchCommand);
commandService.registerCommand(ReplaceAllMatchesCommand);
});
afterEach(() => {
univer.dispose();
});
it('should proxy ReplaceCurrentMatchCommand to findReplaceService.replace', async () => {
const commandService = get(ICommandService);
const findReplaceService = get(IFindReplaceService);
const replaceSpy = vi.spyOn(findReplaceService, 'replace').mockResolvedValue(true);
await expect(commandService.executeCommand(ReplaceCurrentMatchCommand.id)).resolves.toBe(true);
expect(replaceSpy).toHaveBeenCalledTimes(1);
});
it('should return false when confirm is canceled for replace all', async () => {
const commandService = get(ICommandService);
const findReplaceService = get(IFindReplaceService);
const confirmService = get(IConfirmService);
const messageService = get(IMessageService) as TestMessageService;
vi.spyOn(confirmService, 'confirm').mockResolvedValue(false);
const replaceAllSpy = vi.spyOn(findReplaceService, 'replaceAll');
await expect(commandService.executeCommand(ReplaceAllMatchesCommand.id)).resolves.toBe(false);
expect(replaceAllSpy).not.toHaveBeenCalled();
expect(messageService.messages).toHaveLength(0);
});
it('should show success message when all replacements succeed', async () => {
const commandService = get(ICommandService);
const findReplaceService = get(IFindReplaceService);
const messageService = get(IMessageService) as TestMessageService;
vi.spyOn(findReplaceService, 'replaceAll').mockResolvedValue({ success: 3, failure: 0 });
await expect(commandService.executeCommand(ReplaceAllMatchesCommand.id)).resolves.toBe(true);
expect(messageService.messages[0]).toEqual(expect.objectContaining({ type: MessageType.Success }));
});
it('should show warning when partially succeeds and return false', async () => {
const commandService = get(ICommandService);
const findReplaceService = get(IFindReplaceService);
const messageService = get(IMessageService) as TestMessageService;
vi.spyOn(findReplaceService, 'replaceAll').mockResolvedValue({ success: 2, failure: 1 });
await expect(commandService.executeCommand(ReplaceAllMatchesCommand.id)).resolves.toBe(false);
expect(messageService.messages[0]).toEqual(expect.objectContaining({ type: MessageType.Warning }));
});
it('should show error when all replacements fail and return false', async () => {
const commandService = get(ICommandService);
const findReplaceService = get(IFindReplaceService);
const messageService = get(IMessageService) as TestMessageService;
vi.spyOn(findReplaceService, 'replaceAll').mockResolvedValue({ success: 0, failure: 2 });
await expect(commandService.executeCommand(ReplaceAllMatchesCommand.id)).resolves.toBe(false);
expect(messageService.messages[0]).toEqual(expect.objectContaining({ type: MessageType.Error }));
});
});

View File

@@ -0,0 +1,79 @@
/**
* 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 { ICommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, IConfirmService, LocaleService } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IMessageService } from '@univerjs/ui';
import { IFindReplaceService } from '../../services/find-replace.service';
export const ReplaceCurrentMatchCommand: ICommand = {
id: 'ui.command.replace-current-match',
type: CommandType.COMMAND,
handler: (accessor) => {
const findReplaceService = accessor.get(IFindReplaceService);
return findReplaceService.replace();
},
};
const CONFIRM_REPLACE_ALL_ID = 'CONFIRM_REPLACE_ALL';
export const ReplaceAllMatchesCommand: ICommand = {
id: 'ui.command.replace-all-matches',
type: CommandType.COMMAND,
handler: async (accessor) => {
const confirmService = accessor.get(IConfirmService);
const localeService = accessor.get(LocaleService);
const messageService = accessor.get(IMessageService);
if (!await confirmService.confirm({
id: CONFIRM_REPLACE_ALL_ID,
title: { title: localeService.t<LocaleKey>('find-replace.replace.confirm.title') },
cancelText: localeService.t<LocaleKey>('find-replace.button.cancel'),
confirmText: localeService.t<LocaleKey>('find-replace.button.confirm'),
})) {
return false;
}
const findReplaceService = accessor.get(IFindReplaceService);
const result = await findReplaceService.replaceAll();
const { success, failure } = result;
if (failure > 0) {
if (success === 0) {
messageService.show({
type: MessageType.Error,
content: localeService.t<LocaleKey>('find-replace.replace.all-failure'),
});
} else {
messageService.show({
type: MessageType.Warning,
content: localeService.t<LocaleKey>('find-replace.replace.partial-success', `${success}`, `${failure}`),
});
}
return false;
}
messageService.show({
type: MessageType.Success,
content: localeService.t<LocaleKey>('find-replace.replace.all-success', `${success}`),
});
return true;
},
};

View File

@@ -0,0 +1,142 @@
/**
* 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 { Injector, Univer } from '@univerjs/core';
import type { IFindReplaceProvider } from '../../../services/find-replace.service';
import { ICommandService } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTestBed } from '../../../__tests__/create-test-bed';
import { FindReplaceService, IFindReplaceService } from '../../../services/find-replace.service';
import {
CloseFindDialogOperation,
FocusSelectionOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
OpenFindDialogOperation,
OpenReplaceDialogOperation,
} from '../find-replace.operation';
function createProvider(): IFindReplaceProvider {
return {
capabilities: {
caseSensitive: true,
matchesTheWholeWord: false,
matchesTheWholeCell: true,
findDirection: true,
findScope: true,
findBy: true,
},
isSupported: () => true,
find: vi.fn(async () => []),
terminate: vi.fn(),
};
}
describe('find-replace.operation', () => {
let univer: Univer;
let get: Injector['get'];
beforeEach(() => {
const testBed = createTestBed(undefined, [[IFindReplaceService, { useClass: FindReplaceService }]]);
univer = testBed.univer;
get = testBed.get;
const commandService = get(ICommandService);
[
CloseFindDialogOperation,
OpenFindDialogOperation,
OpenReplaceDialogOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
FocusSelectionOperation,
].forEach((op) => commandService.registerCommand(op));
});
afterEach(() => {
univer.dispose();
});
it('should open find dialog or focus input based on reveal state', () => {
const commandService = get(ICommandService);
const findReplaceService = get(IFindReplaceService);
findReplaceService.registerFindReplaceProvider(createProvider());
const startSpy = vi.spyOn(findReplaceService, 'start');
const focusSpy = vi.spyOn(findReplaceService, 'focusFindInput');
expect(commandService.syncExecuteCommand(OpenFindDialogOperation.id)).toBe(true);
expect(startSpy).toHaveBeenCalledTimes(1);
expect(focusSpy).not.toHaveBeenCalled();
expect(commandService.syncExecuteCommand(OpenFindDialogOperation.id)).toBe(true);
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it('should open replace dialog with proper branching', () => {
const commandService = get(ICommandService);
const hiddenService = get(IFindReplaceService);
hiddenService.registerFindReplaceProvider(createProvider());
const hiddenStartSpy = vi.spyOn(hiddenService, 'start');
expect(commandService.syncExecuteCommand(OpenReplaceDialogOperation.id)).toBe(true);
expect(hiddenStartSpy).toHaveBeenCalledWith(true);
hiddenService.terminate();
const revealService = get(IFindReplaceService);
revealService.registerFindReplaceProvider(createProvider());
revealService.start();
const revealSpy = vi.spyOn(revealService, 'revealReplace');
expect(commandService.syncExecuteCommand(OpenReplaceDialogOperation.id)).toBe(true);
expect(revealSpy).toHaveBeenCalledTimes(1);
revealService.terminate();
const focusService = get(IFindReplaceService);
focusService.registerFindReplaceProvider(createProvider());
focusService.start(true);
const focusSpy = vi.spyOn(focusService, 'focusFindInput');
expect(commandService.syncExecuteCommand(OpenReplaceDialogOperation.id)).toBe(true);
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it('should route next/previous/focus operations to the service', () => {
const commandService = get(ICommandService);
const service = get(IFindReplaceService);
const nextSpy = vi.spyOn(service, 'moveToNextMatch');
const previousSpy = vi.spyOn(service, 'moveToPreviousMatch');
const focusSpy = vi.spyOn(service, 'focusSelection');
expect(commandService.syncExecuteCommand(GoToNextMatchOperation.id)).toBe(true);
expect(commandService.syncExecuteCommand(GoToPreviousMatchOperation.id)).toBe(true);
expect(commandService.syncExecuteCommand(FocusSelectionOperation.id)).toBe(true);
expect(nextSpy).toHaveBeenCalledTimes(1);
expect(previousSpy).toHaveBeenCalledTimes(1);
expect(focusSpy).toHaveBeenCalledTimes(1);
});
it('should terminate the current session when closing the find UI', () => {
const commandService = get(ICommandService);
const service = get(IFindReplaceService);
service.registerFindReplaceProvider(createProvider());
service.start();
const terminateSpy = vi.spyOn(service, 'terminate');
expect(commandService.syncExecuteCommand(CloseFindDialogOperation.id)).toBe(true);
expect(terminateSpy).toHaveBeenCalledTimes(1);
expect(service.revealed).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
/**
* 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 { IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { IFindReplaceService } from '../../services/find-replace.service';
export const OpenFindDialogOperation: IOperation = {
id: 'ui.operation.open-find-dialog',
type: CommandType.OPERATION,
handler: (accessor) => {
const findReplaceService = accessor.get(IFindReplaceService);
if (!findReplaceService.revealed) {
findReplaceService.start();
} else {
findReplaceService.focusFindInput();
}
return true;
},
};
export const OpenReplaceDialogOperation: IOperation = {
id: 'ui.operation.open-replace-dialog',
type: CommandType.OPERATION,
handler: (accessor) => {
const findReplaceService = accessor.get(IFindReplaceService);
if (!findReplaceService.revealed) {
findReplaceService.start(true);
} else if (!findReplaceService.replaceRevealed) {
findReplaceService.revealReplace();
} else {
findReplaceService.focusFindInput();
}
return true;
},
};
export const CloseFindDialogOperation: IOperation = {
id: 'ui.operation.close-find-dialog',
type: CommandType.OPERATION,
handler: (accessor) => {
accessor.get(IFindReplaceService).terminate();
return true;
},
};
export const GoToNextMatchOperation: IOperation = {
type: CommandType.OPERATION,
id: 'ui.operation.go-to-next-match',
handler: (accessor) => {
const findReplaceService = accessor.get(IFindReplaceService);
findReplaceService.moveToNextMatch();
return true;
},
};
export const GoToPreviousMatchOperation: IOperation = {
type: CommandType.OPERATION,
id: 'ui.operation.go-to-previous-match',
handler: (accessor) => {
const findReplaceService = accessor.get(IFindReplaceService);
findReplaceService.moveToPreviousMatch();
return true;
},
};
export const FocusSelectionOperation: IOperation = {
type: CommandType.OPERATION,
id: 'ui.operation.focus-selection',
handler: (accessor) => {
const findReplaceService = accessor.get(IFindReplaceService);
findReplaceService.focusSelection();
return true;
},
};

View File

@@ -0,0 +1,27 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
export const FIND_REPLACE_PLUGIN_CONFIG_KEY = 'find-replace.config';
export const configSymbol = Symbol(FIND_REPLACE_PLUGIN_CONFIG_KEY);
export interface IUniverFindReplaceConfig {
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverFindReplaceConfig = {};

View File

@@ -0,0 +1,18 @@
/**
* 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 MOBILE_FIND_REPLACE_SETTINGS_COMPONENT = 'MobileFindReplaceSettings';
export const MOBILE_FIND_REPLACE_SETTINGS_DIALOG_ID = 'MOBILE_FIND_REPLACE_SETTINGS_DIALOG';

View File

@@ -0,0 +1,33 @@
/**
* 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 { afterEach, describe, expect, it } from 'vitest';
import { getFindReplaceDialogDefaultPosition } from '../find-replace.controller';
const ORIGINAL_INNER_WIDTH = window.innerWidth;
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: ORIGINAL_INNER_WIDTH });
});
describe('getFindReplaceDialogDefaultPosition', () => {
it('anchors the dialog to the matching physical edge for each UI direction', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1000 });
expect(getFindReplaceDialogDefaultPosition('ltr')).toEqual({ x: 630, y: 64 });
expect(getFindReplaceDialogDefaultPosition('rtl')).toEqual({ x: 20, y: 64 });
});
});

View File

@@ -0,0 +1,103 @@
/**
* 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 { Injector, Univer } from '@univerjs/core';
import { EDITOR_ACTIVATED, IContextService } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createTestBed } from '../../__tests__/create-test-bed';
import {
FIND_REPLACE_AVAILABLE,
FIND_REPLACE_DIALOG_FOCUS,
FIND_REPLACE_INPUT_FOCUS,
FIND_REPLACE_REPLACE_REVEALED,
} from '../../services/context-keys';
import {
FocusSelectionShortcutItem,
GoToNextFindMatchShortcutItem,
GoToPreviousFindMatchShortcutItem,
MacOpenFindDialogShortcutItem,
OpenFindDialogShortcutItem,
OpenReplaceDialogShortcutItem,
} from '../find-replace.shortcut';
describe('find-replace.shortcut', () => {
let univer: Univer;
let get: Injector['get'];
let contextService: IContextService;
beforeEach(() => {
const testBed = createTestBed();
univer = testBed.univer;
get = testBed.get;
contextService = get(IContextService);
});
afterEach(() => {
univer.dispose();
});
it('should require provider availability and editor inactivity for opening find dialog', () => {
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, false);
contextService.setContextValue(FIND_REPLACE_AVAILABLE, true);
contextService.setContextValue(EDITOR_ACTIVATED, false);
expect(OpenFindDialogShortcutItem.preconditions!(contextService)).toBe(true);
expect(MacOpenFindDialogShortcutItem.preconditions!(contextService)).toBe(true);
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, true);
expect(OpenFindDialogShortcutItem.preconditions!(contextService)).toBe(false);
});
it('should open replace dialog only when dialog is hidden or replace is not revealed', () => {
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, false);
contextService.setContextValue(FIND_REPLACE_REPLACE_REVEALED, false);
contextService.setContextValue(FIND_REPLACE_AVAILABLE, true);
contextService.setContextValue(EDITOR_ACTIVATED, false);
expect(OpenReplaceDialogShortcutItem.preconditions!(contextService)).toBe(true);
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, true);
contextService.setContextValue(FIND_REPLACE_REPLACE_REVEALED, true);
expect(OpenReplaceDialogShortcutItem.preconditions!(contextService)).toBe(false);
});
it('should disable open shortcuts when no provider is available', () => {
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, false);
contextService.setContextValue(FIND_REPLACE_REPLACE_REVEALED, false);
contextService.setContextValue(FIND_REPLACE_AVAILABLE, false);
contextService.setContextValue(EDITOR_ACTIVATED, false);
expect(OpenFindDialogShortcutItem.preconditions!(contextService)).toBe(false);
expect(MacOpenFindDialogShortcutItem.preconditions!(contextService)).toBe(false);
expect(OpenReplaceDialogShortcutItem.preconditions!(contextService)).toBe(false);
});
it('should require input and dialog focus for next/previous actions', () => {
contextService.setContextValue(FIND_REPLACE_INPUT_FOCUS, true);
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, true);
expect(GoToNextFindMatchShortcutItem.preconditions!(contextService)).toBe(true);
expect(GoToPreviousFindMatchShortcutItem.preconditions!(contextService)).toBe(true);
contextService.setContextValue(FIND_REPLACE_INPUT_FOCUS, false);
expect(GoToNextFindMatchShortcutItem.preconditions!(contextService)).toBe(false);
});
it('should focus selection when dialog is focused', () => {
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, true);
expect(FocusSelectionShortcutItem.preconditions!(contextService)).toBe(true);
contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, false);
expect(FocusSelectionShortcutItem.preconditions!(contextService)).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import { SearchIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { FindReplaceDialog } from '../views/dialog/FindReplaceDialog';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
this._registerComponents();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
SearchIcon,
}));
}
private _registerComponents(): void {
([
['FindReplaceDialog', FindReplaceDialog],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(
this._componentManager.register(key, comp)
);
});
}
}

View File

@@ -0,0 +1,173 @@
/**
* 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 { IDisposable, Nullable } from '@univerjs/core';
import type { LocaleKey } from '../locale/types';
import {
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
RxDisposable,
toDisposable,
} from '@univerjs/core';
import { IDialogService, ILayoutService, IMenuManagerService, IShortcutService } from '@univerjs/ui';
import { takeUntil } from 'rxjs';
import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../commands/commands/replace.command';
import {
CloseFindDialogOperation,
FocusSelectionOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
OpenFindDialogOperation,
OpenReplaceDialogOperation,
} from '../commands/operations/find-replace.operation';
import { menuSchema } from '../menu/schema';
import { IFindReplaceService } from '../services/find-replace.service';
import {
FocusSelectionShortcutItem,
GoToNextFindMatchShortcutItem,
GoToPreviousFindMatchShortcutItem,
MacOpenFindDialogShortcutItem,
OpenFindDialogShortcutItem,
OpenReplaceDialogShortcutItem,
} from './find-replace.shortcut';
const FIND_REPLACE_DIALOG_ID = 'DESKTOP_FIND_REPLACE_DIALOG';
const FIND_REPLACE_PANEL_WIDTH = 350;
const FIND_REPLACE_PANEL_HORIZONTAL_PADDING = 20;
const FIND_REPLACE_PANEL_TOP_PADDING = 64;
export class FindReplaceController extends RxDisposable {
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@ICommandService private readonly _commandService: ICommandService,
@IFindReplaceService private readonly _findReplaceService: IFindReplaceService,
@IDialogService private readonly _dialogService: IDialogService,
@ILayoutService private readonly _layoutService: ILayoutService,
@Inject(LocaleService) private readonly _localeService: LocaleService
) {
super();
this._initCommands();
this._initUI();
this._initShortcuts();
}
override dispose(): void {
super.dispose();
this._closingListenerDisposable?.dispose();
this._closingListenerDisposable = null;
}
private _initCommands(): void {
[
OpenFindDialogOperation,
OpenReplaceDialogOperation,
CloseFindDialogOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
ReplaceAllMatchesCommand,
ReplaceCurrentMatchCommand,
FocusSelectionOperation,
].forEach((c) => {
this.disposeWithMe(this._commandService.registerCommand(c));
});
}
private _initShortcuts(): void {
[
OpenReplaceDialogShortcutItem,
OpenFindDialogShortcutItem,
MacOpenFindDialogShortcutItem,
GoToPreviousFindMatchShortcutItem,
GoToNextFindMatchShortcutItem,
FocusSelectionShortcutItem,
].forEach((s) => this.disposeWithMe(this._shortcutService.registerShortcut(s)));
}
private _initUI(): void {
this._menuManagerService.mergeMenu(menuSchema);
// this controller is also responsible for toggling the FindReplaceDialog
this._findReplaceService.stateUpdates$.pipe(takeUntil(this.dispose$)).subscribe((newState) => {
if (newState.revealed === true) {
this._openPanel();
} else if (newState.revealed === false) {
this._closePanel(false);
}
});
}
private _openPanel(): void {
const sessionUnitId = this._univerInstanceService.getFocusedUnit()?.getUnitId();
this._dialogService.open({
id: FIND_REPLACE_DIALOG_ID,
draggable: true,
width: FIND_REPLACE_PANEL_WIDTH,
title: { title: this._localeService.t<LocaleKey>('find-replace.dialog.title') },
children: { label: 'FindReplaceDialog' },
mask: false,
maskClosable: false,
defaultPosition: getFindReplaceDialogDefaultPosition(this._localeService.getDirection()),
preservePositionOnDestroy: true,
onClose: () => this.closePanel(),
});
this._closingListenerDisposable = toDisposable(
this._univerInstanceService.focused$.pipe(takeUntil(this.dispose$)).subscribe((focused) => {
if (!focused || focused !== sessionUnitId) {
this.closePanel();
}
})
);
}
private _closingListenerDisposable: Nullable<IDisposable>;
closePanel(): void {
this._closePanel(true);
}
private _closePanel(terminateSession: boolean): void {
if (!this._closingListenerDisposable) {
return;
}
this._closingListenerDisposable.dispose();
this._closingListenerDisposable = null;
this._dialogService.close(FIND_REPLACE_DIALOG_ID);
if (terminateSession) {
this._findReplaceService.terminate();
}
queueMicrotask(() => this._layoutService.focus());
}
}
export function getFindReplaceDialogDefaultPosition(direction: 'ltr' | 'rtl'): { x: number; y: number } {
const { innerWidth } = window;
const x = direction === 'rtl'
? FIND_REPLACE_PANEL_HORIZONTAL_PADDING
: (innerWidth - FIND_REPLACE_PANEL_WIDTH) - FIND_REPLACE_PANEL_HORIZONTAL_PADDING;
const y = FIND_REPLACE_PANEL_TOP_PADDING;
return { x, y };
}

View File

@@ -0,0 +1,124 @@
/**
* 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 { IContextService } from '@univerjs/core';
import type { IShortcutItem } from '@univerjs/ui';
import { EDITOR_ACTIVATED } from '@univerjs/core';
import { KeyCode, MetaKeys } from '@univerjs/ui';
import {
FocusSelectionOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
OpenFindDialogOperation,
OpenReplaceDialogOperation,
} from '../commands/operations/find-replace.operation';
import {
FIND_REPLACE_AVAILABLE,
FIND_REPLACE_DIALOG_FOCUS,
FIND_REPLACE_INPUT_FOCUS,
FIND_REPLACE_REPLACE_REVEALED,
} from '../services/context-keys';
function whenFindReplaceDialogFocused(contextService: IContextService): boolean {
return contextService.getContextValue(FIND_REPLACE_DIALOG_FOCUS);
}
function whenReplaceRevealed(contextService: IContextService): boolean {
return contextService.getContextValue(FIND_REPLACE_REPLACE_REVEALED);
}
function whenFindReplaceInputFocused(contextService: IContextService): boolean {
return contextService.getContextValue(FIND_REPLACE_INPUT_FOCUS);
}
const FIND_REPLACE_SHORTCUT_GROUP = '7_find-replace-shortcuts';
function whenFindReplaceAvailable(contextService: IContextService): boolean {
return contextService.getContextValue(FIND_REPLACE_AVAILABLE);
}
function whenEditorNotActivated(contextService: IContextService): boolean {
return !contextService.getContextValue(EDITOR_ACTIVATED);
}
export const OpenFindDialogShortcutItem: IShortcutItem = {
id: OpenFindDialogOperation.id,
description: 'find-replace.shortcut.open-find-dialog',
binding: KeyCode.F | MetaKeys.CTRL_COMMAND,
group: FIND_REPLACE_SHORTCUT_GROUP,
groupTitle: 'find-replace.shortcut.panel',
preconditions(contextService) {
return !whenFindReplaceDialogFocused(contextService) && whenFindReplaceAvailable(contextService) && whenEditorNotActivated(contextService);
},
};
export const MacOpenFindDialogShortcutItem: IShortcutItem = {
id: OpenFindDialogOperation.id,
description: 'find-replace.shortcut.open-find-dialog',
binding: KeyCode.F | MetaKeys.CTRL_COMMAND,
mac: KeyCode.F | MetaKeys.MAC_CTRL,
preconditions(contextService) {
return !whenFindReplaceDialogFocused(contextService) && whenFindReplaceAvailable(contextService) && whenEditorNotActivated(contextService);
},
};
export const OpenReplaceDialogShortcutItem: IShortcutItem = {
id: OpenReplaceDialogOperation.id,
description: 'find-replace.shortcut.open-replace-dialog',
binding: KeyCode.H | MetaKeys.CTRL_COMMAND,
mac: KeyCode.H | MetaKeys.MAC_CTRL,
group: FIND_REPLACE_SHORTCUT_GROUP,
groupTitle: 'find-replace.shortcut.panel',
preconditions(contextService) {
return whenFindReplaceAvailable(contextService) && whenEditorNotActivated(contextService) && (!whenFindReplaceDialogFocused(contextService) || !whenReplaceRevealed(contextService));
},
};
export const GoToNextFindMatchShortcutItem: IShortcutItem = {
id: GoToNextMatchOperation.id,
description: 'find-replace.shortcut.go-to-next-match',
binding: KeyCode.ENTER,
group: FIND_REPLACE_SHORTCUT_GROUP,
groupTitle: 'find-replace.shortcut.panel',
priority: 1000,
preconditions(contextService) {
return whenFindReplaceInputFocused(contextService) && whenFindReplaceDialogFocused(contextService);
},
};
export const GoToPreviousFindMatchShortcutItem: IShortcutItem = {
id: GoToPreviousMatchOperation.id,
description: 'find-replace.shortcut.go-to-previous-match',
binding: KeyCode.ENTER | MetaKeys.SHIFT,
group: FIND_REPLACE_SHORTCUT_GROUP,
groupTitle: 'find-replace.shortcut.panel',
priority: 1000,
preconditions(contextService) {
return whenFindReplaceInputFocused(contextService) && whenFindReplaceDialogFocused(contextService);
},
};
export const FocusSelectionShortcutItem: IShortcutItem = {
id: FocusSelectionOperation.id,
description: 'find-replace.shortcut.focus-selection',
binding: KeyCode.ESC,
group: FIND_REPLACE_SHORTCUT_GROUP,
groupTitle: 'find-replace.shortcut.panel',
priority: 1000,
preconditions(contextService) {
return whenFindReplaceDialogFocused(contextService);
},
};

View File

@@ -0,0 +1,36 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import { SearchIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { MOBILE_FIND_REPLACE_SETTINGS_COMPONENT } from '../../const';
import { MobileFindReplaceSettings } from '../../views/mobile/MobileFindReplaceSettings';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) componentManager: ComponentManager,
@Inject(IconManager) iconManager: IconManager
) {
super();
this.disposeWithMe(iconManager.register({ SearchIcon }));
this.disposeWithMe(componentManager.register(
MOBILE_FIND_REPLACE_SETTINGS_COMPONENT,
MobileFindReplaceSettings
));
}
}

View File

@@ -0,0 +1,84 @@
/**
* 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, Inject, Injector, IUniverInstanceService, RxDisposable } from '@univerjs/core';
import { BuiltInUIPart, connectInjector, IDialogService, IMenuManagerService, IUIPartsService } from '@univerjs/ui';
import { takeUntil } from 'rxjs';
import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../../commands/commands/replace.command';
import {
CloseFindDialogOperation,
FocusSelectionOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
OpenFindDialogOperation,
OpenReplaceDialogOperation,
} from '../../commands/operations/find-replace.operation';
import { MOBILE_FIND_REPLACE_SETTINGS_DIALOG_ID } from '../../const';
import { mobileMenuSchema } from '../../menu/mobile-schema';
import { IFindReplaceService } from '../../services/find-replace.service';
import { MobileFindReplaceBar } from '../../views/mobile/MobileFindReplaceBar';
export class FindReplaceMobileController extends RxDisposable {
constructor(
@Inject(Injector) injector: Injector,
@ICommandService commandService: ICommandService,
@IDialogService dialogService: IDialogService,
@IFindReplaceService findReplaceService: IFindReplaceService,
@IMenuManagerService menuManagerService: IMenuManagerService,
@IUniverInstanceService univerInstanceService: IUniverInstanceService,
@IUIPartsService uiPartsService: IUIPartsService
) {
super();
[
OpenFindDialogOperation,
OpenReplaceDialogOperation,
CloseFindDialogOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
ReplaceAllMatchesCommand,
ReplaceCurrentMatchCommand,
FocusSelectionOperation,
].forEach((command) => this.disposeWithMe(commandService.registerCommand(command)));
menuManagerService.mergeMenu(mobileMenuSchema);
this.disposeWithMe(uiPartsService.registerComponent(
BuiltInUIPart.FOOTER,
() => connectInjector(MobileFindReplaceBar, injector)
));
findReplaceService.stateUpdates$.pipe(takeUntil(this.dispose$)).subscribe((state) => {
if (state.revealed === false) {
dialogService.close(MOBILE_FIND_REPLACE_SETTINGS_DIALOG_ID);
}
});
let sessionUnitId: string | null = null;
findReplaceService.stateUpdates$.pipe(takeUntil(this.dispose$)).subscribe((state) => {
if (state.revealed === true) {
sessionUnitId = univerInstanceService.getFocusedUnit()?.getUnitId() ?? null;
} else if (state.revealed === false) {
sessionUnitId = null;
}
});
univerInstanceService.focused$.pipe(takeUntil(this.dispose$)).subscribe((focused) => {
if (sessionUnitId && focused !== sessionUnitId) {
findReplaceService.terminate();
}
});
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,51 @@
/**
* 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 './global.css';
export { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from './commands/commands/replace.command';
export {
CloseFindDialogOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
OpenFindDialogOperation,
OpenReplaceDialogOperation,
} from './commands/operations/find-replace.operation';
export type { IUniverFindReplaceConfig } from './config/config';
export { FindReplaceController } from './controllers/find-replace.controller';
export { menuSchema as FindReplaceMenuSchema } from './menu/schema';
export { UniverFindReplaceMobileUIPlugin } from './mobile-plugin';
export { UniverFindReplacePlugin } from './plugin';
export type {
IFindComplete,
IFindMatch,
IFindMoveParams,
IFindQuery,
IFindReplaceProvider,
IFindReplaceState,
IReplaceAllResult,
} from './services/find-replace.service';
export {
createInitFindReplaceState,
FindBy,
FindDirection,
FindModel,
FindReplaceModel,
FindReplaceService,
FindReplaceState,
FindScope,
IFindReplaceService,
} from './services/find-replace.service';

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'بحث واستبدال',
mobile: {
settings: 'إعدادات البحث والاستبدال',
'settings-title': 'إعدادات البحث والاستبدال',
'previous-match': 'التطابق السابق',
'next-match': 'التطابق التالي',
close: 'إغلاق البحث والاستبدال',
},
shortcut: {
'open-find-dialog': 'فتح مربع حوار البحث',
'open-replace-dialog': 'فتح مربع حوار الاستبدال',
'go-to-next-match': 'الانتقال إلى التطابق التالي',
'go-to-previous-match': 'الانتقال إلى التطابق السابق',
'focus-selection': 'تركيز التحديد',
panel: 'بحث واستبدال',
},
dialog: {
title: 'بحث',
find: 'بحث',
replace: 'استبدال',
'replace-all': 'استبدال الكل',
'case-sensitive': 'حساس لحالة الأحرف',
'find-placeholder': 'البحث',
'advanced-finding': 'بحث واستبدال متقدم',
'replace-placeholder': 'أدخل نص الاستبدال',
'match-the-whole-cell': 'تطابق الخلية بأكملها',
'match-the-whole-word': 'مطابقة الكلمة بالكامل',
'find-direction': {
title: 'اتجاه البحث',
row: 'البحث حسب الصف',
column: 'البحث حسب العمود',
},
'find-scope': {
title: 'نطاق البحث',
'current-sheet': 'الورقة الحالية',
workbook: 'المصنف',
},
'find-by': {
title: 'البحث حسب',
value: 'البحث حسب القيمة',
formula: 'البحث في الصيغ',
},
'no-match': 'اكتمل البحث ولكن لم يتم العثور على أي تطابق.',
'no-result': 'لا توجد نتائج',
},
replace: {
'all-success': 'تم استبدال {0} تطابقات',
'partial-success': 'تم استبدال {0} تطابقات، وتعذر استبدال {1}',
'all-failure': 'فشل الاستبدال',
confirm: {
title: 'هل أنت متأكد من استبدال جميع التطابقات؟',
},
},
button: {
confirm: 'موافق',
cancel: 'إلغاء',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Cerca i reemplaça',
mobile: {
settings: 'Configuració de cerca i reemplaçament',
'settings-title': 'Configuració de cerca i reemplaçament',
'previous-match': 'Coincidència anterior',
'next-match': 'Coincidència següent',
close: 'Tanca la cerca i el reemplaçament',
},
shortcut: {
'open-find-dialog': 'Obrir el diàleg de cerca',
'open-replace-dialog': 'Obrir el diàleg de reemplaçament',
'go-to-next-match': 'Anar a la següent coincidència',
'go-to-previous-match': 'Anar a la coincidència anterior',
'focus-selection': 'Enfocar la selecció',
panel: 'Cerca i reemplaça',
},
dialog: {
title: 'Cerca',
find: 'Cerca',
replace: 'Reemplaça',
'replace-all': 'Reemplaça-ho tot',
'case-sensitive': 'Distingeix majúscules de minúscules',
'find-placeholder': 'Cerca',
'advanced-finding': 'Cerca i reemplaçament avançats',
'replace-placeholder': 'Introduïu la cadena de reemplaçament',
'match-the-whole-cell': 'Coincidir amb la cel·la sencera',
'match-the-whole-word': 'Coincideix amb la paraula sencera',
'find-direction': {
title: 'Direcció de la cerca',
row: 'Cerca per fila',
column: 'Cerca per columna',
},
'find-scope': {
title: 'Abast de la cerca',
'current-sheet': 'Full actual',
workbook: 'Llibre',
},
'find-by': {
title: 'Cerca per',
value: 'Cerca per valor',
formula: 'Cerca fórmula',
},
'no-match': 'S\'ha completat la cerca però no s\'ha trobat cap coincidència.',
'no-result': 'Sense resultats',
},
replace: {
'all-success': 'S\'han reemplaçat totes les {0} coincidències',
'partial-success': 'S\'han reemplaçat {0} coincidències, no s\'han pogut reemplaçar {1}',
'all-failure': 'Error en el reemplaçament',
confirm: {
title: 'Esteu segur que voleu reemplaçar totes les coincidències?',
},
},
button: {
confirm: 'D\'acord',
cancel: 'Cancel·lar',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Suchen & Ersetzen',
mobile: {
settings: 'Einstellungen für Suchen und Ersetzen',
'settings-title': 'Suchen-und-Ersetzen-Einstellungen',
'previous-match': 'Vorheriger Treffer',
'next-match': 'Nächster Treffer',
close: 'Suchen und Ersetzen schließen',
},
shortcut: {
'open-find-dialog': 'Suchdialog öffnen',
'open-replace-dialog': 'Ersetzungsdialog öffnen',
'go-to-next-match': 'Zum nächsten Treffer springen',
'go-to-previous-match': 'Zum vorherigen Treffer springen',
'focus-selection': 'Auswahl fokussieren',
panel: 'Suchen & Ersetzen',
},
dialog: {
title: 'Suchen',
find: 'Suchen',
replace: 'Ersetzen',
'replace-all': 'Alle ersetzen',
'case-sensitive': 'Groß-/Kleinschreibung beachten',
'find-placeholder': 'Suchen',
'advanced-finding': 'Erweitertes Suchen & Ersetzen',
'replace-placeholder': 'Ersetzungstext eingeben',
'match-the-whole-cell': 'Ganze Zelle vergleichen',
'match-the-whole-word': 'Nur ganzes Wort',
'find-direction': {
title: 'Suchrichtung',
row: 'Nach Zeile suchen',
column: 'Nach Spalte suchen',
},
'find-scope': {
title: 'Suchbereich',
'current-sheet': 'Aktuelles Blatt',
workbook: 'Arbeitsmappe',
},
'find-by': {
title: 'Suchen nach',
value: 'Nach Wert suchen',
formula: 'Formel suchen',
},
'no-match': 'Suche abgeschlossen, aber kein Treffer gefunden.',
'no-result': 'Kein Ergebnis',
},
replace: {
'all-success': 'Alle {0} Treffer ersetzt',
'partial-success': '{0} Treffer ersetzt, {1} konnten nicht ersetzt werden',
'all-failure': 'Ersetzen fehlgeschlagen',
confirm: {
title: 'Sollen alle Treffer ersetzt werden?',
},
},
button: {
confirm: 'OK',
cancel: 'Abbrechen',
},
},
};
export default locale;

View File

@@ -0,0 +1,79 @@
/**
* 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.
*/
const locale = {
'find-replace': {
toolbar: 'Find & Replace',
mobile: {
settings: 'Find and replace settings',
'settings-title': 'Find and Replace Settings',
'previous-match': 'Previous match',
'next-match': 'Next match',
close: 'Close find and replace',
},
shortcut: {
'open-find-dialog': 'Open Find Dialog',
'open-replace-dialog': 'Open Replace Dialog',
'go-to-next-match': 'Go to Next Match',
'go-to-previous-match': 'Go to Previous Match',
'focus-selection': 'Focus Selection',
panel: 'Find & Replace',
},
dialog: {
title: 'Find',
find: 'Find',
replace: 'Replace',
'replace-all': 'Replace All',
'case-sensitive': 'Case Sensitive',
'find-placeholder': 'Find',
'advanced-finding': 'Advanced Searching & Replace',
'replace-placeholder': 'Input Replace String',
'match-the-whole-cell': 'Match the Whole Cell',
'match-the-whole-word': 'Match Whole Word',
'find-direction': {
title: 'Find Direction',
row: 'Search by Row',
column: 'Search by Column',
},
'find-scope': {
title: 'Find Range',
'current-sheet': 'Current Sheet',
workbook: 'Workbook',
},
'find-by': {
title: 'Find By',
value: 'Find by Value',
formula: 'Find Formula',
},
'no-match': 'Finding completed but no match found.',
'no-result': 'No Result',
},
replace: {
'all-success': 'Replaced all {0} matches',
'partial-success': 'Replaced {0} matches, failed to replace {1}',
'all-failure': 'Replace failed',
confirm: {
title: 'Are you sure to replace all matches?',
},
},
button: {
confirm: 'OK',
cancel: 'Cancel',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Buscar y reemplazar',
mobile: {
settings: 'Configuración de buscar y reemplazar',
'settings-title': 'Configuración de buscar y reemplazar',
'previous-match': 'Coincidencia anterior',
'next-match': 'Coincidencia siguiente',
close: 'Cerrar buscar y reemplazar',
},
shortcut: {
'open-find-dialog': 'Abrir cuadro de diálogo de búsqueda',
'open-replace-dialog': 'Abrir cuadro de diálogo de reemplazo',
'go-to-next-match': 'Ir a la siguiente coincidencia',
'go-to-previous-match': 'Ir a la coincidencia anterior',
'focus-selection': 'Enfocar selección',
panel: 'Buscar y reemplazar',
},
dialog: {
title: 'Buscar',
find: 'Buscar',
replace: 'Reemplazar',
'replace-all': 'Reemplazar todo',
'case-sensitive': 'Distinguir mayúsculas y minúsculas',
'find-placeholder': 'Buscar',
'advanced-finding': 'Búsqueda y reemplazo avanzados',
'replace-placeholder': 'Introducir cadena de reemplazo',
'match-the-whole-cell': 'Coincidir con toda la celda',
'match-the-whole-word': 'Coincidir con la palabra completa',
'find-direction': {
title: 'Dirección de búsqueda',
row: 'Buscar por fila',
column: 'Buscar por columna',
},
'find-scope': {
title: 'Rango de búsqueda',
'current-sheet': 'Hoja actual',
workbook: 'Libro',
},
'find-by': {
title: 'Buscar por',
value: 'Buscar por valor',
formula: 'Buscar fórmula',
},
'no-match': 'Búsqueda completada pero no se encontraron coincidencias.',
'no-result': 'Sin resultados',
},
replace: {
'all-success': 'Se reemplazaron todas las {0} coincidencias',
'partial-success': 'Se reemplazaron {0} coincidencias; no se pudieron reemplazar {1}',
'all-failure': 'Error al reemplazar',
confirm: {
title: '¿Está seguro de que desea reemplazar todas las coincidencias?',
},
},
button: {
confirm: 'Aceptar',
cancel: 'Cancelar',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'یافتن و جایگزینی',
mobile: {
settings: 'تنظیمات یافتن و جایگزینی',
'settings-title': 'تنظیمات یافتن و جایگزینی',
'previous-match': 'مطابقت قبلی',
'next-match': 'مطابقت بعدی',
close: 'بستن یافتن و جایگزینی',
},
shortcut: {
'open-find-dialog': 'باز کردن پنجره جستجو',
'open-replace-dialog': 'باز کردن پنجره جایگزینی',
'go-to-next-match': 'رفتن به مورد تطابق بعدی',
'go-to-previous-match': 'رفتن به مورد تطابق قبلی',
'focus-selection': 'تمرکز بر انتخاب',
panel: 'یافتن و جایگزینی',
},
dialog: {
title: 'جستجو',
find: 'جستجو',
replace: 'جایگزینی',
'replace-all': 'جایگزینی همه',
'case-sensitive': 'حساس به حروف بزرگ و کوچک',
'find-placeholder': 'جستجو',
'advanced-finding': 'جستجو و جایگزینی پیشرفته',
'replace-placeholder': 'رشته جایگزینی را وارد کنید',
'match-the-whole-cell': 'تطابق کل سلول',
'match-the-whole-word': 'تطبیق کل کلمه',
'find-direction': {
title: 'جهت جستجو',
row: 'جستجو بر اساس ردیف',
column: 'جستجو بر اساس ستون',
},
'find-scope': {
title: 'محدوده جستجو',
'current-sheet': 'شیت فعلی',
workbook: 'کتاب کار',
},
'find-by': {
title: 'جستجو بر اساس',
value: 'جستجو بر اساس مقدار',
formula: 'جستجوی فرمول',
},
'no-match': 'جستجو تکمیل شد اما هیچ تطابقی یافت نشد.',
'no-result': 'بدون نتیجه',
},
replace: {
'all-success': 'همه {0} مورد تطابق جایگزین شدند',
'partial-success': '{0} مورد تطابق جایگزین شد، جایگزینی {1} مورد ناموفق بود',
'all-failure': 'جایگزینی ناموفق بود',
confirm: {
title: 'آیا مطمئن هستید که می‌خواهید همه موارد تطابق را جایگزین کنید؟',
},
},
button: {
confirm: 'تایید',
cancel: 'انصراف',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Rechercher & Remplacer',
mobile: {
settings: 'Paramètres de recherche et de remplacement',
'settings-title': 'Paramètres de recherche et de remplacement',
'previous-match': 'Occurrence précédente',
'next-match': 'Occurrence suivante',
close: 'Fermer la recherche et le remplacement',
},
shortcut: {
'open-find-dialog': 'Ouvrir la boîte de dialogue de recherche',
'open-replace-dialog': 'Ouvrir la boîte de dialogue de remplacement',
'go-to-next-match': 'Aller à la correspondance suivante',
'go-to-previous-match': 'Aller à la correspondance précédente',
'focus-selection': 'Concentrer la sélection',
panel: 'Rechercher & Remplacer',
},
dialog: {
title: 'Rechercher',
find: 'Rechercher',
replace: 'Remplacer',
'replace-all': 'Remplacer tout',
'case-sensitive': 'Sensible à la casse',
'find-placeholder': 'Rechercher',
'advanced-finding': 'Recherche et remplacement avancés',
'replace-placeholder': 'Saisir la chaîne de remplacement',
'match-the-whole-cell': 'Correspondance de la cellule entière',
'match-the-whole-word': 'Mot entier',
'find-direction': {
title: 'Direction de la recherche',
row: 'Rechercher par ligne',
column: 'Rechercher par colonne',
},
'find-scope': {
title: 'Plage de recherche',
'current-sheet': 'Feuille actuelle',
workbook: 'Classeur',
},
'find-by': {
title: 'Rechercher par',
value: 'Rechercher par valeur',
formula: 'Rechercher une formule',
},
'no-match': 'Recherche terminée mais aucune correspondance trouvée.',
'no-result': 'Aucun résultat',
},
replace: {
'all-success': 'Toutes les {0} correspondances ont été remplacées',
'partial-success': '{0} correspondances ont été remplacées, {1} ont échoué',
'all-failure': 'Échec du remplacement',
confirm: {
title: 'Êtes-vous sûr de vouloir remplacer toutes les correspondances ?',
},
},
button: {
confirm: 'OK',
cancel: 'Annuler',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Cari & Ganti',
mobile: {
settings: 'Pengaturan cari dan ganti',
'settings-title': 'Pengaturan Cari dan Ganti',
'previous-match': 'Kecocokan sebelumnya',
'next-match': 'Kecocokan berikutnya',
close: 'Tutup cari dan ganti',
},
shortcut: {
'open-find-dialog': 'Buka Dialog Cari',
'open-replace-dialog': 'Buka Dialog Ganti',
'go-to-next-match': 'Ke Hasil Berikutnya',
'go-to-previous-match': 'Ke Hasil Sebelumnya',
'focus-selection': 'Fokus Pilihan',
panel: 'Cari & Ganti',
},
dialog: {
title: 'Cari',
find: 'Cari',
replace: 'Ganti',
'replace-all': 'Ganti Semua',
'case-sensitive': 'Sensitif Huruf',
'find-placeholder': 'Cari',
'advanced-finding': 'Pencarian & Penggantian Lanjutan',
'replace-placeholder': 'Masukkan String Pengganti',
'match-the-whole-cell': 'Cocokkan Seluruh Sel',
'match-the-whole-word': 'Cocokkan seluruh kata',
'find-direction': {
title: 'Arah Cari',
row: 'Cari per Baris',
column: 'Cari per Kolom',
},
'find-scope': {
title: 'Rentang Cari',
'current-sheet': 'Lembar Saat Ini',
workbook: 'Buku Kerja',
},
'find-by': {
title: 'Cari Berdasarkan',
value: 'Cari berdasarkan Nilai',
formula: 'Cari Rumus',
},
'no-match': 'Pencarian selesai tetapi tidak ditemukan kecocokan.',
'no-result': 'Tidak Ada Hasil',
},
replace: {
'all-success': 'Berhasil mengganti {0} kecocokan',
'partial-success': 'Berhasil mengganti {0} kecocokan, gagal mengganti {1}',
'all-failure': 'Penggantian gagal',
confirm: {
title: 'Apakah Anda yakin ingin mengganti semua kecocokan?',
},
},
button: {
confirm: 'OK',
cancel: 'Batal',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Trova e sostituisci',
mobile: {
settings: 'Impostazioni trova e sostituisci',
'settings-title': 'Impostazioni Trova e sostituisci',
'previous-match': 'Corrispondenza precedente',
'next-match': 'Corrispondenza successiva',
close: 'Chiudi trova e sostituisci',
},
shortcut: {
'open-find-dialog': 'Apri finestra Trova',
'open-replace-dialog': 'Apri finestra Sostituisci',
'go-to-next-match': 'Vai alla corrispondenza successiva',
'go-to-previous-match': 'Vai alla corrispondenza precedente',
'focus-selection': 'Focus selezione',
panel: 'Trova e sostituisci',
},
dialog: {
title: 'Trova',
find: 'Trova',
replace: 'Sostituisci',
'replace-all': 'Sostituisci tutto',
'case-sensitive': 'Maiuscole/minuscole',
'find-placeholder': 'Trova',
'advanced-finding': 'Ricerca e sostituzione avanzate',
'replace-placeholder': 'Inserisci stringa di sostituzione',
'match-the-whole-cell': "Corrispondenza con l'intera cella",
'match-the-whole-word': 'Trova parola intera',
'find-direction': {
title: 'Direzione ricerca',
row: 'Cerca per riga',
column: 'Cerca per colonna',
},
'find-scope': {
title: 'Intervallo ricerca',
'current-sheet': 'Foglio corrente',
workbook: 'Cartella di lavoro',
},
'find-by': {
title: 'Criterio ricerca',
value: 'Trova per valore',
formula: 'Trova formula',
},
'no-match': 'Ricerca completata ma nessuna corrispondenza trovata.',
'no-result': 'Nessun risultato',
},
replace: {
'all-success': 'Sostituite tutte le {0} corrispondenze',
'partial-success': 'Sostituite {0} corrispondenze, {1} non sostituite',
'all-failure': 'Sostituzione non riuscita',
confirm: {
title: 'Sei sicuro di voler sostituire tutte le corrispondenze?',
},
},
button: {
confirm: 'OK',
cancel: 'Annulla',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: '検索と置換',
mobile: {
settings: '検索と置換の設定',
'settings-title': '検索と置換の設定',
'previous-match': '前の一致項目',
'next-match': '次の一致項目',
close: '検索と置換を閉じる',
},
shortcut: {
'open-find-dialog': '検索ダイアログを開く',
'open-replace-dialog': '置換ダイアログを開く',
'go-to-next-match': '次の一致項目へ移動',
'go-to-previous-match': '前の一致項目へ移動',
'focus-selection': '選択範囲にフォーカス',
panel: '検索と置換',
},
dialog: {
title: '検索',
find: '検索',
replace: '置換',
'replace-all': 'すべて置換',
'case-sensitive': '大文字と小文字を区別',
'find-placeholder': '検索',
'advanced-finding': '詳細検索と置換',
'replace-placeholder': '置換文字列を入力',
'match-the-whole-cell': 'セル全体に一致',
'match-the-whole-word': '単語単位で検索',
'find-direction': {
title: '検索方向',
row: '行ごとに検索',
column: '列ごとに検索',
},
'find-scope': {
title: '検索範囲',
'current-sheet': '現在のシート',
workbook: 'ブック全体',
},
'find-by': {
title: '検索条件',
value: '値で検索',
formula: '数式を検索',
},
'no-match': '検索が完了しましたが、一致する項目が見つかりませんでした。',
'no-result': '結果なし',
},
replace: {
'all-success': '一致項目 {0} 件すべてを置換しました',
'partial-success': '{0} 件の一致項目を置換しました。{1} 件の置換に失敗しました',
'all-failure': '置換に失敗しました',
confirm: {
title: 'すべての一致項目を置換してもよろしいですか?',
},
},
button: {
confirm: '確認',
cancel: 'キャンセル',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: '찾기 및 바꾸기',
mobile: {
settings: '찾기 및 바꾸기 설정',
'settings-title': '찾기 및 바꾸기 설정',
'previous-match': '이전 일치 항목',
'next-match': '다음 일치 항목',
close: '찾기 및 바꾸기 닫기',
},
shortcut: {
'open-find-dialog': '찾기 대화 상자 열기',
'open-replace-dialog': '바꾸기 대화 상자 열기',
'go-to-next-match': '다음 일치 항목으로 이동',
'go-to-previous-match': '이전 일치 항목으로 이동',
'focus-selection': '선택 영역에 포커스',
panel: '찾기 및 바꾸기',
},
dialog: {
title: '찾기',
find: '찾기',
replace: '바꾸기',
'replace-all': '모두 바꾸기',
'case-sensitive': '대/소문자 구분',
'find-placeholder': '찾기',
'advanced-finding': '고급 검색 및 바꾸기',
'replace-placeholder': '바꿀 문자열 입력',
'match-the-whole-cell': '전체 셀 내용 일치',
'match-the-whole-word': '단어 단위로 일치',
'find-direction': {
title: '검색 방향',
row: '행 기준 검색',
column: '열 기준 검색',
},
'find-scope': {
title: '검색 범위',
'current-sheet': '현재 시트',
workbook: '통합 문서',
},
'find-by': {
title: '검색 기준',
value: '값으로 찾기',
formula: '수식 찾기',
},
'no-match': '검색이 완료되었으나 일치 항목을 찾을 수 없습니다.',
'no-result': '결과 없음',
},
replace: {
'all-success': '일치 항목 {0}개를 모두 바꿨습니다.',
'partial-success': '일치 항목 {0}개를 바꾸고 {1}개는 바꾸지 못했습니다.',
'all-failure': '바꾸기 실패',
confirm: {
title: '모든 일치 항목을 바꾸시겠습니까?',
},
},
button: {
confirm: '확인',
cancel: '취소',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Znajdź i zamień',
mobile: {
settings: 'Ustawienia znajdowania i zamiany',
'settings-title': 'Ustawienia znajdowania i zamiany',
'previous-match': 'Poprzednie dopasowanie',
'next-match': 'Następne dopasowanie',
close: 'Zamknij znajdowanie i zamianę',
},
shortcut: {
'open-find-dialog': 'Otwórz okno Znajdź',
'open-replace-dialog': 'Otwórz okno Zamień',
'go-to-next-match': 'Przejdź do następnego dopasowania',
'go-to-previous-match': 'Przejdź do poprzedniego dopasowania',
'focus-selection': 'Skup na zaznaczeniu',
panel: 'Znajdź i zamień',
},
dialog: {
title: 'Znajdź',
find: 'Znajdź',
replace: 'Zamień',
'replace-all': 'Zamień wszystko',
'case-sensitive': 'Rozróżniaj wielkość liter',
'find-placeholder': 'Znajdź',
'advanced-finding': 'Zaawansowane wyszukiwanie i zastępowanie',
'replace-placeholder': 'Wprowadź ciąg zastępujący',
'match-the-whole-cell': 'Dopasuj całą komórkę',
'match-the-whole-word': 'Dopasuj całe słowo',
'find-direction': {
title: 'Kierunek wyszukiwania',
row: 'Szukaj według wierszy',
column: 'Szukaj według kolumn',
},
'find-scope': {
title: 'Zakres wyszukiwania',
'current-sheet': 'Bieżący arkusz',
workbook: 'Skoroszyt',
},
'find-by': {
title: 'Szukaj według',
value: 'Szukaj według wartości',
formula: 'Szukaj formuły',
},
'no-match': 'Wyszukiwanie zakończone, ale nie znaleziono dopasowania.',
'no-result': 'Brak wyników',
},
replace: {
'all-success': 'Zastąpiono wszystkie dopasowania: {0}',
'partial-success': 'Zastąpiono dopasowania: {0}, nie udało się zastąpić: {1}',
'all-failure': 'Zastępowanie nie powiodło się',
confirm: {
title: 'Czy na pewno chcesz zastąpić wszystkie dopasowania?',
},
},
button: {
confirm: 'OK',
cancel: 'Anuluj',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Localizar e Substituir',
mobile: {
settings: 'Configurações de localizar e substituir',
'settings-title': 'Configurações de Localizar e Substituir',
'previous-match': 'Correspondência anterior',
'next-match': 'Próxima correspondência',
close: 'Fechar localizar e substituir',
},
shortcut: {
'open-find-dialog': 'Abrir Caixa de Localização',
'open-replace-dialog': 'Abrir Caixa de Substituição',
'go-to-next-match': 'Ir para Próxima Correspondência',
'go-to-previous-match': 'Ir para Correspondência Anterior',
'focus-selection': 'Focar Seleção',
panel: 'Localizar e Substituir',
},
dialog: {
title: 'Localizar',
find: 'Localizar',
replace: 'Substituir',
'replace-all': 'Substituir Tudo',
'case-sensitive': 'Diferenciar Maiúsculas/Minúsculas',
'find-placeholder': 'Localizar',
'advanced-finding': 'Pesquisa e Substituição Avançadas',
'replace-placeholder': 'Digite o Texto de Substituição',
'match-the-whole-cell': 'Corresponder à Célula Inteira',
'match-the-whole-word': 'Corresponder palavra inteira',
'find-direction': {
title: 'Direção da Pesquisa',
row: 'Pesquisar por Linha',
column: 'Pesquisar por Coluna',
},
'find-scope': {
title: 'Intervalo da Pesquisa',
'current-sheet': 'Planilha Atual',
workbook: 'Pasta de Trabalho',
},
'find-by': {
title: 'Pesquisar Por',
value: 'Pesquisar por Valor',
formula: 'Localizar Fórmula',
},
'no-match': 'Pesquisa concluída, mas nenhuma correspondência foi encontrada.',
'no-result': 'Nenhum Resultado',
},
replace: {
'all-success': 'Substituídas todas as {0} correspondências',
'partial-success': '{0} correspondências substituídas, falha ao substituir {1}',
'all-failure': 'Falha na substituição',
confirm: {
title: 'Tem certeza de que deseja substituir todas as correspondências?',
},
},
button: {
confirm: 'OK',
cancel: 'Cancelar',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Найти и заменить',
mobile: {
settings: 'Настройки поиска и замены',
'settings-title': 'Настройки поиска и замены',
'previous-match': 'Предыдущее совпадение',
'next-match': 'Следующее совпадение',
close: 'Закрыть поиск и замену',
},
shortcut: {
'open-find-dialog': 'Открыть диалоговое окно поиска',
'open-replace-dialog': 'Открыть диалоговое окно замены',
'go-to-next-match': 'Перейти к следующему совпадению',
'go-to-previous-match': 'Перейти к предыдущему совпадению',
'focus-selection': 'Фокус на выделении',
panel: 'Найти и заменить',
},
dialog: {
title: 'Найти',
find: 'Найти',
replace: 'Заменить',
'replace-all': 'Заменить все',
'case-sensitive': 'Учитывать регистр',
'find-placeholder': 'Найти',
'advanced-finding': 'Расширенный поиск и замена',
'replace-placeholder': 'Введите строку для замены',
'match-the-whole-cell': 'Соответствие всей ячейке',
'match-the-whole-word': 'Только слово целиком',
'find-direction': {
title: 'Направление поиска',
row: 'Искать по строкам',
column: 'Искать по столбцам',
},
'find-scope': {
title: 'Диапазон поиска',
'current-sheet': 'Текущий лист',
workbook: 'Книга',
},
'find-by': {
title: 'Искать по',
value: 'Искать по значению',
formula: 'Искать формулу',
},
'no-match': 'Поиск завершен, но совпадений не найдено.',
'no-result': 'Нет результатов',
},
replace: {
'all-success': 'Заменены все совпадения: {0}',
'partial-success': 'Заменено совпадений: {0}, не удалось заменить: {1}',
'all-failure': 'Ошибка замены',
confirm: {
title: 'Вы уверены, что хотите заменить все совпадения?',
},
},
button: {
confirm: 'ОК',
cancel: 'Отмена',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Hľadať a nahradiť',
mobile: {
settings: 'Nastavenia hľadania a nahrádzania',
'settings-title': 'Nastavenia hľadania a nahrádzania',
'previous-match': 'Predchádzajúca zhoda',
'next-match': 'Nasledujúca zhoda',
close: 'Zavrieť hľadanie a nahrádzanie',
},
shortcut: {
'open-find-dialog': 'Otvoriť dialógové okno hľadania',
'open-replace-dialog': 'Otvoriť dialógové okno nahradenia',
'go-to-next-match': 'Prejsť na ďalšiu zhodu',
'go-to-previous-match': 'Prejsť na predchádzajúcu zhodu',
'focus-selection': 'Zamerať výber',
panel: 'Hľadať a nahradiť',
},
dialog: {
title: 'Hľadať',
find: 'Hľadať',
replace: 'Nahradiť',
'replace-all': 'Nahradiť všetko',
'case-sensitive': 'Rozlišovať veľké a malé písmená',
'find-placeholder': 'Hľadať',
'advanced-finding': 'Pokročilé hľadanie a nahradenie',
'replace-placeholder': 'Zadajte reťazec na nahradenie',
'match-the-whole-cell': 'Zhoda s celou bunkou',
'match-the-whole-word': 'Hľadať iba celé slová',
'find-direction': {
title: 'Smer hľadania',
row: 'Hľadať podľa riadkov',
column: 'Hľadať podľa stĺpcov',
},
'find-scope': {
title: 'Rozsah hľadania',
'current-sheet': 'Aktuálny hárok',
workbook: 'Zošit',
},
'find-by': {
title: 'Hľadať podľa',
value: 'Hľadať podľa hodnoty',
formula: 'Hľadať vzorec',
},
'no-match': 'Hľadanie bolo dokončené, ale nenašla sa žiadna zhoda.',
'no-result': 'Žiadne výsledky',
},
replace: {
'all-success': 'Všetkých {0} zhôd bolo nahradených',
'partial-success': 'Nahradených {0} zhôd, {1} sa nepodarilo nahradiť',
'all-failure': 'Nahradenie zlyhalo',
confirm: {
title: 'Ste si istí, že chcete nahradiť všetky zhody?',
},
},
button: {
confirm: 'OK',
cancel: 'Zrušiť',
},
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* 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 { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: 'Tìm kiếm và Thay thế',
mobile: {
settings: 'Cài đặt tìm kiếm và thay thế',
'settings-title': 'Cài đặt Tìm kiếm và Thay thế',
'previous-match': 'Kết quả trước',
'next-match': 'Kết quả tiếp theo',
close: 'Đóng tìm kiếm và thay thế',
},
shortcut: {
'open-find-dialog': 'Mở hộp thoại Tìm kiếm',
'open-replace-dialog': 'Mở hộp thoại Thay thế',
'go-to-next-match': 'Chuyển đến kết quả tiếp theo',
'go-to-previous-match': 'Chuyển đến kết quả trước đó',
'focus-selection': 'Tập trung vào vùng chọn',
panel: 'Tìm kiếm và Thay thế',
},
dialog: {
title: 'Tìm kiếm',
find: 'Tìm kiếm',
replace: 'Thay thế',
'replace-all': 'Thay thế Tất cả',
'case-sensitive': 'Phân biệt chữ hoa chữ thường',
'find-placeholder': 'Tìm',
'advanced-finding': 'Tìm kiếm & Thay thế Nâng cao',
'replace-placeholder': 'Nhập chuỗi thay thế',
'match-the-whole-cell': 'Khớp toàn bộ ô',
'match-the-whole-word': 'Khớp toàn bộ từ',
'find-direction': {
title: 'Hướng tìm kiếm',
row: 'Tìm theo hàng',
column: 'Tìm theo cột',
},
'find-scope': {
title: 'Phạm vi tìm kiếm',
'current-sheet': 'Trang tính hiện tại',
workbook: 'Sổ làm việc',
},
'find-by': {
title: 'Tìm theo',
value: 'Tìm theo giá trị',
formula: 'Tìm công thức',
},
'no-match': 'Đã hoàn thành tìm kiếm nhưng không tìm thấy kết quả phù hợp.',
'no-result': 'Không có kết quả',
},
replace: {
'all-success': 'Đã thay thế tất cả {0} kết quả phù hợp',
'partial-success': 'Đã thay thế {0} kết quả phù hợp, không thể thay thế {1}',
'all-failure': 'Thay thế thất bại',
confirm: {
title: 'Bạn có chắc chắn muốn thay thế tất cả các kết quả phù hợp không?',
},
},
button: {
confirm: 'Xác nhận',
cancel: 'Hủy',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: '查找替换',
mobile: {
settings: '查找替换设置',
'settings-title': '查找替换设置',
'previous-match': '上一个匹配项',
'next-match': '下一个匹配项',
close: '关闭查找替换',
},
shortcut: {
'open-find-dialog': '打开查找对话框',
'open-replace-dialog': '打开替换对话框',
'go-to-next-match': '下一个匹配项',
'go-to-previous-match': '下一个匹配项',
'focus-selection': '聚焦选区',
panel: '查找替换',
},
dialog: {
title: '查找',
find: '查找',
replace: '替换',
'replace-all': '替换全部',
'find-placeholder': '输入查找内容',
'advanced-finding': '替换 / 高级查找',
'replace-placeholder': '输入替换内容',
'case-sensitive': '匹配大小写',
'match-the-whole-cell': '匹配整个单元格',
'match-the-whole-word': '全词匹配',
'find-scope': {
title: '查找范围',
'current-sheet': '当前子表',
workbook: '整个工作簿',
},
'find-direction': {
title: '查找顺序',
column: '按列查找',
row: '按行查找',
},
'find-by': {
title: '查找方式',
formula: '查找公式',
value: '查找值',
},
'no-match': '已完成搜索,但是未找到任何匹配项',
'no-result': '无结果',
},
replace: {
'all-success': '已全部替换 {0} 个匹配项',
'partial-success': '已替换 {0} 个匹配项,{1} 个替换失败',
'all-failure': '替换失败',
confirm: {
title: '确定要替换所有的匹配项吗?',
},
},
button: {
confirm: '确定',
cancel: '取消',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: '尋找替換',
mobile: {
settings: '尋找替換設定',
'settings-title': '尋找替換設定',
'previous-match': '上一個符合項目',
'next-match': '下一個符合項目',
close: '關閉尋找替換',
},
shortcut: {
'open-find-dialog': '打開尋找對話框',
'open-replace-dialog': '打開取代對話框',
'go-to-next-match': '前往下一個匹配項',
'go-to-previous-match': '前往上一個匹配項',
'focus-selection': '聚焦選取範圍',
panel: '尋找替換',
},
dialog: {
title: '尋找',
find: '尋找',
replace: '取代',
'replace-all': '全部取代',
'case-sensitive': '區分大小寫',
'find-placeholder': '尋找',
'advanced-finding': '進階搜尋與取代',
'replace-placeholder': '輸入取代字串',
'match-the-whole-cell': '完全符合儲存格內容',
'match-the-whole-word': '全詞匹配',
'find-direction': {
title: '尋找方向',
row: '按列搜尋',
column: '按欄搜尋',
},
'find-scope': {
title: '尋找範圍',
'current-sheet': '目前工作表',
workbook: '整個活頁簿',
},
'find-by': {
title: '尋找方式',
value: '按值尋找',
formula: '尋找公式',
},
'no-match': '已完成尋找,但未找到任何相符項目。',
'no-result': '無結果',
},
replace: {
'all-success': '已取代全部 {0} 個相符項目',
'partial-success': '已取代 {0} 個相符項目,{1} 個取代失敗',
'all-failure': '取代失敗',
confirm: {
title: '確定要取代所有相符項目嗎?',
},
},
button: {
confirm: '確定',
cancel: '取消',
},
},
};
export default locale;

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'find-replace': {
toolbar: '尋找替換',
mobile: {
settings: '尋找替換設定',
'settings-title': '尋找替換設定',
'previous-match': '上一個符合項目',
'next-match': '下一個符合項目',
close: '關閉尋找替換',
},
shortcut: {
'open-find-dialog': '打開尋找對話框',
'open-replace-dialog': '打開取代對話框',
'go-to-next-match': '前往下一個匹配項',
'go-to-previous-match': '前往上一個匹配項',
'focus-selection': '聚焦選取範圍',
panel: '尋找替換',
},
dialog: {
title: '尋找',
find: '尋找',
replace: '取代',
'replace-all': '全部取代',
'case-sensitive': '區分大小寫',
'find-placeholder': '尋找',
'advanced-finding': '進階搜尋與取代',
'replace-placeholder': '輸入取代字串',
'match-the-whole-cell': '完全符合儲存格內容',
'match-the-whole-word': '全詞匹配',
'find-direction': {
title: '尋找方向',
row: '按列搜尋',
column: '按欄搜尋',
},
'find-scope': {
title: '尋找範圍',
'current-sheet': '目前工作表',
workbook: '整個活頁簿',
},
'find-by': {
title: '尋找方式',
value: '按值尋找',
formula: '尋找公式',
},
'no-match': '已完成尋找,但未找到任何相符項目。',
'no-result': '無結果',
},
replace: {
'all-success': '已取代全部 {0} 個相符項目',
'partial-success': '已取代 {0} 個相符項目,{1} 個取代失敗',
'all-failure': '取代失敗',
confirm: {
title: '確定要取代所有相符項目嗎?',
},
},
button: {
confirm: '確定',
cancel: '取消',
},
},
};
export default locale;

View File

@@ -0,0 +1,83 @@
/**
* 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 { EDITOR_ACTIVATED, FOCUSING_SHEET, IContextService, Univer } from '@univerjs/core';
import { afterEach, describe, expect, it } from 'vitest';
import { FIND_REPLACE_AVAILABLE } from '../../services/context-keys';
import { FindReplaceMenuItemFactory } from '../find-replace.menu';
describe('FindReplaceMenuItemFactory', () => {
let univer: Univer | undefined;
afterEach(() => {
univer?.dispose();
univer = undefined;
});
it.each([
{ availability: undefined, disabled: true, label: 'not initialized' },
{ availability: false, disabled: true, label: 'unavailable' },
{ availability: true, disabled: false, label: 'available' },
])('reflects a $label provider before any context changes', ({ availability, disabled }) => {
univer = new Univer();
const injector = univer.__getInjector();
const contextService = injector.get(IContextService);
contextService.setContextValue(EDITOR_ACTIVATED, false);
contextService.setContextValue(FOCUSING_SHEET, true);
if (availability !== undefined) {
contextService.setContextValue(FIND_REPLACE_AVAILABLE, availability);
}
const values: boolean[] = [];
const subscription = FindReplaceMenuItemFactory(injector).disabled$!.subscribe((value) => values.push(value));
try {
expect(values.length).toBeGreaterThan(0);
expect(values[values.length - 1]).toBe(disabled);
} finally {
subscription.unsubscribe();
}
});
it('tracks provider readiness and removal while preserving editor and focus restrictions', () => {
univer = new Univer();
const injector = univer.__getInjector();
const contextService = injector.get(IContextService);
contextService.setContextValue(EDITOR_ACTIVATED, false);
contextService.setContextValue(FOCUSING_SHEET, true);
const values: boolean[] = [];
const subscription = FindReplaceMenuItemFactory(injector).disabled$!.subscribe((value) => values.push(value));
try {
expect(values[values.length - 1]).toBe(true);
contextService.setContextValue(FIND_REPLACE_AVAILABLE, true);
expect(values[values.length - 1]).toBe(false);
contextService.setContextValue(EDITOR_ACTIVATED, true);
expect(values[values.length - 1]).toBe(true);
contextService.setContextValue(EDITOR_ACTIVATED, false);
expect(values[values.length - 1]).toBe(false);
contextService.setContextValue(FOCUSING_SHEET, false);
expect(values[values.length - 1]).toBe(true);
contextService.setContextValue(FOCUSING_SHEET, true);
expect(values[values.length - 1]).toBe(false);
contextService.setContextValue(FIND_REPLACE_AVAILABLE, false);
expect(values[values.length - 1]).toBe(true);
} finally {
subscription.unsubscribe();
}
const finalCount = values.length;
contextService.setContextValue(FIND_REPLACE_AVAILABLE, true);
expect(values).toHaveLength(finalCount);
});
});

View File

@@ -0,0 +1,42 @@
/**
* 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 } from '@univerjs/core';
import type { IMenuButtonItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { EDITOR_ACTIVATED, FOCUSING_SHEET, IContextService, UniverInstanceType } from '@univerjs/core';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, map, startWith } from 'rxjs';
import { OpenFindDialogOperation } from '../commands/operations/find-replace.operation';
import { FIND_REPLACE_AVAILABLE } from '../services/context-keys';
export function FindReplaceMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
const contextService = accessor.get(IContextService);
return {
id: OpenFindDialogOperation.id,
icon: 'SearchIcon',
tooltip: 'find-replace.toolbar',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: combineLatest([
contextService.subscribeContextValue$(EDITOR_ACTIVATED),
contextService.subscribeContextValue$(FOCUSING_SHEET),
contextService.subscribeContextValue$(FIND_REPLACE_AVAILABLE)
.pipe(startWith(contextService.getContextValue(FIND_REPLACE_AVAILABLE))),
]).pipe(map(([editorActivated, focusingSheet, available]) => editorActivated || !focusingSheet || !available)),
};
}

View File

@@ -0,0 +1,29 @@
/**
* 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 { MenuSchemaType } from '@univerjs/ui';
import { RibbonDataGroup } from '@univerjs/ui';
import { OpenFindDialogOperation } from '../commands/operations/find-replace.operation';
import { FindReplaceMenuItemFactory } from './find-replace.menu';
export const mobileMenuSchema: MenuSchemaType = {
[RibbonDataGroup.ORGANIZATION]: {
[OpenFindDialogOperation.id]: {
order: 2,
menuItemFactory: FindReplaceMenuItemFactory,
},
},
};

View File

@@ -0,0 +1,30 @@
/**
* 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 { MenuSchemaType } from '@univerjs/ui';
import { RibbonDataGroup } from '@univerjs/ui';
import { OpenFindDialogOperation } from '../commands/operations/find-replace.operation';
import { FindReplaceMenuItemFactory } from './find-replace.menu';
export const menuSchema: MenuSchemaType = {
[RibbonDataGroup.ORGANIZATION]: {
[OpenFindDialogOperation.id]: {
order: 2,
gridLayout: { row: 1, column: 3 },
menuItemFactory: FindReplaceMenuItemFactory,
},
},
};

View File

@@ -0,0 +1,56 @@
/**
* 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 { IUniverFindReplaceConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin } from '@univerjs/core';
import { UniverMobileUIPlugin } from '@univerjs/ui';
import pkg from '../package.json';
import { defaultPluginConfig, FIND_REPLACE_PLUGIN_CONFIG_KEY } from './config/config';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { FindReplaceMobileController } from './controllers/mobile/find-replace.controller';
import { FindReplaceService, IFindReplaceService } from './services/find-replace.service';
@DependentOn(UniverMobileUIPlugin)
export class UniverFindReplaceMobileUIPlugin extends Plugin {
static override pluginName = 'UNIVER_FIND_REPLACE_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverFindReplaceConfig> = defaultPluginConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { ...rest } = merge({}, defaultPluginConfig, this._config);
this._configService.setConfig(FIND_REPLACE_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([MobileComponentsController]);
this._injector.get(MobileComponentsController);
([
[FindReplaceMobileController],
[IFindReplaceService, { useClass: FindReplaceService }],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
}
override onRendered(): void {
this._injector.get(FindReplaceMobileController);
}
}

View File

@@ -0,0 +1,59 @@
/**
* 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 { IUniverFindReplaceConfig } from './config/config';
import { IConfigService, Inject, Injector, merge, Plugin } from '@univerjs/core';
import pkg from '../package.json';
import { defaultPluginConfig, FIND_REPLACE_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { FindReplaceController } from './controllers/find-replace.controller';
import { FindReplaceService, IFindReplaceService } from './services/find-replace.service';
export class UniverFindReplacePlugin extends Plugin {
static override pluginName = 'UNIVER_FIND_REPLACE_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverFindReplaceConfig> = defaultPluginConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(FIND_REPLACE_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
([
[FindReplaceController],
[IFindReplaceService, { useClass: FindReplaceService }],
] as Dependency[]).forEach((d) => this._injector.add(d));
}
override onRendered(): void {
this._injector.get(FindReplaceController);
}
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FindModel, IFindMatch } from '../find-replace.service';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { FindReplaceModel, FindReplaceState } from '../find-replace.service';
describe('FindReplaceModel', () => {
it('replaceAll should stop searching when all succeed', async () => {
const state = new FindReplaceState();
state.changeState({ findString: 'a', replaceString: 'b', revealed: true });
const univerInstanceService = { getFocusedUnit: vi.fn(() => ({ getUnitId: () => 'u1' })) };
const commandService = { onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })) };
const matchesUpdate$ = new Subject<IFindMatch[]>();
const activelyChangingMatch$ = new Subject<IFindMatch>();
const match1: IFindMatch = { provider: 'p', unitId: 'u1', range: 1, replaceable: true } as any;
const model: FindModel = {
unitId: 'u1',
matchesUpdate$,
activelyChangingMatch$,
getMatches: () => [match1],
moveToNextMatch: vi.fn(() => match1),
moveToPreviousMatch: vi.fn(() => match1),
replace: vi.fn(async () => true),
replaceAll: vi.fn(async () => ({ success: 1, failure: 0 })),
focusSelection: vi.fn(),
dispose: vi.fn(),
} as any;
const provider = {
find: vi.fn(async () => [model]),
terminate: vi.fn(),
};
const providers = new Set([provider as any]);
const findReplaceModel = new FindReplaceModel(state, providers, univerInstanceService as any, commandService as any);
await findReplaceModel.start();
const res = await findReplaceModel.replaceAll();
expect(res).toEqual({ success: 1, failure: 0 });
expect(provider.terminate).toHaveBeenCalled();
findReplaceModel.dispose();
});
});

View File

@@ -0,0 +1,362 @@
/**
* 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 { UnitModel } from '@univerjs/core';
import type {
IFindMatch,
IFindMoveParams,
IFindQuery,
IFindReplaceProviderCapabilities,
IReplaceAllResult,
} from '../find-replace.service';
import {
awaitTime,
ICommandService,
IContextService,
Inject,
Injector,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { firstValueFrom, Subject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { FindBy, FindDirection, FindModel, FindReplaceService, FindScope } from '../find-replace.service';
class TestUniverInstanceService {
readonly focused$ = new Subject<string | null>();
focusedUnit = {
getUnitId: () => 'u1',
type: UniverInstanceType.UNIVER_DOC,
} as UnitModel;
getFocusedUnit() {
return this.focusedUnit;
}
}
class TestCommandService {
onCommandExecuted() {
return { dispose() {} };
}
}
class TestContextService {
readonly values: Array<{ key: unknown; value: unknown }> = [];
setContextValue(key: unknown, value: unknown): void {
this.values.push({ key, value });
}
}
interface ITestModelConfig {
unitId: string;
matches: IFindMatch[];
replaceAllResult?: IReplaceAllResult;
}
class TestFindModel extends FindModel {
readonly matchesUpdate$ = new Subject<IFindMatch[]>();
readonly activelyChangingMatch$ = new Subject<IFindMatch>();
readonly nextParams: Array<IFindMoveParams | undefined> = [];
readonly previousParams: Array<IFindMoveParams | undefined> = [];
readonly replaceCalls: string[] = [];
readonly replaceAllCalls: string[] = [];
nextQueue: Array<IFindMatch | null> = [];
previousQueue: Array<IFindMatch | null> = [];
focusCount = 0;
constructor(
readonly unitId: string,
private _matches: IFindMatch[],
private readonly _replaceAllResult: IReplaceAllResult = { success: _matches.length, failure: 0 }
) {
super();
}
getMatches(): IFindMatch[] {
return this._matches;
}
setMatches(matches: IFindMatch[]): void {
this._matches = matches;
this.matchesUpdate$.next(matches);
}
moveToNextMatch(params?: IFindMoveParams): IFindMatch | null {
this.nextParams.push(params);
return this.nextQueue.length ? this.nextQueue.shift()! : this._matches[0] ?? null;
}
moveToPreviousMatch(params?: IFindMoveParams): IFindMatch | null {
this.previousParams.push(params);
return this.previousQueue.length ? this.previousQueue.shift()! : this._matches.at(-1) ?? null;
}
replace(replaceString: string): Promise<boolean> {
this.replaceCalls.push(replaceString);
return Promise.resolve(this._matches.length > 0);
}
replaceAll(replaceString: string): Promise<IReplaceAllResult> {
this.replaceAllCalls.push(replaceString);
return Promise.resolve(this._replaceAllResult);
}
focusSelection(): void {
this.focusCount++;
}
}
class TestFindReplaceProvider {
capabilities: IFindReplaceProviderCapabilities = {
caseSensitive: true,
matchesTheWholeWord: true,
matchesTheWholeCell: false,
findDirection: false,
findScope: false,
findBy: false,
};
supportedType = UniverInstanceType.UNIVER_DOC;
readonly queries: IFindQuery[] = [];
readonly models: TestFindModel[] = [];
modelConfigs: ITestModelConfig[] = [];
terminateCount = 0;
constructor(@Inject(Injector) private readonly _injector: Injector) {}
isSupported(unit: UnitModel): boolean {
return unit.type === this.supportedType;
}
async find(query: IFindQuery): Promise<FindModel[]> {
this.queries.push(query);
this.models.splice(0, this.models.length, ...this.modelConfigs.map((config) =>
this._injector.createInstance(TestFindModel, config.unitId, config.matches, config.replaceAllResult)
));
return this.models;
}
terminate(): void {
this.terminateCount++;
}
}
function createService(registerProvider = true) {
const injector = new Injector();
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([IContextService, { useClass: TestContextService as never }]);
injector.add([TestFindReplaceProvider]);
injector.add([FindReplaceService]);
const service = injector.get(FindReplaceService);
const provider = injector.get(TestFindReplaceProvider);
if (registerProvider) {
service.registerFindReplaceProvider(provider);
}
return {
injector,
service,
provider,
contextService: injector.get(IContextService) as unknown as TestContextService,
univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService,
};
}
describe('FindReplaceService', () => {
it('selects the provider supporting the focused unit', async () => {
const { service, provider, univerInstanceService } = createService();
univerInstanceService.focusedUnit = {
getUnitId: () => 'doc-1',
type: UniverInstanceType.UNIVER_DOC,
} as UnitModel;
univerInstanceService.focused$.next('doc-1');
expect(service.start()).toBe(true);
await expect(firstValueFrom(service.providerCapabilities$)).resolves.toEqual(provider.capabilities);
expect(provider.queries).toHaveLength(0);
service.terminate();
service.dispose();
});
it('does not start when no provider supports the focused unit', () => {
const { service, univerInstanceService } = createService();
univerInstanceService.focusedUnit = {
getUnitId: () => 'slide-1',
type: UniverInstanceType.UNIVER_SLIDE,
} as UnitModel;
univerInstanceService.focused$.next('slide-1');
expect(service.start()).toBe(false);
service.dispose();
});
it('should start session and move to next match', async () => {
const { service, provider } = createService();
const match1: IFindMatch = { provider: 'p', unitId: 'u1', range: { i: 1 }, replaceable: true } as any;
const match2: IFindMatch = { provider: 'p', unitId: 'u1', range: { i: 2 }, replaceable: false } as any;
provider.modelConfigs = [{ unitId: 'u1', matches: [match1, match2], replaceAllResult: { success: 2, failure: 0 } }];
expect(service.start(false)).toBe(true);
service.changeFindString('hello');
service.changeFindDirection(FindDirection.COLUMN);
service.changeFindScope(FindScope.UNIT);
service.changeFindBy(FindBy.FORMULA);
service.find();
await awaitTime(0);
service.moveToNextMatch();
expect(service.getCurrentMatch()).toEqual(match1);
const replaceables = await new Promise<IFindMatch[]>((resolve) => {
service.replaceables$.subscribe((r) => {
if (r.length) resolve(r);
});
});
expect(replaceables).toEqual([match1]);
service.terminate();
service.dispose();
});
it('should search with the typed replace query on the first navigation in replace mode', async () => {
const { service, provider, contextService } = createService();
const match: IFindMatch = { provider: 'p', unitId: 'u1', range: { row: 1 }, replaceable: true } as any;
provider.modelConfigs = [{ unitId: 'u1', matches: [match] }];
const focusSignals: number[] = [];
service.focusSignal$.subscribe(() => focusSignals.push(focusSignals.length + 1));
expect(service.start(true)).toBe(true);
service.changeInputtingFindString('invoice');
service.moveToNextMatch();
await awaitTime(0);
expect(provider.queries.at(-1)).toMatchObject({
findString: 'invoice',
replaceRevealed: true,
matchesTheWholeWord: false,
});
expect(service.getCurrentMatch()).toEqual(match);
expect(focusSignals).toEqual([1]);
expect(contextService.values.map(({ value }) => value)).toContain(true);
service.terminate();
service.dispose();
});
it('should hide replacement controls without terminating the find session', () => {
const { service, contextService } = createService();
expect(service.start(true)).toBe(true);
service.changeInputtingFindString('invoice');
service.hideReplace();
expect(service.revealed).toBe(true);
expect(service.replaceRevealed).toBe(false);
expect(service.getFindString()).toBe('invoice');
expect(contextService.values.at(-1)?.value).toBe(false);
service.terminate();
service.dispose();
});
it('should navigate across find models when the current unit has no next or previous match', async () => {
const { service, provider } = createService();
const match1: IFindMatch = { provider: 'p', unitId: 'u1', range: { row: 1 }, replaceable: true } as any;
const match2: IFindMatch = { provider: 'p', unitId: 'u2', range: { row: 2 }, replaceable: true } as any;
provider.modelConfigs = [
{ unitId: 'u1', matches: [match1] },
{ unitId: 'u2', matches: [match2] },
];
expect(service.start()).toBe(true);
service.changeFindString('total');
service.find();
await awaitTime(0);
const [firstModel, secondModel] = provider.models;
firstModel.nextQueue = [null];
secondModel.nextQueue = [match2];
service.moveToNextMatch();
expect(service.getCurrentMatch()).toEqual(match2);
expect(secondModel.nextParams).toContainEqual({ ignoreSelection: true });
secondModel.previousQueue = [null];
firstModel.previousQueue = [match1];
service.moveToPreviousMatch();
expect(service.getCurrentMatch()).toEqual(match1);
expect(firstModel.previousParams).toContainEqual({ ignoreSelection: true });
service.terminate();
service.dispose();
});
it('should aggregate replace-all failures without clearing the current search session', async () => {
const { service, provider } = createService();
const match1: IFindMatch = { provider: 'p', unitId: 'u1', range: { row: 1 }, replaceable: true } as any;
const match2: IFindMatch = { provider: 'p', unitId: 'u1', range: { row: 2 }, replaceable: true } as any;
provider.modelConfigs = [
{ unitId: 'u1', matches: [match1], replaceAllResult: { success: 2, failure: 0 } },
{ unitId: 'u2', matches: [match2], replaceAllResult: { success: 0, failure: 1 } },
];
expect(service.start()).toBe(true);
service.changeFindString('draft');
service.changeReplaceString('final');
service.find();
await awaitTime(0);
const terminateCountBeforeReplaceAll = provider.terminateCount;
await expect(service.replaceAll()).resolves.toEqual({ success: 2, failure: 1 });
expect(provider.models.map((model) => model.replaceAllCalls)).toEqual([['final'], ['final']]);
expect(service.getCurrentMatch()).toEqual(match1);
expect(provider.terminateCount).toBe(terminateCountBeforeReplaceAll);
service.terminate();
service.dispose();
});
it('should report no matches and clear replaceable results when providers find nothing', async () => {
const { service, provider } = createService();
provider.modelConfigs = [{ unitId: 'u1', matches: [] }];
const replaceableSnapshots: IFindMatch[][] = [];
service.replaceables$.subscribe((replaceables) => replaceableSnapshots.push(replaceables));
expect(service.start()).toBe(true);
service.changeFindString('missing');
service.find();
await awaitTime(0);
expect(service.getCurrentMatch()).toBeNull();
expect(replaceableSnapshots.at(-1)).toEqual([]);
service.terminate();
service.dispose();
});
it('should fail commands that require an active session when no provider has started one', async () => {
const { service } = createService(false);
expect(service.start()).toBe(false);
expect(await service.replace()).toBe(false);
await expect(service.replaceAll()).rejects.toThrow('model is not initialized');
service.dispose();
});
});

View File

@@ -0,0 +1,30 @@
/**
* 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 FIND_REPLACE_INPUT_FOCUS = 'FIND_REPLACE_INPUT_FOCUS';
/**
* If find replace panel is focused.
*/
export const FIND_REPLACE_DIALOG_FOCUS = 'FIND_REPLACE_DIALOG_FOCUS';
/**
* If the find replace feature is activated and the replace is revealed.
*/
export const FIND_REPLACE_REPLACE_REVEALED = 'FIND_REPLACE_REPLACE_REVEALED';
/** If the focused unit has a find-replace provider. */
export const FIND_REPLACE_AVAILABLE = 'FIND_REPLACE_AVAILABLE';

View File

@@ -0,0 +1,957 @@
/**
* 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 { IDisposable, Nullable, UnitModel } from '@univerjs/core';
import type { Observable } from 'rxjs';
import {
createIdentifier,
Disposable,
DisposableCollection,
ICommandService,
IContextService,
Inject,
Injector,
IUniverInstanceService,
toDisposable,
} from '@univerjs/core';
import { RENDER_RAW_FORMULA_KEY } from '@univerjs/engine-render';
import { BehaviorSubject, combineLatest, debounceTime, Subject, throttleTime } from 'rxjs';
import { FIND_REPLACE_AVAILABLE, FIND_REPLACE_REPLACE_REVEALED } from './context-keys';
export type FindProgressFn = () => void;
export interface IFindComplete<T extends IFindMatch = IFindMatch> {
results: T[];
}
export interface IFindMatch<T = unknown> {
provider: string;
unitId: string;
range: T;
/** Indicates if the match could be replaced. */
replaceable?: boolean;
}
export interface IFindMoveParams {
/** Go to next (previous) matching in a loop. */
loop?: boolean;
/** If the the selection is on the match and then should stay on the match. */
stayIfOnMatch?: boolean;
/** Go the next selection ignoring the current selection's position. */
ignoreSelection?: boolean;
/**
* If this param is true, we should only change matching position without performing focusing.
* This usually happens when "moving" is triggered when a document's content changes.
*/
noFocus?: boolean;
}
export interface IReplaceAllResult {
success: number;
failure: number;
}
export abstract class FindModel extends Disposable {
abstract readonly unitId: string;
/**
* Find model should emit new matches from this observable if they changed no matter due to incremental
* or document's content changes.
*/
abstract readonly matchesUpdate$: Observable<IFindMatch[]>;
abstract readonly activelyChangingMatch$: Observable<IFindMatch>;
abstract getMatches(): IFindMatch[];
abstract moveToNextMatch(params?: IFindMoveParams): IFindMatch | null;
abstract moveToPreviousMatch(params?: IFindMoveParams): IFindMatch | null;
/** Replace the currently focused matching if there is one. */
abstract replace(replaceString: string): Promise<boolean>;
/**
* Replace all matches. This method would return how many
*/
abstract replaceAll(replaceString: string): Promise<IReplaceAllResult>;
abstract focusSelection(): void;
}
/**
* A provider should be implemented by a business to provide the find results.
*/
export interface IFindReplaceProviderCapabilities {
caseSensitive: boolean;
matchesTheWholeWord: boolean;
matchesTheWholeCell: boolean;
findDirection: boolean;
findScope: boolean;
findBy: boolean;
}
export interface IFindReplaceProvider {
readonly capabilities: IFindReplaceProviderCapabilities;
isSupported(unit: UnitModel): boolean;
find(query: IFindQuery): Promise<FindModel[]>;
terminate(): void;
}
type IReplaceableMatch = IFindMatch & { replaceable: boolean };
/**
* This service works as a core of the find & replace feature.
*/
export interface IFindReplaceService {
readonly stateUpdates$: Observable<Partial<IFindReplaceState>>;
readonly state$: Observable<IFindReplaceState>;
readonly currentMatch$: Observable<Nullable<IFindMatch>>;
/** An observable value of all matches those could be replaced. */
readonly replaceables$: Observable<IReplaceableMatch[]>;
readonly focusSignal$: Observable<void>;
readonly providerCapabilities$: Observable<Nullable<IFindReplaceProviderCapabilities>>;
readonly revealed: boolean;
readonly replaceRevealed: boolean;
/**
* Register a find replace provider to the service. The provider is the actual bearer to
* perform the find in different kinds of documents or different environments.
* @param provider the find replace provider
*/
registerFindReplaceProvider(provider: IFindReplaceProvider): IDisposable;
/**
* Get find string from the internal state.
*/
getFindString(): string;
/**
* Start a find & replace session.
* @returns execution result
*/
start(revealReplace?: boolean): boolean;
/**
* Terminate a find session and clear all caches.
*/
terminate(): void;
/**
* Start searching with the current conditions.
*/
find(): void;
focusFindInput(): void;
revealReplace(): void;
hideReplace(): void;
changeFindString(value: string): void;
changeInputtingFindString(value: string): void;
changeReplaceString(value: string): void;
changeCaseSensitive(sensitive: boolean): void;
changeMatchesTheWholeCell(wholeCell: boolean): void;
changeMatchesTheWholeWord(wholeWord: boolean): void;
changeFindScope(scope: FindScope): void;
changeFindDirection(direction: FindDirection): void;
changeFindBy(findBy: FindBy): void;
moveToNextMatch(): void;
moveToPreviousMatch(): void;
replace(): Promise<boolean>;
replaceAll(): Promise<IReplaceAllResult>;
/**
* Focus the selection of the current match.
*/
focusSelection(): void;
getProviders(): Set<IFindReplaceProvider>;
}
export const IFindReplaceService = createIdentifier<IFindReplaceService>('find-replace.service');
/**
* The find query object with finding options.
*/
export interface IFindQuery extends Pick<
IFindReplaceState,
| 'replaceRevealed'
| 'findString'
| 'caseSensitive'
| 'findBy'
| 'findDirection'
| 'findScope'
| 'matchesTheWholeCell'
| 'matchesTheWholeWord'
> { }
/**
*
* @param statusUpdate
*/
function shouldStateUpdateTriggerResearch(statusUpdate: Partial<IFindReplaceState>): boolean {
if (typeof statusUpdate.findString !== 'undefined') return true;
if (typeof statusUpdate.inputtingFindString !== 'undefined') return true;
if (typeof statusUpdate.findDirection !== 'undefined') return true;
if (typeof statusUpdate.matchesTheWholeCell !== 'undefined') return true;
if (typeof statusUpdate.matchesTheWholeWord !== 'undefined') return true;
if (typeof statusUpdate.caseSensitive !== 'undefined') return true;
if (typeof statusUpdate.findScope !== 'undefined') return true;
if (typeof statusUpdate.findBy !== 'undefined') return true;
return false;
}
/**
* This class stores find replace results and provides methods to perform replace or something.
*
* It **only** live through a find-replace session and would be disposed when the user
* close the find replace dialog (considered as session being terminated).
*/
export class FindReplaceModel extends Disposable {
readonly currentMatch$ = new BehaviorSubject<Nullable<IFindMatch>>(null);
readonly replaceables$ = new BehaviorSubject<IReplaceableMatch[]>([]);
/** All find models returned by providers. */
private _findModels: FindModel[] = [];
/** The find model that the current match is from. */
private _matchingModel: FindModel | null = null;
private _matches: IFindMatch[] = [];
private _currentSearchingDisposables: Nullable<DisposableCollection> = null;
get searched(): boolean { return this._findModels.length > 0; }
constructor(
private readonly _state: FindReplaceState,
private readonly _providers: Set<IFindReplaceProvider>,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
// should restart finding when the following conditions changed
this.disposeWithMe(
this._state.stateUpdates$.pipe(throttleTime(200, undefined, { leading: true, trailing: true }))
.subscribe(async (stateUpdate) => {
const state = this._state.state;
if (shouldStateUpdateTriggerResearch(stateUpdate)) {
if (state.findString !== '' && !state.replaceRevealed) {
await this._startSearching();
this._state.changeState({ findCompleted: true });
} else if (stateUpdate.replaceRevealed !== true) {
this._stopSearching();
}
}
})
);
}
override dispose(): void {
super.dispose();
this._stopSearching();
this.currentMatch$.complete();
this.replaceables$.complete();
// reset all state, including
this._state.changeState({ ...createInitFindReplaceState(), revealed: false });
}
async start(): Promise<IFindComplete> {
if (!this._state.findString) {
return { results: [] };
}
const complete = await this._startSearching();
this._state.changeState({ findCompleted: true });
return complete;
}
focusSelection(): void {
this._matchingModel?.focusSelection();
}
/** Call this method to start a `searching`. */
private async _startSearching(): Promise<IFindComplete> {
if (!this._state.findString) {
return { results: [] };
}
const providers = Array.from(this._providers);
const findModels = (this._findModels = (
await Promise.all(providers.map((provider) => provider.find({
findString: this._state.findString,
findDirection: this._state.findDirection,
findScope: this._state.findScope,
findBy: this._state.findBy,
replaceRevealed: this._state.replaceRevealed,
caseSensitive: this._state.caseSensitive,
matchesTheWholeCell: this._state.matchesTheWholeCell,
matchesTheWholeWord: this._state.matchesTheWholeWord,
})))
).flat());
this._subscribeToModelsChanges(findModels);
const newMatches = this._matches = findModels.map((c) => c.getMatches()).flat();
this.replaceables$.next(newMatches.filter((m) => m.replaceable) as IReplaceableMatch[]);
if (!newMatches.length) {
this._state.changeState({ matchesCount: 0, matchesPosition: 0 });
return { results: [] };
}
this._moveToInitialMatch(findModels);
this._state.changeState({ matchesCount: newMatches.length });
return { results: newMatches };
}
/** Terminate the current searching session, when searching string is empty. */
private _stopSearching(): void {
this._providers.forEach((provider) => provider.terminate());
this._findModels = [];
this._matches = [];
this._matchingModel = null;
this._currentSearchingDisposables?.dispose();
this._currentSearchingDisposables = null;
this.currentMatch$.next(null);
this.replaceables$.next([]);
this._state.changeState({
findCompleted: false,
matchesCount: 0,
matchesPosition: 0,
});
}
// When a document's content changes, we should reset all matches and try to move to the next match.
private _subscribeToModelsChanges(models: FindModel[]): void {
const disposables = this._currentSearchingDisposables = new DisposableCollection();
const matchesUpdateSubscription = combineLatest(models.map((model) => model.matchesUpdate$))
.pipe(debounceTime(220))
.subscribe(([...allMatches]) => {
const newMatches = this._matches = allMatches.flat();
if (newMatches.length) {
this._moveToInitialMatch(this._findModels, true);
this._state.changeState({ matchesCount: newMatches.length });
this.replaceables$.next(newMatches.filter((m) => m.replaceable) as IReplaceableMatch[]);
} else {
this._state.changeState({ matchesCount: 0, matchesPosition: 0 });
this.replaceables$.next([]);
}
});
models.forEach((model) => disposables.add(toDisposable(model.activelyChangingMatch$.subscribe((match) => {
const index = this._matches.findIndex((m) => m === match);
this._state.changeState({ matchesPosition: index + 1 });
}))));
disposables.add(toDisposable(matchesUpdateSubscription));
}
async replace(): Promise<boolean> {
if (!this._matchingModel) {
return false;
}
return this._matchingModel.replace(this._state.replaceString);
}
async replaceAll(): Promise<IReplaceAllResult> {
const result = await Promise.all(this._findModels.map((m) => m.replaceAll(this._state.replaceString)))
.then((results) => results.reduce((acc, cur) => {
acc.success += cur.success;
acc.failure += cur.failure;
return acc;
}, { success: 0, failure: 0 }));
if (result.failure === 0) {
this._stopSearching();
}
return result;
}
getCurrentMatch(): Nullable<IFindMatch> {
return this._state.matchesPosition > 0 ? this._matches[this._state.matchesPosition - 1] : null;
}
private _markMatch(match: IFindMatch): void {
const index = this._matches.findIndex((value) => value === match);
this.currentMatch$.next(match);
this._state.changeState({ matchesPosition: index + 1 });
}
moveToNextMatch() {
if (!this._matchingModel) {
return;
}
const loopInCurrentUnit = this._findModels.length === 1;
const nextMatch = this._matchingModel.moveToNextMatch({ loop: loopInCurrentUnit });
if (nextMatch) {
this._markMatch(nextMatch);
return nextMatch;
} else {
const currentModelIndex = this._findModels.findIndex((m) => m === this._matchingModel);
return this._moveToNextUnitMatch(currentModelIndex);
}
}
private _moveToNextUnitMatch(startingIndex: number) {
const l = this._findModels.length;
for (let i = (startingIndex + 1) % l; i !== startingIndex;) {
const nextPositionModel = this._findModels[i];
const nextMatch = nextPositionModel.moveToNextMatch({ ignoreSelection: true });
if (nextMatch) {
this._matchingModel = nextPositionModel;
this._markMatch(nextMatch);
return nextMatch;
}
i = (i + 1) % l;
}
if (this._matchingModel) {
const nextMatch = this._matchingModel.moveToNextMatch({ ignoreSelection: true });
if (nextMatch) this._markMatch(nextMatch);
return nextMatch;
}
}
moveToPreviousMatch() {
if (!this._matchingModel) {
return;
}
const loopInCurrentUnit = this._findModels.length === 1;
const nextMatch = this._matchingModel.moveToPreviousMatch({ loop: loopInCurrentUnit });
if (nextMatch) {
const index = this._matches.findIndex((value) => value === nextMatch);
this.currentMatch$.next(nextMatch);
this._state.changeState({ matchesPosition: index + 1 });
return nextMatch;
} else {
const l = this._findModels.length;
const currentModelIndex = this._findModels.findIndex((m) => m === this._matchingModel);
for (let i = (currentModelIndex - 1 + l) % l; i !== currentModelIndex;) {
const nextPositionModel = this._findModels[i];
const nextMatch = nextPositionModel.moveToPreviousMatch({ ignoreSelection: true });
if (nextMatch) {
this._matchingModel = nextPositionModel;
this._markMatch(nextMatch);
return nextMatch;
}
i = (i - 1) % l;
}
const nextMatch = this._matchingModel.moveToPreviousMatch({ ignoreSelection: true });
if (nextMatch) this._markMatch(nextMatch);
return nextMatch;
}
}
private _moveToInitialMatch(findModels: FindModel[], noFocus = false): number {
const focusedUnitId = this._univerInstanceService.getFocusedUnit()?.getUnitId();
if (!focusedUnitId) {
return -1;
}
const i = findModels.findIndex((model) => model.unitId === focusedUnitId);
if (i !== -1) {
this._matchingModel = findModels[i];
const nextMatch = this._matchingModel.moveToNextMatch({ stayIfOnMatch: true, noFocus });
if (nextMatch) {
this._markMatch(nextMatch);
return i;
}
}
this._moveToNextUnitMatch(i);
return 0;
}
}
export enum FindDirection {
/** Default. */
ROW = 'row',
COLUMN = 'column',
}
export enum FindBy {
VALUE = 'value',
FORMULA = 'formula',
}
export enum FindScope {
/** Default. */
SUBUNIT = 'subunit',
/** Find the scope in the current unit. */
UNIT = 'unit',
}
export interface IFindReplaceState {
revealed: boolean;
/** The string user inputs in the input box. */
findString: string;
inputtingFindString: string;
replaceString?: string;
/** Indicates if is in replacing mode. */
replaceRevealed: boolean;
/** The currently focused match's index (1-based). */
matchesPosition: number;
/** The number of all matches. */
matchesCount: number;
/** Indicates if an user triggered finding process is progressed. */
findCompleted: boolean;
caseSensitive: boolean;
matchesTheWholeCell: boolean;
matchesTheWholeWord: boolean;
findDirection: FindDirection;
findScope: FindScope;
findBy: FindBy;
}
export function createInitFindReplaceState(): IFindReplaceState {
return {
caseSensitive: false,
findBy: FindBy.VALUE,
findCompleted: false,
findDirection: FindDirection.ROW,
findScope: FindScope.SUBUNIT,
findString: '',
inputtingFindString: '',
matchesCount: 0,
matchesPosition: 0,
matchesTheWholeCell: false,
matchesTheWholeWord: false,
replaceRevealed: false,
replaceString: '',
revealed: true,
};
}
/**
* This class stores find replace options state. These state are stored
* here instead of the React component so we can change state from
* operations.
*/
export class FindReplaceState {
private readonly _stateUpdates$ = new Subject<Partial<IFindReplaceState>>();
readonly stateUpdates$: Observable<Partial<IFindReplaceState>> = this._stateUpdates$.asObservable();
private readonly _state$ = new BehaviorSubject<IFindReplaceState>(createInitFindReplaceState());
readonly state$ = this._state$.asObservable();
get state(): IFindReplaceState {
return this._state$.getValue();
}
private _findString = '';
private _inputtingFindString = '';
private _replaceString = '';
private _revealed = false;
private _replaceRevealed = false;
private _matchesPosition = 0;
private _matchesCount = 0;
private _caseSensitive = true;
private _matchesTheWholeCell = false;
private _matchesTheWholeWord = false;
private _findDirection = FindDirection.ROW;
private _findScope = FindScope.SUBUNIT;
private _findBy = FindBy.VALUE;
private _findCompleted = false;
get inputtingFindString(): string { return this._inputtingFindString; }
get findString(): string { return this._findString; }
get revealed(): boolean { return this._revealed; }
get replaceRevealed(): boolean { return this._replaceRevealed; }
get matchesPosition(): number { return this._matchesPosition; }
get matchesCount(): number { return this._matchesCount; }
get replaceString(): string { return this._replaceString; }
get caseSensitive(): boolean { return this._caseSensitive; }
get matchesTheWholeCell(): boolean { return this._matchesTheWholeCell; }
get matchesTheWholeWord(): boolean { return this._matchesTheWholeWord; }
get findDirection(): FindDirection { return this._findDirection; }
get findScope(): FindScope { return this._findScope; }
get findBy(): FindBy { return this._findBy; }
get findCompleted(): boolean { return this._findCompleted; }
// eslint-disable-next-line max-lines-per-function, complexity
changeState(changes: Partial<IFindReplaceState>): void {
let changed = false;
const changedState: Partial<IFindReplaceState> = {};
if (typeof changes.findString !== 'undefined' && changes.findString !== this._findString) {
this._findString = changes.findString;
changedState.findString = this._findString;
changed = true;
}
if (typeof changes.revealed !== 'undefined' && changes.revealed !== this._revealed) {
this._revealed = changes.revealed;
changedState.revealed = changes.revealed;
changed = true;
}
if (typeof changes.replaceRevealed !== 'undefined' && changes.replaceRevealed !== this._replaceRevealed) {
this._replaceRevealed = changes.replaceRevealed;
changedState.replaceRevealed = changes.replaceRevealed;
changed = true;
}
if (typeof changes.replaceString !== 'undefined' && changes.replaceString !== this._replaceString) {
this._replaceString = changes.replaceString;
changedState.replaceString = changes.replaceString;
changed = true;
}
if (typeof changes.matchesCount !== 'undefined' && changes.matchesCount !== this._matchesCount) {
this._matchesCount = changes.matchesCount;
changedState.matchesCount = changes.matchesCount;
changed = true;
}
if (typeof changes.matchesPosition !== 'undefined' && changes.matchesPosition !== this._matchesPosition) {
this._matchesPosition = changes.matchesPosition;
changedState.matchesPosition = changes.matchesPosition;
changed = true;
}
if (typeof changes.findBy !== 'undefined' && changes.findBy !== this._findBy) {
this._findBy = changes.findBy;
changedState.findBy = changes.findBy;
changed = true;
}
if (typeof changes.findScope !== 'undefined' && changes.findScope !== this._findScope) {
this._findScope = changes.findScope;
changedState.findScope = changes.findScope;
changed = true;
}
if (typeof changes.findDirection !== 'undefined' && changes.findDirection !== this._findDirection) {
this._findDirection = changes.findDirection;
changedState.findDirection = changes.findDirection;
changed = true;
}
if (typeof changes.caseSensitive !== 'undefined' && changes.caseSensitive !== this._caseSensitive) {
this._caseSensitive = changes.caseSensitive;
changedState.caseSensitive = changes.caseSensitive;
changed = true;
}
if (typeof changes.matchesTheWholeCell !== 'undefined' && changes.matchesTheWholeCell !== this._matchesTheWholeCell) {
this._matchesTheWholeCell = changes.matchesTheWholeCell;
changedState.matchesTheWholeCell = changes.matchesTheWholeCell;
changed = true;
}
if (typeof changes.matchesTheWholeWord !== 'undefined' && changes.matchesTheWholeWord !== this._matchesTheWholeWord) {
this._matchesTheWholeWord = changes.matchesTheWholeWord;
changedState.matchesTheWholeWord = changes.matchesTheWholeWord;
changed = true;
}
if (typeof changes.inputtingFindString !== 'undefined' && changes.inputtingFindString !== this._inputtingFindString) {
this._inputtingFindString = changes.inputtingFindString;
changedState.inputtingFindString = changes.inputtingFindString;
changed = true;
}
if (typeof changes.findCompleted !== 'undefined' && changes.findCompleted !== this._findCompleted) {
this._findCompleted = changes.findCompleted;
changedState.findCompleted = changes.findCompleted;
changed = true;
}
if (changed) {
this._state$.next({
caseSensitive: this._caseSensitive,
findBy: this._findBy,
findCompleted: this._findCompleted,
findDirection: this._findDirection,
findScope: this._findScope,
findString: this._findString,
inputtingFindString: this._inputtingFindString,
matchesCount: this._matchesCount,
matchesPosition: this._matchesPosition,
matchesTheWholeCell: this._matchesTheWholeCell,
matchesTheWholeWord: this._matchesTheWholeWord,
replaceRevealed: this._replaceRevealed,
revealed: this._revealed,
});
this._stateUpdates$.next(changedState);
}
}
}
// Since Univer' Find&Replace features works in a plugin manner,
// each `IFind&Replace` provider should have their own implementations
// of 'state' and 'model'.
export class FindReplaceService extends Disposable implements IFindReplaceService {
private readonly _providers = new Set<IFindReplaceProvider>();
private readonly _state = new FindReplaceState();
private _model: Nullable<FindReplaceModel>;
private _modelDisposables: Nullable<DisposableCollection> = null;
private readonly _currentMatch$ = new BehaviorSubject<Nullable<IFindMatch>>(null);
readonly currentMatch$ = this._currentMatch$.asObservable();
private readonly _replaceables$ = new BehaviorSubject<IReplaceableMatch[]>([]);
readonly replaceables$ = this._replaceables$.asObservable();
private readonly _focusSignal$ = new Subject<void>();
readonly focusSignal$ = this._focusSignal$.asObservable();
private readonly _providerCapabilities$ = new BehaviorSubject<Nullable<IFindReplaceProviderCapabilities>>(null);
readonly providerCapabilities$ = this._providerCapabilities$.asObservable();
private _activeProvider: Nullable<IFindReplaceProvider> = null;
get stateUpdates$() { return this._state.stateUpdates$; }
get state$() { return this._state.state$; }
get revealed(): boolean { return this._state.revealed; }
get replaceRevealed(): boolean { return this._state.replaceRevealed; }
constructor(
@Inject(Injector) private readonly _injector: Injector,
@IContextService private readonly _contextService: IContextService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this.disposeWithMe(toDisposable(this._univerInstanceService.focused$.subscribe(() => this._syncActiveProvider())));
}
override dispose(): void {
super.dispose();
this._currentMatch$.next(null);
this._currentMatch$.complete();
this._replaceables$.next([]);
this._replaceables$.complete();
this._focusSignal$.complete();
this._providerCapabilities$.complete();
}
getProviders(): Set<IFindReplaceProvider> {
return this._providers;
}
getCurrentMatch(): Nullable<IFindMatch> {
return this._model?.getCurrentMatch();
}
getFindString(): string {
return this._state.findString;
}
changeFindString(findString: string): void {
this._state.changeState({ findString });
}
focusFindInput(): void {
this._focusSignal$.next();
}
changeInputtingFindString(value: string): void {
if (value) {
this._state.changeState({ inputtingFindString: value });
} else {
this._state.changeState({ inputtingFindString: '', findString: '' });
}
}
changeReplaceString(replaceString: string): void {
this._state.changeState({ replaceString });
}
changeMatchesTheWholeCell(matchesTheWholeCell: boolean): void {
this._state.changeState({ matchesTheWholeCell });
}
changeMatchesTheWholeWord(matchesTheWholeWord: boolean): void {
this._state.changeState({ matchesTheWholeWord });
}
changeCaseSensitive(caseSensitive: boolean): void {
this._state.changeState({ caseSensitive });
}
changeFindBy(findBy: FindBy): void {
this._state.changeState({ findBy });
this._toggleDisplayRawFormula(findBy === FindBy.FORMULA);
}
changeFindScope(scope: FindScope): void {
this._state.changeState({ findScope: scope });
}
changeFindDirection(direction: FindDirection): void {
this._state.changeState({ findDirection: direction });
}
moveToNextMatch(): void {
if (!this._model) {
return;
}
if (this._state.replaceRevealed && !this._model.searched) {
this._state.changeState({ findString: this._state.inputtingFindString });
this._model.start();
} else {
this._model.moveToNextMatch();
}
this._focusSignal$.next();
}
moveToPreviousMatch(): void {
if (!this._model) {
return;
}
if (this._state.replaceRevealed && !this._model.searched) {
this._state.changeState({ findString: this._state.inputtingFindString });
this._model.start();
} else {
this._model.moveToPreviousMatch();
}
this._focusSignal$.next();
}
async replace(): Promise<boolean> {
if (!this._model) {
return false;
}
return this._model.replace();
}
async replaceAll(): Promise<IReplaceAllResult> {
if (!this._model) {
throw new Error('[FindReplaceService] replaceAll: model is not initialized!');
}
return this._model.replaceAll();
}
revealReplace(): void {
this._state.changeState({ replaceRevealed: true, inputtingFindString: this._state.findString });
this._toggleRevealReplace(true);
}
hideReplace(): void {
this._state.changeState({
replaceRevealed: false,
findString: this._state.inputtingFindString,
});
this._toggleRevealReplace(false);
}
focusSelection(): void {
this._model?.focusSelection();
}
start(revealReplace = false): boolean {
if (!this._activeProvider) {
return false;
}
this._model = this._injector.createInstance(FindReplaceModel, this._state, new Set([this._activeProvider]));
this._modelDisposables = new DisposableCollection();
this._modelDisposables.add(toDisposable(this._model.currentMatch$.subscribe((match) => this._currentMatch$.next(match))));
this._modelDisposables.add(toDisposable(this._model.replaceables$.subscribe((replaceables) => this._replaceables$.next(replaceables))));
const newState = createInitFindReplaceState();
if (revealReplace) {
newState.replaceRevealed = true;
}
this._state.changeState(newState);
this._toggleRevealReplace(revealReplace);
return true;
}
find(): void {
this._model?.start();
}
terminate(): void {
this._model?.dispose();
this._model = null;
this._modelDisposables?.dispose();
this._modelDisposables = null;
this._toggleDisplayRawFormula(false);
this._toggleRevealReplace(false);
}
registerFindReplaceProvider(provider: IFindReplaceProvider): IDisposable {
this._providers.add(provider);
this._syncActiveProvider();
return toDisposable(() => {
this._providers.delete(provider);
this._syncActiveProvider();
});
}
private _syncActiveProvider(): void {
const unit = this._univerInstanceService.getFocusedUnit();
const provider = unit == null
? null
: Array.from(this._providers).find((candidate) => candidate.isSupported(unit)) ?? null;
if (provider === this._activeProvider) return;
if (this.revealed) this.terminate();
this._activeProvider = provider;
this._providerCapabilities$.next(provider?.capabilities ?? null);
this._contextService.setContextValue(FIND_REPLACE_AVAILABLE, provider != null);
}
private _toggleRevealReplace(revealReplace: boolean): void {
this._contextService.setContextValue(FIND_REPLACE_REPLACE_REVEALED, revealReplace);
}
private _toggleDisplayRawFormula(force: boolean): void {
this._contextService.setContextValue(RENDER_RAW_FORMULA_KEY, force);
}
}

View File

@@ -0,0 +1,109 @@
/**
* 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 { Meta } from '@storybook/react';
import type { Dependency } from '@univerjs/core';
import { LocaleService, LocaleType } from '@univerjs/core';
import { DesktopLayoutService, ILayoutService, RediContext } from '@univerjs/ui';
import { useContext, useMemo, useState } from 'react';
import { FindReplaceController } from '../../controllers/find-replace.controller';
import enUS from '../../locale/en-US';
import ruRU from '../../locale/ru-RU';
import zhCN from '../../locale/zh-CN';
import { FindReplaceService, IFindReplaceService } from '../../services/find-replace.service';
import { FindReplaceDialog } from './FindReplaceDialog';
const meta: Meta = {
title: 'Find Replace Dialog',
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};
export default meta;
function FindDialogDemo() {
const { injector } = useContext(RediContext);
const [inject] = useState(() => {
const deps: Dependency[] = [
[IFindReplaceService, { useClass: FindReplaceService }],
[ILayoutService, { useClass: DesktopLayoutService }],
[FindReplaceController],
];
injector?.get(LocaleService).load({
[LocaleType.EN_US]: enUS,
[LocaleType.ZH_CN]: zhCN,
[LocaleType.RU_RU]: ruRU,
});
deps.forEach((dependency) => injector?.add(dependency));
return injector;
});
const memoizedValue = useMemo(() => ({ injector: inject }), [inject]);
return (
<RediContext.Provider value={memoizedValue}>
<FindReplaceDialog />
</RediContext.Provider>
);
}
export const FindDialog = {
render() {
return <FindDialogDemo />;
},
};
function ReplaceDialogDemo() {
const { injector } = useContext(RediContext);
const [inject] = useState(() => {
const deps: Dependency[] = [
[IFindReplaceService, { useClass: FindReplaceService }],
[ILayoutService, { useClass: DesktopLayoutService }],
[FindReplaceController],
];
injector?.get(LocaleService).load({
[LocaleType.EN_US]: enUS,
[LocaleType.ZH_CN]: zhCN,
[LocaleType.RU_RU]: ruRU,
});
deps.forEach((dependency) => injector?.add(dependency));
return injector;
});
const memoizedValue = useMemo(() => ({ injector: inject }), [inject]);
return (
<RediContext.Provider value={memoizedValue}>
<FindReplaceDialog />
</RediContext.Provider>
);
}
export const ReplaceDialog = {
render() {
return <ReplaceDialogDemo />;
},
};

View File

@@ -0,0 +1,358 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { ICommandService, IContextService, LocaleService } from '@univerjs/core';
import { Button, Checkbox, FormDualColumnLayout, FormLayout, Input, MessageType, Select } from '@univerjs/design';
import { ILayoutService, IMessageService, useDebounceFn, useDependency, useObservable } from '@univerjs/ui';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
import { fromEvent } from 'rxjs';
import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../../commands/commands/replace.command';
import { OpenReplaceDialogOperation } from '../../commands/operations/find-replace.operation';
import { FIND_REPLACE_DIALOG_FOCUS, FIND_REPLACE_INPUT_FOCUS } from '../../services/context-keys';
import { FindBy, FindDirection, FindScope, IFindReplaceService } from '../../services/find-replace.service';
import { SearchInput } from './SearchInput';
interface ISubFormRef {
focus(): void;
selectHasFocus(): boolean;
}
const FIND_SCOPE_OPTIONS: Array<{ label: LocaleKey; value: FindScope }> = [
{ label: 'find-replace.dialog.find-scope.current-sheet', value: FindScope.SUBUNIT },
{ label: 'find-replace.dialog.find-scope.workbook', value: FindScope.UNIT },
];
const FIND_DIRECTION_OPTIONS: Array<{ label: LocaleKey; value: FindDirection }> = [
{ label: 'find-replace.dialog.find-direction.row', value: FindDirection.ROW },
{ label: 'find-replace.dialog.find-direction.column', value: FindDirection.COLUMN },
];
const FIND_BY_OPTIONS: Array<{ label: LocaleKey; value: FindBy }> = [
{ label: 'find-replace.dialog.find-by.value', value: FindBy.VALUE },
{ label: 'find-replace.dialog.find-by.formula', value: FindBy.FORMULA },
];
function useFindInputFocus(findReplaceService: IFindReplaceService, ref: Parameters<typeof useImperativeHandle>[0]) {
const pendingFocusFrameRef = useRef<number | null>(null);
const focus = useCallback(() => {
(document.querySelector('.univer-find-input input') as HTMLInputElement | null)?.focus();
}, []);
const focusAfterDialogClose = useCallback(() => {
focus();
pendingFocusFrameRef.current = requestAnimationFrame(() => {
pendingFocusFrameRef.current = null;
focus();
});
}, [focus]);
const selectHasFocus = useCallback(() => {
const allInputs = document.querySelectorAll('[data-u-comp=find-replace-dialog] [data-u-comp=search-input]');
return Array.from(allInputs).some((input) => input === document.activeElement);
}, []);
useImperativeHandle(ref, () => ({ focus, selectHasFocus }));
useEffect(() => {
const subscription = findReplaceService.focusSignal$.subscribe(() => focus());
return () => {
subscription.unsubscribe();
if (pendingFocusFrameRef.current !== null) {
cancelAnimationFrame(pendingFocusFrameRef.current);
}
};
}, [findReplaceService, focus]);
return { focus, focusAfterDialogClose, selectHasFocus };
}
export const FindDialog = forwardRef(function FindDialogImpl(_props, ref) {
const localeService = useDependency(LocaleService);
const findReplaceService = useDependency(IFindReplaceService);
const commandService = useDependency(ICommandService);
const state = useObservable(findReplaceService.state$, undefined, true);
const { findCompleted, findString, matchesCount, matchesPosition } = state;
const revealReplace = useCallback(() => {
commandService.executeCommand(OpenReplaceDialogOperation.id);
}, [commandService]);
const onFindStringChange = useDebounceFn((findString: string) => {
return findReplaceService.changeFindString(findString);
}, 500);
useFindInputFocus(findReplaceService, ref);
return (
<>
<SearchInput
findCompleted={findCompleted}
matchesCount={matchesCount}
matchesPosition={matchesPosition}
findReplaceService={findReplaceService}
initialFindString={findString}
onChange={onFindStringChange}
/>
<div className="univer-mt-4 univer-text-center">
<a
className={`
hover:univer-text-primary-500/80
univer-cursor-pointer univer-text-sm univer-text-primary-500 univer-transition-colors
`}
onClick={revealReplace}
>
{localeService.t<LocaleKey>('find-replace.dialog.advanced-finding')}
</a>
</div>
</>
);
});
export const ReplaceDialog = forwardRef(function ReplaceDialogImpl(_props, ref) {
const findReplaceService = useDependency(IFindReplaceService);
const localeService = useDependency(LocaleService);
const commandService = useDependency(ICommandService);
const messageService = useDependency(IMessageService);
const currentMatch = useObservable(findReplaceService.currentMatch$, undefined, true);
const replaceables = useObservable(findReplaceService.replaceables$, undefined, true);
const capabilities = useObservable(findReplaceService.providerCapabilities$, null, true);
const state = useObservable(findReplaceService.state$, undefined, true);
const {
matchesCount,
matchesPosition,
findString,
inputtingFindString,
replaceString,
caseSensitive,
matchesTheWholeCell,
matchesTheWholeWord,
findDirection,
findScope,
findBy,
findCompleted,
} = state;
const findDisabled = inputtingFindString.length === 0;
const replaceDisabled = matchesCount === 0 || !currentMatch?.replaceable;
const replaceAllDisabled = replaceables.length === 0;
const onFindStringChange = useCallback(
(newValue: string) => findReplaceService.changeInputtingFindString(newValue),
[findReplaceService]
);
const onReplaceStringChange = useCallback(
(replaceString: string) => findReplaceService.changeReplaceString(replaceString),
[findReplaceService]
);
const { focusAfterDialogClose } = useFindInputFocus(findReplaceService, ref);
const onClickFindButton = useCallback(() => {
if (findString === inputtingFindString) {
findReplaceService.moveToNextMatch();
} else {
findReplaceService.changeFindString(inputtingFindString);
findReplaceService.find();
}
}, [findString, inputtingFindString, findReplaceService]);
const onClickReplaceButton = useCallback(() => commandService.executeCommand(ReplaceCurrentMatchCommand.id), [commandService]);
const onClickReplaceAllButton = useCallback(async () => {
await commandService.executeCommand(ReplaceAllMatchesCommand.id);
focusAfterDialogClose();
}, [commandService, focusAfterDialogClose]);
const onChangeFindDirection = useCallback((findDirection: string) => {
findReplaceService.changeFindDirection(findDirection as FindDirection);
}, [findReplaceService]);
const onChangeFindScope = useCallback((findScope: string) => {
findReplaceService.changeFindScope(findScope as FindScope);
}, [findReplaceService]);
const onChangeFindBy = useCallback((findBy: string) => {
findReplaceService.changeFindBy(findBy as FindBy);
}, [findReplaceService]);
const findScopeOptions = FIND_SCOPE_OPTIONS.map((option) => ({ ...option, label: localeService.t(option.label) }));
const findDirectionOptions = FIND_DIRECTION_OPTIONS.map((option) => ({ ...option, label: localeService.t(option.label) }));
const findByOptions = FIND_BY_OPTIONS.map((option) => ({ ...option, label: localeService.t(option.label) }));
useEffect(() => {
const shouldDisplayNoMatchInfo = findCompleted && matchesCount === 0;
if (shouldDisplayNoMatchInfo) {
messageService.show({
content: localeService.t<LocaleKey>('find-replace.dialog.no-match'),
type: MessageType.Warning,
duration: 5000,
});
}
}, [findCompleted, matchesCount, messageService, localeService]);
return (
<div>
<FormLayout label={localeService.t<LocaleKey>('find-replace.dialog.find')}>
<SearchInput
findCompleted={findCompleted}
className="univer-find-input"
matchesCount={matchesCount}
matchesPosition={matchesPosition}
findReplaceService={findReplaceService}
initialFindString={inputtingFindString}
onChange={onFindStringChange}
/>
</FormLayout>
<FormLayout label={localeService.t<LocaleKey>('find-replace.dialog.replace')}>
<Input
placeholder={localeService.t<LocaleKey>('find-replace.dialog.replace-placeholder')}
value={replaceString}
onChange={(value) => onReplaceStringChange(value)}
/>
</FormLayout>
{capabilities?.findDirection && (
<FormLayout label={localeService.t<LocaleKey>('find-replace.dialog.find-direction.title')}>
<Select value={findDirection} options={findDirectionOptions} onChange={onChangeFindDirection} />
</FormLayout>
)}
{(capabilities?.findScope || capabilities?.findBy) && (
<FormDualColumnLayout>
<>
{capabilities.findScope && (
<FormLayout label={localeService.t<LocaleKey>('find-replace.dialog.find-scope.title')}>
<Select value={findScope} options={findScopeOptions} onChange={onChangeFindScope} />
</FormLayout>
)}
{capabilities.findBy && (
<FormLayout label={localeService.t<LocaleKey>('find-replace.dialog.find-by.title')}>
<Select value={findBy} options={findByOptions} onChange={onChangeFindBy} />
</FormLayout>
)}
</>
</FormDualColumnLayout>
)}
{(capabilities?.caseSensitive || capabilities?.matchesTheWholeCell || capabilities?.matchesTheWholeWord) && (
<FormDualColumnLayout>
<>
{capabilities.caseSensitive && (
<FormLayout>
<Checkbox
checked={caseSensitive}
onChange={(checked) => {
findReplaceService.changeCaseSensitive(Boolean(checked));
if (findCompleted) {
findReplaceService.find();
}
}}
>
{localeService.t<LocaleKey>('find-replace.dialog.case-sensitive')}
</Checkbox>
</FormLayout>
)}
{capabilities.matchesTheWholeCell && (
<FormLayout>
<Checkbox checked={matchesTheWholeCell} onChange={(checked) => findReplaceService.changeMatchesTheWholeCell(Boolean(checked))}>
{localeService.t<LocaleKey>('find-replace.dialog.match-the-whole-cell')}
</Checkbox>
</FormLayout>
)}
{capabilities.matchesTheWholeWord && (
<FormLayout>
<Checkbox
checked={matchesTheWholeWord}
onChange={(checked) => {
findReplaceService.changeMatchesTheWholeWord(Boolean(checked));
if (findCompleted) {
findReplaceService.find();
}
}}
>
{localeService.t<LocaleKey>('find-replace.dialog.match-the-whole-word')}
</Checkbox>
</FormLayout>
)}
</>
</FormDualColumnLayout>
)}
<div className="univer-mt-6 univer-flex univer-justify-between">
<Button variant="primary" onClick={onClickFindButton} disabled={findDisabled}>{localeService.t<LocaleKey>('find-replace.dialog.find')}</Button>
<span className="univer-inline-flex univer-gap-2">
<Button disabled={replaceDisabled} onClick={onClickReplaceButton}>{localeService.t<LocaleKey>('find-replace.dialog.replace')}</Button>
<Button disabled={replaceAllDisabled} onClick={onClickReplaceAllButton}>{localeService.t<LocaleKey>('find-replace.dialog.replace-all')}</Button>
</span>
</div>
</div>
);
});
export function FindReplaceDialog() {
const findReplaceService = useDependency(IFindReplaceService);
const layoutService = useDependency(ILayoutService);
const contextService = useDependency(IContextService);
const state = useObservable(findReplaceService.state$, undefined, true);
const dialogContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = dialogContainerRef.current;
if (!container) return;
const disposable = layoutService.registerContainerElement(container);
return () => disposable.dispose();
}, [layoutService]);
const focusRef = useRef<ISubFormRef>(null);
const setDialogContainerFocus = useCallback(
(focused: boolean) => contextService.setContextValue(FIND_REPLACE_DIALOG_FOCUS, focused),
[contextService]
);
const setDialogInputFocus = useCallback(
(focused: boolean) => contextService.setContextValue(FIND_REPLACE_INPUT_FOCUS, focused),
[contextService]
);
useEffect(() => {
const focusSubscription = fromEvent(document, 'focusin').subscribe((event) => {
if (event.target && dialogContainerRef.current?.contains(event.target as Node)) {
setDialogContainerFocus(true);
} else {
setDialogContainerFocus(false);
}
if (!focusRef.current || !focusRef.current.selectHasFocus()) {
setDialogInputFocus(false);
} else {
setDialogInputFocus(true);
}
});
// Focus the input element the first time we open the find replace dialog.
focusRef.current?.focus();
setDialogContainerFocus(true);
setDialogInputFocus(true);
return () => {
focusSubscription.unsubscribe();
setDialogContainerFocus(false);
};
}, [setDialogContainerFocus, setDialogInputFocus]);
return (
<div ref={dialogContainerRef} data-u-comp="find-replace-dialog">
{!state.replaceRevealed ? <FindDialog ref={focusRef} /> : <ReplaceDialog ref={focusRef} />}
</div>
);
}

View File

@@ -0,0 +1,90 @@
/**
* 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 { IInputProps } from '@univerjs/design';
import type { LocaleKey } from '../../locale/types';
import type { IFindReplaceService } from '../../services/find-replace.service';
import { LocaleService } from '@univerjs/core';
import { Input, Pager } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
export interface ISearchInputProps extends Pick<IInputProps, 'onFocus' | 'onBlur' | 'className' | 'onChange'> {
findCompleted: boolean;
findReplaceService: IFindReplaceService;
matchesPosition: number;
matchesCount: number;
initialFindString: string;
}
export function SearchInput(props: ISearchInputProps) {
const {
findCompleted: findComplete,
matchesCount,
matchesPosition,
initialFindString,
findReplaceService,
onChange,
...rest
} = props;
const localeService = useDependency(LocaleService);
const [value, setValue] = useState(initialFindString);
const noResult = findComplete && matchesCount === 0;
const text = noResult
? localeService.t<LocaleKey>('find-replace.dialog.no-result')
: matchesCount === 0
? ' '
: undefined;
function handleChangePosition(newIndex: number) {
if (matchesPosition === matchesCount && newIndex === 1) {
findReplaceService.moveToNextMatch();
} else if (matchesPosition === 1 && newIndex === matchesCount) {
findReplaceService.moveToPreviousMatch();
} else if (newIndex < matchesPosition) {
findReplaceService.moveToPreviousMatch();
} else {
findReplaceService.moveToNextMatch();
}
}
return (
<div className="univer-relative univer-flex univer-items-center univer-gap-2" onDrag={(e) => e.stopPropagation()}>
<Input
data-u-comp="search-input"
autoFocus
placeholder={localeService.t<LocaleKey>('find-replace.dialog.find-placeholder')}
value={value}
onChange={(value) => {
setValue(value);
onChange?.(value);
}}
slot={(
<Pager
loop
text={text}
value={matchesPosition}
total={matchesCount}
onChange={handleChangePosition}
/>
)}
{...rest}
/>
</div>
);
}

View File

@@ -0,0 +1,638 @@
/**
* 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 { awaitTime, ICommandService, toDisposable, UniverInstanceType } from '@univerjs/core';
import { ILayoutService, IMessageService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { createTestBed } from '../../../__tests__/create-test-bed';
import {
ReplaceAllMatchesCommand,
ReplaceCurrentMatchCommand,
} from '../../../commands/commands/replace.command';
import {
OpenFindDialogOperation,
OpenReplaceDialogOperation,
} from '../../../commands/operations/find-replace.operation';
import {
FindBy,
FindDirection,
FindModel,
FindReplaceService,
FindScope,
IFindReplaceService,
} from '../../../services/find-replace.service';
import { FindReplaceDialog } from '../FindReplaceDialog';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
interface ICellText {
id: string;
text: string;
}
interface ICellRange {
id: string;
}
interface IObservedFindState {
matchesCount: number;
matchesPosition: number;
}
type FindProvider = Parameters<IFindReplaceService['registerFindReplaceProvider']>[0];
type FindQuery = Parameters<FindProvider['find']>[0];
type FindCapabilities = FindProvider['capabilities'];
type FindMoveParams = Parameters<FindModel['moveToNextMatch']>[0];
type FindMatch = NonNullable<ReturnType<FindModel['moveToNextMatch']>> & { range: ICellRange };
type ReplaceAllResult = Awaited<ReturnType<FindModel['replaceAll']>>;
const INITIAL_ROWS: ICellText[] = [
{ id: 'A1', text: 'apple' },
{ id: 'A2', text: 'pineapple' },
{ id: 'A3', text: 'Apple' },
{ id: 'A4', text: 'banana' },
];
const FIND_LABEL = 'find-replace.dialog.find';
const REPLACE_LABEL = 'find-replace.dialog.replace';
const REPLACE_ALL_LABEL = 'find-replace.dialog.replace-all';
const ADVANCED_LABEL = 'find-replace.dialog.advanced-finding';
class TextFindModel extends FindModel {
private readonly _matchesUpdate$ = new BehaviorSubject<FindMatch[]>([]);
private readonly _activelyChangingMatch$ = new Subject<FindMatch>();
private _matches: FindMatch[] = [];
private _position = -1;
override readonly unitId = 'test';
override readonly matchesUpdate$ = this._matchesUpdate$.asObservable();
override readonly activelyChangingMatch$ = this._activelyChangingMatch$.asObservable();
constructor(
private readonly _rows: ICellText[],
private readonly _query: FindQuery
) {
super();
this._refreshMatches();
}
override dispose(): void {
this._matchesUpdate$.complete();
this._activelyChangingMatch$.complete();
super.dispose();
}
override getMatches(): FindMatch[] {
return this._matches;
}
override moveToNextMatch(params?: FindMoveParams): FindMatch | null {
if (this._matches.length === 0) {
return null;
}
if (params?.stayIfOnMatch && this._position >= 0) {
return this._emitCurrent();
}
if (params?.ignoreSelection) {
this._position = 0;
} else {
this._position = this._position + 1;
if (this._position >= this._matches.length) {
this._position = params?.loop ? 0 : this._matches.length - 1;
}
}
return this._emitCurrent();
}
override moveToPreviousMatch(params?: FindMoveParams): FindMatch | null {
if (this._matches.length === 0) {
return null;
}
if (params?.ignoreSelection) {
this._position = this._matches.length - 1;
} else {
this._position = this._position - 1;
if (this._position < 0) {
this._position = params?.loop ? this._matches.length - 1 : 0;
}
}
return this._emitCurrent();
}
override async replace(replaceString: string): Promise<boolean> {
const match = this._matches[this._position];
if (!match) {
return false;
}
const row = this._rows.find((item) => item.id === match.range.id);
if (!row) {
return false;
}
row.text = replaceString;
this._refreshMatches();
return true;
}
override async replaceAll(replaceString: string): Promise<ReplaceAllResult> {
const ids = new Set(this._matches.map((match) => match.range.id));
this._rows.forEach((row) => {
if (ids.has(row.id)) {
row.text = replaceString;
}
});
const success = ids.size;
this._refreshMatches();
return { success, failure: 0 };
}
override focusSelection(): void {
const match = this._matches[this._position];
if (match) {
this._activelyChangingMatch$.next(match);
}
}
private _emitCurrent(): FindMatch {
const match = this._matches[this._position];
this._activelyChangingMatch$.next(match);
return match;
}
private _refreshMatches(): void {
this._matches = this._rows
.filter((row) => this._doesRowMatch(row))
.map((row) => ({
provider: 'text-provider',
unitId: this.unitId,
range: { id: row.id },
replaceable: true,
}));
this._position = this._matches.length > 0 ? Math.min(Math.max(this._position, 0), this._matches.length - 1) : -1;
this._matchesUpdate$.next(this._matches);
}
private _doesRowMatch(row: ICellText): boolean {
const source = this._query.caseSensitive ? row.text : row.text.toLocaleLowerCase();
const target = this._query.caseSensitive ? this._query.findString : this._query.findString.toLocaleLowerCase();
return this._query.matchesTheWholeCell ? source === target : source.includes(target);
}
}
class TestFindReplaceProvider {
capabilities: FindCapabilities = {
caseSensitive: true,
matchesTheWholeWord: false,
matchesTheWholeCell: true,
findDirection: true,
findScope: true,
findBy: true,
};
readonly rows = INITIAL_ROWS.map((row) => ({ ...row }));
readonly queries: FindQuery[] = [];
isSupported(unit: { type: UniverInstanceType }): boolean {
return unit.type === UniverInstanceType.UNIVER_SHEET;
}
async find(query: FindQuery): Promise<FindModel[]> {
this.queries.push({ ...query });
return [new TextFindModel(this.rows, query)];
}
terminate(): void {
}
}
class TestLayoutService {
private _focused = false;
readonly rootContainerElement = document.body;
get isFocused() {
return this._focused;
}
focus() {
this._focused = true;
}
registerFocusHandler(): ReturnType<typeof toDisposable> {
return toDisposable(() => undefined);
}
registerRootContainerElement(): ReturnType<typeof toDisposable> {
return toDisposable(() => undefined);
}
registerContentElement(): ReturnType<typeof toDisposable> {
return toDisposable(() => undefined);
}
registerContainerElement(): ReturnType<typeof toDisposable> {
return toDisposable(() => undefined);
}
getContentElement() {
return document.body;
}
checkElementInCurrentContainers() {
return true;
}
checkContentIsFocused() {
return this._focused;
}
}
function createDialogTestBed(capabilities?: FindCapabilities) {
const testBed = createTestBed(undefined, [
[IFindReplaceService, { useClass: FindReplaceService }],
[ILayoutService, { useClass: TestLayoutService as never }],
[TestFindReplaceProvider],
]);
const commandService = testBed.get(ICommandService);
[
OpenFindDialogOperation,
OpenReplaceDialogOperation,
ReplaceCurrentMatchCommand,
ReplaceAllMatchesCommand,
].forEach((command) => commandService.registerCommand(command));
const provider = testBed.get(TestFindReplaceProvider);
if (capabilities) provider.capabilities = capabilities;
const findReplaceService = testBed.get(IFindReplaceService);
findReplaceService.registerFindReplaceProvider(provider);
return {
...testBed,
commandService,
provider,
findReplaceService,
};
}
function renderDialog(injector: ReturnType<ReturnType<typeof createTestBed>['univer']['__getInjector']>) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
<FindReplaceDialog />
</RediContext.Provider>
);
});
return { container, root };
}
function getInputs(container: HTMLElement): HTMLInputElement[] {
return Array.from(container.querySelectorAll('input[type="text"]'));
}
function getButton(container: HTMLElement, text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === text);
if (!button) {
throw new Error(`Button "${text}" was not found.`);
}
return button as HTMLButtonElement;
}
function getLink(container: HTMLElement, text: string): HTMLElement {
const link = Array.from(container.querySelectorAll('a')).find((item) => item.textContent?.trim() === text);
if (!link) {
throw new Error(`Link "${text}" was not found.`);
}
return link as HTMLElement;
}
function setInputValue(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
async function flushSearch() {
await act(async () => {
await awaitTime(260);
});
}
describe('FindReplaceDialog', () => {
let root: ReturnType<typeof createRoot> | undefined;
let container: HTMLElement | undefined;
afterEach(() => {
if (root) {
act(() => root!.unmount());
}
container?.remove();
root = undefined;
container = undefined;
});
it('runs a find session from the simple dialog input', async () => {
const testBed = createDialogTestBed();
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenFindDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
await act(async () => {
setInputValue(getInputs(container!)[0], 'apple');
});
await act(async () => {
await awaitTime(560);
});
await flushSearch();
expect(testBed.provider.queries.at(-1)).toMatchObject({
findString: 'apple',
replaceRevealed: false,
findDirection: FindDirection.ROW,
findScope: FindScope.SUBUNIT,
findBy: FindBy.VALUE,
caseSensitive: false,
matchesTheWholeCell: false,
});
expect(readCurrentMatch(testBed.findReplaceService)?.range).toEqual({ id: 'A1' });
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 3, matchesPosition: 1 });
});
it('reveals replace controls from the advanced link without restarting the session', async () => {
const testBed = createDialogTestBed();
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenFindDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
await act(async () => {
getLink(container!, ADVANCED_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testBed.findReplaceService.replaceRevealed).toBe(true);
expect(getButton(container!, FIND_LABEL).disabled).toBe(true);
expect(getButton(container!, REPLACE_LABEL).disabled).toBe(true);
expect(getButton(container!, REPLACE_ALL_LABEL).disabled).toBe(true);
expect(getInputs(container!)).toHaveLength(2);
});
it('shows only controls supported by the active provider', async () => {
const testBed = createDialogTestBed({
caseSensitive: true,
matchesTheWholeWord: true,
matchesTheWholeCell: false,
findDirection: false,
findScope: false,
findBy: false,
});
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenReplaceDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
expect(container.textContent).toContain('find-replace.dialog.case-sensitive');
expect(container.textContent).toContain('find-replace.dialog.match-the-whole-word');
expect(container.textContent).not.toContain('find-replace.dialog.find-direction.title');
expect(container.textContent).not.toContain('find-replace.dialog.match-the-whole-cell');
});
it('replaces the selected result and then replaces all remaining results', async () => {
const testBed = createDialogTestBed();
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenReplaceDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
await act(async () => {
const inputs = getInputs(container!);
setInputValue(inputs[0], 'apple');
setInputValue(inputs[1], 'orange');
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 3, matchesPosition: 1 });
await act(async () => {
getButton(container!, REPLACE_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.rows.map((row) => row.text)).toEqual(['orange', 'pineapple', 'Apple', 'banana']);
await act(async () => {
getButton(container!, REPLACE_ALL_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.rows.map((row) => row.text)).toEqual(['orange', 'orange', 'orange', 'banana']);
expect((testBed.get(IMessageService) as unknown as { messages: Array<{ content: string }> }).messages.at(-1)?.content).toBe('find-replace.replace.all-success');
});
it('moves to the next result when finding the same replace dialog input again', async () => {
const testBed = createDialogTestBed();
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenReplaceDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
await act(async () => {
const inputs = getInputs(container!);
setInputValue(inputs[0], 'apple');
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(1);
expect(readCurrentMatch(testBed.findReplaceService)?.range).toEqual({ id: 'A1' });
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 3, matchesPosition: 1 });
await act(async () => {
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(1);
expect(readCurrentMatch(testBed.findReplaceService)?.range).toEqual({ id: 'A2' });
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 3, matchesPosition: 2 });
});
it('uses the replace dialog options when searching', async () => {
const testBed = createDialogTestBed();
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenReplaceDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
await act(async () => {
const inputs = getInputs(container!);
setInputValue(inputs[0], 'Apple');
const checkboxes = Array.from(container!.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
checkboxes.forEach((checkbox) => checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true })));
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries.at(-1)).toMatchObject({
findString: 'Apple',
replaceRevealed: true,
caseSensitive: true,
matchesTheWholeCell: true,
});
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 1, matchesPosition: 1 });
expect(readCurrentMatch(testBed.findReplaceService)?.range).toEqual({ id: 'A3' });
});
it('reruns completed searches when case or whole-word matching changes', async () => {
const testBed = createDialogTestBed({
caseSensitive: true,
matchesTheWholeWord: true,
matchesTheWholeCell: false,
findDirection: false,
findScope: false,
findBy: false,
});
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenReplaceDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
const checkboxes = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]'));
await act(async () => {
setInputValue(getInputs(container!)[0], 'apple');
checkboxes.forEach((checkbox) => checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true })));
});
expect(testBed.provider.queries).toHaveLength(0);
await act(async () => {
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(1);
await act(async () => {
checkboxes[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(2);
expect(testBed.provider.queries.at(-1)).toMatchObject({ caseSensitive: false, matchesTheWholeWord: true });
await act(async () => {
checkboxes[1].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(3);
expect(testBed.provider.queries.at(-1)).toMatchObject({ caseSensitive: false, matchesTheWholeWord: false });
await act(async () => {
setInputValue(getInputs(container!)[0], 'kiwi');
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(4);
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 0, matchesPosition: 0 });
await act(async () => {
checkboxes[0].dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries).toHaveLength(5);
expect(testBed.provider.queries.at(-1)).toMatchObject({ findString: 'kiwi', caseSensitive: true });
});
it('warns and keeps replace actions disabled when a replace dialog search has no results', async () => {
const testBed = createDialogTestBed();
await act(async () => {
testBed.commandService.syncExecuteCommand(OpenReplaceDialogOperation.id);
});
const rendered = renderDialog(testBed.univer.__getInjector());
root = rendered.root;
container = rendered.container;
await act(async () => {
const inputs = getInputs(container!);
setInputValue(inputs[0], 'kiwi');
setInputValue(inputs[1], 'orange');
getButton(container!, FIND_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await flushSearch();
expect(testBed.provider.queries.at(-1)).toMatchObject({
findString: 'kiwi',
replaceRevealed: true,
});
expect(readState(testBed.findReplaceService)).toMatchObject({ matchesCount: 0, matchesPosition: 0 });
expect(readCurrentMatch(testBed.findReplaceService)).toBeNull();
expect((testBed.get(IMessageService) as unknown as { messages: Array<{ content: string }> }).messages.at(-1)?.content).toBe('find-replace.dialog.no-match');
expect(getButton(container!, REPLACE_LABEL).disabled).toBe(true);
expect(getButton(container!, REPLACE_ALL_LABEL).disabled).toBe(true);
expect(testBed.provider.rows.map((row) => row.text)).toEqual(INITIAL_ROWS.map((row) => row.text));
});
});
function readState(findReplaceService: IFindReplaceService): IObservedFindState {
let value!: IObservedFindState;
const subscription = findReplaceService.state$.subscribe((state) => {
value = state;
});
subscription.unsubscribe();
return value;
}
function readCurrentMatch(findReplaceService: IFindReplaceService): FindMatch | null {
let value: FindMatch | null = null;
const subscription = findReplaceService.currentMatch$.subscribe((match) => {
value = match == null ? null : match as FindMatch;
});
subscription.unsubscribe();
return value;
}

View File

@@ -0,0 +1,310 @@
/**
* 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 { ReactNode } from 'react';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { clsx, Input, resetButtonClassName } from '@univerjs/design';
import { ArrowDownIcon, ArrowUpIcon, CloseIcon, ConfigureTabIcon, SearchIcon } from '@univerjs/icons';
import { IDialogService, MobileKeyboardInsetContext, useDependency, useObservable } from '@univerjs/ui';
import { useContext, useEffect, useRef, useState } from 'react';
import { ReplaceAllMatchesCommand, ReplaceCurrentMatchCommand } from '../../commands/commands/replace.command';
import {
CloseFindDialogOperation,
GoToNextMatchOperation,
GoToPreviousMatchOperation,
} from '../../commands/operations/find-replace.operation';
import {
MOBILE_FIND_REPLACE_SETTINGS_COMPONENT,
MOBILE_FIND_REPLACE_SETTINGS_DIALOG_ID,
} from '../../const';
import { IFindReplaceService } from '../../services/find-replace.service';
const SEARCH_DEBOUNCE_TIME = 500;
export function MobileFindReplaceBar() {
const findReplaceService = useDependency(IFindReplaceService);
useObservable(findReplaceService.state$, undefined, true);
if (!findReplaceService.revealed) {
return null;
}
return <MobileFindReplaceBarContent />;
}
function MobileFindReplaceBarContent() {
const keyboardInset = useContext(MobileKeyboardInsetContext);
const commandService = useDependency(ICommandService);
const dialogService = useDependency(IDialogService);
const findReplaceService = useDependency(IFindReplaceService);
const localeService = useDependency(LocaleService);
const state = useObservable(findReplaceService.state$, undefined, true);
const currentMatch = useObservable(findReplaceService.currentMatch$, null, true);
const replaceables = useObservable(findReplaceService.replaceables$, [], true);
const [findValue, setFindValue] = useState(() =>
state.replaceRevealed ? state.inputtingFindString : state.findString);
const inputRef = useRef<HTMLInputElement>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suppressNextFocusRef = useRef(false);
useEffect(() => {
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}, []);
useEffect(() => {
const subscription = findReplaceService.focusSignal$.subscribe(() => {
if (suppressNextFocusRef.current) {
suppressNextFocusRef.current = false;
return;
}
inputRef.current?.focus();
});
return () => subscription.unsubscribe();
}, [findReplaceService]);
useEffect(() => () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
}, []);
function clearSearchTimer(): void {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
searchTimerRef.current = null;
}
}
function commitSearch(value: string): void {
clearSearchTimer();
if (!value) {
findReplaceService.changeInputtingFindString('');
return;
}
if (findReplaceService.replaceRevealed) {
findReplaceService.changeInputtingFindString(value);
findReplaceService.changeFindString(value);
findReplaceService.find();
return;
}
findReplaceService.changeFindString(value);
}
function scheduleSearch(value: string): void {
clearSearchTimer();
if (!value) {
commitSearch(value);
return;
}
searchTimerRef.current = setTimeout(() => commitSearch(value), SEARCH_DEBOUNCE_TIME);
}
function blurActiveElement(): void {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
function executeAfterBlur(commandId: string, suppressFocusSignal = false): void {
blurActiveElement();
if (findValue !== state.findString) {
commitSearch(findValue);
if (commandId === GoToNextMatchOperation.id || commandId === GoToPreviousMatchOperation.id) {
return;
}
}
suppressNextFocusRef.current = suppressFocusSignal;
const execution = commandService.executeCommand(commandId);
suppressNextFocusRef.current = false;
execution.catch(() => undefined);
}
function openSettings(): void {
commitSearch(findValue);
blurActiveElement();
dialogService.open({
id: MOBILE_FIND_REPLACE_SETTINGS_DIALOG_ID,
title: { title: localeService.t<LocaleKey>('find-replace.mobile.settings-title') },
children: { label: MOBILE_FIND_REPLACE_SETTINGS_COMPONENT },
});
}
const noResult = state.findCompleted && state.matchesCount === 0;
const resultText = noResult
? localeService.t<LocaleKey>('find-replace.dialog.no-result')
: state.matchesCount > 0 ? `${state.matchesPosition}/${state.matchesCount}` : undefined;
const navigationDisabled = state.matchesCount === 0;
const replaceDisabled = state.matchesCount === 0 || !currentMatch?.replaceable;
const replaceAllDisabled = replaceables.length === 0;
return (
<div
data-u-comp="mobile-find-replace-bar"
className="
univer-fixed univer-inset-x-0 univer-z-40 univer-grid univer-gap-2 univer-bg-gray-0 univer-px-2
univer-pt-1.5 univer-shadow-[0_-4px_16px_rgba(0,0,0,0.08)]
dark:!univer-bg-gray-800
"
style={{
bottom: keyboardInset,
paddingBottom: 'calc(6px + env(safe-area-inset-bottom, 0px))',
}}
>
<div className="univer-flex">
<IconButton
label={localeService.t<LocaleKey>('find-replace.mobile.settings')}
onClick={openSettings}
>
<ConfigureTabIcon />
</IconButton>
<div className="univer-relative univer-mr-2 univer-min-w-0 univer-flex-1">
<SearchIcon
className="
univer-pointer-events-none univer-absolute univer-left-3 univer-top-1/2 univer-z-10
univer-size-5 -univer-translate-y-1/2 univer-text-gray-500
"
/>
<Input
ref={inputRef}
size="middle"
allowClear
value={findValue}
placeholder={localeService.t<LocaleKey>('find-replace.dialog.find-placeholder')}
inputClass="!univer-pl-10"
slot={resultText
? <span className="univer-text-xs univer-text-gray-400">{resultText}</span>
: undefined}
onChange={(value) => {
setFindValue(value);
scheduleSearch(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
executeAfterBlur(GoToNextMatchOperation.id);
}
}}
/>
</div>
<IconButton
disabled={navigationDisabled}
label={localeService.t<LocaleKey>('find-replace.mobile.previous-match')}
onClick={() => executeAfterBlur(GoToPreviousMatchOperation.id, true)}
>
<ArrowUpIcon />
</IconButton>
<IconButton
disabled={navigationDisabled}
label={localeService.t<LocaleKey>('find-replace.mobile.next-match')}
onClick={() => executeAfterBlur(GoToNextMatchOperation.id, true)}
>
<ArrowDownIcon />
</IconButton>
<IconButton
label={localeService.t<LocaleKey>('find-replace.mobile.close')}
onClick={() => {
blurActiveElement();
commandService.executeCommand(CloseFindDialogOperation.id).catch(() => undefined);
}}
>
<CloseIcon />
</IconButton>
</div>
{state.replaceRevealed && (
<div className="univer-flex univer-gap-1 univer-pl-9">
<Input
size="middle"
className="univer-min-w-0 univer-flex-1"
value={state.replaceString}
placeholder={localeService.t<LocaleKey>('find-replace.dialog.replace-placeholder')}
onChange={(value) => findReplaceService.changeReplaceString(value)}
/>
<ActionButton
disabled={replaceDisabled}
onClick={() => executeAfterBlur(ReplaceCurrentMatchCommand.id)}
>
{localeService.t<LocaleKey>('find-replace.dialog.replace')}
</ActionButton>
<ActionButton
disabled={replaceAllDisabled}
onClick={() => executeAfterBlur(ReplaceAllMatchesCommand.id)}
>
{localeService.t<LocaleKey>('find-replace.dialog.replace-all')}
</ActionButton>
</div>
)}
</div>
);
}
interface IIconButtonProps {
children: ReactNode;
disabled?: boolean;
label: string;
onClick: () => void;
}
function IconButton(props: IIconButtonProps) {
return (
<button
type="button"
aria-label={props.label}
disabled={props.disabled}
className={clsx(resetButtonClassName, `
univer-flex univer-size-9 univer-flex-none univer-items-center univer-justify-center univer-rounded-full
univer-text-lg univer-text-gray-700
active:univer-bg-gray-100
disabled:univer-opacity-35
dark:!univer-text-gray-200
dark:active:!univer-bg-gray-700
`)}
onClick={props.onClick}
>
{props.children}
</button>
);
}
interface IActionButtonProps {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}
function ActionButton(props: IActionButtonProps) {
return (
<button
type="button"
disabled={props.disabled}
className={clsx(resetButtonClassName, `
univer-h-10 univer-flex-none univer-whitespace-nowrap univer-rounded-lg univer-bg-primary-600 univer-px-3
univer-text-sm univer-font-medium univer-text-gray-0
active:univer-bg-primary-700
disabled:univer-bg-gray-100 disabled:univer-text-gray-400
dark:disabled:!univer-bg-gray-700 dark:disabled:!univer-text-gray-500
`)}
onClick={props.onClick}
>
{props.children}
</button>
);
}

View File

@@ -0,0 +1,179 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { Checkbox, MobileSelect } from '@univerjs/design';
import { useDependency, useObservable } from '@univerjs/ui';
import { FindBy, FindDirection, FindScope, IFindReplaceService } from '../../services/find-replace.service';
const FIND_SCOPE_OPTIONS: Array<{ label: LocaleKey; value: FindScope }> = [
{ label: 'find-replace.dialog.find-scope.current-sheet', value: FindScope.SUBUNIT },
{ label: 'find-replace.dialog.find-scope.workbook', value: FindScope.UNIT },
];
const FIND_DIRECTION_OPTIONS: Array<{ label: LocaleKey; value: FindDirection }> = [
{ label: 'find-replace.dialog.find-direction.row', value: FindDirection.ROW },
{ label: 'find-replace.dialog.find-direction.column', value: FindDirection.COLUMN },
];
const FIND_BY_OPTIONS: Array<{ label: LocaleKey; value: FindBy }> = [
{ label: 'find-replace.dialog.find-by.value', value: FindBy.VALUE },
{ label: 'find-replace.dialog.find-by.formula', value: FindBy.FORMULA },
];
export function MobileFindReplaceSettings() {
const findReplaceService = useDependency(IFindReplaceService);
const localeService = useDependency(LocaleService);
const state = useObservable(findReplaceService.state$, undefined, true);
const capabilities = useObservable(findReplaceService.providerCapabilities$, null, true);
function refreshSearch(force = false): void {
if (state.findString && (force || state.replaceRevealed)) {
findReplaceService.find();
}
}
const findScopeOptions = FIND_SCOPE_OPTIONS.map((option) => ({
...option,
label: localeService.t<LocaleKey>(option.label),
}));
const findDirectionOptions = FIND_DIRECTION_OPTIONS.map((option) => ({
...option,
label: localeService.t<LocaleKey>(option.label),
}));
const findByOptions = FIND_BY_OPTIONS.map((option) => ({
...option,
label: localeService.t<LocaleKey>(option.label),
}));
return (
<div className="univer-grid univer-gap-2" data-u-comp="mobile-find-replace-settings">
<SettingCheckbox
checked={state.replaceRevealed}
label={localeService.t<LocaleKey>('find-replace.dialog.replace')}
onChange={(checked) => {
if (checked) {
findReplaceService.revealReplace();
} else {
findReplaceService.hideReplace();
}
refreshSearch(true);
}}
/>
{capabilities?.findDirection && (
<SettingSelect
label={localeService.t<LocaleKey>('find-replace.dialog.find-direction.title')}
value={state.findDirection}
options={findDirectionOptions}
onChange={(value) => {
findReplaceService.changeFindDirection(value as FindDirection);
refreshSearch();
}}
/>
)}
{capabilities?.findScope && (
<SettingSelect
label={localeService.t<LocaleKey>('find-replace.dialog.find-scope.title')}
value={state.findScope}
options={findScopeOptions}
onChange={(value) => {
findReplaceService.changeFindScope(value as FindScope);
refreshSearch();
}}
/>
)}
{capabilities?.findBy && (
<SettingSelect
label={localeService.t<LocaleKey>('find-replace.dialog.find-by.title')}
value={state.findBy}
options={findByOptions}
onChange={(value) => {
findReplaceService.changeFindBy(value as FindBy);
refreshSearch();
}}
/>
)}
{capabilities?.caseSensitive && (
<SettingCheckbox
checked={state.caseSensitive}
label={localeService.t<LocaleKey>('find-replace.dialog.case-sensitive')}
onChange={(checked) => {
findReplaceService.changeCaseSensitive(checked);
refreshSearch();
}}
/>
)}
{capabilities?.matchesTheWholeCell && (
<SettingCheckbox
checked={state.matchesTheWholeCell}
label={localeService.t<LocaleKey>('find-replace.dialog.match-the-whole-cell')}
onChange={(checked) => {
findReplaceService.changeMatchesTheWholeCell(checked);
refreshSearch();
}}
/>
)}
{capabilities?.matchesTheWholeWord && (
<SettingCheckbox
checked={state.matchesTheWholeWord}
label={localeService.t<LocaleKey>('find-replace.dialog.match-the-whole-word')}
onChange={(checked) => {
findReplaceService.changeMatchesTheWholeWord(checked);
refreshSearch();
}}
/>
)}
</div>
);
}
interface ISettingSelectProps {
label: string;
value: string;
options: Array<{ label: string; value: string }>;
onChange: (value: string) => void;
}
function SettingSelect(props: ISettingSelectProps) {
return (
<label
className="
univer-grid univer-gap-1 univer-text-sm univer-text-gray-700
dark:!univer-text-gray-300
"
>
<span>{props.label}</span>
<MobileSelect value={props.value} options={props.options} onChange={props.onChange} />
</label>
);
}
interface ISettingCheckboxProps {
checked: boolean;
label: string;
onChange: (checked: boolean) => void;
}
function SettingCheckbox(props: ISettingCheckboxProps) {
return (
<div className="univer-flex univer-min-h-9 univer-items-center">
<Checkbox checked={props.checked} onChange={(checked) => props.onChange(Boolean(checked))}>
{props.label}
</Checkbox>
</div>
);
}

View File

@@ -0,0 +1,27 @@
/**
* 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 { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

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

View File

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

View File

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