snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
27
packages/docs-find-replace/README.md
Normal file
27
packages/docs-find-replace/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @univerjs/docs-find-replace
|
||||
|
||||
```bash
|
||||
pnpm add @univerjs/docs-find-replace @univerjs/find-replace
|
||||
```
|
||||
|
||||
```ts
|
||||
import { LocaleType, mergeLocales, Univer } from '@univerjs/core';
|
||||
import { UniverDocsFindReplacePlugin } from '@univerjs/docs-find-replace';
|
||||
import { UniverFindReplacePlugin } from '@univerjs/find-replace';
|
||||
import FindReplaceEnUS from '@univerjs/find-replace/locale/en-US';
|
||||
import '@univerjs/find-replace/lib/index.css';
|
||||
|
||||
const univer = new Univer({
|
||||
locale: LocaleType.EN_US,
|
||||
locales: {
|
||||
[LocaleType.EN_US]: mergeLocales(FindReplaceEnUS),
|
||||
},
|
||||
});
|
||||
|
||||
univer.registerPlugin(UniverFindReplacePlugin);
|
||||
univer.registerPlugin(UniverDocsFindReplacePlugin);
|
||||
```
|
||||
|
||||
Register the shared find-and-replace plugin before the Docs adapter so it can provide the common UI, locale strings, and services. Configure the rest of the Docs plugin stack before adding these two plugins.
|
||||
|
||||
Phase one searches and replaces literal text in the current document body, including tables. It excludes headers, footers, comments, drawings, regular expressions, formatting search, special characters, result sidebars, presets, and Facade APIs.
|
||||
84
packages/docs-find-replace/package.json
Normal file
84
packages/docs-find-replace/package.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "@univerjs/docs-find-replace",
|
||||
"version": "1.0.0-rc.0",
|
||||
"private": false,
|
||||
"description": "Find and replace support for Univer Docs.",
|
||||
"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",
|
||||
"docs",
|
||||
"find-replace",
|
||||
"plugin"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"./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/docs": "workspace:*",
|
||||
"@univerjs/docs-ui": "workspace:*",
|
||||
"@univerjs/engine-render": "workspace:*",
|
||||
"@univerjs/find-replace": "workspace:*",
|
||||
"@univerjs/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@univerjs-infra/shared": "workspace:*",
|
||||
"react": "18.3.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import type { IFindQuery } from '@univerjs/find-replace';
|
||||
import { BooleanNumber, CustomRangeType, ICommandService, UndoCommandId } from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { createCommandTestBed } from '@univerjs/docs-ui/commands/commands/__tests__/create-command-test-bed';
|
||||
import { FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DocsReplaceCommand } from '../docs-replace.command';
|
||||
|
||||
function docsQuery(findString: string, overrides: Partial<IFindQuery> = {}): IFindQuery {
|
||||
return {
|
||||
findString,
|
||||
replaceRevealed: true,
|
||||
caseSensitive: false,
|
||||
matchesTheWholeWord: false,
|
||||
matchesTheWholeCell: false,
|
||||
findDirection: FindDirection.ROW,
|
||||
findScope: FindScope.SUBUNIT,
|
||||
findBy: FindBy.VALUE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function styledDocument(dataStream: string): IDocumentData {
|
||||
return {
|
||||
id: 'test-doc',
|
||||
body: {
|
||||
dataStream,
|
||||
textRuns: [{ st: 0, ed: dataStream.length - 2, ts: { bl: BooleanNumber.TRUE } }],
|
||||
},
|
||||
documentStyle: {},
|
||||
};
|
||||
}
|
||||
|
||||
function registerCommands(commandService: ICommandService): void {
|
||||
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
|
||||
commandService.registerCommand(DocsReplaceCommand);
|
||||
}
|
||||
|
||||
describe('DocsReplaceCommand', () => {
|
||||
it('replaces all matches in one undoable mutation and keeps the first style', async () => {
|
||||
const testBed = createCommandTestBed(styledDocument('cat cat\r\n'));
|
||||
const commandService = testBed.get(ICommandService);
|
||||
registerCommands(commandService);
|
||||
|
||||
await expect(commandService.executeCommand(DocsReplaceCommand.id, {
|
||||
unitId: 'test-doc',
|
||||
query: docsQuery('cat'),
|
||||
replaceString: 'dog',
|
||||
})).resolves.toEqual({ success: 2, failure: 0 });
|
||||
expect(testBed.doc.getBody()?.dataStream).toBe('dog dog\r\n');
|
||||
expect(testBed.doc.getBody()?.textRuns?.[0].ts?.bl).toBe(BooleanNumber.TRUE);
|
||||
|
||||
await commandService.executeCommand(UndoCommandId);
|
||||
expect(testBed.doc.getBody()?.dataStream).toBe('cat cat\r\n');
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
|
||||
it('skips whole entities and rejects a stale current range', async () => {
|
||||
const testBed = createCommandTestBed({
|
||||
id: 'test-doc',
|
||||
body: {
|
||||
dataStream: 'cat @cat\r\n',
|
||||
customRanges: [{
|
||||
startIndex: 4,
|
||||
endIndex: 7,
|
||||
rangeId: 'mention-1',
|
||||
rangeType: CustomRangeType.MENTION,
|
||||
wholeEntity: true,
|
||||
}],
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
const commandService = testBed.get(ICommandService);
|
||||
registerCommands(commandService);
|
||||
|
||||
await expect(commandService.executeCommand(DocsReplaceCommand.id, {
|
||||
unitId: 'test-doc',
|
||||
query: docsQuery('cat'),
|
||||
replaceString: 'dog',
|
||||
})).resolves.toEqual({ success: 1, failure: 1 });
|
||||
await expect(commandService.executeCommand(DocsReplaceCommand.id, {
|
||||
unitId: 'test-doc',
|
||||
query: docsQuery('cat'),
|
||||
replaceString: 'dog',
|
||||
range: { startOffset: 20, endOffset: 23, collapsed: false },
|
||||
})).resolves.toEqual({ success: 0, failure: 1 });
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
|
||||
it('supports deletion', async () => {
|
||||
const testBed = createCommandTestBed(styledDocument('cat dog\r\n'));
|
||||
const commandService = testBed.get(ICommandService);
|
||||
registerCommands(commandService);
|
||||
|
||||
await expect(commandService.executeCommand(DocsReplaceCommand.id, {
|
||||
unitId: 'test-doc',
|
||||
query: docsQuery('cat'),
|
||||
replaceString: '',
|
||||
})).resolves.toEqual({ success: 1, failure: 0 });
|
||||
expect(testBed.doc.getBody()?.dataStream).toBe(' dog\r\n');
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
|
||||
it('does not replace text in a disabled document', async () => {
|
||||
const snapshot = styledDocument('cat\r\n');
|
||||
snapshot.disabled = true;
|
||||
const testBed = createCommandTestBed(snapshot);
|
||||
const commandService = testBed.get(ICommandService);
|
||||
registerCommands(commandService);
|
||||
|
||||
await expect(commandService.executeCommand(DocsReplaceCommand.id, {
|
||||
unitId: 'test-doc',
|
||||
query: docsQuery('cat'),
|
||||
replaceString: 'dog',
|
||||
})).resolves.toEqual({ success: 0, failure: 1 });
|
||||
expect(testBed.doc.getBody()?.dataStream).toBe('cat\r\n');
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
});
|
||||
10
packages/docs-find-replace/src/commands/commands/__tests__/docs-ui-test-bed.d.ts
vendored
Normal file
10
packages/docs-find-replace/src/commands/commands/__tests__/docs-ui-test-bed.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
declare module '@univerjs/docs-ui/commands/commands/__tests__/create-command-test-bed' {
|
||||
import type { Dependency, DocumentDataModel, IDocumentData, Injector, Univer } from '@univerjs/core';
|
||||
|
||||
export function createCommandTestBed(docData?: IDocumentData, dependencies?: Dependency[]): {
|
||||
univer: Univer;
|
||||
get: Injector['get'];
|
||||
doc: DocumentDataModel;
|
||||
injector: Injector;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 { DocumentDataModel, ICommand, IDocumentBody, ITextRange } from '@univerjs/core';
|
||||
import type { IFindQuery, IReplaceAllResult } from '@univerjs/find-replace';
|
||||
import {
|
||||
BuildTextUtils,
|
||||
CommandType,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
MemoryCursor,
|
||||
TextX,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { findDocRanges } from '../../controllers/utils';
|
||||
|
||||
export interface IDocsReplaceCommandParams {
|
||||
unitId: string;
|
||||
query: IFindQuery;
|
||||
replaceString: string;
|
||||
range?: ITextRange;
|
||||
}
|
||||
|
||||
export const DocsReplaceCommand: ICommand<IDocsReplaceCommandParams, IReplaceAllResult> = {
|
||||
id: 'docs.command.replace',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params) return { success: 0, failure: 0 };
|
||||
const instanceService = accessor.get(IUniverInstanceService);
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const doc = instanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const body = doc?.getBody();
|
||||
if (!doc || !body) return { success: 0, failure: params.range ? 1 : 0 };
|
||||
|
||||
const matches = findDocRanges(body, params.query, !!doc.getSnapshot().disabled);
|
||||
const candidates = params.range
|
||||
? matches.filter((match) => match.startOffset === params.range!.startOffset && match.endOffset === params.range!.endOffset)
|
||||
: matches;
|
||||
const replaceable = candidates.filter((match) => match.replaceable);
|
||||
const failure = (params.range && candidates.length === 0 ? 1 : candidates.length) - replaceable.length;
|
||||
if (!replaceable.length) return { success: 0, failure };
|
||||
|
||||
const textX = new TextX();
|
||||
const memoryCursor = new MemoryCursor();
|
||||
for (const match of replaceable) {
|
||||
const slice = doc.sliceBody(match.startOffset, match.endOffset);
|
||||
const insertBody: IDocumentBody | null = params.replaceString
|
||||
? {
|
||||
dataStream: params.replaceString,
|
||||
textRuns: slice?.textRuns?.length
|
||||
? [{ ...slice.textRuns[0], st: 0, ed: params.replaceString.length }]
|
||||
: undefined,
|
||||
customRanges: slice?.customRanges?.length
|
||||
? [{ ...slice.customRanges[0], startIndex: 0, endIndex: params.replaceString.length - 1 }]
|
||||
: undefined,
|
||||
}
|
||||
: null;
|
||||
textX.push(...BuildTextUtils.selection.delete([{
|
||||
startOffset: match.startOffset,
|
||||
endOffset: match.endOffset,
|
||||
collapsed: false,
|
||||
}], body, memoryCursor.cursor, insertBody, false));
|
||||
memoryCursor.moveCursorTo(match.endOffset);
|
||||
}
|
||||
|
||||
const actions = JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(doc));
|
||||
const result = commandService.syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: params.unitId,
|
||||
actions,
|
||||
textRanges: [],
|
||||
});
|
||||
return result
|
||||
? { success: replaceable.length, failure }
|
||||
: { success: 0, failure: failure + replaceable.length };
|
||||
},
|
||||
};
|
||||
24
packages/docs-find-replace/src/config/config.ts
Normal file
24
packages/docs-find-replace/src/config/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const DOCS_FIND_REPLACE_PLUGIN_CONFIG_KEY = 'docs-find-replace.config';
|
||||
|
||||
export const configSymbol = Symbol(DOCS_FIND_REPLACE_PLUGIN_CONFIG_KEY);
|
||||
|
||||
export interface IUniverDocsFindReplaceConfig {
|
||||
}
|
||||
|
||||
export const defaultPluginConfig: IUniverDocsFindReplaceConfig = {};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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, toDisposable } from '@univerjs/core';
|
||||
import { createCommandTestBed } from '@univerjs/docs-ui/commands/commands/__tests__/create-command-test-bed';
|
||||
import { IFindReplaceService } from '@univerjs/find-replace';
|
||||
import { IMenuManagerService } from '@univerjs/ui';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DocsReplaceCommand } from '../../commands/commands/docs-replace.command';
|
||||
import { menuSchema } from '../../menu/schema';
|
||||
import { DocsFindReplaceProvider } from '../../services/docs-find-replace.provider';
|
||||
import { DocsFindReplaceController } from '../docs-find-replace.controller';
|
||||
|
||||
describe('DocsFindReplaceController', () => {
|
||||
it('registers and disposes the provider, command, and menu', () => {
|
||||
const providerDisposed = vi.fn();
|
||||
const registerProvider = vi.fn(() => toDisposable(providerDisposed));
|
||||
const mergeMenu = vi.fn();
|
||||
const testBed = createCommandTestBed(undefined, [
|
||||
[IFindReplaceService, { useValue: { registerFindReplaceProvider: registerProvider } as unknown as IFindReplaceService }],
|
||||
[IMenuManagerService, { useValue: { mergeMenu } as unknown as IMenuManagerService }],
|
||||
]);
|
||||
testBed.injector.add([DocsFindReplaceProvider]);
|
||||
const commandService = testBed.get(ICommandService);
|
||||
const registerCommand = vi.spyOn(commandService, 'registerCommand');
|
||||
|
||||
const controller = testBed.injector.createInstance(DocsFindReplaceController);
|
||||
expect(registerProvider).toHaveBeenCalledWith(testBed.injector.get(DocsFindReplaceProvider));
|
||||
expect(registerCommand).toHaveBeenCalledWith(DocsReplaceCommand);
|
||||
expect(mergeMenu).toHaveBeenCalledWith(menuSchema);
|
||||
|
||||
controller.dispose();
|
||||
expect(providerDisposed).toHaveBeenCalledOnce();
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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 { IDocumentBody } from '@univerjs/core';
|
||||
import type { IFindQuery } from '@univerjs/find-replace';
|
||||
import { CustomRangeType, DataStreamTreeTokenType } from '@univerjs/core';
|
||||
import { FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findDocRanges } from '../utils';
|
||||
|
||||
function query(findString: string, overrides: Partial<IFindQuery> = {}): IFindQuery {
|
||||
return {
|
||||
findString,
|
||||
replaceRevealed: true,
|
||||
caseSensitive: false,
|
||||
matchesTheWholeWord: false,
|
||||
matchesTheWholeCell: false,
|
||||
findDirection: FindDirection.ROW,
|
||||
findScope: FindScope.SUBUNIT,
|
||||
findBy: FindBy.VALUE,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('findDocRanges', () => {
|
||||
it('matches literal text and respects case', () => {
|
||||
const body = { dataStream: 'Apple a.b apple\r\n' } as IDocumentBody;
|
||||
expect(findDocRanges(body, query('a.b'), false)).toEqual([
|
||||
{ startOffset: 6, endOffset: 9, replaceable: true },
|
||||
]);
|
||||
expect(findDocRanges(body, query('apple', { caseSensitive: true }), false)).toEqual([
|
||||
{ startOffset: 10, endOffset: 15, replaceable: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses Docs word segmentation for English and Chinese', () => {
|
||||
expect(findDocRanges({ dataStream: 'cat scatter\r\n' }, query('cat', { matchesTheWholeWord: true }), false)).toHaveLength(1);
|
||||
expect(findDocRanges({ dataStream: '中文测试\r\n' }, query('中文', { matchesTheWholeWord: true }), false)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('finds table text and marks whole entities non-replaceable', () => {
|
||||
const body: IDocumentBody = {
|
||||
dataStream: `${DataStreamTreeTokenType.TABLE_START}${DataStreamTreeTokenType.TABLE_ROW_START}${DataStreamTreeTokenType.TABLE_CELL_START}target\r\n`,
|
||||
customRanges: [{
|
||||
startIndex: 3,
|
||||
endIndex: 8,
|
||||
rangeId: 'mention',
|
||||
rangeType: CustomRangeType.MENTION,
|
||||
wholeEntity: true,
|
||||
}],
|
||||
};
|
||||
|
||||
expect(findDocRanges(body, query('target'), false)).toEqual([
|
||||
{ startOffset: 3, endOffset: 9, replaceable: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks every match non-replaceable in a disabled document', () => {
|
||||
expect(findDocRanges({ dataStream: 'cat cat\r\n' }, query('cat'), true)).toEqual([
|
||||
{ startOffset: 0, endOffset: 3, replaceable: false },
|
||||
{ startOffset: 4, endOffset: 7, replaceable: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps matches in consumer-facing replacement text back to the native entity', () => {
|
||||
const body: IDocumentBody = {
|
||||
dataStream: 'A\uFFFCC\r\n',
|
||||
customRanges: [{
|
||||
startIndex: 1,
|
||||
endIndex: 1,
|
||||
rangeId: 'formula-1',
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
wholeEntity: true,
|
||||
}],
|
||||
};
|
||||
|
||||
expect(findDocRanges(body, query('42'), false, {
|
||||
text: 'A42C\r\n',
|
||||
characters: [
|
||||
{ startOffset: 0, endOffset: 1, replaceable: true },
|
||||
{ startOffset: 1, endOffset: 2, replaceable: false },
|
||||
{ startOffset: 1, endOffset: 2, replaceable: false },
|
||||
{ startOffset: 2, endOffset: 3, replaceable: true },
|
||||
{ startOffset: 3, endOffset: 4, replaceable: true },
|
||||
{ startOffset: 4, endOffset: 5, replaceable: true },
|
||||
],
|
||||
})).toEqual([
|
||||
{ startOffset: 1, endOffset: 2, replaceable: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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, ICommandService, Inject } from '@univerjs/core';
|
||||
import { IFindReplaceService } from '@univerjs/find-replace';
|
||||
import { IMenuManagerService } from '@univerjs/ui';
|
||||
import { DocsReplaceCommand } from '../commands/commands/docs-replace.command';
|
||||
import { menuSchema } from '../menu/schema';
|
||||
import { DocsFindReplaceProvider } from '../services/docs-find-replace.provider';
|
||||
|
||||
export class DocsFindReplaceController extends Disposable {
|
||||
constructor(
|
||||
@Inject(DocsFindReplaceProvider) provider: DocsFindReplaceProvider,
|
||||
@IFindReplaceService findReplaceService: IFindReplaceService,
|
||||
@ICommandService commandService: ICommandService,
|
||||
@IMenuManagerService menuManagerService: IMenuManagerService
|
||||
) {
|
||||
super();
|
||||
|
||||
this.disposeWithMe(provider);
|
||||
this.disposeWithMe(findReplaceService.registerFindReplaceProvider(provider));
|
||||
this.disposeWithMe(commandService.registerCommand(DocsReplaceCommand));
|
||||
menuManagerService.mergeMenu(menuSchema);
|
||||
}
|
||||
}
|
||||
75
packages/docs-find-replace/src/controllers/utils.ts
Normal file
75
packages/docs-find-replace/src/controllers/utils.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 { IDocumentBody } from '@univerjs/core';
|
||||
import type { IResolvedDocText } from '@univerjs/docs';
|
||||
import type { IFindQuery } from '@univerjs/find-replace';
|
||||
import { regexp } from '@univerjs/core';
|
||||
|
||||
export interface IDocFindRange {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
replaceable: boolean;
|
||||
}
|
||||
|
||||
export function findDocRanges(
|
||||
body: IDocumentBody,
|
||||
query: IFindQuery,
|
||||
disabled: boolean,
|
||||
resolved: IResolvedDocText = {
|
||||
text: body.dataStream,
|
||||
characters: Array.from({ length: body.dataStream.length }, (_value, index) => ({
|
||||
startOffset: index,
|
||||
endOffset: index + 1,
|
||||
replaceable: true,
|
||||
})),
|
||||
}
|
||||
): IDocFindRange[] {
|
||||
if (!query.findString) return [];
|
||||
|
||||
const expression = regexp.createLiteralRegExp(query.findString, query.caseSensitive ? 'gu' : 'giu');
|
||||
const wordRanges = query.matchesTheWholeWord
|
||||
? new Set(Array.from(new Intl.Segmenter(undefined, { granularity: 'word' }).segment(resolved.text))
|
||||
.filter((item) => item.isWordLike)
|
||||
.map((item) => `${item.index}:${item.index + item.segment.length}`))
|
||||
: null;
|
||||
return Array.from(resolved.text.matchAll(expression))
|
||||
.filter((match) => {
|
||||
return wordRanges?.has(`${match.index}:${match.index + match[0].length}`) ?? true;
|
||||
})
|
||||
.flatMap((match) => {
|
||||
const characters = resolved.characters.slice(
|
||||
match.index,
|
||||
match.index + match[0].length
|
||||
);
|
||||
if (!characters.length) {
|
||||
return [];
|
||||
}
|
||||
const startOffset = Math.min(...characters.map((character) => character.startOffset));
|
||||
const endOffset = Math.max(...characters.map((character) => character.endOffset));
|
||||
const overlapsWholeEntity = body.customRanges?.some((range) =>
|
||||
range.wholeEntity && range.startIndex < endOffset && range.endIndex + 1 > startOffset
|
||||
) ?? false;
|
||||
|
||||
return [{
|
||||
startOffset,
|
||||
endOffset,
|
||||
replaceable: !disabled &&
|
||||
!overlapsWholeEntity &&
|
||||
characters.every((character) => character.replaceable),
|
||||
}];
|
||||
});
|
||||
}
|
||||
58
packages/docs-find-replace/src/index.ts
Normal file
58
packages/docs-find-replace/src/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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 { IUniverDocsFindReplaceConfig } from './config/config';
|
||||
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
|
||||
import { UniverDocsPlugin } from '@univerjs/docs';
|
||||
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
|
||||
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
|
||||
import { UniverFindReplacePlugin } from '@univerjs/find-replace';
|
||||
import pkg from '../package.json';
|
||||
import { defaultPluginConfig, DOCS_FIND_REPLACE_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { DocsFindReplaceController } from './controllers/docs-find-replace.controller';
|
||||
import { DocsFindReplaceProvider } from './services/docs-find-replace.provider';
|
||||
|
||||
@DependentOn(UniverDocsPlugin, UniverRenderEnginePlugin, UniverDocsUIPlugin, UniverFindReplacePlugin)
|
||||
export class UniverDocsFindReplacePlugin extends Plugin {
|
||||
static override pluginName = 'DOCS_FIND_REPLACE_PLUGIN';
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
static override type = UniverInstanceType.UNIVER_DOC;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverDocsFindReplaceConfig> = defaultPluginConfig,
|
||||
@Inject(Injector) protected override readonly _injector: Injector,
|
||||
@IConfigService private readonly _configService: IConfigService
|
||||
) {
|
||||
super();
|
||||
|
||||
const { ...rest } = merge(
|
||||
{},
|
||||
defaultPluginConfig,
|
||||
this._config
|
||||
);
|
||||
this._configService.setConfig(DOCS_FIND_REPLACE_PLUGIN_CONFIG_KEY, rest);
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
this._injector.add([DocsFindReplaceProvider]);
|
||||
this._injector.add([DocsFindReplaceController]);
|
||||
}
|
||||
|
||||
override onSteady(): void {
|
||||
this._injector.get(DocsFindReplaceController);
|
||||
}
|
||||
}
|
||||
33
packages/docs-find-replace/src/menu/find-replace.menu.ts
Normal file
33
packages/docs-find-replace/src/menu/find-replace.menu.ts
Normal 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 type { IAccessor } from '@univerjs/core';
|
||||
import type { IMenuButtonItem } from '@univerjs/ui';
|
||||
import { FOCUSING_DOC, IContextService } from '@univerjs/core';
|
||||
import { OpenFindDialogOperation } from '@univerjs/find-replace';
|
||||
import { MenuItemType } from '@univerjs/ui';
|
||||
import { map } from 'rxjs';
|
||||
|
||||
export function DocsFindReplaceMenuItemFactory(accessor: IAccessor): IMenuButtonItem {
|
||||
const contextService = accessor.get(IContextService);
|
||||
return {
|
||||
id: OpenFindDialogOperation.id,
|
||||
icon: 'SearchIcon',
|
||||
tooltip: 'find-replace.toolbar',
|
||||
type: MenuItemType.BUTTON,
|
||||
disabled$: contextService.subscribeContextValue$(FOCUSING_DOC).pipe(map((focused) => !focused)),
|
||||
};
|
||||
}
|
||||
29
packages/docs-find-replace/src/menu/schema.ts
Normal file
29
packages/docs-find-replace/src/menu/schema.ts
Normal 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 { OpenFindDialogOperation } from '@univerjs/find-replace';
|
||||
import { RibbonStartGroup } from '@univerjs/ui';
|
||||
import { DocsFindReplaceMenuItemFactory } from './find-replace.menu';
|
||||
|
||||
export const menuSchema: MenuSchemaType = {
|
||||
[RibbonStartGroup.OTHERS]: {
|
||||
[OpenFindDialogOperation.id]: {
|
||||
order: 3,
|
||||
menuItemFactory: DocsFindReplaceMenuItemFactory,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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 { Documents, DocumentSkeleton, ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { IFindQuery } from '@univerjs/find-replace';
|
||||
import { ICommandService } from '@univerjs/core';
|
||||
import { DocSelectionManagerService, DocSkeletonManagerService, DocTextResolverService, RichTextEditingMutation } from '@univerjs/docs';
|
||||
import { DocBackScrollRenderController, getTextRangeFromCharIndex } from '@univerjs/docs-ui';
|
||||
import { createCommandTestBed } from '@univerjs/docs-ui/commands/commands/__tests__/create-command-test-bed';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocsReplaceCommand } from '../../commands/commands/docs-replace.command';
|
||||
import { DocsFindModel } from '../docs-find.model';
|
||||
|
||||
const getTextRangeFromCharIndexMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@univerjs/docs-ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@univerjs/docs-ui')>();
|
||||
return { ...actual, getTextRangeFromCharIndex: getTextRangeFromCharIndexMock };
|
||||
});
|
||||
|
||||
function docsQuery(findString: string): IFindQuery {
|
||||
return {
|
||||
findString,
|
||||
replaceRevealed: true,
|
||||
caseSensitive: false,
|
||||
matchesTheWholeWord: false,
|
||||
matchesTheWholeCell: false,
|
||||
findDirection: FindDirection.ROW,
|
||||
findScope: FindScope.SUBUNIT,
|
||||
findBy: FindBy.VALUE,
|
||||
};
|
||||
}
|
||||
|
||||
function activeRange(startOffset: number, endOffset: number): ITextRangeWithStyle {
|
||||
return { startOffset, endOffset, collapsed: startOffset === endOffset, isActive: true, segmentId: '' };
|
||||
}
|
||||
|
||||
function createModelTestBed(dataStream = 'cat xx cat\r\n') {
|
||||
const scrollToRange = vi.fn();
|
||||
const testBed = createCommandTestBed(
|
||||
{ id: 'test-doc', body: { dataStream }, documentStyle: {} },
|
||||
[
|
||||
[DocBackScrollRenderController, { useValue: { scrollToRange } }],
|
||||
[DocTextResolverService],
|
||||
]
|
||||
);
|
||||
const commandService = testBed.get(ICommandService);
|
||||
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
|
||||
commandService.registerCommand(DocsReplaceCommand);
|
||||
const selectionManager = testBed.get(DocSelectionManagerService);
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId: 'test-doc', subUnitId: 'test-doc' });
|
||||
const skeletonManager = testBed.get(DocSkeletonManagerService);
|
||||
vi.spyOn(skeletonManager, 'getSkeleton').mockReturnValue(null as never);
|
||||
const model = testBed.injector.createInstance(DocsFindModel, testBed.doc, skeletonManager);
|
||||
return { ...testBed, commandService, selectionManager, skeletonManager, scrollToRange, model };
|
||||
}
|
||||
|
||||
describe('DocsFindModel', () => {
|
||||
beforeEach(() => {
|
||||
getTextRangeFromCharIndexMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts from the cursor and honors movement flags', () => {
|
||||
const { model, selectionManager, univer } = createModelTestBed();
|
||||
selectionManager.__TEST_ONLY_add([activeRange(5, 5)]);
|
||||
const replaceDocRanges = vi.spyOn(selectionManager, 'replaceDocRanges');
|
||||
model.start(docsQuery('cat'));
|
||||
|
||||
expect(model.moveToNextMatch({ stayIfOnMatch: true, noFocus: true })?.range.startOffset).toBe(7);
|
||||
expect(model.moveToNextMatch({ loop: true, noFocus: true })?.range.startOffset).toBe(0);
|
||||
expect(model.moveToPreviousMatch({ loop: true, noFocus: true })?.range.startOffset).toBe(7);
|
||||
expect(replaceDocRanges).not.toHaveBeenCalled();
|
||||
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('ignores the cursor when requested and stays on an exact selected match', () => {
|
||||
const { model, selectionManager, univer } = createModelTestBed();
|
||||
selectionManager.__TEST_ONLY_add([activeRange(5, 5)]);
|
||||
model.start(docsQuery('cat'));
|
||||
expect(model.moveToNextMatch({ ignoreSelection: true, noFocus: true })?.range.startOffset).toBe(0);
|
||||
|
||||
model.dispose();
|
||||
const second = createModelTestBed();
|
||||
second.selectionManager.__TEST_ONLY_add([activeRange(0, 3)]);
|
||||
second.model.start(docsQuery('cat'));
|
||||
expect(second.model.moveToNextMatch({ stayIfOnMatch: true, noFocus: true })?.range.startOffset).toBe(0);
|
||||
second.model.dispose();
|
||||
second.univer.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('selects and scrolls to the active match without focusing the document editor', () => {
|
||||
const { model, selectionManager, scrollToRange, univer } = createModelTestBed();
|
||||
const replaceDocRanges = vi.spyOn(selectionManager, 'replaceDocRanges');
|
||||
model.start(docsQuery('cat'));
|
||||
model.moveToNextMatch({ ignoreSelection: true, noFocus: true });
|
||||
model.focusSelection();
|
||||
|
||||
expect(replaceDocRanges).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ startOffset: 0, endOffset: 3 }),
|
||||
], { unitId: 'test-doc', subUnitId: 'test-doc' }, true, { shouldFocus: false });
|
||||
expect(scrollToRange).toHaveBeenCalledWith(expect.objectContaining({ startOffset: 0, endOffset: 3 }));
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('passes a range only for current replacement', async () => {
|
||||
const { model, commandService, univer } = createModelTestBed();
|
||||
const executeCommand = vi.spyOn(commandService, 'executeCommand');
|
||||
model.start(docsQuery('cat'));
|
||||
model.moveToNextMatch({ ignoreSelection: true, noFocus: true });
|
||||
|
||||
await expect(model.replace('dog')).resolves.toBe(true);
|
||||
expect(executeCommand).toHaveBeenCalledWith(DocsReplaceCommand.id, expect.objectContaining({
|
||||
range: expect.objectContaining({ startOffset: 0, endOffset: 3 }),
|
||||
}));
|
||||
await model.replaceAll('dog');
|
||||
expect(executeCommand).toHaveBeenLastCalledWith(DocsReplaceCommand.id, expect.not.objectContaining({ range: expect.anything() }));
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('rescans after a rich-text mutation', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { model, univer } = createModelTestBed();
|
||||
model.start(docsQuery('cat'));
|
||||
model.moveToNextMatch({ ignoreSelection: true, noFocus: true });
|
||||
const update = firstValueFrom(model.matchesUpdate$);
|
||||
|
||||
await expect(model.replace('dog')).resolves.toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(await update).toEqual(model.getMatches());
|
||||
expect(model.getMatches()).toHaveLength(1);
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('rescans when a consumer-facing text projection changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { get, model, univer } = createModelTestBed();
|
||||
model.start(docsQuery('cat'));
|
||||
const update = firstValueFrom(model.matchesUpdate$);
|
||||
|
||||
get(DocTextResolverService).notifyTextChanged('test-doc');
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(await update).toEqual(model.getMatches());
|
||||
model.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('disposes every TextRange highlight', () => {
|
||||
const { model, skeletonManager, get, univer } = createModelTestBed();
|
||||
vi.mocked(skeletonManager.getSkeleton).mockReturnValue({} as DocumentSkeleton);
|
||||
const render = get(IRenderManagerService).getRenderUnitById('test-doc')!;
|
||||
(render as unknown as { mainComponent: Documents }).mainComponent = {} as Documents;
|
||||
const first = { dispose: vi.fn() };
|
||||
const second = { dispose: vi.fn() };
|
||||
getTextRangeFromCharIndexMock.mockReturnValueOnce(first).mockReturnValueOnce(second);
|
||||
|
||||
model.start(docsQuery('cat'));
|
||||
expect(getTextRangeFromCharIndex).toHaveBeenCalledTimes(2);
|
||||
model.dispose();
|
||||
expect(first.dispose).toHaveBeenCalledOnce();
|
||||
expect(second.dispose).toHaveBeenCalledOnce();
|
||||
univer.dispose();
|
||||
});
|
||||
});
|
||||
298
packages/docs-find-replace/src/models/docs-find.model.ts
Normal file
298
packages/docs-find-replace/src/models/docs-find.model.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* 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 { DocumentDataModel, ITextRange, Nullable } from '@univerjs/core';
|
||||
import type { DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import type { Documents, ITextSelectionStyle } from '@univerjs/engine-render';
|
||||
import type { IFindMatch, IFindMoveParams, IFindQuery, IReplaceAllResult } from '@univerjs/find-replace';
|
||||
import type { IDocsReplaceCommandParams } from '../commands/commands/docs-replace.command';
|
||||
import {
|
||||
ColorKit,
|
||||
fromCallback,
|
||||
ICommandService,
|
||||
Inject,
|
||||
ThemeService,
|
||||
toDisposable,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
DocSelectionManagerService,
|
||||
DocTextResolverService,
|
||||
RichTextEditingMutation,
|
||||
} from '@univerjs/docs';
|
||||
import { DocBackScrollRenderController, getTextRangeFromCharIndex } from '@univerjs/docs-ui';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { FindModel } from '@univerjs/find-replace';
|
||||
import { debounceTime, filter, merge, Subject } from 'rxjs';
|
||||
import { DocsReplaceCommand } from '../commands/commands/docs-replace.command';
|
||||
import { findDocRanges } from '../controllers/utils';
|
||||
|
||||
export const DOCS_FIND_REPLACE_PROVIDER = 'docs-find-replace-provider';
|
||||
|
||||
export interface IDocFindMatch extends IFindMatch<ITextRange> {
|
||||
provider: typeof DOCS_FIND_REPLACE_PROVIDER;
|
||||
range: ITextRange;
|
||||
replaceable: boolean;
|
||||
}
|
||||
|
||||
export class DocsFindModel extends FindModel {
|
||||
private readonly _matchesUpdate$ = new Subject<IDocFindMatch[]>();
|
||||
override readonly matchesUpdate$ = this._matchesUpdate$.asObservable();
|
||||
private readonly _activelyChangingMatch$ = new Subject<IDocFindMatch>();
|
||||
override readonly activelyChangingMatch$ = this._activelyChangingMatch$.asObservable();
|
||||
private _matches: IDocFindMatch[] = [];
|
||||
private _position = -1;
|
||||
private _query: Nullable<IFindQuery> = null;
|
||||
private _highlights: Array<{ dispose(): void }> = [];
|
||||
|
||||
override readonly unitId: string;
|
||||
|
||||
constructor(
|
||||
private readonly _doc: DocumentDataModel,
|
||||
private readonly _skeletonManager: DocSkeletonManagerService,
|
||||
@Inject(DocSelectionManagerService) private readonly _selectionManager: DocSelectionManagerService,
|
||||
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@Inject(ThemeService) private readonly _themeService: ThemeService,
|
||||
@Inject(DocTextResolverService)
|
||||
private readonly _textResolverService: DocTextResolverService
|
||||
) {
|
||||
super();
|
||||
this.unitId = _doc.getUnitId();
|
||||
|
||||
this.disposeWithMe(toDisposable(merge(
|
||||
fromCallback(this._commandService.onCommandExecuted.bind(this._commandService))
|
||||
.pipe(filter(([command]) => command.id === RichTextEditingMutation.id &&
|
||||
getUnitId(command.params) === this.unitId)),
|
||||
this._textResolverService.textChanged$
|
||||
.pipe(filter((unitId) => unitId === this.unitId))
|
||||
)
|
||||
.pipe(
|
||||
debounceTime(220)
|
||||
)
|
||||
.subscribe(() => this._scan(true))));
|
||||
this.disposeWithMe(toDisposable(this._skeletonManager.currentSkeleton$.subscribe(() => this._refreshHighlights())));
|
||||
}
|
||||
|
||||
get currentMatch(): Nullable<IDocFindMatch> {
|
||||
return this._position < 0 ? null : this._matches[this._position] ?? null;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this._disposeHighlights();
|
||||
this._matchesUpdate$.complete();
|
||||
this._activelyChangingMatch$.complete();
|
||||
}
|
||||
|
||||
override getMatches(): IDocFindMatch[] {
|
||||
return this._matches;
|
||||
}
|
||||
|
||||
start(query: IFindQuery): void {
|
||||
this._query = query;
|
||||
this._position = -1;
|
||||
this._scan(false);
|
||||
}
|
||||
|
||||
override moveToNextMatch(params?: IFindMoveParams): IDocFindMatch | null {
|
||||
if (!this._matches.length) return null;
|
||||
if (params?.stayIfOnMatch && this.currentMatch) {
|
||||
return this._activate(this._position, params.noFocus);
|
||||
}
|
||||
|
||||
let index: number;
|
||||
if (params?.ignoreSelection) {
|
||||
index = 0;
|
||||
} else if (this._position >= 0) {
|
||||
index = this._position + 1;
|
||||
} else {
|
||||
const selection = this._selectionManager.getActiveTextRange();
|
||||
const selectedMatch = params?.stayIfOnMatch ? this._selectionMatchIndex() : -1;
|
||||
index = selectedMatch >= 0
|
||||
? selectedMatch
|
||||
: this._matches.findIndex((match) => match.range.startOffset >= (selection?.endOffset ?? 0));
|
||||
}
|
||||
|
||||
if (index < 0 || index >= this._matches.length) index = params?.loop ? 0 : -1;
|
||||
if (index < 0) {
|
||||
this._position = -1;
|
||||
this._refreshHighlights();
|
||||
return null;
|
||||
}
|
||||
return this._activate(index, params?.noFocus);
|
||||
}
|
||||
|
||||
override moveToPreviousMatch(params?: IFindMoveParams): IDocFindMatch | null {
|
||||
if (!this._matches.length) return null;
|
||||
if (params?.stayIfOnMatch && this.currentMatch) {
|
||||
return this._activate(this._position, params.noFocus);
|
||||
}
|
||||
|
||||
let index: number;
|
||||
if (params?.ignoreSelection) {
|
||||
index = this._matches.length - 1;
|
||||
} else if (this._position >= 0) {
|
||||
index = this._position - 1;
|
||||
} else {
|
||||
const selection = this._selectionManager.getActiveTextRange();
|
||||
const selectedMatch = params?.stayIfOnMatch ? this._selectionMatchIndex() : -1;
|
||||
index = selectedMatch >= 0
|
||||
? selectedMatch
|
||||
: this._matches.findLastIndex((match) => match.range.endOffset <= (selection?.startOffset ?? 0));
|
||||
}
|
||||
|
||||
if (index < 0) index = params?.loop ? this._matches.length - 1 : -1;
|
||||
if (index < 0) {
|
||||
this._position = -1;
|
||||
this._refreshHighlights();
|
||||
return null;
|
||||
}
|
||||
return this._activate(index, params?.noFocus);
|
||||
}
|
||||
|
||||
override async replace(replaceString: string): Promise<boolean> {
|
||||
const current = this.currentMatch;
|
||||
if (!current?.replaceable || !this._query) return false;
|
||||
const result = await this._commandService.executeCommand<IDocsReplaceCommandParams, IReplaceAllResult>(DocsReplaceCommand.id, {
|
||||
unitId: this.unitId,
|
||||
query: this._query,
|
||||
replaceString,
|
||||
range: current.range,
|
||||
});
|
||||
return result?.success === 1;
|
||||
}
|
||||
|
||||
override async replaceAll(replaceString: string): Promise<IReplaceAllResult> {
|
||||
if (!this._query) return { success: 0, failure: 0 };
|
||||
return await this._commandService.executeCommand<IDocsReplaceCommandParams, IReplaceAllResult>(DocsReplaceCommand.id, {
|
||||
unitId: this.unitId,
|
||||
query: this._query,
|
||||
replaceString,
|
||||
}) ?? { success: 0, failure: this._matches.length };
|
||||
}
|
||||
|
||||
override focusSelection(): void {
|
||||
if (this.currentMatch) this._focusMatch(this.currentMatch);
|
||||
}
|
||||
|
||||
private _scan(emit: boolean): void {
|
||||
if (!this._query) return;
|
||||
const body = this._doc.getBody();
|
||||
if (!body) {
|
||||
this._matches = [];
|
||||
this._position = -1;
|
||||
this._disposeHighlights();
|
||||
if (emit) this._matchesUpdate$.next([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStart = this.currentMatch?.range.startOffset;
|
||||
this._matches = findDocRanges(
|
||||
body,
|
||||
this._query,
|
||||
!!this._doc.getSnapshot().disabled,
|
||||
this._textResolverService.resolve(this.unitId, body)
|
||||
)
|
||||
.map((range) => ({
|
||||
provider: DOCS_FIND_REPLACE_PROVIDER,
|
||||
unitId: this.unitId,
|
||||
range: { ...range, collapsed: false },
|
||||
replaceable: range.replaceable,
|
||||
}));
|
||||
this._position = previousStart == null
|
||||
? -1
|
||||
: this._matches.findIndex((match) => match.range.startOffset >= previousStart);
|
||||
this._refreshHighlights();
|
||||
if (emit) this._matchesUpdate$.next(this._matches);
|
||||
}
|
||||
|
||||
private _selectionMatchIndex(): number {
|
||||
const selection = this._selectionManager.getActiveTextRange();
|
||||
if (!selection) return -1;
|
||||
return this._matches.findIndex((match) =>
|
||||
match.range.startOffset <= selection.startOffset && match.range.endOffset >= selection.endOffset
|
||||
);
|
||||
}
|
||||
|
||||
private _activate(index: number, noFocus = false): IDocFindMatch | null {
|
||||
const match = this._matches[index];
|
||||
if (!match) return null;
|
||||
this._position = index;
|
||||
if (!noFocus) this._focusMatch(match);
|
||||
else this._refreshHighlights();
|
||||
return match;
|
||||
}
|
||||
|
||||
private _focusMatch(match: IDocFindMatch): void {
|
||||
this._selectionManager.replaceDocRanges([match.range], {
|
||||
unitId: this.unitId,
|
||||
subUnitId: this.unitId,
|
||||
}, true, { shouldFocus: false });
|
||||
this._renderManagerService.getRenderUnitById(this.unitId)
|
||||
?.with(DocBackScrollRenderController)
|
||||
.scrollToRange(match.range);
|
||||
this._activelyChangingMatch$.next(match);
|
||||
this._refreshHighlights();
|
||||
}
|
||||
|
||||
private _refreshHighlights(): void {
|
||||
this._disposeHighlights();
|
||||
const render = this._renderManagerService.getRenderUnitById(this.unitId);
|
||||
const skeleton = this._skeletonManager.getSkeleton();
|
||||
const document = render?.mainComponent as Nullable<Documents>;
|
||||
if (!render || !skeleton || !document) return;
|
||||
|
||||
const color = this._themeService.getColorFromTheme('yellow.400');
|
||||
const passiveStyle: ITextSelectionStyle = {
|
||||
strokeWidth: 0,
|
||||
stroke: 'rgba(0,0,0,0)',
|
||||
strokeActive: 'rgba(0,0,0,0)',
|
||||
fill: new ColorKit(color).setAlpha(0.3).toRgbString(),
|
||||
};
|
||||
const activeStyle: ITextSelectionStyle = {
|
||||
...passiveStyle,
|
||||
fill: new ColorKit(color).setAlpha(0.65).toRgbString(),
|
||||
};
|
||||
|
||||
this._matches.forEach((match, index) => {
|
||||
const range = getTextRangeFromCharIndex(
|
||||
match.range.startOffset,
|
||||
match.range.endOffset,
|
||||
render.scene,
|
||||
document,
|
||||
skeleton,
|
||||
index === this._position ? activeStyle : passiveStyle,
|
||||
'',
|
||||
-1
|
||||
);
|
||||
if (range) this._highlights.push(range);
|
||||
});
|
||||
}
|
||||
|
||||
private _disposeHighlights(): void {
|
||||
this._highlights.forEach((highlight) => highlight.dispose());
|
||||
this._highlights = [];
|
||||
}
|
||||
}
|
||||
|
||||
function getUnitId(params: unknown): string | undefined {
|
||||
return params != null &&
|
||||
typeof params === 'object' &&
|
||||
'unitId' in params &&
|
||||
typeof params.unitId === 'string'
|
||||
? params.unitId
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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 { IFindQuery } from '@univerjs/find-replace';
|
||||
import { UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSkeletonManagerService, DocTextResolverService } from '@univerjs/docs';
|
||||
import { createCommandTestBed } from '@univerjs/docs-ui/commands/commands/__tests__/create-command-test-bed';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { FindBy, FindDirection, FindScope } from '@univerjs/find-replace';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DocsFindReplaceProvider } from '../docs-find-replace.provider';
|
||||
|
||||
const query: IFindQuery = {
|
||||
findString: 'cat',
|
||||
replaceRevealed: true,
|
||||
caseSensitive: false,
|
||||
matchesTheWholeWord: false,
|
||||
matchesTheWholeCell: false,
|
||||
findDirection: FindDirection.ROW,
|
||||
findScope: FindScope.SUBUNIT,
|
||||
findBy: FindBy.VALUE,
|
||||
};
|
||||
|
||||
describe('DocsFindReplaceProvider', () => {
|
||||
it('supports Docs and creates one model for the current document', async () => {
|
||||
const testBed = createCommandTestBed(
|
||||
{ id: 'test-doc', body: { dataStream: 'cat\r\n' }, documentStyle: {} },
|
||||
[[DocTextResolverService]]
|
||||
);
|
||||
const skeleton = testBed.get(IRenderManagerService).getRenderUnitById('test-doc')!.with(DocSkeletonManagerService);
|
||||
vi.spyOn(skeleton, 'getSkeleton').mockReturnValue(null as never);
|
||||
testBed.injector.add([DocsFindReplaceProvider]);
|
||||
const provider = testBed.injector.get(DocsFindReplaceProvider);
|
||||
|
||||
expect(provider.isSupported(testBed.doc)).toBe(true);
|
||||
expect(provider.isSupported({ type: UniverInstanceType.UNIVER_SHEET } as UnitModel)).toBe(false);
|
||||
expect(provider.capabilities).toEqual({
|
||||
caseSensitive: true,
|
||||
matchesTheWholeWord: true,
|
||||
matchesTheWholeCell: false,
|
||||
findDirection: false,
|
||||
findScope: false,
|
||||
findBy: false,
|
||||
});
|
||||
await expect(provider.find(query)).resolves.toHaveLength(1);
|
||||
|
||||
provider.dispose();
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 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 { DocumentDataModel, Nullable, UnitModel } from '@univerjs/core';
|
||||
import type { IFindQuery, IFindReplaceProvider } from '@univerjs/find-replace';
|
||||
import { Disposable, Inject, Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocSkeletonManagerService } from '@univerjs/docs';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { DocsFindModel } from '../models/docs-find.model';
|
||||
|
||||
export class DocsFindReplaceProvider extends Disposable implements IFindReplaceProvider {
|
||||
readonly capabilities = {
|
||||
caseSensitive: true,
|
||||
matchesTheWholeWord: true,
|
||||
matchesTheWholeCell: false,
|
||||
findDirection: false,
|
||||
findScope: false,
|
||||
findBy: false,
|
||||
};
|
||||
|
||||
private _model: Nullable<DocsFindModel> = null;
|
||||
|
||||
constructor(
|
||||
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
|
||||
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
|
||||
@Inject(Injector) private readonly _injector: Injector
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isSupported(unit: UnitModel): boolean {
|
||||
return unit.type === UniverInstanceType.UNIVER_DOC;
|
||||
}
|
||||
|
||||
async find(query: IFindQuery): Promise<DocsFindModel[]> {
|
||||
this.terminate();
|
||||
const doc = this._instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
|
||||
if (!doc) return [];
|
||||
const skeleton = this._renderManagerService.getRenderUnitById(doc.getUnitId())?.with(DocSkeletonManagerService);
|
||||
if (!skeleton) return [];
|
||||
|
||||
this._model = this._injector.createInstance(DocsFindModel, doc, skeleton);
|
||||
this._model.start(query);
|
||||
return [this._model];
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this._model?.dispose();
|
||||
this._model = null;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.terminate();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
8
packages/docs-find-replace/tsconfig.json
Normal file
8
packages/docs-find-replace/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
packages/docs-find-replace/tsconfig.node.json
Normal file
12
packages/docs-find-replace/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/node",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/__tests__/**", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
3
packages/docs-find-replace/vitest.config.ts
Normal file
3
packages/docs-find-replace/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user