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:
37
packages/docs/README.md
Normal file
37
packages/docs/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @univerjs/docs
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/docs)
|
||||
[](https://npmjs.com/package/@univerjs/docs)
|
||||
[](https://npmjs.com/package/@univerjs/docs)
|
||||
|
||||
`@univerjs/docs` provides the document data model and core rich-text operations for Univer Docs, independent of the UI layer.
|
||||
|
||||
## Package Overview
|
||||
|
||||
| Package | UMD global | CSS | Locales | Facade entry |
|
||||
| --- | --- | :---: | :---: | :---: |
|
||||
| `@univerjs/docs` | `UniverDocs` | No | No | Yes |
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @univerjs/docs
|
||||
# or
|
||||
npm install @univerjs/docs
|
||||
```
|
||||
|
||||
Keep all `@univerjs/*` packages on the same version.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { UniverDocsPlugin } from '@univerjs/docs';
|
||||
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.univer.ai)
|
||||
- [NPM package](https://npmjs.com/package/@univerjs/docs)
|
||||
- [GitHub repository](https://github.com/dream-num/univer)
|
||||
92
packages/docs/package.json
Normal file
92
packages/docs/package.json
Normal file
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "@univerjs/docs",
|
||||
"version": "1.0.0-rc.0",
|
||||
"private": false,
|
||||
"description": "Core document model and rich-text operations 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",
|
||||
"document",
|
||||
"rich-text",
|
||||
"plugin"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*",
|
||||
"./facade": "./src/facade/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "./lib/es/index.js",
|
||||
"module": "./lib/es/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./lib/es/index.js",
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./lib/es/*",
|
||||
"require": "./lib/cjs/*",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./facade": {
|
||||
"import": "./lib/es/facade.js",
|
||||
"require": "./lib/cjs/facade.js",
|
||||
"types": "./lib/types/facade/index.d.ts"
|
||||
},
|
||||
"./lib/facade": {
|
||||
"import": "./lib/es/facade.js",
|
||||
"require": "./lib/cjs/facade.js",
|
||||
"types": "./lib/types/facade/index.d.ts"
|
||||
},
|
||||
"./lib/*": "./lib/*"
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:bundle": "univer-cli build",
|
||||
"build:types": "tsc -p tsconfig.node.json",
|
||||
"build": "pnpm run build:bundle && pnpm run build:types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": ">=7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@univerjs/core": "workspace:*",
|
||||
"@univerjs/engine-render": "workspace:*",
|
||||
"@univerjs/protocol": "workspace:*",
|
||||
"@univerjs/rpc": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@univerjs-infra/shared": "workspace:*",
|
||||
"rxjs": "^7.8.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
459
packages/docs/src/__tests__/docs.integration.spec.ts
Normal file
459
packages/docs/src/__tests__/docs.integration.spec.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* 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, ICustomRangeForInterceptor, IDocumentData, IInterceptor } from '@univerjs/core';
|
||||
import type { IRenderContext } from '@univerjs/engine-render';
|
||||
import {
|
||||
awaitTime,
|
||||
CustomRangeType,
|
||||
DisposableCollection,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
LifecycleService,
|
||||
LifecycleStages,
|
||||
LocaleService,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DocsRenameMutation } from '../commands/mutations/docs-rename.mutation';
|
||||
import { SetTextSelectionsOperation } from '../commands/operations/text-selection.operation';
|
||||
import { UniverDocsPlugin } from '../plugin';
|
||||
import { DocInterceptorService } from '../services/doc-interceptor/doc-interceptor.service';
|
||||
import { DOC_INTERCEPTOR_POINT } from '../services/doc-interceptor/interceptor-const';
|
||||
import { DocLayoutExecutorService } from '../services/doc-layout-executor.service';
|
||||
import { DocSelectionManagerService } from '../services/doc-selection-manager.service';
|
||||
import { DocSkeletonManagerService } from '../services/doc-skeleton-manager.service';
|
||||
import { DocStateChangeManagerService } from '../services/doc-state-change-manager.service';
|
||||
import { DocStateEmitService } from '../services/doc-state-emit.service';
|
||||
import { DocViewModelManagerService } from '../services/doc-view-model-manager.service';
|
||||
import { addCustomRangeBySelectionFactory, deleteCustomRangeFactory } from '../utils/custom-range-factory';
|
||||
import { replaceSelectionFactory } from '../utils/replace-selection-factory';
|
||||
|
||||
function registerRenderManagerForDoc(
|
||||
injector: ReturnType<Univer['__getInjector']>,
|
||||
doc: DocumentDataModel
|
||||
) {
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
const context: IRenderContext<DocumentDataModel> = {
|
||||
unitId: doc.getUnitId(),
|
||||
unit: doc,
|
||||
} as never;
|
||||
|
||||
const skeletonManager = new DocSkeletonManagerService(
|
||||
context,
|
||||
injector.get(LocaleService),
|
||||
univerInstanceService,
|
||||
injector.get(DocLayoutExecutorService)
|
||||
);
|
||||
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
injector.get(IRenderManagerService).addRender(doc.getUnitId(), {
|
||||
unitId: doc.getUnitId(),
|
||||
type: UniverInstanceType.UNIVER_DOC,
|
||||
engine: new DisposableCollection() as never,
|
||||
scene: new DisposableCollection() as never,
|
||||
mainComponent: null,
|
||||
components: new Map(),
|
||||
isMainScene: true,
|
||||
activated$: new BehaviorSubject(true),
|
||||
with: <T>() => skeletonManager as T,
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
isDisposed: () => false,
|
||||
});
|
||||
|
||||
return skeletonManager;
|
||||
}
|
||||
|
||||
function createTestDocData(id: string): IDocumentData {
|
||||
return {
|
||||
id,
|
||||
body: {
|
||||
dataStream: 'Hello\r\n',
|
||||
customRanges: [{
|
||||
startIndex: 0,
|
||||
endIndex: 4,
|
||||
rangeId: 'range-1',
|
||||
rangeType: CustomRangeType.HYPERLINK,
|
||||
wholeEntity: true,
|
||||
properties: { url: 'https://example.invalid' },
|
||||
}],
|
||||
},
|
||||
documentStyle: {
|
||||
pageSize: { width: 100, height: 100 },
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('docs integration', () => {
|
||||
let univer: Univer;
|
||||
let injector: ReturnType<Univer['__getInjector']>;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
injector = univer.__getInjector();
|
||||
injector.get(LifecycleService).stage = LifecycleStages.Starting;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('renames doc through command service mutation', async () => {
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createTestDocData('doc-1')
|
||||
);
|
||||
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
univerInstanceService.focusUnit(doc.getUnitId());
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
const ok = await commandService.executeCommand(DocsRenameMutation.id, {
|
||||
unitId: doc.getUnitId(),
|
||||
name: 'Renamed',
|
||||
});
|
||||
|
||||
expect(ok).toBeTruthy();
|
||||
expect(doc.getTitle()).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('manages selections and emits selection operations via real command flow', async () => {
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createTestDocData('doc-2')
|
||||
);
|
||||
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
univerInstanceService.focusUnit(doc.getUnitId());
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
|
||||
const selectionManager = injector.get(DocSelectionManagerService);
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection({
|
||||
unitId: doc.getUnitId(),
|
||||
subUnitId: doc.getUnitId(),
|
||||
});
|
||||
|
||||
selectionManager.__TEST_ONLY_add([
|
||||
{
|
||||
startOffset: 5,
|
||||
endOffset: 5,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
segmentId: '',
|
||||
style: null as never,
|
||||
},
|
||||
{
|
||||
startOffset: 0,
|
||||
endOffset: 2,
|
||||
collapsed: false,
|
||||
isActive: false,
|
||||
segmentId: '',
|
||||
style: null as never,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(5);
|
||||
expect(selectionManager.getDocRanges()?.map((r) => r.startOffset)).toEqual([0, 5]);
|
||||
|
||||
const refreshLogs: unknown[] = [];
|
||||
const refreshSub = selectionManager.refreshSelection$.subscribe((v) => v && refreshLogs.push(v));
|
||||
selectionManager.replaceDocRanges([
|
||||
{ startOffset: 1, endOffset: 1, collapsed: true, segmentId: '' },
|
||||
] as never);
|
||||
|
||||
expect(refreshLogs.length).toBeGreaterThan(0);
|
||||
refreshSub.unsubscribe();
|
||||
|
||||
const executed: Array<{ id: string; params: unknown }> = [];
|
||||
const dispose = commandService.onCommandExecuted((info) => executed.push({ id: info.id, params: info.params }));
|
||||
|
||||
selectionManager.__replaceTextRangesWithNoRefresh(
|
||||
{
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
style: null as never,
|
||||
textRanges: [{ startOffset: 2, endOffset: 3, collapsed: false, segmentId: '' }] as never,
|
||||
rectRanges: [],
|
||||
isEditing: true,
|
||||
},
|
||||
{ unitId: doc.getUnitId(), subUnitId: doc.getUnitId() }
|
||||
);
|
||||
|
||||
await awaitTime(0);
|
||||
expect(executed.some((e) => e.id === SetTextSelectionsOperation.id)).toBe(true);
|
||||
dispose.dispose();
|
||||
});
|
||||
|
||||
it('replaces a selection through the factory-generated rich text mutation', async () => {
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createTestDocData('doc-2a')
|
||||
);
|
||||
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
univerInstanceService.focusUnit(doc.getUnitId());
|
||||
const skeletonManager = registerRenderManagerForDoc(injector, doc);
|
||||
|
||||
const selectionManager = injector.get(DocSelectionManagerService);
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection({
|
||||
unitId: doc.getUnitId(),
|
||||
subUnitId: doc.getUnitId(),
|
||||
});
|
||||
selectionManager.__TEST_ONLY_add([{
|
||||
startOffset: 5,
|
||||
endOffset: 5,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
segmentId: '',
|
||||
style: null as never,
|
||||
}]);
|
||||
|
||||
const docStateEmitService = injector.get(DocStateEmitService);
|
||||
const stateChanges: unknown[] = [];
|
||||
const sub = docStateEmitService.docStateChangeParams$.subscribe((value) => value && stateChanges.push(value));
|
||||
|
||||
const mutation = replaceSelectionFactory(injector as never, {
|
||||
unitId: doc.getUnitId(),
|
||||
body: {
|
||||
dataStream: ' world',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mutation).not.toBe(false);
|
||||
if (mutation === false) {
|
||||
throw new Error('Expected replaceSelectionFactory to create a mutation.');
|
||||
}
|
||||
expect(mutation.params.textRanges?.[0]?.startOffset).toBe(11);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
const ok = await commandService.executeCommand(mutation.id, {
|
||||
...mutation.params,
|
||||
trigger: 'integration-spec' as never,
|
||||
});
|
||||
|
||||
await awaitTime(0);
|
||||
|
||||
expect(ok).toBeTruthy();
|
||||
expect(doc.getBody()?.dataStream).toBe('Hello world\r\n');
|
||||
expect(stateChanges).toHaveLength(1);
|
||||
|
||||
sub.unsubscribe();
|
||||
skeletonManager.dispose();
|
||||
});
|
||||
|
||||
it('emits composed doc state changes from the docs package without docs-ui', async () => {
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createTestDocData('doc-state-change')
|
||||
);
|
||||
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
univerInstanceService.focusUnit(doc.getUnitId());
|
||||
const skeletonManager = registerRenderManagerForDoc(injector, doc);
|
||||
|
||||
const selectionManager = injector.get(DocSelectionManagerService);
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection({
|
||||
unitId: doc.getUnitId(),
|
||||
subUnitId: doc.getUnitId(),
|
||||
});
|
||||
selectionManager.__TEST_ONLY_add([{
|
||||
startOffset: 5,
|
||||
endOffset: 5,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
segmentId: '',
|
||||
style: null as never,
|
||||
}]);
|
||||
|
||||
const docStateChangeManagerService = injector.get(DocStateChangeManagerService);
|
||||
const stateChanges: unknown[] = [];
|
||||
const sub = docStateChangeManagerService.docStateChange$.subscribe((value) => value && stateChanges.push(value));
|
||||
|
||||
const mutation = replaceSelectionFactory(injector as never, {
|
||||
unitId: doc.getUnitId(),
|
||||
body: {
|
||||
dataStream: ' from docs',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mutation).not.toBe(false);
|
||||
if (mutation === false) {
|
||||
throw new Error('Expected replaceSelectionFactory to create a mutation.');
|
||||
}
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
const ok = await commandService.executeCommand(mutation.id, {
|
||||
...mutation.params,
|
||||
debounce: false,
|
||||
trigger: 'doc-state-change-spec' as never,
|
||||
});
|
||||
|
||||
await awaitTime(0);
|
||||
|
||||
expect(ok).toBeTruthy();
|
||||
expect(stateChanges).toHaveLength(1);
|
||||
expect(doc.getBody()?.dataStream).toBe('Hello from docs\r\n');
|
||||
|
||||
sub.unsubscribe();
|
||||
skeletonManager.dispose();
|
||||
});
|
||||
|
||||
it('adds and removes custom ranges through the same mutation pipeline', async () => {
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createTestDocData('doc-2b')
|
||||
);
|
||||
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
univerInstanceService.focusUnit(doc.getUnitId());
|
||||
const skeletonManager = registerRenderManagerForDoc(injector, doc);
|
||||
|
||||
const selectionManager = injector.get(DocSelectionManagerService);
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection({
|
||||
unitId: doc.getUnitId(),
|
||||
subUnitId: doc.getUnitId(),
|
||||
});
|
||||
selectionManager.__TEST_ONLY_add([{
|
||||
startOffset: 0,
|
||||
endOffset: 4,
|
||||
collapsed: false,
|
||||
isActive: true,
|
||||
segmentId: '',
|
||||
style: null as never,
|
||||
}]);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
const addMutation = addCustomRangeBySelectionFactory(injector as never, {
|
||||
unitId: doc.getUnitId(),
|
||||
rangeId: 'range-2',
|
||||
rangeType: CustomRangeType.HYPERLINK,
|
||||
wholeEntity: true,
|
||||
properties: {
|
||||
url: 'https://added.invalid',
|
||||
},
|
||||
});
|
||||
|
||||
expect(addMutation).not.toBe(false);
|
||||
if (addMutation === false) {
|
||||
throw new Error('Expected addCustomRangeBySelectionFactory to create a mutation.');
|
||||
}
|
||||
await commandService.executeCommand(addMutation.id, addMutation.params);
|
||||
|
||||
expect(doc.getBody()?.customRanges?.find((range) => range.rangeId === 'range-2')?.properties).toEqual({
|
||||
url: 'https://added.invalid',
|
||||
});
|
||||
|
||||
const deleteMutation = deleteCustomRangeFactory(injector as never, {
|
||||
unitId: doc.getUnitId(),
|
||||
rangeId: 'range-2',
|
||||
});
|
||||
|
||||
expect(deleteMutation).not.toBe(false);
|
||||
if (deleteMutation === false) {
|
||||
throw new Error('Expected deleteCustomRangeFactory to create a mutation.');
|
||||
}
|
||||
await commandService.executeCommand(deleteMutation.id, deleteMutation.params);
|
||||
|
||||
expect(doc.getBody()?.customRanges?.some((range) => range.rangeId === 'range-2')).toBe(false);
|
||||
skeletonManager.dispose();
|
||||
});
|
||||
|
||||
it('builds skeleton/view-model and applies interceptors for custom ranges', () => {
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createTestDocData('doc-3')
|
||||
);
|
||||
|
||||
const univerInstanceService = injector.get(IUniverInstanceService);
|
||||
univerInstanceService.focusUnit(doc.getUnitId());
|
||||
|
||||
const context: IRenderContext<DocumentDataModel> = {
|
||||
unitId: doc.getUnitId(),
|
||||
unit: doc,
|
||||
} as never;
|
||||
|
||||
const skeletonManager = new DocSkeletonManagerService(
|
||||
context,
|
||||
injector.get(LocaleService),
|
||||
univerInstanceService,
|
||||
injector.get(DocLayoutExecutorService)
|
||||
);
|
||||
|
||||
const viewModelManager = new DocViewModelManagerService(context, univerInstanceService);
|
||||
expect(viewModelManager.getViewModel(doc.getUnitId())).toBeDefined();
|
||||
|
||||
const interceptorService = new DocInterceptorService(context, skeletonManager);
|
||||
interceptorService.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_RANGE, {
|
||||
priority: 10,
|
||||
handler: (range, ctx, next) => {
|
||||
const nextRange = next(range);
|
||||
return nextRange ? { ...nextRange, properties: { ...nextRange.properties, unitId: ctx.unitId } } : nextRange;
|
||||
},
|
||||
} as IInterceptor<ICustomRangeForInterceptor, {
|
||||
index: number;
|
||||
unitId: string;
|
||||
customRanges: ICustomRangeForInterceptor[];
|
||||
}>);
|
||||
|
||||
interceptorService.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_RANGE, {
|
||||
priority: -10,
|
||||
handler: (range, _ctx, next) => {
|
||||
const nextRange = next(range);
|
||||
return nextRange ? { ...nextRange, properties: { ...nextRange.properties, tagged: true } } : nextRange;
|
||||
},
|
||||
} as IInterceptor<ICustomRangeForInterceptor, {
|
||||
index: number;
|
||||
unitId: string;
|
||||
customRanges: ICustomRangeForInterceptor[];
|
||||
}>);
|
||||
|
||||
const range = skeletonManager.getViewModel().getCustomRange(1);
|
||||
expect(range?.rangeId).toBe('range-1');
|
||||
expect(range?.properties).toMatchObject({ unitId: doc.getUnitId(), tagged: true });
|
||||
|
||||
const docStateEmitService = new DocStateEmitService();
|
||||
const values: unknown[] = [];
|
||||
const sub = docStateEmitService.docStateChangeParams$.subscribe((v) => v && values.push(v));
|
||||
docStateEmitService.emitStateChangeInfo({
|
||||
commandId: 'test',
|
||||
unitId: doc.getUnitId(),
|
||||
trigger: null,
|
||||
redoState: { actions: [], textRanges: null },
|
||||
undoState: { actions: [], textRanges: null },
|
||||
});
|
||||
expect(values.length).toBe(1);
|
||||
sub.unsubscribe();
|
||||
|
||||
interceptorService.dispose();
|
||||
viewModelManager.dispose();
|
||||
skeletonManager.dispose();
|
||||
});
|
||||
});
|
||||
91
packages/docs/src/__tests__/facade-utils.spec.ts
Normal file
91
packages/docs/src/__tests__/facade-utils.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPlainTextInsertBody,
|
||||
getNormalizedPlainTextCursorOffset,
|
||||
getRemovedLeadingParagraphBreakLength,
|
||||
} from '../facade/utils';
|
||||
|
||||
function expectParagraphIds(paragraphs: NonNullable<ReturnType<typeof buildPlainTextInsertBody>['paragraphs']>): void {
|
||||
for (const paragraph of paragraphs) {
|
||||
expect(paragraph.paragraphId).toMatch(/^para_/);
|
||||
}
|
||||
|
||||
expect(new Set(paragraphs.map((paragraph) => paragraph.paragraphId)).size).toBe(paragraphs.length);
|
||||
}
|
||||
|
||||
describe('facade utils', () => {
|
||||
it('removes a leading paragraph break when plain text is inserted at the document start', () => {
|
||||
const body = buildPlainTextInsertBody('\r\nHello', { removeLeadingParagraphBreak: true });
|
||||
|
||||
expect(body).toEqual({
|
||||
dataStream: 'Hello',
|
||||
customDecorations: [],
|
||||
customRanges: [],
|
||||
textRuns: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a leading paragraph break when the caller does not remove it', () => {
|
||||
const body = buildPlainTextInsertBody('\r\nHello');
|
||||
|
||||
expect(body.dataStream).toBe('\rHello');
|
||||
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([0]);
|
||||
expectParagraphIds(body.paragraphs!);
|
||||
});
|
||||
|
||||
it('normalizes plain text line feeds and adds paragraph metadata', () => {
|
||||
const body = buildPlainTextInsertBody('Hello\nWorld\rAgain');
|
||||
|
||||
expect(body.dataStream).toBe('Hello\rWorld\rAgain');
|
||||
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([5, 11]);
|
||||
expectParagraphIds(body.paragraphs!);
|
||||
});
|
||||
|
||||
it('preserves cloned paragraph style for generated paragraph metadata', () => {
|
||||
const paragraphStyle = { horizontalAlign: 2 };
|
||||
const body = buildPlainTextInsertBody('A\nB\nC', { paragraphStyle });
|
||||
|
||||
expect(body.dataStream).toBe('A\rB\rC');
|
||||
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([1, 3]);
|
||||
expectParagraphIds(body.paragraphs!);
|
||||
expect(body.paragraphs?.map((paragraph) => paragraph.paragraphStyle)).toEqual([paragraphStyle, paragraphStyle]);
|
||||
expect(body.paragraphs?.[0].paragraphStyle).not.toBe(paragraphStyle);
|
||||
expect(body.paragraphs?.[1].paragraphStyle).not.toBe(paragraphStyle);
|
||||
expect(body.paragraphs?.[0].paragraphStyle).not.toBe(body.paragraphs?.[1].paragraphStyle);
|
||||
});
|
||||
|
||||
it('adds paragraph metadata for a trailing paragraph break', () => {
|
||||
const body = buildPlainTextInsertBody('Hello\n');
|
||||
|
||||
expect(body.dataStream).toBe('Hello\r');
|
||||
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([5]);
|
||||
expectParagraphIds(body.paragraphs!);
|
||||
});
|
||||
|
||||
it('reports removed leading paragraph break length for cursor offsets', () => {
|
||||
expect(getRemovedLeadingParagraphBreakLength('\nHello', true)).toBe(1);
|
||||
expect(getRemovedLeadingParagraphBreakLength('\r\nHello', true)).toBe(1);
|
||||
expect(getRemovedLeadingParagraphBreakLength('\r\nHello')).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes cursor offsets with collapsed paragraph breaks', () => {
|
||||
expect(getNormalizedPlainTextCursorOffset('Hello\r\n', 'Hello\r\n'.length)).toBe('Hello\r'.length);
|
||||
expect(getNormalizedPlainTextCursorOffset('\r\nHello', '\r\nHello'.length, true)).toBe('Hello'.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* 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 { BlockType, DataStreamTreeTokenType, DeleteDirection, DrawingTypeEnum, getRichTextEditPath, ICommandService, IUndoRedoService, JSONX, PositionedObjectLayoutType, TextXActionType } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { RichTextEditingMutation } from '../../mutations/core-editing.mutation';
|
||||
import { DeleteTextCommand, InsertTextCommand, UpdateTextCommand } from '../core-editing.command';
|
||||
|
||||
describe('core editing commands', () => {
|
||||
let testBed: ReturnType<typeof createTestBed>;
|
||||
let commandService: ICommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
testBed = createTestBed();
|
||||
commandService = testBed.get(ICommandService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
|
||||
it('deletes text to the left of the collapsed range in the active document', () => {
|
||||
const result = commandService.syncExecuteCommand(DeleteTextCommand.id, {
|
||||
unitId: 'test',
|
||||
range: {
|
||||
startOffset: 6,
|
||||
endOffset: 6,
|
||||
collapsed: true,
|
||||
},
|
||||
direction: DeleteDirection.LEFT,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(testBed.univerAPI.getActiveDocument()?.save().body?.dataStream).toBe('Hello\r\n');
|
||||
});
|
||||
|
||||
it('deletes a drawing with backspace and restores its references through history', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-drawing' }],
|
||||
customBlocks: [{ blockId: 'drawing-1', blockType: BlockType.DRAWING, startIndex: 0 }],
|
||||
},
|
||||
drawings: {
|
||||
'drawing-1': {
|
||||
drawingId: 'drawing-1',
|
||||
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
|
||||
docTransform: {
|
||||
angle: 0,
|
||||
positionH: { posOffset: 0, relativeFrom: 0 },
|
||||
positionV: { posOffset: 0, relativeFrom: 0 },
|
||||
size: { height: 40, width: 80 },
|
||||
},
|
||||
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
|
||||
subUnitId: 'test',
|
||||
unitId: 'test',
|
||||
},
|
||||
},
|
||||
drawingsOrder: ['drawing-1'],
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
testBed.get(IUndoRedoService);
|
||||
|
||||
expect(commandService.syncExecuteCommand(DeleteTextCommand.id, {
|
||||
unitId: 'test',
|
||||
range: { startOffset: 1, endOffset: 1, collapsed: true },
|
||||
direction: DeleteDirection.LEFT,
|
||||
})).toBe(true);
|
||||
expect(testBed.doc.getSnapshot()).toMatchObject({
|
||||
body: { customBlocks: [] },
|
||||
drawings: {},
|
||||
drawingsOrder: [],
|
||||
});
|
||||
|
||||
expect(testBed.univerAPI.getActiveDocument()?.undo()).toBe(true);
|
||||
expect(testBed.doc.getSnapshot()).toMatchObject({
|
||||
body: { customBlocks: [{ blockId: 'drawing-1', startIndex: 0 }] },
|
||||
drawings: { 'drawing-1': { drawingId: 'drawing-1' } },
|
||||
drawingsOrder: ['drawing-1'],
|
||||
});
|
||||
|
||||
expect(testBed.univerAPI.getActiveDocument()?.redo()).toBe(true);
|
||||
expect(testBed.doc.getSnapshot()).toMatchObject({
|
||||
body: { customBlocks: [] },
|
||||
drawings: {},
|
||||
drawingsOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not delete the document minimum paragraph sentinel', () => {
|
||||
const result = commandService.syncExecuteCommand(DeleteTextCommand.id, {
|
||||
unitId: 'test',
|
||||
range: {
|
||||
startOffset: 6,
|
||||
endOffset: 6,
|
||||
collapsed: true,
|
||||
},
|
||||
direction: DeleteDirection.RIGHT,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(testBed.univerAPI.getActiveDocument()?.save().body?.dataStream).toBe('Hello,\r\n');
|
||||
});
|
||||
|
||||
it('does not delete the minimum paragraph sentinel in a header segment', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'Body\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_101', startIndex: 5 }],
|
||||
},
|
||||
headers: {
|
||||
'header-1': {
|
||||
headerId: 'header-1',
|
||||
body: {
|
||||
dataStream: 'Head\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'header' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_102', startIndex: 5 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
const result = commandService.syncExecuteCommand(DeleteTextCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'header-1',
|
||||
range: {
|
||||
startOffset: 4,
|
||||
endOffset: 4,
|
||||
collapsed: true,
|
||||
},
|
||||
direction: DeleteDirection.RIGHT,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(testBed.doc.getSnapshot().headers?.['header-1'].body.dataStream).toBe('Head\r\n');
|
||||
});
|
||||
|
||||
it('keeps a header segment structurally valid when replacing all text', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'B\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_103', startIndex: 2 }],
|
||||
},
|
||||
headers: {
|
||||
'header-1': {
|
||||
headerId: 'header-1',
|
||||
body: {
|
||||
dataStream: 'LongHead\r\n',
|
||||
paragraphs: [{ startIndex: 8, paragraphId: 'header' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_104', startIndex: 9 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
const result = commandService.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'header-1',
|
||||
body: { dataStream: 'X' },
|
||||
range: {
|
||||
startOffset: 0,
|
||||
endOffset: 10,
|
||||
collapsed: false,
|
||||
segmentId: 'header-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(testBed.doc.getSnapshot().headers?.['header-1'].body.dataStream).toBe('X\r\n');
|
||||
});
|
||||
|
||||
it('keeps a footer segment structurally valid when replacing all text', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'B\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_105', startIndex: 2 }],
|
||||
},
|
||||
footers: {
|
||||
'footer-1': {
|
||||
footerId: 'footer-1',
|
||||
body: {
|
||||
dataStream: 'LongFooter\r\n',
|
||||
paragraphs: [{ startIndex: 10, paragraphId: 'footer' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_106', startIndex: 11 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
const result = commandService.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'footer-1',
|
||||
body: { dataStream: 'Y' },
|
||||
range: {
|
||||
startOffset: 0,
|
||||
endOffset: 12,
|
||||
collapsed: false,
|
||||
segmentId: 'footer-1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(testBed.doc.getSnapshot().footers?.['footer-1'].body.dataStream).toBe('Y\r\n');
|
||||
});
|
||||
|
||||
it('rejects invalid rich text mutations after validating a structure-preserving header edit', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'Body\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_107', startIndex: 5 }],
|
||||
},
|
||||
headers: {
|
||||
'header-1': {
|
||||
headerId: 'header-1',
|
||||
body: {
|
||||
dataStream: 'Head\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'header' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_108', startIndex: 5 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
expect(commandService.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'header-1',
|
||||
body: { dataStream: 'X' },
|
||||
range: {
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
collapsed: true,
|
||||
segmentId: 'header-1',
|
||||
},
|
||||
})).toBe(true);
|
||||
|
||||
const actions = JSONX.getInstance().editOp([
|
||||
{ t: TextXActionType.RETAIN, len: 5 },
|
||||
{ t: TextXActionType.DELETE, len: 2 },
|
||||
], getRichTextEditPath(testBed.doc, 'header-1'));
|
||||
|
||||
expect(() => commandService.syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'header-1',
|
||||
actions,
|
||||
textRanges: null,
|
||||
})).toThrow('[DocStructure] header header-1');
|
||||
expect(testBed.doc.getSnapshot().headers?.['header-1'].body.dataStream).toBe('XHead\r\n');
|
||||
});
|
||||
|
||||
it('allows structure-preserving text edits in documents with legacy orphan custom-block tokens', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'A\b\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_109', startIndex: 3 }],
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
expect(commandService.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: 'test',
|
||||
body: { dataStream: 'X' },
|
||||
range: {
|
||||
startOffset: 1,
|
||||
endOffset: 1,
|
||||
collapsed: true,
|
||||
},
|
||||
})).toBe(true);
|
||||
expect(testBed.doc.getBody()?.dataStream).toBe('AX\b\r\n');
|
||||
});
|
||||
|
||||
it('inserts a paragraph around imported DOCX raw custom blocks without rolling back Enter', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`,
|
||||
paragraphs: [{ startIndex: 3, paragraphId: 'body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_110', startIndex: 4 }],
|
||||
docxRawCustomBlocks: [{
|
||||
startIndex: 1,
|
||||
blockId: 'raw-footnote-1',
|
||||
blockType: 1,
|
||||
docxRawXml: '<w:footnoteReference w:id="1"/>',
|
||||
}],
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
expect(commandService.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: 'test',
|
||||
body: {
|
||||
dataStream: DataStreamTreeTokenType.PARAGRAPH,
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'inserted' }],
|
||||
},
|
||||
range: {
|
||||
startOffset: 2,
|
||||
endOffset: 2,
|
||||
collapsed: true,
|
||||
},
|
||||
})).toBe(true);
|
||||
expect(testBed.doc.getBody()).toMatchObject({
|
||||
dataStream: `A${DataStreamTreeTokenType.CUSTOM_BLOCK}${DataStreamTreeTokenType.PARAGRAPH}B${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`,
|
||||
paragraphs: [
|
||||
{ startIndex: 2 },
|
||||
{ startIndex: 4 },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_110', startIndex: 5 }],
|
||||
docxRawCustomBlocks: [{
|
||||
startIndex: 1,
|
||||
blockId: 'raw-footnote-1',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('expands deletion to whole custom entities', () => {
|
||||
testBed.univer.dispose();
|
||||
testBed = createTestBed({
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'A@B\r\n',
|
||||
paragraphs: [{ startIndex: 3, paragraphId: 'para_entity' }],
|
||||
customRanges: [{
|
||||
startIndex: 1,
|
||||
endIndex: 2,
|
||||
rangeId: 'mention-1',
|
||||
rangeType: 0,
|
||||
wholeEntity: true,
|
||||
}],
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
commandService = testBed.get(ICommandService);
|
||||
|
||||
const result = commandService.syncExecuteCommand(DeleteTextCommand.id, {
|
||||
unitId: 'test',
|
||||
range: {
|
||||
startOffset: 2,
|
||||
endOffset: 2,
|
||||
collapsed: true,
|
||||
},
|
||||
direction: DeleteDirection.RIGHT,
|
||||
len: 1,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(testBed.univerAPI.getActiveDocument()?.save().body?.dataStream).toBe('A\r\n');
|
||||
});
|
||||
|
||||
it('updates text style over a range without changing the document text', () => {
|
||||
const result = commandService.syncExecuteCommand(UpdateTextCommand.id, {
|
||||
unitId: 'test',
|
||||
range: {
|
||||
startOffset: 0,
|
||||
endOffset: 5,
|
||||
collapsed: false,
|
||||
},
|
||||
updateBody: {
|
||||
textRuns: [{
|
||||
st: 0,
|
||||
ed: 5,
|
||||
ts: { bl: 1 },
|
||||
}],
|
||||
},
|
||||
coverType: 0,
|
||||
textRanges: [{
|
||||
startOffset: 5,
|
||||
endOffset: 5,
|
||||
collapsed: true,
|
||||
}],
|
||||
});
|
||||
|
||||
const body = testBed.univerAPI.getActiveDocument()?.save().body;
|
||||
expect(result).toBe(true);
|
||||
expect(body?.dataStream).toBe('Hello,\r\n');
|
||||
});
|
||||
|
||||
it('returns false when the target document or body cannot be resolved', () => {
|
||||
expect(commandService.syncExecuteCommand(DeleteTextCommand.id, {
|
||||
unitId: 'missing-doc',
|
||||
range: {
|
||||
startOffset: 1,
|
||||
endOffset: 1,
|
||||
collapsed: true,
|
||||
},
|
||||
direction: DeleteDirection.LEFT,
|
||||
})).toBe(false);
|
||||
|
||||
expect(commandService.syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: 'missing-doc',
|
||||
body: { dataStream: 'X' },
|
||||
range: {
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
collapsed: true,
|
||||
},
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DocumentFlavor, ICommandService } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { CreateHeaderFooterCommand, HeaderFooterType } from '../create-header-footer.command';
|
||||
|
||||
describe('CreateHeaderFooterCommand', () => {
|
||||
let testBed: ReturnType<typeof createTestBed>;
|
||||
let commandService: ICommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
const documentData = createDocumentData('test', {
|
||||
dataStream: 'Hello,\r\n',
|
||||
paragraphs: [{ startIndex: 6, paragraphId: 'para_fixture_19' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_109', startIndex: 7 }],
|
||||
});
|
||||
documentData.documentStyle = {
|
||||
...documentData.documentStyle,
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
};
|
||||
testBed = createTestBed(documentData);
|
||||
commandService = testBed.get(ICommandService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
|
||||
it('creates only the requested header segment by default', () => {
|
||||
expect(commandService.syncExecuteCommand(CreateHeaderFooterCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'header-segment-1',
|
||||
createType: HeaderFooterType.DEFAULT_HEADER,
|
||||
})).toBe(true);
|
||||
|
||||
const snapshot = testBed.doc.getSnapshot();
|
||||
expect(snapshot.documentStyle.defaultHeaderId).toBe('header-segment-1');
|
||||
expect(snapshot.headers?.['header-segment-1'].body?.dataStream).toBe('\r\n');
|
||||
expect(snapshot.documentStyle.defaultFooterId).toBeFalsy();
|
||||
expect(Object.keys(snapshot.footers ?? {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('can create a paired footer for UI compatibility', () => {
|
||||
expect(commandService.syncExecuteCommand(CreateHeaderFooterCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'header-segment-1',
|
||||
createType: HeaderFooterType.DEFAULT_HEADER,
|
||||
createMode: 'pair',
|
||||
})).toBe(true);
|
||||
|
||||
const snapshot = testBed.doc.getSnapshot();
|
||||
expect(snapshot.documentStyle.defaultHeaderId).toBe('header-segment-1');
|
||||
expect(snapshot.documentStyle.defaultFooterId).toEqual(expect.any(String));
|
||||
expect(snapshot.footers?.[snapshot.documentStyle.defaultFooterId!].body?.dataStream).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('links a header to a specific section without changing the document default', () => {
|
||||
const sectionId = testBed.doc.getSnapshot().body?.sectionBreaks?.[0].sectionId;
|
||||
expect(commandService.syncExecuteCommand(CreateHeaderFooterCommand.id, {
|
||||
unitId: 'test',
|
||||
segmentId: 'section-header-1',
|
||||
createType: HeaderFooterType.DEFAULT_HEADER,
|
||||
sectionId,
|
||||
})).toBe(true);
|
||||
|
||||
const snapshot = testBed.doc.getSnapshot();
|
||||
expect(snapshot.body?.sectionBreaks?.[0].defaultHeaderId).toBe('section-header-1');
|
||||
expect(snapshot.documentStyle.defaultHeaderId).toBeFalsy();
|
||||
expect(snapshot.headers?.['section-header-1'].body?.dataStream).toBe('\r\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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, RedoCommand, UndoCommand } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { SetDocumentNameCommand } from '../set-document-name.command';
|
||||
|
||||
describe('SetDocumentNameCommand', () => {
|
||||
let testBed: ReturnType<typeof createTestBed>;
|
||||
let commandService: ICommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
testBed = createTestBed(createDocumentData('test', { dataStream: '\r\n' }));
|
||||
commandService = testBed.get(ICommandService);
|
||||
});
|
||||
|
||||
afterEach(() => testBed.univer.dispose());
|
||||
|
||||
it('renames a document and supports undo and redo', () => {
|
||||
expect(
|
||||
commandService.syncExecuteCommand(SetDocumentNameCommand.id, {
|
||||
unitId: 'test',
|
||||
name: 'New name',
|
||||
})
|
||||
).toBe(true);
|
||||
expect(testBed.doc.getTitle()).toBe('New name');
|
||||
|
||||
expect(commandService.syncExecuteCommand(UndoCommand.id)).toBe(true);
|
||||
expect(testBed.doc.getTitle()).toBe('');
|
||||
expect(commandService.syncExecuteCommand(RedoCommand.id)).toBe(true);
|
||||
expect(testBed.doc.getTitle()).toBe('New name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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 {
|
||||
IAuthzIoService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IPermissionService,
|
||||
OBJECT_PERMISSION_CONFIG_KEY,
|
||||
ObjectPermissionService,
|
||||
Univer,
|
||||
} from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentPermissionRuleModel } from '../../../services/permission/document-permission-rule.model';
|
||||
|
||||
import { SetDocumentPermissionRuleMutation } from '../../mutations/set-document-permission-rule.mutation';
|
||||
import { SetDocumentPermissionCommand } from '../set-document-permission.command';
|
||||
|
||||
const univers: Univer[] = [];
|
||||
afterEach(() => univers.splice(0).forEach((univer) => univer.dispose()));
|
||||
|
||||
describe('SetDocumentPermissionCommand', () => {
|
||||
it('awaits the Authz write and propagates failure without changing effective permissions', async () => {
|
||||
const list = vi.fn(async () => [] as Array<{ unitID: string; objectID: string; objectType: UnitObject }>);
|
||||
const create = vi.fn(async () => {
|
||||
throw new Error('Authz rejected');
|
||||
});
|
||||
const univer = new Univer({ override: [[IAuthzIoService, { useValue: {
|
||||
list,
|
||||
listCollaborators: async () => [],
|
||||
allowed: async ({ actions }: { actions: UnitAction[] }) => actions.map((action) => ({ action, allowed: true })),
|
||||
create,
|
||||
} }]] });
|
||||
univers.push(univer);
|
||||
const injector = univer.__getInjector();
|
||||
const commands = injector.get(ICommandService);
|
||||
injector.add([DocumentPermissionRuleModel]);
|
||||
injector.get(IConfigService).setConfig(OBJECT_PERMISSION_CONFIG_KEY, [UnitObject.Document, UnitObject.DocumentParagraph]);
|
||||
injector.get(ObjectPermissionService).registerRuleModel(UnitObject.Document, injector.get(DocumentPermissionRuleModel), SetDocumentPermissionRuleMutation.id);
|
||||
commands.registerCommand(SetDocumentPermissionRuleMutation);
|
||||
commands.registerCommand(SetDocumentPermissionCommand);
|
||||
await expect(commands.executeCommand(SetDocumentPermissionCommand.id, { unitId: 'unit', objectId: 'paragraph//a', action: UnitAction.Edit, value: false })).rejects.toThrow('Authz rejected');
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ objectType: UnitObject.DocumentParagraph, documentObject: expect.objectContaining({ unitID: 'unit' }) }));
|
||||
expect(injector.get(DocumentPermissionRuleModel).getRules('unit')).toEqual([]);
|
||||
expect(injector.get(IPermissionService).getPermissionPoint(`${UnitObject.DocumentParagraph}.${UnitAction.Edit}_unit_paragraph//a`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects unsupported child actions in policy payloads before calling Authz', async () => {
|
||||
const update = vi.fn();
|
||||
const univer = new Univer({ override: [[IAuthzIoService, { useValue: { update } }]] });
|
||||
univers.push(univer);
|
||||
const commands = univer.__getInjector().get(ICommandService);
|
||||
commands.registerCommand(SetDocumentPermissionCommand);
|
||||
const result = await commands.executeCommand(SetDocumentPermissionCommand.id, { unitId: 'unit', objectId: 'paragraph//a', action: UnitAction.Edit, value: false, policy: { edit: 'owner', collaborators: [], strategies: [{ action: UnitAction.Print, role: 2 }] } });
|
||||
expect(result).toBe(false);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 {
|
||||
IAllowedRequest,
|
||||
ICreateRequest,
|
||||
IListPermPointResponse,
|
||||
IUpdatePermPointRequest,
|
||||
} from '@univerjs/protocol';
|
||||
import {
|
||||
IAuthzIoService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IPermissionService,
|
||||
IUndoRedoService,
|
||||
IUniverInstanceService,
|
||||
OBJECT_PERMISSION_CONFIG_KEY,
|
||||
ObjectPermissionService,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { ObjectScope, UnitAction, UnitObject, UnitRole } from '@univerjs/protocol';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { FDocumentPermission } from '../../../facade/f-document-permission';
|
||||
import { DocumentPermissionRuleModel } from '../../../services/permission/document-permission-rule.model';
|
||||
import { SetDocumentPermissionRuleMutation } from '../../mutations/set-document-permission-rule.mutation';
|
||||
import { SetDocumentPermissionRulesMutation } from '../../mutations/set-document-permission-rules.mutation';
|
||||
import { SetDocumentPermissionsCommand } from '../set-document-permissions.command';
|
||||
|
||||
const univers: Univer[] = [];
|
||||
afterEach(() => univers.splice(0).forEach((univer) => univer.dispose()));
|
||||
function setup() {
|
||||
let sequence = 0;
|
||||
const policies: IListPermPointResponse['objects'] = [];
|
||||
const authz = {
|
||||
create: vi.fn(async (request: ICreateRequest) => {
|
||||
const payload = request.documentObject ?? request.slideObject ?? request.baseObject ?? request.boardObject!;
|
||||
const objectID = `permission-${++sequence}`;
|
||||
policies.push({ ...payload, objectID, objectType: request.objectType, creator: undefined, actions: [], shareOn: false, shareRole: UnitRole.Reader, shareScope: 0 });
|
||||
return objectID;
|
||||
}),
|
||||
list: async ({ objectIDs }: { objectIDs: string[] }) => policies.filter((policy) => objectIDs.includes(policy.objectID)),
|
||||
allowed: async ({ actions }: IAllowedRequest) => actions.map((action) => ({ action, allowed: true })),
|
||||
batchAllowed: async (requests: IAllowedRequest[]) => requests.map((request) => ({ ...request, actions: request.actions.map((action) => ({ action, allowed: action !== UnitAction.Edit })) })),
|
||||
update: vi.fn(async (request: IUpdatePermPointRequest) => {
|
||||
Object.assign(policies.find((policy) => policy.objectID === request.objectID)!, request);
|
||||
}),
|
||||
};
|
||||
const univer = new Univer({ override: [[IAuthzIoService, { useValue: authz }]] });
|
||||
univers.push(univer);
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocumentPermissionRuleModel]);
|
||||
const model = injector.get(DocumentPermissionRuleModel);
|
||||
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'unit', body: { dataStream: '\r\n' } });
|
||||
injector.get(IUniverInstanceService).focusUnit('unit');
|
||||
const commands = injector.get(ICommandService);
|
||||
commands.registerCommand(SetDocumentPermissionRulesMutation);
|
||||
commands.registerCommand(SetDocumentPermissionsCommand);
|
||||
injector.get(ObjectPermissionService).registerRuleModel(UnitObject.Document, model, SetDocumentPermissionRuleMutation.id, SetDocumentPermissionRulesMutation.id);
|
||||
injector.get(IConfigService).setConfig(OBJECT_PERMISSION_CONFIG_KEY, [UnitObject.DocumentParagraph]);
|
||||
const permission = new FDocumentPermission('unit', commands, injector.get(IPermissionService));
|
||||
return { model, commands, permission, authz, policies, permissions: injector.get(IPermissionService), history: injector.get(IUndoRedoService) };
|
||||
}
|
||||
|
||||
it('uses Authz IDs across batch creation, policy updates, removal, and one-step binding undo', async () => {
|
||||
const { permission, model, authz, policies, permissions, history, commands } = setup();
|
||||
const first = 'paragraph//a';
|
||||
const second = 'paragraph//b';
|
||||
const policy = { edit: 'owner' as const, collaborators: [], strategies: [] };
|
||||
expect(await permission.setObjectPermissions([{ objectId: first, policy }, { objectId: second, policy }])).toEqual({ succeeded: [first, second], failed: [] });
|
||||
const rules = model.getRules('unit');
|
||||
expect(rules).toHaveLength(2);
|
||||
expect(permissions.getPermissionPoint(`${UnitObject.DocumentParagraph}.${UnitAction.Edit}_unit_${first}`)?.value).toBe(false);
|
||||
expect(await permission.setObjectPermissions([{ objectId: first, policy: { ...policy, edit: 'all' } }, { objectId: second, policy: null }])).toEqual({ succeeded: [first, second], failed: [] });
|
||||
expect(authz.update).toHaveBeenCalledWith(expect.objectContaining({ objectID: rules[0].permissionId }));
|
||||
expect(policies[0].scope?.edit).toBe(ObjectScope.AllCollaborator);
|
||||
expect(model.getRules('unit')).toEqual([rules[0]]);
|
||||
const undo = history.pitchTopUndoElement()!;
|
||||
expect(undo.undoMutations).toHaveLength(1);
|
||||
await commands.executeCommand(undo.undoMutations[0].id, undo.undoMutations[0].params);
|
||||
expect(model.getRules('unit')).toEqual(rules);
|
||||
expect(authz.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('validates every policy before writes and reports partial Authz failures through the facade', async () => {
|
||||
const { permission, model, authz } = setup();
|
||||
const policy = { edit: 'owner' as const, collaborators: [], strategies: [] };
|
||||
await expect(permission.setObjectPermissions([
|
||||
{ objectId: 'paragraph//a', policy },
|
||||
{ objectId: 'paragraph//b', policy: { ...policy, strategies: [{ action: UnitAction.Copy, role: UnitRole.Owner }] } },
|
||||
])).rejects.toThrow('Invalid');
|
||||
expect(authz.create).not.toHaveBeenCalled();
|
||||
authz.create.mockRejectedValueOnce(new Error('Forbidden'));
|
||||
const result = await permission.setObjectPermissions([{ objectId: 'paragraph//a', policy }, { objectId: 'paragraph//b', policy }]);
|
||||
expect(result.succeeded).toEqual(['paragraph//b']);
|
||||
expect(result.failed).toEqual([{ objectId: 'paragraph//a', error: expect.any(Error) }]);
|
||||
expect(model.getRules('unit').map((rule) => rule.objectId)).toEqual(['paragraph//b']);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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 { DocumentFlavor, ICommandService } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { SetSectionHeaderFooterLinkCommand } from '../set-section-header-footer-link.command';
|
||||
|
||||
describe('SetSectionHeaderFooterLinkCommand', () => {
|
||||
let testBed: ReturnType<typeof createTestBed>;
|
||||
let commandService: ICommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
const data = createDocumentData('section-header-link-doc', {
|
||||
dataStream: 'One\r\nTwo\r\nThree\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'para_one' },
|
||||
{ startIndex: 8, paragraphId: 'para_two' },
|
||||
{ startIndex: 15, paragraphId: 'para_three' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ startIndex: 4, sectionId: 'section_one', defaultHeaderId: 'header-a' },
|
||||
{ startIndex: 9, sectionId: 'section_two' },
|
||||
{ startIndex: 16, sectionId: 'section_three', defaultHeaderId: 'header-c' },
|
||||
],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
data.headers = {
|
||||
'header-a': {
|
||||
headerId: 'header-a',
|
||||
body: {
|
||||
dataStream: 'Shared\r\n',
|
||||
paragraphs: [{ startIndex: 6, paragraphId: 'header_para' }],
|
||||
sectionBreaks: [{ startIndex: 7, sectionId: 'header_section' }],
|
||||
},
|
||||
},
|
||||
'header-c': {
|
||||
headerId: 'header-c',
|
||||
body: {
|
||||
dataStream: 'Third\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'header_c_para' }],
|
||||
sectionBreaks: [{ startIndex: 6, sectionId: 'header_c_section' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
testBed = createTestBed(data);
|
||||
commandService = testBed.get(ICommandService);
|
||||
});
|
||||
|
||||
afterEach(() => testBed.univer.dispose());
|
||||
|
||||
it('unlinks by cloning the inherited resource and links again by removing the explicit reference', () => {
|
||||
const bodyBefore = testBed.doc.getBody()?.dataStream;
|
||||
expect(commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: 'section-header-link-doc',
|
||||
sectionId: 'section_two',
|
||||
kind: 'header',
|
||||
variant: 'default',
|
||||
linkedToPrevious: false,
|
||||
segmentId: 'header-b',
|
||||
})).toBe(true);
|
||||
|
||||
let snapshot = testBed.doc.getSnapshot();
|
||||
expect(snapshot.body?.sectionBreaks?.[1].defaultHeaderId).toBe('header-b');
|
||||
expect(snapshot.headers?.['header-b']).toEqual({
|
||||
...snapshot.headers?.['header-a'],
|
||||
headerId: 'header-b',
|
||||
});
|
||||
|
||||
expect(commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: 'section-header-link-doc',
|
||||
sectionId: 'section_two',
|
||||
kind: 'header',
|
||||
variant: 'default',
|
||||
linkedToPrevious: true,
|
||||
})).toBe(true);
|
||||
|
||||
snapshot = testBed.doc.getSnapshot();
|
||||
expect(snapshot.body?.sectionBreaks?.[1].defaultHeaderId).toBeUndefined();
|
||||
expect(snapshot.headers?.['header-b']).toBeUndefined();
|
||||
expect(snapshot.body?.dataStream).toBe(bodyBefore);
|
||||
});
|
||||
|
||||
it('rejects linking the first section and duplicate unlink operations', () => {
|
||||
expect(commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: 'section-header-link-doc',
|
||||
sectionId: 'section_one',
|
||||
kind: 'header',
|
||||
variant: 'default',
|
||||
linkedToPrevious: true,
|
||||
})).toBe(false);
|
||||
expect(commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: 'section-header-link-doc',
|
||||
sectionId: 'section_three',
|
||||
kind: 'header',
|
||||
variant: 'default',
|
||||
linkedToPrevious: false,
|
||||
segmentId: 'header-d',
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('creates an empty independent footer when the previous section has none', () => {
|
||||
expect(commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: 'section-header-link-doc',
|
||||
sectionId: 'section_two',
|
||||
kind: 'footer',
|
||||
variant: 'default',
|
||||
linkedToPrevious: false,
|
||||
segmentId: 'footer-b',
|
||||
})).toBe(true);
|
||||
|
||||
const snapshot = testBed.doc.getSnapshot();
|
||||
expect(snapshot.body?.sectionBreaks?.[1].defaultFooterId).toBe('footer-b');
|
||||
expect(snapshot.footers?.['footer-b'].body?.dataStream).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('rejects the operation in modern documents', () => {
|
||||
testBed.doc.updateDocumentStyle({
|
||||
...testBed.doc.getDocumentStyle(),
|
||||
documentFlavor: DocumentFlavor.MODERN,
|
||||
});
|
||||
expect(commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: 'section-header-link-doc',
|
||||
sectionId: 'section_two',
|
||||
kind: 'header',
|
||||
variant: 'default',
|
||||
linkedToPrevious: false,
|
||||
segmentId: 'header-modern',
|
||||
})).toBe(false);
|
||||
expect(testBed.doc.getSnapshot().headers?.['header-modern']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 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 { ColumnSeparatorType, DataStreamTreeTokenType, DocumentFlavor, ICommandService, IUndoRedoService, PageOrientType, RedoCommand, SectionType, UndoCommand } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { InsertDocumentColumnBreakCommand, InsertDocumentSectionBreakCommand, UpdateDocumentSectionCommand } from '../update-document-section.command';
|
||||
|
||||
describe('UpdateDocumentSectionCommand', () => {
|
||||
let testBed: ReturnType<typeof createTestBed>;
|
||||
let commandService: ICommandService;
|
||||
|
||||
beforeEach(() => {
|
||||
const data = createDocumentData('section-command-doc', {
|
||||
dataStream: 'One\r\nTwo\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'para_one' },
|
||||
{ startIndex: 8, paragraphId: 'para_two' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ startIndex: 4, sectionId: 'section_one' },
|
||||
{ startIndex: 9, sectionId: 'section_two' },
|
||||
],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
testBed = createTestBed(data);
|
||||
commandService = testBed.get(ICommandService);
|
||||
});
|
||||
|
||||
afterEach(() => testBed.univer.dispose());
|
||||
|
||||
it('updates selected sections by id through one rich-text command', () => {
|
||||
expect(commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
updates: [
|
||||
{
|
||||
sectionId: 'section_one',
|
||||
config: {
|
||||
columnProperties: [
|
||||
{ width: 190, paddingEnd: 18 },
|
||||
{ width: 190, paddingEnd: 0 },
|
||||
],
|
||||
columnSeparatorType: ColumnSeparatorType.BETWEEN_EACH_COLUMN,
|
||||
sectionType: SectionType.CONTINUOUS,
|
||||
},
|
||||
},
|
||||
{
|
||||
sectionId: 'section_two',
|
||||
config: {
|
||||
columnProperties: [
|
||||
{ width: 120, paddingEnd: 12 },
|
||||
{ width: 120, paddingEnd: 12 },
|
||||
{ width: 120, paddingEnd: 0 },
|
||||
],
|
||||
columnSeparatorType: ColumnSeparatorType.BETWEEN_EACH_COLUMN,
|
||||
sectionType: SectionType.NEXT_PAGE,
|
||||
},
|
||||
},
|
||||
],
|
||||
})).toBe(true);
|
||||
|
||||
expect(testBed.doc.getBody()?.sectionBreaks).toEqual([
|
||||
expect.objectContaining({ sectionId: 'section_one', sectionType: SectionType.CONTINUOUS, columnProperties: expect.arrayContaining([expect.objectContaining({ width: 190 })]) }),
|
||||
expect.objectContaining({ sectionId: 'section_two', sectionType: SectionType.NEXT_PAGE, columnProperties: expect.arrayContaining([expect.objectContaining({ width: 120 })]) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects unknown ids and modern documents without partial updates', () => {
|
||||
expect(commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
updates: [
|
||||
{ sectionId: 'section_one', config: { sectionType: SectionType.NEXT_PAGE } },
|
||||
{ sectionId: 'missing', config: { sectionType: SectionType.NEXT_PAGE } },
|
||||
],
|
||||
})).toBe(false);
|
||||
expect(testBed.doc.getBody()?.sectionBreaks?.[0].sectionType).toBeUndefined();
|
||||
|
||||
testBed.doc.updateDocumentStyle({
|
||||
...testBed.doc.getDocumentStyle(),
|
||||
documentFlavor: DocumentFlavor.MODERN,
|
||||
});
|
||||
expect(commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
updates: [{ sectionId: 'section_one', config: { sectionType: SectionType.NEXT_PAGE } }],
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid page geometry without mutating the section', () => {
|
||||
expect(commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
updates: [{ sectionId: 'section_one', config: { marginLeft: -1 } }],
|
||||
})).toBe(false);
|
||||
expect(commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
updates: [{
|
||||
sectionId: 'section_one',
|
||||
config: {
|
||||
pageSize: { width: 100, height: 100 },
|
||||
marginLeft: 50,
|
||||
marginRight: 50,
|
||||
},
|
||||
}],
|
||||
})).toBe(false);
|
||||
expect(testBed.doc.getBody()?.sectionBreaks?.[0].marginLeft).toBeUndefined();
|
||||
});
|
||||
|
||||
it('updates section page geometry and numbering through one rich-text mutation', () => {
|
||||
expect(commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
updates: [{
|
||||
sectionId: 'section_one',
|
||||
config: {
|
||||
pageNumberStart: 5,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
marginTop: 48,
|
||||
marginBottom: 56,
|
||||
marginLeft: 64,
|
||||
marginRight: 72,
|
||||
},
|
||||
}],
|
||||
})).toBe(true);
|
||||
|
||||
expect(testBed.doc.getBody()?.sectionBreaks?.[0]).toMatchObject({
|
||||
pageNumberStart: 5,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
marginTop: 48,
|
||||
marginBottom: 56,
|
||||
marginLeft: 64,
|
||||
marginRight: 72,
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a top-level column break with OOXML round-trip metadata', () => {
|
||||
expect(commandService.syncExecuteCommand(InsertDocumentColumnBreakCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
offset: 2,
|
||||
})).toBe(true);
|
||||
|
||||
const body = testBed.doc.getBody()!;
|
||||
expect(body.dataStream[2]).toBe(DataStreamTreeTokenType.COLUMN_BREAK);
|
||||
expect(body.customRanges).toContainEqual(expect.objectContaining({
|
||||
startIndex: 2,
|
||||
endIndex: 2,
|
||||
wholeEntity: true,
|
||||
properties: { breakType: 'column' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('inserts a boundary and configures the following section as one undoable mutation', () => {
|
||||
testBed.get(IUndoRedoService);
|
||||
const originalBody = structuredClone(testBed.doc.getBody())!;
|
||||
|
||||
expect(commandService.syncExecuteCommand(InsertDocumentSectionBreakCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
offset: 5,
|
||||
sectionId: 'section_inserted',
|
||||
nextSectionType: SectionType.ODD_PAGE,
|
||||
})).toBe(true);
|
||||
expect(testBed.doc.getBody()?.sectionBreaks).toEqual([
|
||||
expect.objectContaining({ sectionId: 'section_one', startIndex: 4 }),
|
||||
expect.objectContaining({ sectionId: 'section_inserted', startIndex: 5 }),
|
||||
expect.objectContaining({ sectionId: 'section_two', startIndex: 10, sectionType: SectionType.ODD_PAGE }),
|
||||
]);
|
||||
|
||||
expect(commandService.syncExecuteCommand(UndoCommand.id)).toBe(true);
|
||||
expect(testBed.doc.getBody()?.dataStream).toBe(originalBody.dataStream);
|
||||
expect(testBed.doc.getBody()?.sectionBreaks).toEqual(originalBody.sectionBreaks);
|
||||
expect(commandService.syncExecuteCommand(RedoCommand.id)).toBe(true);
|
||||
expect(testBed.doc.getBody()?.sectionBreaks).toEqual([
|
||||
expect.objectContaining({ sectionId: 'section_one', startIndex: 4 }),
|
||||
expect.objectContaining({ sectionId: 'section_inserted', startIndex: 5 }),
|
||||
expect.objectContaining({ sectionId: 'section_two', startIndex: 10, sectionType: SectionType.ODD_PAGE }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid section insertion inputs without partial changes', () => {
|
||||
const originalBody = structuredClone(testBed.doc.getBody())!;
|
||||
|
||||
expect(commandService.syncExecuteCommand(InsertDocumentSectionBreakCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
offset: 5,
|
||||
sectionId: 'section_one',
|
||||
})).toBe(false);
|
||||
expect(commandService.syncExecuteCommand(InsertDocumentSectionBreakCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
offset: 5,
|
||||
sectionId: 'section_bad_geometry',
|
||||
config: {
|
||||
pageSize: { width: 100, height: 100 },
|
||||
marginLeft: 50,
|
||||
marginRight: 50,
|
||||
},
|
||||
})).toBe(false);
|
||||
expect(commandService.syncExecuteCommand(InsertDocumentSectionBreakCommand.id, {
|
||||
unitId: 'section-command-doc',
|
||||
offset: 10,
|
||||
sectionId: 'section_without_following_section',
|
||||
nextSectionType: SectionType.NEXT_PAGE,
|
||||
})).toBe(false);
|
||||
|
||||
expect(testBed.doc.getBody()).toEqual(originalBody);
|
||||
});
|
||||
});
|
||||
308
packages/docs/src/commands/commands/core-editing.command.ts
Normal file
308
packages/docs/src/commands/commands/core-editing.command.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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, IDocumentData, IMutationInfo, ITextRange, Nullable, UpdateDocsAttributeType } from '@univerjs/core';
|
||||
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { IRichTextEditingMutationParams } from '../mutations/core-editing.mutation';
|
||||
import {
|
||||
BuildTextUtils,
|
||||
CommandType,
|
||||
DeleteDirection,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSelectionManagerService } from '../../services/doc-selection-manager.service';
|
||||
import { RichTextEditingMutation } from '../mutations/core-editing.mutation';
|
||||
|
||||
export interface IInsertTextCommandParams {
|
||||
unitId: string;
|
||||
body: IDocumentBody;
|
||||
range: ITextRange;
|
||||
segmentId?: string;
|
||||
cursorOffset?: number;
|
||||
debounce?: boolean;
|
||||
textRanges?: Nullable<ITextRangeWithStyle[]>;
|
||||
noNeedSetTextRange?: boolean;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command to insert text. The changed range could be non-collapsed, mainly use in line break and normal input.
|
||||
*/
|
||||
export const InsertTextCommand: ICommand<IInsertTextCommandParams> = {
|
||||
id: 'doc.command.insert-text',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params: IInsertTextCommandParams) => {
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const {
|
||||
range,
|
||||
segmentId,
|
||||
body,
|
||||
unitId,
|
||||
cursorOffset,
|
||||
debounce = true,
|
||||
textRanges,
|
||||
noNeedSetTextRange,
|
||||
isEditing,
|
||||
} = params;
|
||||
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
|
||||
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
|
||||
if (docDataModel == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeRange = docSelectionManagerService.getActiveTextRange();
|
||||
const rangeSegmentId = 'segmentId' in range && typeof range.segmentId === 'string' ? range.segmentId : undefined;
|
||||
const targetSegmentId = segmentId ?? rangeSegmentId ?? activeRange?.segmentId ?? '';
|
||||
const originBody = docDataModel.getSelfOrHeaderFooterModel(targetSegmentId)?.getBody();
|
||||
|
||||
if (originBody == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { startOffset, collapsed } = range;
|
||||
const cursorMove = cursorOffset ?? body.dataStream.length;
|
||||
const mutationTextRanges = textRanges ?? [{
|
||||
startOffset: startOffset + cursorMove,
|
||||
endOffset: startOffset + cursorMove,
|
||||
collapsed,
|
||||
}];
|
||||
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
segmentId: targetSegmentId,
|
||||
actions: [],
|
||||
textRanges: mutationTextRanges,
|
||||
debounce,
|
||||
noNeedSetTextRange,
|
||||
isEditing,
|
||||
},
|
||||
};
|
||||
|
||||
doMutation.params.actions = buildInsertTextActions(docDataModel, originBody, range, body, targetSegmentId);
|
||||
|
||||
const result = commandService.syncExecuteCommand<
|
||||
IRichTextEditingMutationParams,
|
||||
IRichTextEditingMutationParams
|
||||
>(doMutation.id, doMutation.params);
|
||||
|
||||
return Boolean(result);
|
||||
},
|
||||
};
|
||||
|
||||
function buildInsertTextActions(
|
||||
documentDataModel: DocumentDataModel,
|
||||
originBody: IDocumentBody,
|
||||
range: ITextRange,
|
||||
insertBody: IDocumentBody,
|
||||
segmentId?: string
|
||||
): IRichTextEditingMutationParams['actions'] {
|
||||
const { startOffset, endOffset, collapsed } = range;
|
||||
const textX = new TextX();
|
||||
if (collapsed) {
|
||||
if (startOffset > 0) {
|
||||
textX.push({ t: TextXActionType.RETAIN, len: startOffset });
|
||||
}
|
||||
textX.push({ t: TextXActionType.INSERT, body: insertBody, len: insertBody.dataStream.length });
|
||||
} else {
|
||||
textX.push(...BuildTextUtils.selection.delete([range], originBody, 0, insertBody));
|
||||
}
|
||||
|
||||
const textActions = JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(documentDataModel, segmentId));
|
||||
return collapsed
|
||||
? textActions
|
||||
: appendRemovedDrawingActions(textActions, documentDataModel, originBody, startOffset, endOffset, segmentId);
|
||||
}
|
||||
|
||||
function appendRemovedDrawingActions(
|
||||
textActions: IRichTextEditingMutationParams['actions'],
|
||||
documentDataModel: DocumentDataModel,
|
||||
body: IDocumentBody,
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
segmentId?: string
|
||||
): IRichTextEditingMutationParams['actions'] {
|
||||
const drawingActions = BuildTextUtils.drawing.remove(documentDataModel.getSnapshot(), [{
|
||||
startOffset,
|
||||
endOffset,
|
||||
collapsed: false,
|
||||
}], body, segmentId);
|
||||
const rawActions = [textActions, ...drawingActions];
|
||||
|
||||
return rawActions.reduce((accumulator, action) => JSONX.compose(accumulator, action));
|
||||
}
|
||||
|
||||
export interface IDeleteTextCommandParams {
|
||||
unitId: string;
|
||||
range: ITextRange;
|
||||
direction: DeleteDirection;
|
||||
len?: number;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command to delete text, mainly used in BACKSPACE and DELETE when collapsed is true. ONLY handle collapsed range!!!
|
||||
*/
|
||||
export const DeleteTextCommand: ICommand<IDeleteTextCommandParams> = {
|
||||
id: 'doc.command.delete-text',
|
||||
type: CommandType.COMMAND,
|
||||
|
||||
handler: (accessor, params: IDeleteTextCommandParams) => {
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const { range, segmentId, unitId, direction, len = 1 } = params;
|
||||
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
if (docDataModel == null || body == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { startOffset } = range;
|
||||
let start = direction === DeleteDirection.LEFT ? startOffset - len : startOffset;
|
||||
let end = direction === DeleteDirection.LEFT ? startOffset - 1 : startOffset + len - 1;
|
||||
|
||||
const customRange = body.customRanges?.find((customRange) => customRange.startIndex <= start && customRange.endIndex >= end);
|
||||
|
||||
if (customRange?.wholeEntity) {
|
||||
start = customRange.startIndex;
|
||||
end = Math.max(end, customRange.endIndex);
|
||||
}
|
||||
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
segmentId,
|
||||
actions: [],
|
||||
textRanges: [{
|
||||
startOffset: start,
|
||||
endOffset: start,
|
||||
collapsed: true,
|
||||
}],
|
||||
debounce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const textX = new TextX();
|
||||
const jsonX = JSONX.getInstance();
|
||||
|
||||
textX.push(...BuildTextUtils.selection.delete([{
|
||||
...range,
|
||||
startOffset: start,
|
||||
endOffset: end + 1,
|
||||
collapsed: false,
|
||||
}], body));
|
||||
|
||||
const path = getRichTextEditPath(docDataModel, segmentId);
|
||||
doMutation.params.actions = appendRemovedDrawingActions(
|
||||
jsonX.editOp(textX.serialize(), path),
|
||||
docDataModel,
|
||||
body,
|
||||
start,
|
||||
end + 1,
|
||||
segmentId
|
||||
);
|
||||
|
||||
const result = commandService.syncExecuteCommand<
|
||||
IRichTextEditingMutationParams,
|
||||
IRichTextEditingMutationParams
|
||||
>(doMutation.id, doMutation.params);
|
||||
|
||||
return Boolean(result);
|
||||
},
|
||||
};
|
||||
|
||||
export interface IUpdateTextCommandParams {
|
||||
unitId: string;
|
||||
updateBody: IDocumentBody;
|
||||
range: ITextRange;
|
||||
coverType: UpdateDocsAttributeType;
|
||||
textRanges: ITextRangeWithStyle[];
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The command to update text properties, mainly used in BACKSPACE.
|
||||
*/
|
||||
export const UpdateTextCommand: ICommand<IUpdateTextCommandParams> = {
|
||||
id: 'doc.command.update-text',
|
||||
|
||||
type: CommandType.COMMAND,
|
||||
|
||||
handler: (accessor, params: IUpdateTextCommandParams) => {
|
||||
const { range, segmentId, updateBody, coverType, unitId, textRanges } = params;
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
|
||||
|
||||
if (docDataModel == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
actions: [],
|
||||
textRanges,
|
||||
},
|
||||
};
|
||||
|
||||
const textX = new TextX();
|
||||
const jsonX = JSONX.getInstance();
|
||||
|
||||
const { startOffset, endOffset } = range;
|
||||
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: startOffset,
|
||||
});
|
||||
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
body: updateBody,
|
||||
len: endOffset - startOffset,
|
||||
coverType,
|
||||
});
|
||||
|
||||
const path = getRichTextEditPath(docDataModel, segmentId);
|
||||
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
|
||||
|
||||
const result = commandService.syncExecuteCommand<
|
||||
IRichTextEditingMutationParams,
|
||||
IRichTextEditingMutationParams
|
||||
>(doMutation.id, doMutation.params);
|
||||
|
||||
return Boolean(result);
|
||||
},
|
||||
};
|
||||
|
||||
export interface ICoverCommandParams {
|
||||
unitId: string;
|
||||
|
||||
snapshot?: IDocumentData;
|
||||
clearUndoRedoStack?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* 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 { BooleanNumber, DocumentDataModel, ICommand, IDocumentBody, IHeaderAndFooterBase, IMutationInfo, JSONXActions } from '@univerjs/core';
|
||||
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { IRichTextEditingMutationParams } from '../mutations/core-editing.mutation';
|
||||
import {
|
||||
CommandType,
|
||||
createParagraphId,
|
||||
createSectionId,
|
||||
DocumentFlavor,
|
||||
generateRandomId,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '../mutations/core-editing.mutation';
|
||||
|
||||
export enum HeaderFooterType {
|
||||
FIRST_PAGE_HEADER,
|
||||
FIRST_PAGE_FOOTER,
|
||||
DEFAULT_HEADER,
|
||||
DEFAULT_FOOTER,
|
||||
EVEN_PAGE_HEADER,
|
||||
EVEN_PAGE_FOOTER,
|
||||
}
|
||||
|
||||
export interface IHeaderFooterProps {
|
||||
/** Distance from the page edge to the header, in points (pt). */
|
||||
marginHeader?: number;
|
||||
/** Distance from the page edge to the footer, in points (pt). */
|
||||
marginFooter?: number;
|
||||
useFirstPageHeaderFooter?: BooleanNumber;
|
||||
evenAndOddHeaders?: BooleanNumber;
|
||||
}
|
||||
|
||||
export type HeaderFooterCreateMode = 'single' | 'pair';
|
||||
|
||||
export interface ICreateHeaderFooterCommandParams {
|
||||
unitId: string;
|
||||
createType?: HeaderFooterType;
|
||||
segmentId?: string;
|
||||
headerFooterProps?: IHeaderFooterProps;
|
||||
createMode?: HeaderFooterCreateMode;
|
||||
/** Optional stable section id. Omit to configure the document-level default. */
|
||||
sectionId?: string;
|
||||
}
|
||||
|
||||
export function getEmptyHeaderFooterBody(): IDocumentBody {
|
||||
return {
|
||||
dataStream: '\r\n',
|
||||
textRuns: [{
|
||||
st: 0,
|
||||
ed: 0,
|
||||
ts: {
|
||||
fs: 9,
|
||||
},
|
||||
}],
|
||||
customBlocks: [],
|
||||
paragraphs: [
|
||||
{
|
||||
startIndex: 0,
|
||||
paragraphId: createParagraphId(new Set()),
|
||||
paragraphStyle: {
|
||||
spaceAbove: { v: 0 },
|
||||
lineSpacing: 1.5,
|
||||
spaceBelow: { v: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
sectionBreaks: [
|
||||
{
|
||||
sectionId: createSectionId(new Set()),
|
||||
startIndex: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createHeaderFooterAction(
|
||||
segmentId: string | undefined,
|
||||
createType: HeaderFooterType,
|
||||
headerFooterConfig: IHeaderFooterProps,
|
||||
actions: JSONXActions,
|
||||
createMode: HeaderFooterCreateMode = 'single',
|
||||
configPath: Array<string | number> = ['documentStyle']
|
||||
) {
|
||||
const jsonX = JSONX.getInstance();
|
||||
const ID_LEN = 6;
|
||||
const firstSegmentId = segmentId ?? generateRandomId(ID_LEN);
|
||||
const isHeader = createType === HeaderFooterType.DEFAULT_HEADER || createType === HeaderFooterType.FIRST_PAGE_HEADER || createType === HeaderFooterType.EVEN_PAGE_HEADER;
|
||||
const insertAction = jsonX.insertOp([isHeader ? 'headers' : 'footers', firstSegmentId], {
|
||||
[isHeader ? 'headerId' : 'footerId']: firstSegmentId,
|
||||
body: getEmptyHeaderFooterBody(),
|
||||
});
|
||||
|
||||
actions!.push(insertAction!);
|
||||
|
||||
let key = 'defaultHeaderId';
|
||||
let pairKey: string | undefined = 'defaultFooterId';
|
||||
|
||||
switch (createType) {
|
||||
case HeaderFooterType.DEFAULT_HEADER:
|
||||
key = 'defaultHeaderId';
|
||||
pairKey = 'defaultFooterId';
|
||||
break;
|
||||
case HeaderFooterType.DEFAULT_FOOTER:
|
||||
key = 'defaultFooterId';
|
||||
pairKey = 'defaultHeaderId';
|
||||
break;
|
||||
case HeaderFooterType.FIRST_PAGE_HEADER:
|
||||
key = 'firstPageHeaderId';
|
||||
pairKey = 'firstPageFooterId';
|
||||
break;
|
||||
case HeaderFooterType.FIRST_PAGE_FOOTER:
|
||||
key = 'firstPageFooterId';
|
||||
pairKey = 'firstPageHeaderId';
|
||||
break;
|
||||
case HeaderFooterType.EVEN_PAGE_HEADER:
|
||||
key = 'evenPageHeaderId';
|
||||
pairKey = 'evenPageFooterId';
|
||||
break;
|
||||
case HeaderFooterType.EVEN_PAGE_FOOTER:
|
||||
key = 'evenPageFooterId';
|
||||
pairKey = 'evenPageHeaderId';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown header footer type: ${createType}`);
|
||||
}
|
||||
|
||||
const linkedSegmentIds: Array<[string, string]> = [[key, firstSegmentId]];
|
||||
|
||||
if (createMode === 'pair' && pairKey != null) {
|
||||
const secondSegmentId = generateRandomId(ID_LEN);
|
||||
const insertPairAction = jsonX.insertOp([isHeader ? 'footers' : 'headers', secondSegmentId], {
|
||||
[isHeader ? 'footerId' : 'headerId']: secondSegmentId,
|
||||
body: getEmptyHeaderFooterBody(),
|
||||
});
|
||||
actions!.push(insertPairAction!);
|
||||
linkedSegmentIds.push([pairKey, secondSegmentId]);
|
||||
}
|
||||
|
||||
for (const [k, id] of linkedSegmentIds) {
|
||||
if (headerFooterConfig[k as keyof IHeaderFooterProps] != null) {
|
||||
const replaceAction = jsonX.replaceOp([...configPath, k], headerFooterConfig[k as keyof IHeaderFooterProps], id);
|
||||
actions!.push(replaceAction!);
|
||||
} else {
|
||||
const insertAction = jsonX.insertOp([...configPath, k], id);
|
||||
actions!.push(insertAction!);
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export const CreateHeaderFooterCommand: ICommand<ICreateHeaderFooterCommandParams> = {
|
||||
id: 'doc.command.create-header-footer',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params: ICreateHeaderFooterCommandParams) => {
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const { unitId, segmentId, createType, headerFooterProps, createMode = 'single', sectionId } = params;
|
||||
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
|
||||
if (docDataModel == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { documentStyle, body } = docDataModel.getSnapshot();
|
||||
|
||||
// The modern document flavor does not support header/footer, so we return false if the document is modern.
|
||||
if (documentStyle.documentFlavor === DocumentFlavor.MODERN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawActions: JSONXActions = [];
|
||||
const jsonX = JSONX.getInstance();
|
||||
const sectionIndex = sectionId == null ? -1 : body?.sectionBreaks?.findIndex((section) => section.sectionId === sectionId) ?? -1;
|
||||
const sectionBreak = sectionIndex < 0 ? undefined : body?.sectionBreaks?.[sectionIndex];
|
||||
if (sectionId != null && !sectionBreak) {
|
||||
return false;
|
||||
}
|
||||
const headerFooterConfig: IHeaderFooterProps & IHeaderAndFooterBase = sectionBreak ?? documentStyle;
|
||||
const configPath: Array<string | number> = sectionId == null
|
||||
? ['documentStyle']
|
||||
: ['body', 'sectionBreaks', sectionIndex];
|
||||
|
||||
if (createType != null) {
|
||||
createHeaderFooterAction(segmentId, createType, headerFooterConfig, rawActions, createMode, configPath);
|
||||
}
|
||||
|
||||
if (headerFooterProps != null) {
|
||||
Object.keys(headerFooterProps).forEach((key) => {
|
||||
const value = headerFooterProps[key as keyof IHeaderFooterProps];
|
||||
const oldValue = headerFooterConfig[key as keyof IHeaderFooterProps];
|
||||
if (value === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = oldValue === undefined
|
||||
? jsonX.insertOp([...configPath, key], value)
|
||||
: jsonX.replaceOp([...configPath, key], oldValue, value);
|
||||
|
||||
rawActions.push(action!);
|
||||
});
|
||||
}
|
||||
|
||||
if (rawActions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textRanges: ITextRangeWithStyle[] = [{
|
||||
startOffset: 0,
|
||||
endOffset: 0,
|
||||
collapsed: true,
|
||||
}];
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
actions: rawActions.reduce((acc, cur) => JSONX.compose(acc, cur as JSONXActions), null as JSONXActions),
|
||||
textRanges,
|
||||
debounce: true,
|
||||
},
|
||||
};
|
||||
|
||||
if (headerFooterProps?.marginFooter != null || headerFooterProps?.marginHeader != null) {
|
||||
doMutation.params.noNeedSetTextRange = true;
|
||||
}
|
||||
|
||||
const result = commandService.syncExecuteCommand<
|
||||
IRichTextEditingMutationParams,
|
||||
IRichTextEditingMutationParams
|
||||
>(doMutation.id, doMutation.params);
|
||||
|
||||
return Boolean(result);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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,
|
||||
IDocumentDefaultParagraphStyle,
|
||||
IMutationInfo,
|
||||
JSONXActions,
|
||||
Nullable,
|
||||
} from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../mutations/core-editing.mutation';
|
||||
import {
|
||||
CommandType,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '../mutations/core-editing.mutation';
|
||||
|
||||
export type IDocumentDefaultParagraphStylePatch = {
|
||||
[K in keyof IDocumentDefaultParagraphStyle]?: Nullable<IDocumentDefaultParagraphStyle[K]>;
|
||||
};
|
||||
|
||||
export interface ISetDocumentDefaultParagraphStyleCommandParams {
|
||||
unitId: string;
|
||||
defaultParagraphStyle: Nullable<IDocumentDefaultParagraphStylePatch>;
|
||||
}
|
||||
|
||||
export const SetDocumentDefaultParagraphStyleCommand: ICommand<ISetDocumentDefaultParagraphStyleCommandParams> = {
|
||||
id: 'doc.command.set-default-paragraph-style',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (params == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
|
||||
if (documentDataModel == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldStyle = documentDataModel.getSnapshot().documentStyle.defaultParagraphStyle;
|
||||
const jsonX = JSONX.getInstance();
|
||||
const path = ['documentStyle', 'defaultParagraphStyle'];
|
||||
const rawActions: JSONXActions = [];
|
||||
|
||||
if (params.defaultParagraphStyle == null) {
|
||||
if (oldStyle != null) {
|
||||
rawActions.push(jsonX.removeOp(path, oldStyle)!);
|
||||
}
|
||||
} else if (oldStyle == null) {
|
||||
const newStyle = Object.fromEntries(
|
||||
Object.entries(params.defaultParagraphStyle)
|
||||
.filter(([, value]) => value != null)
|
||||
.map(([key, value]) => [key, Tools.deepClone(value)])
|
||||
) as IDocumentDefaultParagraphStyle;
|
||||
|
||||
if (Object.keys(newStyle).length > 0) {
|
||||
rawActions.push(jsonX.insertOp(path, newStyle)!);
|
||||
}
|
||||
} else {
|
||||
Object.entries(params.defaultParagraphStyle).forEach(([key, value]) => {
|
||||
const styleKey = key as keyof IDocumentDefaultParagraphStyle;
|
||||
const oldValue = oldStyle[styleKey];
|
||||
const propertyPath = [...path, key];
|
||||
|
||||
if (value == null) {
|
||||
if (oldValue != null) {
|
||||
rawActions.push(jsonX.removeOp(propertyPath, oldValue)!);
|
||||
}
|
||||
} else if (oldValue == null) {
|
||||
rawActions.push(jsonX.insertOp(propertyPath, Tools.deepClone(value))!);
|
||||
} else {
|
||||
rawActions.push(jsonX.replaceOp(propertyPath, oldValue, Tools.deepClone(value))!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const actions = rawActions.reduce(
|
||||
(acc, action) => JSONX.compose(acc, action as JSONXActions),
|
||||
null as JSONXActions
|
||||
);
|
||||
|
||||
if (rawActions.length === 0 || JSONX.isNoop(actions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId: params.unitId,
|
||||
actions,
|
||||
textRanges: null,
|
||||
noNeedSetTextRange: true,
|
||||
debounce: true,
|
||||
isEditing: false,
|
||||
},
|
||||
};
|
||||
|
||||
return Boolean(commandService.syncExecuteCommand(mutation.id, mutation.params));
|
||||
},
|
||||
};
|
||||
@@ -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 { DocumentDataModel, ICommand } from '@univerjs/core';
|
||||
import {
|
||||
CommandType,
|
||||
ICommandService,
|
||||
IUndoRedoService,
|
||||
IUniverInstanceService,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocsRenameMutation } from '../mutations/docs-rename.mutation';
|
||||
|
||||
export interface ISetDocumentNameCommandParams {
|
||||
unitId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const SetDocumentNameCommand: ICommand<ISetDocumentNameCommandParams> = {
|
||||
id: 'doc.command.set-name',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = accessor
|
||||
.get(IUniverInstanceService)
|
||||
.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const redoParams = { unitId: params.unitId, name: params.name };
|
||||
const undoParams = { unitId: params.unitId, name: document.getTitle() || '' };
|
||||
const result = commandService.syncExecuteCommand(DocsRenameMutation.id, redoParams);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
accessor.get(IUndoRedoService).pushUndoRedo({
|
||||
unitID: params.unitId,
|
||||
redoMutations: [{ id: DocsRenameMutation.id, params: redoParams }],
|
||||
undoMutations: [{ id: DocsRenameMutation.id, params: undoParams }],
|
||||
});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -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 { ICommand, IObjectPermissionPolicy } from '@univerjs/core';
|
||||
import type { DocumentUnitPermissionAction } from '../../services/permission/document-permission';
|
||||
import { CommandType, ObjectPermissionService } from '@univerjs/core';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import {
|
||||
createDocumentPermissionPoint,
|
||||
DOCUMENT_UNIT_PERMISSION_ACTIONS,
|
||||
} from '../../services/permission/document-permission';
|
||||
|
||||
export interface ISetDocumentPermissionCommandParams {
|
||||
unitId: string;
|
||||
objectId: string;
|
||||
action: DocumentUnitPermissionAction;
|
||||
value: boolean;
|
||||
policy?: IObjectPermissionPolicy;
|
||||
/** Remove the child permission rule and restore inherited rights; never deletes content. */
|
||||
remove?: boolean;
|
||||
}
|
||||
|
||||
export const SetDocumentPermissionCommand: ICommand<ISetDocumentPermissionCommandParams> = {
|
||||
type: CommandType.COMMAND,
|
||||
id: 'doc.command.set-permission',
|
||||
async handler(accessor, params) {
|
||||
if (!params || !DOCUMENT_UNIT_PERMISSION_ACTIONS.includes(params.action)) {
|
||||
return false;
|
||||
}
|
||||
if (params.objectId !== params.unitId && params.action !== UnitAction.Edit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (params.policy && (!['all', 'owner', 'members'].includes(params.policy.edit) ||
|
||||
params.policy.strategies.some((strategy) => !DOCUMENT_UNIT_PERMISSION_ACTIONS.includes(strategy.action as typeof params.action) ||
|
||||
(params.objectId !== params.unitId && strategy.action !== UnitAction.Edit)))) {
|
||||
return false;
|
||||
}
|
||||
const point = createDocumentPermissionPoint(params.unitId, params.objectId, params.action);
|
||||
const target = { unitId: params.unitId, objectId: params.objectId, objectType: point.type };
|
||||
const service = accessor.get(ObjectPermissionService);
|
||||
if (params.remove) {
|
||||
await service.remove(target);
|
||||
} else if (params.policy) {
|
||||
await service.save(target, params.policy);
|
||||
} else {
|
||||
await service.setPoint(target, point, params.value);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -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 type { ICommand, IObjectPermissionBatchResult, ISetObjectPermissionsCommandParams } from '@univerjs/core';
|
||||
import { CommandType, ObjectPermissionService } from '@univerjs/core';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
import { createDocumentPermissionPoint } from '../../services/permission/document-permission';
|
||||
|
||||
export const SetDocumentPermissionsCommand: ICommand<ISetObjectPermissionsCommandParams, IObjectPermissionBatchResult> = {
|
||||
type: CommandType.COMMAND,
|
||||
id: 'doc.command.set-permissions',
|
||||
async handler(accessor, params) {
|
||||
if (!params?.unitId || !Array.isArray(params.changes) || !params.changes.length || params.changes.some((change) =>
|
||||
!change?.objectId || change.objectId === params.unitId || (change.policy !== null &&
|
||||
(!change.policy || !['all', 'owner', 'members'].includes(change.policy.edit) ||
|
||||
!Array.isArray(change.policy.collaborators) || !Array.isArray(change.policy.strategies) ||
|
||||
change.policy.strategies.some((strategy) => strategy.action !== UnitAction.Edit))))) {
|
||||
throw new Error('Invalid object permission batch.');
|
||||
}
|
||||
return accessor.get(ObjectPermissionService).saveMany(params.changes.map((change) => {
|
||||
const point = createDocumentPermissionPoint(params.unitId, change.objectId, UnitAction.Edit);
|
||||
return { unitId: params.unitId, objectId: change.objectId, objectType: point.type, policy: change.policy };
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 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, IDocumentData, IMutationInfo, ISectionBreak, JSONXActions, SectionHeaderFooterKind, SectionHeaderFooterReferenceKey, SectionHeaderFooterVariant } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../mutations/core-editing.mutation';
|
||||
import {
|
||||
CommandType,
|
||||
DocumentFlavor,
|
||||
generateRandomId,
|
||||
getSectionHeaderFooterReferenceKey,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
resolveSectionHeaderFooterReference,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { getTopLevelSectionBreaks } from '../../utils/sections';
|
||||
import { RichTextEditingMutation } from '../mutations/core-editing.mutation';
|
||||
import { getEmptyHeaderFooterBody } from './create-header-footer.command';
|
||||
|
||||
export interface ISetSectionHeaderFooterLinkCommandParams {
|
||||
unitId: string;
|
||||
sectionId: string;
|
||||
kind: SectionHeaderFooterKind;
|
||||
variant: SectionHeaderFooterVariant;
|
||||
linkedToPrevious: boolean;
|
||||
/** Stable id to use when unlinking. Generated automatically when omitted. */
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
interface ISectionHeaderFooterLinkContext {
|
||||
snapshot: IDocumentData;
|
||||
sections: ISectionBreak[];
|
||||
sectionIndex: number;
|
||||
storageIndex: number;
|
||||
key: SectionHeaderFooterReferenceKey;
|
||||
}
|
||||
|
||||
export const SetSectionHeaderFooterLinkCommand: ICommand<ISetSectionHeaderFooterLinkCommandParams> = {
|
||||
id: 'doc.command.set-section-header-footer-link',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
const instanceService = accessor.get(IUniverInstanceService);
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const documentDataModel = instanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const snapshot = documentDataModel?.getSnapshot();
|
||||
if (!documentDataModel || !snapshot?.body || snapshot.documentStyle.documentFlavor !== DocumentFlavor.TRADITIONAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sections = getTopLevelSectionBreaks(snapshot.body);
|
||||
const sectionIndex = sections.findIndex((section) => section.sectionId === params.sectionId);
|
||||
if (sectionIndex <= 0) {
|
||||
return false;
|
||||
}
|
||||
const storageIndex = snapshot.body.sectionBreaks?.findIndex((item) => item.sectionId === params.sectionId) ?? -1;
|
||||
if (storageIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = getSectionHeaderFooterReferenceKey(params.kind, params.variant);
|
||||
const context = { snapshot, sections, sectionIndex, storageIndex, key };
|
||||
const rawActions = params.linkedToPrevious
|
||||
? buildLinkActions(context, params.kind)
|
||||
: buildUnlinkActions(context, params.kind, params.segmentId);
|
||||
if (!rawActions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId: params.unitId,
|
||||
actions: rawActions.reduce((actions, action) => JSONX.compose(actions, action as JSONXActions), null as JSONXActions),
|
||||
textRanges: null,
|
||||
noNeedSetTextRange: true,
|
||||
debounce: true,
|
||||
isEditing: false,
|
||||
trigger: SetSectionHeaderFooterLinkCommand.id,
|
||||
},
|
||||
};
|
||||
return Boolean(commandService.syncExecuteCommand(mutation.id, mutation.params));
|
||||
},
|
||||
};
|
||||
|
||||
function buildLinkActions(context: ISectionHeaderFooterLinkContext, kind: SectionHeaderFooterKind): JSONXActions | null {
|
||||
const { snapshot, sections, sectionIndex, storageIndex, key } = context;
|
||||
const section = sections[sectionIndex];
|
||||
const explicitSegmentId = section[key];
|
||||
if (typeof explicitSegmentId !== 'string' || !explicitSegmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jsonX = JSONX.getInstance();
|
||||
const actions: JSONXActions = [jsonX.removeOp(['body', 'sectionBreaks', storageIndex, key], explicitSegmentId)!];
|
||||
const referenceKeys = kind === 'header'
|
||||
? ['defaultHeaderId', 'firstPageHeaderId', 'evenPageHeaderId'] as const
|
||||
: ['defaultFooterId', 'firstPageFooterId', 'evenPageFooterId'] as const;
|
||||
const referencedByDocument = referenceKeys.some((referenceKey) => snapshot.documentStyle[referenceKey] === explicitSegmentId);
|
||||
const referencedBySection = sections.some((item) => referenceKeys.some((referenceKey) => (
|
||||
!(item.sectionId === section.sectionId && referenceKey === key) && item[referenceKey] === explicitSegmentId
|
||||
)));
|
||||
const resources = kind === 'header' ? snapshot.headers : snapshot.footers;
|
||||
if (!referencedByDocument && !referencedBySection && resources?.[explicitSegmentId]) {
|
||||
actions.push(jsonX.removeOp([kind === 'header' ? 'headers' : 'footers', explicitSegmentId], resources[explicitSegmentId])!);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildUnlinkActions(
|
||||
context: ISectionHeaderFooterLinkContext,
|
||||
kind: SectionHeaderFooterKind,
|
||||
requestedSegmentId?: string
|
||||
): JSONXActions | null {
|
||||
const { snapshot, sections, sectionIndex, storageIndex, key } = context;
|
||||
const explicitSegmentId = sections[sectionIndex][key];
|
||||
if (typeof explicitSegmentId === 'string' && explicitSegmentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceSegmentId = resolveSectionHeaderFooterReference(snapshot.documentStyle, sections, sectionIndex - 1, key).segmentId;
|
||||
const segmentId = requestedSegmentId ?? generateRandomId(6);
|
||||
const resources = kind === 'header' ? snapshot.headers : snapshot.footers;
|
||||
if (resources?.[segmentId]) {
|
||||
return null;
|
||||
}
|
||||
const source = sourceSegmentId ? resources?.[sourceSegmentId] : undefined;
|
||||
const idKey = kind === 'header' ? 'headerId' : 'footerId';
|
||||
const resource = source
|
||||
? { ...Tools.deepClone(source), [idKey]: segmentId }
|
||||
: { [idKey]: segmentId, body: getEmptyHeaderFooterBody() };
|
||||
const jsonX = JSONX.getInstance();
|
||||
return [
|
||||
jsonX.insertOp([kind === 'header' ? 'headers' : 'footers', segmentId], resource)!,
|
||||
jsonX.insertOp(['body', 'sectionBreaks', storageIndex, key], segmentId)!,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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, IParagraph, IParagraphStyle, JSONXActions } from '@univerjs/core';
|
||||
import {
|
||||
CommandType,
|
||||
getParagraphContentStartOffset,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
RESTORE_INSERTED_PARAGRAPH_IDS,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
UpdateDocsAttributeType,
|
||||
} from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '../mutations/core-editing.mutation';
|
||||
|
||||
export interface IUpdateDocumentParagraphStyleCommandParams {
|
||||
unitId: string;
|
||||
segmentId?: string;
|
||||
paragraphId: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
style: IParagraphStyle;
|
||||
}
|
||||
|
||||
function isValidParams(
|
||||
params: IUpdateDocumentParagraphStyleCommandParams | undefined
|
||||
): params is IUpdateDocumentParagraphStyleCommandParams {
|
||||
return Boolean(
|
||||
params?.unitId &&
|
||||
params.paragraphId &&
|
||||
Number.isInteger(params.startOffset) &&
|
||||
Number.isInteger(params.endOffset) &&
|
||||
params.startOffset >= 0 &&
|
||||
params.endOffset >= params.startOffset &&
|
||||
params.style &&
|
||||
Object.keys(params.style).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getCurrentParagraph(
|
||||
body: IDocumentBody,
|
||||
params: IUpdateDocumentParagraphStyleCommandParams
|
||||
): IParagraph | null {
|
||||
const matches = (body.paragraphs ?? []).filter((paragraph) =>
|
||||
paragraph.paragraphId === params.paragraphId);
|
||||
if (matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paragraph = matches[0];
|
||||
return getParagraphContentStartOffset(body, paragraph) === params.startOffset &&
|
||||
paragraph.startIndex === params.endOffset &&
|
||||
body.dataStream[params.endOffset] === '\r'
|
||||
? paragraph
|
||||
: null;
|
||||
}
|
||||
|
||||
function createParagraphStyleTextX(
|
||||
body: IDocumentBody,
|
||||
paragraph: IParagraph,
|
||||
params: IUpdateDocumentParagraphStyleCommandParams
|
||||
): { textX: TextX; initializesTextRuns: boolean } {
|
||||
const textX = new TextX();
|
||||
const initializesTextRuns = Boolean(
|
||||
params.style.textStyle &&
|
||||
params.startOffset < params.endOffset &&
|
||||
body.textRuns == null
|
||||
);
|
||||
if (params.startOffset > 0) {
|
||||
textX.push({ t: TextXActionType.RETAIN, len: params.startOffset });
|
||||
}
|
||||
if (params.style.textStyle && params.startOffset < params.endOffset && !initializesTextRuns) {
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: params.endOffset - params.startOffset,
|
||||
coverType: UpdateDocsAttributeType.COVER,
|
||||
body: {
|
||||
dataStream: '',
|
||||
textRuns: [{
|
||||
st: 0,
|
||||
ed: params.endOffset - params.startOffset,
|
||||
ts: Tools.deepClone(params.style.textStyle),
|
||||
}],
|
||||
},
|
||||
});
|
||||
} else if (params.startOffset < params.endOffset) {
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: params.endOffset - params.startOffset,
|
||||
});
|
||||
}
|
||||
|
||||
const paragraphBody: IDocumentBody = {
|
||||
dataStream: '',
|
||||
paragraphs: [{
|
||||
...Tools.deepClone(paragraph),
|
||||
startIndex: 0,
|
||||
paragraphStyle: {
|
||||
...Tools.deepClone(paragraph.paragraphStyle ?? {}),
|
||||
...Tools.deepClone(params.style),
|
||||
},
|
||||
}],
|
||||
};
|
||||
Object.defineProperty(paragraphBody, RESTORE_INSERTED_PARAGRAPH_IDS, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
});
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: 1,
|
||||
coverType: UpdateDocsAttributeType.REPLACE,
|
||||
body: paragraphBody,
|
||||
});
|
||||
|
||||
return { textX, initializesTextRuns };
|
||||
}
|
||||
|
||||
export const UpdateDocumentParagraphStyleCommand: ICommand<IUpdateDocumentParagraphStyleCommandParams> = {
|
||||
id: 'doc.command.update-paragraph-style',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!isValidParams(params)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const instanceService = accessor.get(IUniverInstanceService);
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const documentDataModel = instanceService.getUnit<DocumentDataModel>(
|
||||
params.unitId,
|
||||
UniverInstanceType.UNIVER_DOC
|
||||
);
|
||||
const segmentId = params.segmentId ?? '';
|
||||
const body = documentDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
if (!documentDataModel || !body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paragraph = getCurrentParagraph(body, params);
|
||||
if (!paragraph) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { textX, initializesTextRuns } = createParagraphStyleTextX(body, paragraph, params);
|
||||
const jsonX = JSONX.getInstance();
|
||||
const path = getRichTextEditPath(documentDataModel, segmentId);
|
||||
let actions = jsonX.editOp(textX.serialize(), path);
|
||||
if (initializesTextRuns) {
|
||||
const initializeTextRunsActions: JSONXActions = jsonX.replaceOp([...path, 'textRuns'], undefined, [{
|
||||
st: params.startOffset,
|
||||
ed: params.endOffset,
|
||||
ts: Tools.deepClone(params.style.textStyle),
|
||||
}]);
|
||||
actions = JSONX.compose(
|
||||
initializeTextRunsActions,
|
||||
actions
|
||||
);
|
||||
}
|
||||
|
||||
return Boolean(commandService.syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: params.unitId,
|
||||
segmentId,
|
||||
actions,
|
||||
textRanges: null,
|
||||
noNeedSetTextRange: true,
|
||||
isEditing: false,
|
||||
trigger: UpdateDocumentParagraphStyleCommand.id,
|
||||
}));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 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, IMutationInfo, ISectionBreak } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../mutations/core-editing.mutation';
|
||||
import {
|
||||
ColumnSeparatorType,
|
||||
CommandType,
|
||||
containsInteriorInsertionOffset,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
DocxBreakType,
|
||||
generateRandomId,
|
||||
getBlockRangeInterval,
|
||||
getColumnGroupRangeInterval,
|
||||
getRichTextEditPath,
|
||||
getTableRangeInterval,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
MemoryCursor,
|
||||
PageOrientType,
|
||||
SectionType,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
UpdateDocsAttributeType,
|
||||
} from '@univerjs/core';
|
||||
import { getSectionContentWidth } from '../../utils/section-columns';
|
||||
import { getTopLevelSectionBreaks } from '../../utils/sections';
|
||||
import { RichTextEditingMutation } from '../mutations/core-editing.mutation';
|
||||
|
||||
export type IDocumentSectionConfig = Omit<ISectionBreak, 'sectionId' | 'startIndex'>;
|
||||
|
||||
export interface IDocumentSectionUpdate {
|
||||
sectionId: string;
|
||||
config: Partial<IDocumentSectionConfig>;
|
||||
}
|
||||
|
||||
export interface IUpdateDocumentSectionCommandParams {
|
||||
unitId: string;
|
||||
updates: IDocumentSectionUpdate[];
|
||||
}
|
||||
|
||||
export interface IInsertDocumentSectionBreakCommandParams {
|
||||
unitId: string;
|
||||
offset: number;
|
||||
sectionId: string;
|
||||
config?: Partial<IDocumentSectionConfig>;
|
||||
nextSectionType?: SectionType;
|
||||
}
|
||||
|
||||
export interface IDeleteDocumentSectionBreakCommandParams {
|
||||
unitId: string;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
export interface IInsertDocumentColumnBreakCommandParams {
|
||||
unitId: string;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export const UpdateDocumentSectionCommand: ICommand<IUpdateDocumentSectionCommandParams> = {
|
||||
id: 'doc.command.update-section',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params?.updates.length || params.updates.some(({ sectionId, config }) => !sectionId || Object.keys(config).length === 0)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const instanceService = accessor.get(IUniverInstanceService);
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const documentDataModel = instanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (!documentDataModel || documentDataModel.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = documentDataModel.getBody();
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updates = new Map(params.updates.map(({ sectionId, config }) => [sectionId, config]));
|
||||
if (updates.size !== params.updates.length) {
|
||||
return false;
|
||||
}
|
||||
const selectedIds = new Set(updates.keys());
|
||||
const sections = getTopLevelSectionBreaks(body)
|
||||
.filter((section) => selectedIds.has(section.sectionId))
|
||||
.sort((left, right) => left.startIndex - right.startIndex);
|
||||
if (sections.length !== selectedIds.size) {
|
||||
return false;
|
||||
}
|
||||
const documentStyle = documentDataModel.getDocumentStyle();
|
||||
if (sections.some((section) => !isValidSectionConfig(
|
||||
{ ...section, ...updates.get(section.sectionId) },
|
||||
documentStyle
|
||||
))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cursor = new MemoryCursor();
|
||||
const textX = new TextX();
|
||||
for (const section of sections) {
|
||||
textX.push({ t: TextXActionType.RETAIN, len: section.startIndex - cursor.cursor });
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: 1,
|
||||
coverType: UpdateDocsAttributeType.REPLACE,
|
||||
body: {
|
||||
dataStream: '',
|
||||
sectionBreaks: [{
|
||||
...Tools.deepClone(section),
|
||||
...Tools.deepClone(updates.get(section.sectionId)!),
|
||||
sectionId: section.sectionId,
|
||||
startIndex: 0,
|
||||
}],
|
||||
},
|
||||
});
|
||||
cursor.moveCursorTo(section.startIndex + 1);
|
||||
}
|
||||
|
||||
const jsonX = JSONX.getInstance();
|
||||
const mutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId: params.unitId,
|
||||
actions: jsonX.editOp(textX.serialize(), getRichTextEditPath(documentDataModel)),
|
||||
textRanges: null,
|
||||
noNeedSetTextRange: true,
|
||||
debounce: true,
|
||||
isEditing: false,
|
||||
trigger: UpdateDocumentSectionCommand.id,
|
||||
},
|
||||
};
|
||||
|
||||
return Boolean(commandService.syncExecuteCommand(mutation.id, mutation.params));
|
||||
},
|
||||
};
|
||||
|
||||
export const InsertDocumentSectionBreakCommand: ICommand<IInsertDocumentSectionBreakCommandParams> = {
|
||||
id: 'doc.command.insert-section-break',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
const context = getTraditionalDocumentContext(accessor, params.unitId);
|
||||
if (!context || !params.sectionId || !Number.isInteger(params.offset) ||
|
||||
(params.nextSectionType != null && !isValidEnumValue(SectionType, params.nextSectionType))) {
|
||||
return false;
|
||||
}
|
||||
const { body, documentDataModel, commandService } = context;
|
||||
const insertedSection: ISectionBreak = {
|
||||
...Tools.deepClone(params.config ?? {}),
|
||||
sectionId: params.sectionId,
|
||||
startIndex: params.offset,
|
||||
};
|
||||
if (!isValidTopLevelInsertionOffset(body, params.offset) ||
|
||||
body.sectionBreaks?.some((section) => section.sectionId === params.sectionId) ||
|
||||
!isValidSectionConfig(insertedSection, documentDataModel.getDocumentStyle())) {
|
||||
return false;
|
||||
}
|
||||
const nextSection = params.nextSectionType == null
|
||||
? undefined
|
||||
: getTopLevelSectionBreaks(body).find((section) => section.startIndex >= params.offset);
|
||||
if (params.nextSectionType != null && !nextSection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textX = new TextX();
|
||||
textX.retain(params.offset);
|
||||
textX.insert(1, {
|
||||
dataStream: DataStreamTreeTokenType.SECTION_BREAK,
|
||||
sectionBreaks: [{
|
||||
...insertedSection,
|
||||
startIndex: 0,
|
||||
}],
|
||||
});
|
||||
if (nextSection && params.nextSectionType != null) {
|
||||
textX.retain(nextSection.startIndex - params.offset);
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: 1,
|
||||
coverType: UpdateDocsAttributeType.REPLACE,
|
||||
body: {
|
||||
dataStream: '',
|
||||
sectionBreaks: [{
|
||||
...Tools.deepClone(nextSection),
|
||||
sectionType: params.nextSectionType,
|
||||
startIndex: 0,
|
||||
}],
|
||||
},
|
||||
});
|
||||
}
|
||||
return executeSectionTextX(commandService, documentDataModel, textX, InsertDocumentSectionBreakCommand.id);
|
||||
},
|
||||
};
|
||||
|
||||
export const InsertDocumentColumnBreakCommand: ICommand<IInsertDocumentColumnBreakCommandParams> = {
|
||||
id: 'doc.command.insert-column-break',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
const context = getTraditionalDocumentContext(accessor, params.unitId);
|
||||
if (!context || !isValidTopLevelInsertionOffset(context.body, params.offset)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textX = new TextX();
|
||||
textX.retain(params.offset);
|
||||
textX.insert(1, {
|
||||
dataStream: DataStreamTreeTokenType.COLUMN_BREAK,
|
||||
customRanges: [{
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
rangeId: `docx-break-${generateRandomId()}`,
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
wholeEntity: true,
|
||||
properties: { breakType: DocxBreakType.COLUMN },
|
||||
}],
|
||||
});
|
||||
return executeSectionTextX(context.commandService, context.documentDataModel, textX, InsertDocumentColumnBreakCommand.id);
|
||||
},
|
||||
};
|
||||
|
||||
export const DeleteDocumentSectionBreakCommand: ICommand<IDeleteDocumentSectionBreakCommandParams> = {
|
||||
id: 'doc.command.delete-section-break',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
if (!params) {
|
||||
return false;
|
||||
}
|
||||
const context = getTraditionalDocumentContext(accessor, params.unitId);
|
||||
if (!context || !params.sectionId) {
|
||||
return false;
|
||||
}
|
||||
const sections = getTopLevelSectionBreaks(context.body);
|
||||
if (sections.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
const section = sections.find((item) => item.sectionId === params.sectionId);
|
||||
if (!section || section === sections[sections.length - 1]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textX = new TextX();
|
||||
textX.retain(section.startIndex);
|
||||
textX.delete(1);
|
||||
return executeSectionTextX(context.commandService, context.documentDataModel, textX, DeleteDocumentSectionBreakCommand.id);
|
||||
},
|
||||
};
|
||||
|
||||
function getTraditionalDocumentContext(accessor: Parameters<typeof UpdateDocumentSectionCommand.handler>[0], unitId?: string) {
|
||||
if (!unitId) {
|
||||
return null;
|
||||
}
|
||||
const instanceService = accessor.get(IUniverInstanceService);
|
||||
const documentDataModel = instanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const body = documentDataModel?.getBody();
|
||||
if (!documentDataModel || !body || documentDataModel.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
body,
|
||||
documentDataModel,
|
||||
commandService: accessor.get(ICommandService),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidTopLevelInsertionOffset(body: IDocumentBody, offset: number): boolean {
|
||||
return Number.isInteger(offset) &&
|
||||
offset >= 0 &&
|
||||
offset <= body.dataStream.length &&
|
||||
!body.tables?.some((range) => containsInteriorInsertionOffset(getTableRangeInterval(range), offset)) &&
|
||||
!body.columnGroups?.some((range) => containsInteriorInsertionOffset(getColumnGroupRangeInterval(range), offset)) &&
|
||||
!body.blockRanges?.some((range) => containsInteriorInsertionOffset(getBlockRangeInterval(range), offset));
|
||||
}
|
||||
|
||||
function isValidSectionConfig(
|
||||
section: ISectionBreak,
|
||||
documentStyle: ReturnType<DocumentDataModel['getDocumentStyle']>
|
||||
): boolean {
|
||||
if (section.sectionType != null && !isValidEnumValue(SectionType, section.sectionType)) {
|
||||
return false;
|
||||
}
|
||||
if (section.pageOrient != null && !isValidEnumValue(PageOrientType, section.pageOrient)) {
|
||||
return false;
|
||||
}
|
||||
if (section.columnSeparatorType != null && !isValidEnumValue(ColumnSeparatorType, section.columnSeparatorType)) {
|
||||
return false;
|
||||
}
|
||||
if (section.pageNumberStart != null &&
|
||||
(!Number.isInteger(section.pageNumberStart) || section.pageNumberStart < 1)) {
|
||||
return false;
|
||||
}
|
||||
if (section.pageSize &&
|
||||
(!isPositiveFinite(section.pageSize.width) || !isPositiveFinite(section.pageSize.height))) {
|
||||
return false;
|
||||
}
|
||||
if ([section.marginTop, section.marginBottom, section.marginLeft, section.marginRight]
|
||||
.some((margin) => margin != null && (!Number.isFinite(margin) || margin < 0))) {
|
||||
return false;
|
||||
}
|
||||
const pageSize = section.pageSize ?? documentStyle.pageSize;
|
||||
const marginTop = section.marginTop ?? documentStyle.marginTop ?? 0;
|
||||
const marginBottom = section.marginBottom ?? documentStyle.marginBottom ?? 0;
|
||||
const marginLeft = section.marginLeft ?? documentStyle.marginLeft ?? 0;
|
||||
const marginRight = section.marginRight ?? documentStyle.marginRight ?? 0;
|
||||
if ((pageSize?.width != null && marginLeft + marginRight >= pageSize.width) ||
|
||||
(pageSize?.height != null && marginTop + marginBottom >= pageSize.height)) {
|
||||
return false;
|
||||
}
|
||||
const columns = section.columnProperties ?? [];
|
||||
if (columns.some(({ width, paddingEnd }) =>
|
||||
!Number.isFinite(width) || !Number.isFinite(paddingEnd) || width < 0 || paddingEnd < 0)) {
|
||||
return false;
|
||||
}
|
||||
return columns.reduce((sum, { width, paddingEnd }) => sum + width + paddingEnd, 0) <=
|
||||
getSectionContentWidth(documentStyle, section);
|
||||
}
|
||||
|
||||
function isPositiveFinite(value: number | undefined): boolean {
|
||||
return value != null && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isValidEnumValue(enumObject: object, value: number): boolean {
|
||||
return Object.values(enumObject).includes(value);
|
||||
}
|
||||
|
||||
function executeSectionTextX(
|
||||
commandService: ICommandService,
|
||||
documentDataModel: DocumentDataModel,
|
||||
textX: TextX,
|
||||
trigger: string
|
||||
): boolean {
|
||||
const actions = JSONX.getInstance().editOp(textX.serialize(), getRichTextEditPath(documentDataModel));
|
||||
return Boolean(commandService.syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: documentDataModel.getUnitId(),
|
||||
actions,
|
||||
textRanges: null,
|
||||
noNeedSetTextRange: true,
|
||||
debounce: true,
|
||||
isEditing: false,
|
||||
trigger,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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 { IRichTextEditingMutationParams } from '../core-editing.mutation';
|
||||
import { CustomRangeType, DocumentFlavor, ICommandService, JSONX, TextX } from '@univerjs/core';
|
||||
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from '../../../facade/__tests__/create-test-bed';
|
||||
import { DocSelectionManagerService } from '../../../services/doc-selection-manager.service';
|
||||
import { InsertTextCommand } from '../../commands/core-editing.command';
|
||||
import { RichTextEditingMutation, transformDocumentTextRanges } from '../core-editing.mutation';
|
||||
|
||||
describe('transformDocumentTextRanges', () => {
|
||||
it('moves an active body caret through an insertion with right priority', () => {
|
||||
expect(transformDocumentTextRanges(['body', {
|
||||
et: 'text-x',
|
||||
e: [
|
||||
{ t: 'r', len: 10 },
|
||||
{ t: 'i', len: 6, body: { dataStream: 'REMOTE' } },
|
||||
],
|
||||
}], [{
|
||||
startOffset: 14,
|
||||
endOffset: 14,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
segmentId: '',
|
||||
}])).toEqual([expect.objectContaining({
|
||||
startOffset: 20,
|
||||
endOffset: 20,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
})]);
|
||||
});
|
||||
|
||||
it('treats an omitted segment id as the document body', () => {
|
||||
expect(transformDocumentTextRanges(['body', {
|
||||
et: 'text-x',
|
||||
e: [
|
||||
{ t: 'i', len: 6, body: { dataStream: 'REMOTE' } },
|
||||
],
|
||||
}], [{
|
||||
startOffset: 14,
|
||||
endOffset: 14,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
}])).toEqual([expect.objectContaining({
|
||||
startOffset: 20,
|
||||
endOffset: 20,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
})]);
|
||||
});
|
||||
|
||||
it('does not move a body selection through a header mutation', () => {
|
||||
const range = {
|
||||
startOffset: 14,
|
||||
endOffset: 18,
|
||||
collapsed: false,
|
||||
segmentId: '',
|
||||
};
|
||||
expect(transformDocumentTextRanges(['header-1', 'body', {
|
||||
et: 'text-x',
|
||||
e: [{ t: 'i', len: 6, body: { dataStream: 'REMOTE' } }],
|
||||
}], [range])).toEqual([range]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RichTextEditingMutation selection scheduling', () => {
|
||||
it('rejects a remote footnote whose segment identity belongs to a header', () => {
|
||||
const snapshot = createDocumentData('note-collision', {
|
||||
dataStream: 'Body\r\n',
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: 4 }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: 5 }],
|
||||
});
|
||||
snapshot.headers = { shared: { headerId: 'shared', body: {
|
||||
dataStream: 'Header\r\n',
|
||||
paragraphs: [{ paragraphId: 'hp', startIndex: 6 }],
|
||||
sectionBreaks: [{ sectionId: 'hs', startIndex: 7 }],
|
||||
} } };
|
||||
const bed = createTestBed(snapshot);
|
||||
try {
|
||||
const before = structuredClone(bed.doc.getSnapshot());
|
||||
expect(() => bed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: snapshot.id,
|
||||
segmentId: '',
|
||||
textRanges: null,
|
||||
actions: JSONX.getInstance().insertOp(['notes', 'shared'], { type: 'footnote' as const, noteId: 'shared', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'np', startIndex: 0 }], sectionBreaks: [{ sectionId: 'ns', startIndex: 1 }] } }),
|
||||
}, { fromCollab: true })).toThrow('invalid-note-id');
|
||||
expect(bed.doc.getSnapshot()).toEqual(before);
|
||||
expect(bed.doc.getSelfOrHeaderFooterModel('shared')?.getBody()?.dataStream).toBe('Header\r\n');
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps consecutive text, spaces and paragraphs in the active footnote', async () => {
|
||||
const snapshot = createDocumentData('note-input', {
|
||||
dataStream: 'A\uFFFCB\r\n',
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: 3 }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: 4 }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
});
|
||||
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
snapshot.notes = { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: '\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 0 }],
|
||||
sectionBreaks: [{ sectionId: 'ns', startIndex: 1 }],
|
||||
} } };
|
||||
const bed = createTestBed(snapshot);
|
||||
const selectionManager = bed.get(DocSelectionManagerService);
|
||||
const target = { unitId: 'note-input', subUnitId: 'note-input' };
|
||||
selectionManager.__TEST_ONLY_setCurrentSelection(target);
|
||||
selectionManager.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'note' }],
|
||||
rectRanges: [],
|
||||
segmentId: 'note',
|
||||
segmentPage: 0,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
}, target);
|
||||
try {
|
||||
for (const text of ['First', ' ', 'line', '\r', 'Next', ' ', 'line']) {
|
||||
const range = selectionManager.getActiveTextRange()!;
|
||||
expect(range.segmentId).toBe('note');
|
||||
bed.get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: target.unitId,
|
||||
range,
|
||||
body: text === '\r'
|
||||
? { dataStream: text, paragraphs: [{ paragraphId: 'next-p', startIndex: 0 }] }
|
||||
: { dataStream: text },
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(selectionManager.getSelectionInfo()?.segmentId).toBe('note');
|
||||
}
|
||||
expect(bed.doc.getSnapshot().notes?.note.body.dataStream).toBe('First line\rNext line\r\n');
|
||||
expect(bed.doc.getBody()?.dataStream).toBe('A\uFFFCB\r\n');
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 2])('publishes deletion at offset %i and its collaborative undo atomically', (offset) => {
|
||||
const snapshot = createDocumentData('note-delete', {
|
||||
dataStream: 'A\uFFFCB\r\n',
|
||||
customDecorations: [],
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: 3 }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: 4 }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 1, endIndex: 1, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
});
|
||||
snapshot.documentStyle = { ...snapshot.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
snapshot.notes = { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 11 }],
|
||||
sectionBreaks: [{ sectionId: 'ns', startIndex: 12 }],
|
||||
} } };
|
||||
const bed = createTestBed(structuredClone(snapshot));
|
||||
const peer = createTestBed(structuredClone(snapshot));
|
||||
const params: IRichTextEditingMutationParams = {
|
||||
unitId: 'note-delete',
|
||||
segmentId: '',
|
||||
textRanges: null,
|
||||
actions: JSONX.getInstance().editOp(new TextX().retain(offset).delete(1).serialize(), ['body']),
|
||||
};
|
||||
try {
|
||||
const before = structuredClone(bed.doc.getSnapshot());
|
||||
const expectedText = offset === 1 ? 'AB\r\n' : 'A\uFFFC\r\n';
|
||||
const noteExists = offset !== 1;
|
||||
const observed: Array<{ text?: string; noteExists: boolean }> = [];
|
||||
const subscription = bed.doc.change$.subscribe(() => observed.push({
|
||||
text: bed.doc.getBody()?.dataStream,
|
||||
noteExists: bed.doc.getSnapshot().notes?.note != null,
|
||||
}));
|
||||
observed.length = 0;
|
||||
const apply = vi.spyOn(JSONX, 'apply');
|
||||
const undo = bed.get(ICommandService).syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(RichTextEditingMutation.id, params);
|
||||
expect(apply).toHaveBeenCalledTimes(offset === 1 ? 2 : 1);
|
||||
apply.mockRestore();
|
||||
expect(bed.doc.getBody()?.dataStream).toBe(expectedText);
|
||||
expect(bed.doc.getSnapshot().notes?.note != null).toBe(noteExists);
|
||||
expect(observed).toEqual([{ text: expectedText, noteExists }]);
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, params, { fromCollab: true });
|
||||
expect(peer.doc.getSnapshot()).toEqual(bed.doc.getSnapshot());
|
||||
bed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, undo);
|
||||
peer.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, undo, { fromCollab: true });
|
||||
expect(bed.doc.getSnapshot()).toEqual(before);
|
||||
expect(peer.doc.getSnapshot()).toEqual(before);
|
||||
expect(observed).toEqual([{ text: expectedText, noteExists }, { text: 'A\uFFFCB\r\n', noteExists: true }]);
|
||||
subscription.unsubscribe();
|
||||
} finally {
|
||||
bed.univer.dispose();
|
||||
peer.univer.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not refresh selection for metadata changes without ranges', async () => {
|
||||
const testBed = createTestBed();
|
||||
const selectionManager = testBed.get(DocSelectionManagerService);
|
||||
const refreshSelection = vi.spyOn(selectionManager, 'refreshSelection');
|
||||
const documentStyle = testBed.doc.getDocumentStyle();
|
||||
const previousMargin = documentStyle.marginTop;
|
||||
const actions = JSONX.getInstance().replaceOp(
|
||||
['documentStyle', 'marginTop'],
|
||||
previousMargin,
|
||||
(previousMargin ?? 0) + 1
|
||||
);
|
||||
|
||||
testBed.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: testBed.doc.getUnitId(),
|
||||
actions,
|
||||
textRanges: [],
|
||||
trigger: 'test.metadata-command',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshSelection).not.toHaveBeenCalled();
|
||||
testBed.univer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import { JSONX } from '@univerjs/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { validateDocStructureMutation } from '../doc-structure-mutation-validation';
|
||||
|
||||
describe('validateDocStructureMutation', () => {
|
||||
it('skips body structure scans for table metadata updates', () => {
|
||||
const getSelfOrHeaderFooterModel = vi.fn(() => {
|
||||
throw new Error('body structure should not be scanned');
|
||||
});
|
||||
const model = {
|
||||
getSnapshot: () => ({}),
|
||||
getSelfOrHeaderFooterModel,
|
||||
} as unknown as DocumentDataModel;
|
||||
const actions = JSONX.getInstance().replaceOp(
|
||||
['tableSource', 'table-1', 'tableColumns', 0, 'size', 'width', 'v'],
|
||||
80,
|
||||
96
|
||||
);
|
||||
const undoActions = JSONX.getInstance().replaceOp(
|
||||
['tableSource', 'table-1', 'tableColumns', 0, 'size', 'width', 'v'],
|
||||
96,
|
||||
80
|
||||
);
|
||||
|
||||
// Regression: table resize on a 560-page document spent hundreds of milliseconds
|
||||
// validating an unchanged body before every visual update.
|
||||
expect(validateDocStructureMutation(model, '', actions, undoActions)).toBe(true);
|
||||
expect(getSelfOrHeaderFooterModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips body structure scans for named-style metadata updates', () => {
|
||||
const getSelfOrHeaderFooterModel = vi.fn(() => {
|
||||
throw new Error('body structure should not be scanned');
|
||||
});
|
||||
const model = {
|
||||
getSnapshot: () => ({}),
|
||||
getSelfOrHeaderFooterModel,
|
||||
} as unknown as DocumentDataModel;
|
||||
const actions = JSONX.getInstance().replaceOp(
|
||||
['styles', 'heading-1', 'paragraphStyle', 'spaceBelow', 'v'],
|
||||
0,
|
||||
12
|
||||
);
|
||||
const undoActions = JSONX.getInstance().replaceOp(
|
||||
['styles', 'heading-1', 'paragraphStyle', 'spaceBelow', 'v'],
|
||||
12,
|
||||
0
|
||||
);
|
||||
|
||||
expect(validateDocStructureMutation(model, '', actions, undoActions)).toBe(true);
|
||||
expect(getSelfOrHeaderFooterModel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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 { DocumentDataModel, IDocumentData } from '@univerjs/core';
|
||||
import { ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DocsRenameMutation } from '../docs-rename.mutation';
|
||||
|
||||
const unitId = 'rename-doc';
|
||||
|
||||
function createDocData(): IDocumentData {
|
||||
return {
|
||||
id: unitId,
|
||||
title: 'Original document',
|
||||
body: {
|
||||
dataStream: 'Hello\r\n',
|
||||
},
|
||||
documentStyle: {
|
||||
pageSize: {
|
||||
width: 594.3,
|
||||
height: 840.51,
|
||||
},
|
||||
marginTop: 72,
|
||||
marginBottom: 72,
|
||||
marginRight: 90,
|
||||
marginLeft: 90,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocsRenameMutation', () => {
|
||||
let univer: Univer;
|
||||
let commandService: ICommandService;
|
||||
let univerInstanceService: IUniverInstanceService;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData());
|
||||
commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand(DocsRenameMutation);
|
||||
univerInstanceService = injector.get(IUniverInstanceService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('renames the target document unit', async () => {
|
||||
const result = await commandService.executeCommand(DocsRenameMutation.id, {
|
||||
unitId,
|
||||
name: 'Quarterly notes',
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC)?.getSnapshot().title).toBe('Quarterly notes');
|
||||
});
|
||||
|
||||
it('does not rename when the target document does not exist', async () => {
|
||||
const result = await commandService.executeCommand(DocsRenameMutation.id, {
|
||||
unitId: 'missing-doc',
|
||||
name: 'Missing',
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC)?.getSnapshot().title).toBe('Original document');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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, IResourceManagerService, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { UnitObject } from '@univerjs/protocol';
|
||||
import { afterEach, expect, it } from 'vitest';
|
||||
import { DocumentPermissionRuleModel } from '../../../services/permission/document-permission-rule.model';
|
||||
import { SetDocumentPermissionRuleMutation } from '../set-document-permission-rule.mutation';
|
||||
|
||||
const univers: Univer[] = [];
|
||||
afterEach(() => univers.splice(0).forEach((univer) => univer.dispose()));
|
||||
function setup() {
|
||||
const univer = new Univer();
|
||||
univers.push(univer);
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocumentPermissionRuleModel]);
|
||||
const model = injector.get(DocumentPermissionRuleModel);
|
||||
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'unit', body: { dataStream: '\r\n' } });
|
||||
const commands = injector.get(ICommandService);
|
||||
commands.registerCommand(SetDocumentPermissionRuleMutation);
|
||||
return { univer, model, commands, resources: injector.get(IResourceManagerService) };
|
||||
}
|
||||
it('round trips bindings through the business resource and deterministically replays removal and restoration', () => {
|
||||
const first = setup();
|
||||
const params = { unitId: 'unit', objectId: 'stable/object', objectType: UnitObject.DocumentParagraph, rule: { objectId: 'stable/object', objectType: UnitObject.DocumentParagraph, permissionId: 'authz-rule-1' } };
|
||||
expect(first.commands.syncExecuteCommand(SetDocumentPermissionRuleMutation.id, params)).toBe(true);
|
||||
const second = setup();
|
||||
second.resources.loadResources('unit', first.resources.getResources('unit', UniverInstanceType.UNIVER_DOC));
|
||||
expect(second.model.getRules('unit')).toEqual([params.rule]);
|
||||
expect(second.commands.syncExecuteCommand(SetDocumentPermissionRuleMutation.id, { ...params, rule: null })).toBe(true);
|
||||
expect(second.model.getRules('unit')).toEqual([]);
|
||||
expect(second.commands.syncExecuteCommand(SetDocumentPermissionRuleMutation.id, params)).toBe(true);
|
||||
const copy = second.model.getRules('unit');
|
||||
copy[0].permissionId = 'changed';
|
||||
expect(second.model.getRules('unit')).toEqual([params.rule]);
|
||||
expect(second.commands.syncExecuteCommand(SetDocumentPermissionRuleMutation.id, { ...params, unitId: 'missing' })).toBe(false);
|
||||
expect(second.commands.syncExecuteCommand(SetDocumentPermissionRuleMutation.id, { ...params, objectType: UnitObject.Worksheet })).toBe(false);
|
||||
second.resources.unloadResources('unit', UniverInstanceType.UNIVER_DOC);
|
||||
expect(second.model.getRules('unit')).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICommandService, IResourceManagerService, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { UnitObject } from '@univerjs/protocol';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { DocumentPermissionRuleModel } from '../../../services/permission/document-permission-rule.model';
|
||||
import { SetDocumentPermissionRulesMutation } from '../set-document-permission-rules.mutation';
|
||||
|
||||
const univers: Univer[] = [];
|
||||
afterEach(() => univers.splice(0).forEach((univer) => univer.dispose()));
|
||||
function setup() {
|
||||
const univer = new Univer();
|
||||
univers.push(univer);
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([DocumentPermissionRuleModel]);
|
||||
const model = injector.get(DocumentPermissionRuleModel);
|
||||
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'unit', body: { dataStream: '\r\n' } });
|
||||
const commands = injector.get(ICommandService);
|
||||
commands.registerCommand(SetDocumentPermissionRulesMutation);
|
||||
return { univer, model, commands, resources: injector.get(IResourceManagerService) };
|
||||
}
|
||||
const objectType = UnitObject.DocumentParagraph;
|
||||
function binding(objectId: string, permissionId: string) {
|
||||
return { objectId, objectType, rule: { objectId, objectType, permissionId } };
|
||||
}
|
||||
|
||||
it('replays a mixed batch through the product resource and publishes one change', () => {
|
||||
const first = setup();
|
||||
const initial = { unitId: 'unit', rules: [binding('a', 'permission-a'), binding('b', 'permission-b')] };
|
||||
first.commands.syncExecuteCommand(SetDocumentPermissionRulesMutation.id, initial);
|
||||
const changed = vi.fn();
|
||||
const subscription = first.model.changed$.subscribe(changed);
|
||||
const batch = { unitId: 'unit', rules: [binding('a', 'replacement'), { objectId: 'b', objectType, rule: null }, binding('c', 'permission-c')] };
|
||||
expect(first.commands.syncExecuteCommand(SetDocumentPermissionRulesMutation.id, batch)).toBe(true);
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
subscription.unsubscribe();
|
||||
const second = setup();
|
||||
second.resources.loadResources('unit', first.resources.getResources('unit', UniverInstanceType.UNIVER_DOC));
|
||||
expect(second.model.getRules('unit')).toEqual([binding('a', 'replacement').rule, binding('c', 'permission-c').rule]);
|
||||
expect(second.commands.syncExecuteCommand(SetDocumentPermissionRulesMutation.id, batch)).toBe(true);
|
||||
expect(second.model.getRules('unit')).toEqual(first.model.getRules('unit'));
|
||||
});
|
||||
|
||||
it('rejects an invalid or duplicate entry without applying the valid prefix', () => {
|
||||
const { model, commands } = setup();
|
||||
const original = binding('a', 'original');
|
||||
commands.syncExecuteCommand(SetDocumentPermissionRulesMutation.id, { unitId: 'unit', rules: [original] });
|
||||
const changed = vi.fn();
|
||||
const subscription = model.changed$.subscribe(changed);
|
||||
for (const rules of [
|
||||
[binding('a', 'replacement'), binding('b', '')],
|
||||
[binding('a', 'replacement'), binding('a', 'duplicate')],
|
||||
[],
|
||||
]) {
|
||||
expect(commands.syncExecuteCommand(SetDocumentPermissionRulesMutation.id, { unitId: 'unit', rules })).toBe(false);
|
||||
expect(model.getRules('unit')).toEqual([original.rule]);
|
||||
}
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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 RICH_TEXT_EDITING_MUTATION_ID = 'doc.mutation.rich-text-editing';
|
||||
369
packages/docs/src/commands/mutations/core-editing.mutation.ts
Normal file
369
packages/docs/src/commands/mutations/core-editing.mutation.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* 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,
|
||||
ICustomRange,
|
||||
IDocumentData,
|
||||
IExecutionOptions,
|
||||
IMutation,
|
||||
IMutationCommonParams,
|
||||
JSONXActions,
|
||||
Nullable,
|
||||
TextXAction,
|
||||
TPriority,
|
||||
} from '@univerjs/core';
|
||||
import type { DocumentViewModel, ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { IDocStateChangeInfo } from '../../services/doc-state-emit.service';
|
||||
import {
|
||||
CommandType,
|
||||
CustomRangeType,
|
||||
IUniverInstanceService,
|
||||
JSON1,
|
||||
JSONX,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { IRenderManagerService } from '@univerjs/engine-render';
|
||||
import { DocSelectionManagerService } from '../../services/doc-selection-manager.service';
|
||||
import { DocSkeletonManagerService } from '../../services/doc-skeleton-manager.service';
|
||||
import { DocStateEmitService } from '../../services/doc-state-emit.service';
|
||||
import { RICH_TEXT_EDITING_MUTATION_ID } from './core-editing.mutation-id';
|
||||
import { validateDocStructureMutation } from './doc-structure-mutation-validation';
|
||||
|
||||
export enum DocHistoryAction {
|
||||
DeleteChart = 'delete-chart',
|
||||
DeleteDivider = 'delete-divider',
|
||||
DeleteImage = 'delete-image',
|
||||
DeleteShape = 'delete-shape',
|
||||
EditTableCell = 'edit-table-cell',
|
||||
FormatParagraph = 'format-paragraph',
|
||||
InsertCustomRange = 'insert-custom-range',
|
||||
UpdateImage = 'update-image',
|
||||
UpdatePageLayout = 'update-page-layout',
|
||||
}
|
||||
|
||||
export interface IRichTextEditingMutationParams extends IMutationCommonParams {
|
||||
unitId: string;
|
||||
historyAction?: string;
|
||||
historyActions?: string[];
|
||||
actions: JSONXActions;
|
||||
textRanges: Nullable<ITextRangeWithStyle[]>;
|
||||
segmentId?: string;
|
||||
prevTextRanges?: Nullable<ITextRangeWithStyle[]>;
|
||||
noNeedSetTextRange?: boolean;
|
||||
isCompositionEnd?: boolean;
|
||||
noHistory?: boolean;
|
||||
// Do you need to compose the undo and redo of history, and compose of the change states.
|
||||
debounce?: boolean;
|
||||
options?: { [key: string]: boolean };
|
||||
// Whether this mutation is from a sync operation.
|
||||
isSync?: boolean;
|
||||
isEditing?: boolean;
|
||||
syncer?: string;
|
||||
}
|
||||
|
||||
function extractDocumentBodyActions(actions: JSONXActions, segmentId: string): Nullable<JSONXActions> {
|
||||
if (!Array.isArray(actions)) {
|
||||
return;
|
||||
}
|
||||
const bodyActions = actions.indexOf('body') > -1
|
||||
? actions
|
||||
: actions.find((action) => Array.isArray(action) && action.indexOf('body') > -1);
|
||||
if (!Array.isArray(bodyActions)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bodyIndex = bodyActions.indexOf('body');
|
||||
if (bodyIndex === -1) {
|
||||
return;
|
||||
}
|
||||
const actionSegmentId = bodyIndex === 0 ? '' : bodyActions[bodyIndex - 1];
|
||||
return actionSegmentId === segmentId ? bodyActions.slice(bodyIndex) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms document selections through the same JSONX actions applied by a rich-text mutation.
|
||||
* Collaboration and rendering use this shared offset rule so the Main interaction window follows
|
||||
* the transformed local caret before an authoritative background layout is published.
|
||||
*/
|
||||
export function transformDocumentTextRanges(
|
||||
actions: JSONXActions,
|
||||
textRanges: ITextRangeWithStyle[],
|
||||
priority: TPriority = 'right'
|
||||
): ITextRangeWithStyle[] {
|
||||
if (textRanges.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segmentId = textRanges[0].segmentId ?? '';
|
||||
|
||||
const bodyActions = extractDocumentBodyActions(actions, segmentId);
|
||||
if (bodyActions == null) {
|
||||
return textRanges;
|
||||
}
|
||||
|
||||
return textRanges.map((textRange) => {
|
||||
const startOffset = JSONX.transformPosition(bodyActions, textRange.startOffset, priority);
|
||||
const endOffset = JSONX.transformPosition(bodyActions, textRange.endOffset, priority);
|
||||
return {
|
||||
...textRange,
|
||||
startOffset,
|
||||
endOffset,
|
||||
collapsed: startOffset === endOffset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function canRemoveFootnoteReference(actions: JSONXActions, references: ICustomRange[]): boolean {
|
||||
let canRemove = false;
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, (component) => {
|
||||
const path = cursor.getPath();
|
||||
if (path.length === 0) {
|
||||
canRemove = true;
|
||||
}
|
||||
if (canRemove || path[0] !== 'body') {
|
||||
return;
|
||||
}
|
||||
if (path.length === 1 && component.et === TextX.id && Array.isArray(component.e)) {
|
||||
let offset = 0;
|
||||
for (const action of component.e as TextXAction[]) {
|
||||
if (action.t === TextXActionType.DELETE && references.some((reference) =>
|
||||
offset <= reference.endIndex && offset + action.len > reference.startIndex
|
||||
)) {
|
||||
canRemove = true;
|
||||
return;
|
||||
}
|
||||
if (action.t === TextXActionType.RETAIN && action.body?.customRanges != null) {
|
||||
canRemove = true;
|
||||
return;
|
||||
}
|
||||
if (action.t !== TextXActionType.INSERT) {
|
||||
offset += action.len;
|
||||
}
|
||||
}
|
||||
} else if (path.length === 1 || path[1] === 'customRanges' || path[1] === 'dataStream') {
|
||||
canRemove = true;
|
||||
}
|
||||
});
|
||||
return canRemove;
|
||||
}
|
||||
|
||||
function includeFootnoteCleanup(before: IDocumentData, actions: JSONXActions): JSONXActions {
|
||||
if (!before.notes) {
|
||||
return actions;
|
||||
}
|
||||
const references = before.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE)) ?? [];
|
||||
if (references.length === 0 || !canRemoveFootnoteReference(actions, references)) {
|
||||
return actions;
|
||||
}
|
||||
// TextX edits mutate their body. Isolate the cleanup preview, and only run it
|
||||
// for edits that can remove a reference; ordinary typing keeps the fast path.
|
||||
const after = JSONX.apply(Tools.deepClone(before), actions) as unknown as IDocumentData;
|
||||
const remaining = new Set(after.body?.customRanges?.filter((range) => (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))
|
||||
.map((range) => range.properties?.noteId));
|
||||
let cleanup: JSONXActions = null;
|
||||
const previousIds = new Set(references.map((reference) => reference.properties?.noteId));
|
||||
for (const id of previousIds) {
|
||||
if (typeof id === 'string' && !remaining.has(id) && after.notes?.[id]) {
|
||||
cleanup = JSONX.compose(cleanup, JSONX.getInstance().removeOp(['notes', id], after.notes[id]));
|
||||
}
|
||||
}
|
||||
return JSONX.isNoop(cleanup) ? actions : JSONX.compose(actions, cleanup);
|
||||
}
|
||||
|
||||
function applyValidatedDocumentActions(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
actions: JSONXActions
|
||||
): { actions: JSONXActions; undoActions: JSONXActions; preservesStructure: boolean } {
|
||||
const before = documentDataModel.getSnapshot();
|
||||
const appliedActions = includeFootnoteCleanup(before, actions);
|
||||
const undoActions = JSONX.invertWithDoc(appliedActions, before);
|
||||
documentDataModel.apply(appliedActions);
|
||||
try {
|
||||
return {
|
||||
actions: appliedActions,
|
||||
undoActions,
|
||||
preservesStructure: validateDocStructureMutation(documentDataModel, segmentId, appliedActions, undoActions),
|
||||
};
|
||||
} catch (error) {
|
||||
documentDataModel.apply(undoActions);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function resetDocumentViewModel(
|
||||
documentViewModel: DocumentViewModel | null | undefined,
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
actions: JSONXActions,
|
||||
preservesStructure: boolean
|
||||
): void {
|
||||
if (documentViewModel == null) {
|
||||
return;
|
||||
}
|
||||
const didResetIncrementally = segmentId === '' && preservesStructure && (
|
||||
documentViewModel.resetByValidatedTextMutation?.(documentDataModel, actions) ||
|
||||
documentViewModel.resetByValidatedMetadataMutation?.(documentDataModel, actions)
|
||||
);
|
||||
if (!didResetIncrementally) {
|
||||
documentViewModel.reset(documentDataModel);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDocumentSelectionUpdate(
|
||||
selectionManager: DocSelectionManagerService,
|
||||
params: IRichTextEditingMutationParams,
|
||||
isSync: boolean
|
||||
): void {
|
||||
const { unitId, textRanges, trigger, noNeedSetTextRange, isEditing = true } = params;
|
||||
if (noNeedSetTextRange || textRanges == null || textRanges.length === 0 || trigger == null || isSync) {
|
||||
return;
|
||||
}
|
||||
const selectionTarget = { unitId, subUnitId: unitId };
|
||||
const currentSelection = selectionManager.getSelectionInfo(selectionTarget);
|
||||
const logicalTextRanges = textRanges.map((textRange, index) => ({
|
||||
...textRange,
|
||||
segmentId: textRange.segmentId ?? params.segmentId ?? '',
|
||||
collapsed: textRange.startOffset === textRange.endOffset,
|
||||
isActive: index === textRanges.length - 1,
|
||||
}));
|
||||
if (currentSelection != null) {
|
||||
// Advance logical intent with the mutation. Only its visual refresh is
|
||||
// deferred; a later input or pointer selection must supersede this one.
|
||||
selectionManager.replaceSelectionInfoWithoutRefresh({
|
||||
...currentSelection,
|
||||
segmentId: logicalTextRanges[logicalTextRanges.length - 1].segmentId,
|
||||
textRanges: logicalTextRanges,
|
||||
rectRanges: [],
|
||||
isEditing,
|
||||
options: params.options,
|
||||
}, selectionTarget);
|
||||
}
|
||||
const updatedSelection = selectionManager.getSelectionInfo(selectionTarget);
|
||||
queueMicrotask(() => {
|
||||
if (selectionManager.getSelectionInfo(selectionTarget) !== updatedSelection) {
|
||||
return;
|
||||
}
|
||||
if (updatedSelection == null) {
|
||||
selectionManager.replaceDocRanges(logicalTextRanges, selectionTarget, isEditing, params.options);
|
||||
} else {
|
||||
selectionManager.refreshSelection(selectionTarget, isEditing);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The core mutator to change rich text actions. The execution result would be undo mutation params. Could be directly
|
||||
* send to undo redo service (will be used by the triggering command).
|
||||
*/
|
||||
export const RichTextEditingMutation: IMutation<IRichTextEditingMutationParams, IRichTextEditingMutationParams> = {
|
||||
id: RICH_TEXT_EDITING_MUTATION_ID,
|
||||
|
||||
type: CommandType.MUTATION,
|
||||
|
||||
handler: (accessor, params, options?: IExecutionOptions) => {
|
||||
const {
|
||||
unitId,
|
||||
segmentId = '',
|
||||
actions,
|
||||
textRanges,
|
||||
prevTextRanges,
|
||||
trigger,
|
||||
noHistory,
|
||||
isCompositionEnd,
|
||||
debounce,
|
||||
isEditing = true,
|
||||
isSync: paramsIsSync,
|
||||
syncer,
|
||||
} = params;
|
||||
const isSync = Boolean(paramsIsSync || options?.fromCollab || options?.fromChangeset);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const renderManagerService = accessor.get(IRenderManagerService);
|
||||
const docStateEmitService = accessor.get(DocStateEmitService);
|
||||
|
||||
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
const documentViewModel = renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService).getViewModel();
|
||||
if (documentDataModel == null) {
|
||||
throw new Error(`DocumentDataModel not found for unitId: ${unitId}`);
|
||||
}
|
||||
|
||||
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
|
||||
const docRanges = docSelectionManagerService.getDocRanges() ?? [];
|
||||
// Capture selection intent before applying actions so undo can restore structural selections.
|
||||
const selectionInfo = docSelectionManagerService.getSelectionInfo();
|
||||
|
||||
// TODO: `disabled` is only used for read only demo, and will be removed in the future.
|
||||
const disabled = !!documentDataModel.getSnapshot().disabled;
|
||||
|
||||
if (JSONX.isNoop(actions) || (actions && actions.length === 0) || disabled) {
|
||||
// The actions' length maybe 0 when the mutation is from collaborative editing.
|
||||
// The return result will not be used.
|
||||
return {
|
||||
unitId,
|
||||
actions: [],
|
||||
textRanges: docRanges,
|
||||
};
|
||||
}
|
||||
|
||||
const { actions: appliedActions, undoActions, preservesStructure } = applyValidatedDocumentActions(
|
||||
documentDataModel,
|
||||
segmentId,
|
||||
actions
|
||||
);
|
||||
|
||||
// Publish reference deletion and note cleanup in the same deterministic mutation.
|
||||
params.actions = appliedActions;
|
||||
resetDocumentViewModel(documentViewModel, documentDataModel, segmentId, appliedActions, preservesStructure);
|
||||
scheduleDocumentSelectionUpdate(docSelectionManagerService, params, isSync);
|
||||
|
||||
// Step 4: Emit state change event.
|
||||
const changeState: IDocStateChangeInfo = {
|
||||
commandId: RICH_TEXT_EDITING_MUTATION_ID,
|
||||
unitId,
|
||||
segmentId,
|
||||
trigger,
|
||||
noHistory,
|
||||
debounce,
|
||||
redoState: {
|
||||
actions: appliedActions,
|
||||
textRanges,
|
||||
options: params.options,
|
||||
isEditing,
|
||||
},
|
||||
undoState: {
|
||||
actions: undoActions,
|
||||
textRanges: prevTextRanges ?? docRanges,
|
||||
options: selectionInfo?.options,
|
||||
isEditing: selectionInfo?.isEditing,
|
||||
},
|
||||
isCompositionEnd,
|
||||
isSync,
|
||||
syncer,
|
||||
};
|
||||
docStateEmitService.emitStateChangeInfo(changeState);
|
||||
|
||||
return {
|
||||
unitId,
|
||||
actions: undoActions,
|
||||
textRanges: docRanges,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 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, IDocumentBody, JSONXActions, JSONXPath } from '@univerjs/core';
|
||||
import {
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
|
||||
getRichTextEditPath,
|
||||
|
||||
JSON1,
|
||||
|
||||
TextX,
|
||||
TextXActionType,
|
||||
validateDocBodyStructure,
|
||||
validateDocumentStructure,
|
||||
} from '@univerjs/core';
|
||||
|
||||
const STRUCTURAL_BODY_FIELDS = [
|
||||
'paragraphs',
|
||||
'sectionBreaks',
|
||||
'customBlocks',
|
||||
'docxRawCustomBlocks',
|
||||
'docxRawBlocks',
|
||||
'docxExportExcludedRanges',
|
||||
'tables',
|
||||
'columnGroups',
|
||||
'blockRanges',
|
||||
] satisfies Array<keyof IDocumentBody>;
|
||||
const STRUCTURAL_DATA_STREAM_TOKENS = new Set<string>([
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.SECTION_BREAK,
|
||||
DataStreamTreeTokenType.TABLE_START,
|
||||
DataStreamTreeTokenType.TABLE_ROW_START,
|
||||
DataStreamTreeTokenType.TABLE_CELL_START,
|
||||
DataStreamTreeTokenType.TABLE_CELL_END,
|
||||
DataStreamTreeTokenType.TABLE_ROW_END,
|
||||
DataStreamTreeTokenType.TABLE_END,
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_START,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_END,
|
||||
DataStreamTreeTokenType.BLOCK_START,
|
||||
DataStreamTreeTokenType.BLOCK_END,
|
||||
DataStreamTreeTokenType.CUSTOM_BLOCK,
|
||||
]);
|
||||
const NON_STRUCTURAL_ROOT_FIELDS = new Set<string>([
|
||||
'disabled',
|
||||
'documentStyle',
|
||||
'drawings',
|
||||
'drawingsOrder',
|
||||
'lists',
|
||||
'locale',
|
||||
'resources',
|
||||
'rev',
|
||||
'settings',
|
||||
'styles',
|
||||
'tableSource',
|
||||
'title',
|
||||
]);
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value != null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function containsStructuralToken(dataStream: string): boolean {
|
||||
for (let index = 0; index < dataStream.length; index++) {
|
||||
if (STRUCTURAL_DATA_STREAM_TOKENS.has(dataStream[index])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStructurePreservingBody(value: unknown): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataStream = value.dataStream;
|
||||
if (dataStream != null && (typeof dataStream !== 'string' || containsStructuralToken(dataStream))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return STRUCTURAL_BODY_FIELDS.every((field) => {
|
||||
const metadata = value[field];
|
||||
return metadata == null || (Array.isArray(metadata) && metadata.length === 0);
|
||||
});
|
||||
}
|
||||
|
||||
function isStructurePreservingTextXEdit(value: unknown): boolean {
|
||||
if (!Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value.every((action) => {
|
||||
if (!isRecord(action) || typeof action.len !== 'number' || !Number.isFinite(action.len)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (action.t === TextXActionType.DELETE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (action.t === TextXActionType.INSERT) {
|
||||
return isStructurePreservingBody(action.body);
|
||||
}
|
||||
|
||||
return action.t === TextXActionType.RETAIN &&
|
||||
(action.body == null || isStructurePreservingBody(action.body));
|
||||
});
|
||||
}
|
||||
|
||||
function pathsEqual(left: JSONXPath, right: JSONXPath): boolean {
|
||||
return left.length === right.length && left.every((item, index) => item === right[index]);
|
||||
}
|
||||
|
||||
function isStructurePreservingJSONXEdit(actions: JSONXActions, expectedPath: JSONXPath): boolean {
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
let hasComponent = false;
|
||||
let isStructurePreserving = true;
|
||||
|
||||
cursor.traverse(null, (component) => {
|
||||
if (!isStructurePreserving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = cursor.getPath();
|
||||
hasComponent = true;
|
||||
if (NON_STRUCTURAL_ROOT_FIELDS.has(String(path[0]))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const componentKeys = Object.keys(component);
|
||||
if (component.et !== TextX.id || componentKeys.some((key) => key !== 'et' && key !== 'e') || !pathsEqual(path, expectedPath)) {
|
||||
isStructurePreserving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const edit: unknown = component.e;
|
||||
isStructurePreserving = isStructurePreservingTextXEdit(edit);
|
||||
});
|
||||
|
||||
return hasComponent && isStructurePreserving;
|
||||
}
|
||||
|
||||
function getSegmentType(documentDataModel: DocumentDataModel, segmentId: string): 'body' | 'header' | 'footer' | 'note' {
|
||||
if (!segmentId) {
|
||||
return 'body';
|
||||
}
|
||||
|
||||
const { headers, footers } = documentDataModel.getSnapshot();
|
||||
if (headers?.[segmentId]) {
|
||||
return 'header';
|
||||
}
|
||||
|
||||
if (footers?.[segmentId]) {
|
||||
return 'footer';
|
||||
}
|
||||
|
||||
if (documentDataModel.getSnapshot().notes?.[segmentId]) {
|
||||
return 'note';
|
||||
}
|
||||
|
||||
return 'body';
|
||||
}
|
||||
|
||||
function assertValidDocBodyStructure(documentDataModel: DocumentDataModel, segmentId: string): void {
|
||||
const segmentModel = documentDataModel.getSelfOrHeaderFooterModel(segmentId);
|
||||
const body = segmentModel?.getBody();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentType = getSegmentType(documentDataModel, segmentId);
|
||||
const note = documentDataModel.getSnapshot().notes?.[segmentId];
|
||||
const issues = note
|
||||
? validateDocumentStructure({ notes: { [segmentId]: note } })
|
||||
: validateDocBodyStructure(body, { segmentType, segmentId: segmentId || undefined });
|
||||
if (!issues.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = issues.map((issue) => `${issue.code}${issue.index == null ? '' : `@${issue.index}`}`).join(', ');
|
||||
const segmentLabel = segmentId ? `${segmentType} ${segmentId}` : segmentType;
|
||||
throw new Error(`[DocStructure] ${segmentLabel}: ${detail}`);
|
||||
}
|
||||
|
||||
export function validateDocStructureMutation(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
actions: JSONXActions,
|
||||
undoActions: JSONXActions
|
||||
): boolean {
|
||||
const editPath = getRichTextEditPath(documentDataModel, segmentId);
|
||||
const preservesStructure =
|
||||
isStructurePreservingJSONXEdit(actions, editPath) &&
|
||||
isStructurePreservingJSONXEdit(undoActions, editPath);
|
||||
|
||||
let changesFootnoteStructure = false;
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, (component) => {
|
||||
const path = cursor.getPath();
|
||||
if ((path[0] === 'notes' && (path.length <= 2 || path[2] !== 'body')) || path.includes('customRanges')) {
|
||||
changesFootnoteStructure = true;
|
||||
}
|
||||
if (component.et === TextX.id && Array.isArray(component.e)) {
|
||||
for (const action of component.e) {
|
||||
const ranges: unknown = isRecord(action) && isRecord(action.body) ? action.body.customRanges : undefined;
|
||||
if (Array.isArray(ranges) && ranges.some((range) => isRecord(range) && (range.rangeType === CustomRangeType.FOOTNOTE || range.rangeType === CustomRangeType.ENDNOTE))) {
|
||||
changesFootnoteStructure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (changesFootnoteStructure) {
|
||||
const issues = validateDocumentStructure(documentDataModel.getSnapshot());
|
||||
if (issues.length > 0) {
|
||||
throw new Error(`[DocStructure] ${issues.map((issue) => `${issue.code}@${issue.index ?? issue.segmentId ?? ''}`).join(', ')}`);
|
||||
}
|
||||
} else if (!preservesStructure) {
|
||||
assertValidDocBodyStructure(documentDataModel, segmentId);
|
||||
}
|
||||
return preservesStructure && !changesFootnoteStructure;
|
||||
}
|
||||
38
packages/docs/src/commands/mutations/docs-rename.mutation.ts
Normal file
38
packages/docs/src/commands/mutations/docs-rename.mutation.ts
Normal file
@@ -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 type { DocumentDataModel, IMutation } from '@univerjs/core';
|
||||
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
|
||||
export interface IDocsRenameMutationParams {
|
||||
name: string;
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
export const DocsRenameMutation: IMutation<IDocsRenameMutationParams> = {
|
||||
id: 'doc.mutation.rename-doc',
|
||||
type: CommandType.MUTATION,
|
||||
handler: (accessor, params) => {
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const doc = univerInstanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (!doc) {
|
||||
return false;
|
||||
}
|
||||
doc.setName(params.name);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -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 { IMutation, ISetObjectPermissionRuleMutationParams } from '@univerjs/core';
|
||||
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocumentPermissionRuleModel } from '../../services/permission/document-permission-rule.model';
|
||||
|
||||
export const SetDocumentPermissionRuleMutation: IMutation<ISetObjectPermissionRuleMutationParams> = {
|
||||
id: 'doc.mutation.set-permission-rule',
|
||||
type: CommandType.MUTATION,
|
||||
handler: (accessor, params) => {
|
||||
if (!params || !accessor.get(IUniverInstanceService).getUnit(params.unitId, UniverInstanceType.UNIVER_DOC)) {
|
||||
return false;
|
||||
}
|
||||
return accessor.get(DocumentPermissionRuleModel).setRule(params.unitId, params.objectType, params.objectId, params.rule);
|
||||
},
|
||||
};
|
||||
@@ -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 { IMutation, ISetObjectPermissionRulesMutationParams } from '@univerjs/core';
|
||||
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocumentPermissionRuleModel } from '../../services/permission/document-permission-rule.model';
|
||||
|
||||
export const SetDocumentPermissionRulesMutation: IMutation<ISetObjectPermissionRulesMutationParams> = {
|
||||
id: 'doc.mutation.set-permission-rules',
|
||||
type: CommandType.MUTATION,
|
||||
handler: (accessor, params) => {
|
||||
if (!params || !accessor.get(IUniverInstanceService).getUnit(params.unitId, UniverInstanceType.UNIVER_DOC)) {
|
||||
return false;
|
||||
}
|
||||
return accessor.get(DocumentPermissionRuleModel).setRules(params.unitId, params.rules);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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 type { ITextRangeWithStyle, ITextSelectionStyle } from '@univerjs/engine-render';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
|
||||
export interface ISetTextSelectionsOperationParams {
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
segmentId: string;
|
||||
// Whether it occurs at the same time as text editing.
|
||||
isEditing: boolean;
|
||||
style: ITextSelectionStyle;
|
||||
ranges: ITextRangeWithStyle[];
|
||||
}
|
||||
|
||||
export const SetTextSelectionsOperation: IOperation<ISetTextSelectionsOperationParams> = {
|
||||
id: 'doc.operation.set-selections',
|
||||
|
||||
type: CommandType.OPERATION,
|
||||
|
||||
handler: () => {
|
||||
// for menu highlight use and share cursor.
|
||||
return true;
|
||||
},
|
||||
};
|
||||
25
packages/docs/src/config/config.ts
Normal file
25
packages/docs/src/config/config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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_PLUGIN_CONFIG_KEY = 'docs.config';
|
||||
|
||||
export const configSymbol = Symbol(DOCS_PLUGIN_CONFIG_KEY);
|
||||
|
||||
export interface IUniverDocsConfig {
|
||||
hasScroll?: boolean;
|
||||
}
|
||||
|
||||
export const defaultPluginConfig: IUniverDocsConfig = {};
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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 { IDocumentData, Univer } from '@univerjs/core';
|
||||
import type { FDocument } from '../../facade/f-document';
|
||||
import {
|
||||
CommandType,
|
||||
DocumentFlavor,
|
||||
DrawingTypeEnum,
|
||||
ErrorService,
|
||||
ICommandService,
|
||||
IPermissionService,
|
||||
IUndoRedoService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
RedoCommand,
|
||||
TextXActionType,
|
||||
UndoCommand,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { InsertTextCommand } from '../../commands/commands/core-editing.command';
|
||||
import { DeleteDocumentSectionBreakCommand } from '../../commands/commands/update-document-section.command';
|
||||
import { RichTextEditingMutation } from '../../commands/mutations/core-editing.mutation';
|
||||
import { DocsRenameMutation } from '../../commands/mutations/docs-rename.mutation';
|
||||
import { createDocumentData, createTableDocument, createTestBed } from '../../facade/__tests__/create-test-bed';
|
||||
import {
|
||||
createDocumentPermissionPoint,
|
||||
DOCUMENT_UNIT_PERMISSION_ACTIONS,
|
||||
getDocumentEntityPermissionObjectId,
|
||||
getDocumentParagraphPermissionObjectId,
|
||||
getDocumentSectionPermissionObjectId,
|
||||
} from '../../services/permission/document-permission';
|
||||
|
||||
function createTwoSectionDocument(): IDocumentData {
|
||||
const data = createDocumentData('permission-test', {
|
||||
dataStream: 'One\r\nTwo\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'paragraph-one' },
|
||||
{ startIndex: 8, paragraphId: 'paragraph-two' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section-one', startIndex: 4 },
|
||||
{ sectionId: 'section-two', startIndex: 9 },
|
||||
],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
return data;
|
||||
}
|
||||
|
||||
function createSharedHeaderDocument(): IDocumentData {
|
||||
const data = createTwoSectionDocument();
|
||||
data.headers = {
|
||||
'shared-header': {
|
||||
headerId: 'shared-header',
|
||||
body: {
|
||||
dataStream: 'Head\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'header-paragraph' }],
|
||||
sectionBreaks: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
data.documentStyle.defaultHeaderId = 'shared-header';
|
||||
return data;
|
||||
}
|
||||
|
||||
function createDrawingDocument(): IDocumentData {
|
||||
const data = createDocumentData('permission-drawing', {
|
||||
dataStream: 'A\b\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'drawing-paragraph' }],
|
||||
sectionBreaks: [{ sectionId: 'drawing-section', startIndex: 3 }],
|
||||
customBlocks: [{ startIndex: 1, blockId: 'drawing-one' }],
|
||||
});
|
||||
data.drawings = {
|
||||
'drawing-one': {
|
||||
drawingId: 'drawing-one',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
docTransform: { angle: 0 },
|
||||
} as never,
|
||||
};
|
||||
data.drawingsOrder = ['drawing-one'];
|
||||
return data;
|
||||
}
|
||||
|
||||
describe('DocPermissionController', () => {
|
||||
let univer: Univer;
|
||||
let document: FDocument;
|
||||
let get: ReturnType<typeof createTestBed>['get'];
|
||||
|
||||
beforeEach(() => {
|
||||
const testBed = createTestBed(createTwoSectionDocument());
|
||||
univer = testBed.univer;
|
||||
get = testBed.get;
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('registers every whole-Document permission point when the unit is created', () => {
|
||||
const permissionService = get(IPermissionService);
|
||||
DOCUMENT_UNIT_PERMISSION_ACTIONS.forEach((action) => {
|
||||
expect(permissionService.getPermissionPoint(
|
||||
createDocumentPermissionPoint(document.getId(), document.getId(), action).id
|
||||
)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses dedicated UnitObject values for stable Document scopes', () => {
|
||||
const unitId = document.getId();
|
||||
expect(createDocumentPermissionPoint(
|
||||
unitId,
|
||||
getDocumentSectionPermissionObjectId('', 'section-one'),
|
||||
UnitAction.Edit
|
||||
).type).toBe(UnitObject.DocumentSection);
|
||||
expect(createDocumentPermissionPoint(
|
||||
unitId,
|
||||
getDocumentParagraphPermissionObjectId('', 'paragraph-one'),
|
||||
UnitAction.Edit
|
||||
).type).toBe(UnitObject.DocumentParagraph);
|
||||
expect(createDocumentPermissionPoint(
|
||||
unitId,
|
||||
getDocumentEntityPermissionObjectId('', 'table', 'table-one'),
|
||||
UnitAction.Edit
|
||||
).type).toBe(UnitObject.DocumentEntity);
|
||||
});
|
||||
|
||||
it('enforces document edit permission through the facade and command pipeline', async () => {
|
||||
await document.getPermission().setReadOnly();
|
||||
|
||||
const targets: Array<string | undefined> = [];
|
||||
const subscription = get(ErrorService).error$.subscribe((error) => targets.push(error.permissionTarget));
|
||||
expect(document.getPermission().canEdit()).toBe(false);
|
||||
expect(document.getParagraph('paragraph-one')!.setText('Denied')).toBe(false);
|
||||
expect(document.getParagraph('paragraph-two')!.setText('Also denied')).toBe(false);
|
||||
expect(document.getParagraph('paragraph-one')!.setText('Denied again')).toBe(false);
|
||||
expect(targets).toHaveLength(3);
|
||||
expect(targets[0]).toBeDefined();
|
||||
expect(targets[1]).not.toBe(targets[0]);
|
||||
expect(targets[2]).toBe(targets[0]);
|
||||
expect(document.getParagraph('paragraph-one')!.getText()).toBe('One');
|
||||
expect(document.getParagraph('paragraph-two')!.getText()).toBe('Two');
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('uses section and paragraph ids as hierarchical edit boundaries', async () => {
|
||||
const firstSection = document.getSection(0)!;
|
||||
const firstParagraph = document.getParagraphs()[0];
|
||||
const secondParagraph = document.getParagraph('paragraph-two')!;
|
||||
if (!firstParagraph) {
|
||||
throw new Error('Paragraph not found.');
|
||||
}
|
||||
|
||||
await firstSection.getPermission().setReadOnly();
|
||||
|
||||
expect(firstSection.getPermission().canEdit()).toBe(false);
|
||||
expect(firstParagraph.getPermission().canEdit()).toBe(false);
|
||||
expect(secondParagraph.getPermission().canEdit()).toBe(true);
|
||||
expect(firstParagraph.setText('Denied')).toBe(false);
|
||||
expect(secondParagraph.setText('Allowed')).toBe(true);
|
||||
|
||||
await document.getSection(0)!.getPermission().setEditable();
|
||||
await document.getParagraph('paragraph-one')!.getPermission().setEditable(false);
|
||||
expect(document.getParagraph('paragraph-one')!.setText('Denied again')).toBe(false);
|
||||
});
|
||||
|
||||
it('requires every owner Section to edit a shared header', async () => {
|
||||
univer.dispose();
|
||||
const testBed = createTestBed(createSharedHeaderDocument());
|
||||
univer = testBed.univer;
|
||||
get = testBed.get;
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
await document.getSection(1)!.getPermission().setEditable(false);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: document.getId(),
|
||||
segmentId: 'shared-header',
|
||||
range: { startOffset: 1, endOffset: 1, collapsed: true },
|
||||
body: { dataStream: 'X' },
|
||||
})).toBe(false);
|
||||
|
||||
await document.getSection(1)!.getPermission().setEditable(true);
|
||||
expect(get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: document.getId(),
|
||||
segmentId: 'shared-header',
|
||||
range: { startOffset: 1, endOffset: 1, collapsed: true },
|
||||
body: { dataStream: 'X' },
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts collaboration mutations despite local edit permission', async () => {
|
||||
await document.getPermission().setReadOnly();
|
||||
const errors: string[] = [];
|
||||
const subscription = get(ErrorService).error$.subscribe((error) => errors.push(error.code ?? ''));
|
||||
expect(get(ICommandService).syncExecuteCommand(DocsRenameMutation.id, {
|
||||
unitId: document.getId(),
|
||||
name: 'Blocked local name',
|
||||
})).toBe(false);
|
||||
expect(errors).toEqual(['PERMISSION_DENIED']);
|
||||
errors.length = 0;
|
||||
|
||||
await expect(get(ICommandService).executeCommand(DocsRenameMutation.id, {
|
||||
unitId: document.getId(),
|
||||
name: 'Remote name',
|
||||
}, { fromCollab: true })).resolves.toBe(true);
|
||||
expect(document.getName()).toBe('Remote name');
|
||||
expect(errors).toEqual([]);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it('applies incoming changesets without creating local Undo history', async () => {
|
||||
get(IUndoRedoService).clearUndoRedo(document.getId());
|
||||
await document.getPermission().setReadOnly();
|
||||
|
||||
await expect(get(ICommandService).executeCommand(DocsRenameMutation.id, {
|
||||
unitId: document.getId(),
|
||||
name: 'Remote changeset',
|
||||
}, { fromChangeset: true })).resolves.toBe(true);
|
||||
await document.getPermission().setEditable(true);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(UndoCommand.id)).toBe(false);
|
||||
expect(document.getName()).toBe('Remote changeset');
|
||||
});
|
||||
|
||||
it('enforces stable entity ids together with their current Section parent', async () => {
|
||||
univer.dispose();
|
||||
const testBed = createTestBed(createTableDocument('permission-entity'));
|
||||
univer = testBed.univer;
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
|
||||
await document.getEntityPermission('', 'table', 'table-1').setEditable(false);
|
||||
|
||||
expect(document.getEntityPermission('', 'table', 'table-1').canEdit()).toBe(false);
|
||||
expect(document.getTextRange(0, 2).setText('Denied')).toBe(false);
|
||||
});
|
||||
|
||||
it('enforces Drawing entity permission on direct JSONX transform mutations', async () => {
|
||||
univer.dispose();
|
||||
const testBed = createTestBed(createDrawingDocument());
|
||||
univer = testBed.univer;
|
||||
get = testBed.get;
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
const drawingId = document.getDocumentDataModel().getSnapshot().drawingsOrder?.[0];
|
||||
if (!drawingId) {
|
||||
throw new Error('Drawing not found.');
|
||||
}
|
||||
await document.getEntityPermission('', 'drawing', drawingId).setReadOnly();
|
||||
const actions = JSONX.getInstance().replaceOp(
|
||||
['drawings', drawingId, 'docTransform', 'angle'],
|
||||
0,
|
||||
15
|
||||
);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: document.getId(),
|
||||
segmentId: '',
|
||||
actions,
|
||||
textRanges: [],
|
||||
})).toBe(false);
|
||||
expect(document.getDocumentDataModel().getSnapshot().drawings?.[drawingId].docTransform?.angle).toBe(0);
|
||||
});
|
||||
|
||||
it('enforces paragraph permission on direct RichText mutations used by paste and undo/redo', async () => {
|
||||
await document.getParagraph('paragraph-one')!.getPermission().setEditable(false);
|
||||
const actions = JSONX.getInstance().editOp([
|
||||
{ t: TextXActionType.RETAIN, len: 1 },
|
||||
{ t: TextXActionType.INSERT, len: 1, body: { dataStream: 'X' } },
|
||||
]);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: document.getId(),
|
||||
segmentId: '',
|
||||
actions,
|
||||
textRanges: [],
|
||||
})).toBe(false);
|
||||
expect(get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: document.getId(),
|
||||
segmentId: '',
|
||||
actions: JSONX.getInstance().editOp([{ t: TextXActionType.DELETE, len: 1 }]),
|
||||
textRanges: [],
|
||||
})).toBe(false);
|
||||
expect(document.getParagraph('paragraph-one')!.getText()).toBe('One');
|
||||
|
||||
await document.getParagraph('paragraph-one')!.getPermission().setEditable(true);
|
||||
expect(get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: document.getId(),
|
||||
segmentId: '',
|
||||
actions,
|
||||
textRanges: [],
|
||||
})).toBeTruthy();
|
||||
expect(document.getParagraph('paragraph-one')!.getText()).toBe('OXne');
|
||||
});
|
||||
|
||||
it('keeps denied Undo history available until the paragraph is editable again', async () => {
|
||||
const paragraph = document.getParagraph('paragraph-one')!;
|
||||
expect(paragraph.setText('Changed')).toBe(true);
|
||||
await paragraph.getPermission().setEditable(false);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(UndoCommand.id)).toBe(false);
|
||||
expect(paragraph.getText()).toBe('Changed');
|
||||
|
||||
await paragraph.getPermission().setEditable(true);
|
||||
expect(get(ICommandService).syncExecuteCommand(UndoCommand.id)).toBe(true);
|
||||
expect(paragraph.getText()).toBe('One');
|
||||
});
|
||||
|
||||
it('keeps denied Redo history available until the paragraph is editable again', async () => {
|
||||
const paragraph = document.getParagraph('paragraph-one')!;
|
||||
expect(paragraph.setText('Changed')).toBe(true);
|
||||
expect(get(ICommandService).syncExecuteCommand(UndoCommand.id)).toBe(true);
|
||||
await paragraph.getPermission().setEditable(false);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(RedoCommand.id)).toBe(false);
|
||||
expect(paragraph.getText()).toBe('One');
|
||||
|
||||
await paragraph.getPermission().setEditable(true);
|
||||
expect(get(ICommandService).syncExecuteCommand(RedoCommand.id)).toBe(true);
|
||||
expect(paragraph.getText()).toBe('Changed');
|
||||
});
|
||||
|
||||
it('rejects a range edit when any covered Paragraph is read-only', async () => {
|
||||
await document.getParagraph('paragraph-two')!.getPermission().setEditable(false);
|
||||
|
||||
expect(document.getTextRange(0, 8).setText('Denied')).toBe(false);
|
||||
expect(document.getParagraph('paragraph-one')!.getText()).toBe('One');
|
||||
expect(document.getParagraph('paragraph-two')!.getText()).toBe('Two');
|
||||
});
|
||||
|
||||
it('requires both adjacent Sections before deleting their shared boundary', async () => {
|
||||
await document.getSection(1)!.getPermission().setEditable(false);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(DeleteDocumentSectionBreakCommand.id, {
|
||||
unitId: document.getId(),
|
||||
sectionId: 'section-one',
|
||||
})).toBe(false);
|
||||
expect(document.getSection(0)?.getId()).toBe('section-one');
|
||||
expect(document.getSection(1)?.getId()).toBe('section-two');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[UnitAction.Copy, 'doc.command.copy-current-paragraph'],
|
||||
[UnitAction.Print, 'docs.operation.print'],
|
||||
[UnitAction.Export, 'docs-exchange-client.operation.export-doc'],
|
||||
[UnitAction.Comment, 'docs.command.add-comment'],
|
||||
[UnitAction.Comment, 'docs.operation.add-drawing-comment'],
|
||||
] as const)('enforces unit action %s on its existing command surface', async (point, commandId) => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: commandId, type: CommandType.OPERATION, handler: () => true });
|
||||
await document.getPermission().setPoint(point, false);
|
||||
|
||||
expect(commandService.syncExecuteCommand(commandId)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Copy and Comment available when only Document Edit is denied', async () => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: 'doc.command.copy-current-paragraph', type: CommandType.OPERATION, handler: () => true });
|
||||
commandService.registerCommand({ id: 'docs.command.add-comment', type: CommandType.COMMAND, handler: () => true });
|
||||
await document.getPermission().setReadOnly();
|
||||
|
||||
expect(commandService.syncExecuteCommand('doc.command.copy-current-paragraph')).toBe(true);
|
||||
expect(commandService.syncExecuteCommand('docs.command.add-comment')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks local comment mutations and still accepts remote comments', async () => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: 'thread-comment.mutation.add-comment', type: CommandType.MUTATION, handler: () => true });
|
||||
await document.getPermission().setPoint(UnitAction.Comment, false);
|
||||
const params = { unitId: document.getId() };
|
||||
|
||||
expect(commandService.syncExecuteCommand('thread-comment.mutation.add-comment', params)).toBe(false);
|
||||
await expect(commandService.executeCommand(
|
||||
'thread-comment.mutation.add-comment',
|
||||
params,
|
||||
{ fromChangeset: true }
|
||||
)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('blocks extension edit commands and resource mutations at the Unit ceiling', async () => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: 'doc.command.insert-float-image', type: CommandType.COMMAND, handler: () => true });
|
||||
commandService.registerCommand({ id: 'doc.command.switch-mode', type: CommandType.COMMAND, handler: () => true });
|
||||
commandService.registerCommand({ id: 'docs-code.mutation.set-config', type: CommandType.MUTATION, handler: () => true });
|
||||
commandService.registerCommand({ id: 'doc.command.open-header-footer-panel', type: CommandType.COMMAND, handler: () => true });
|
||||
await document.getPermission().setReadOnly();
|
||||
const params = { unitId: document.getId() };
|
||||
|
||||
expect(commandService.syncExecuteCommand('doc.command.insert-float-image', params)).toBe(false);
|
||||
expect(commandService.syncExecuteCommand('doc.command.switch-mode', params)).toBe(false);
|
||||
expect(commandService.syncExecuteCommand('docs-code.mutation.set-config', params)).toBe(false);
|
||||
expect(commandService.syncExecuteCommand('doc.command.open-header-footer-panel', params)).toBe(true);
|
||||
});
|
||||
|
||||
it('enforces stable Entity permissions on direct extension mutations', async () => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: 'doc.mutation.update-shape-data', type: CommandType.MUTATION, handler: () => true });
|
||||
await document.getEntityPermission('', 'custom-block', 'shape-one').setEditable(false);
|
||||
const params = { unitId: document.getId(), shapeId: 'shape-one' };
|
||||
|
||||
expect(commandService.syncExecuteCommand('doc.mutation.update-shape-data', params)).toBe(false);
|
||||
expect(commandService.syncExecuteCommand('doc.mutation.update-shape-data', {
|
||||
...params,
|
||||
formulaLastValueGuard: { sessionId: 1 },
|
||||
})).toBe(true);
|
||||
await expect(commandService.executeCommand(
|
||||
'doc.mutation.update-shape-data',
|
||||
params,
|
||||
{ fromChangeset: true }
|
||||
)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('keeps derived Formula persistence available in read-only Documents', async () => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: 'docs-formula.mutation.set-last-values', type: CommandType.MUTATION, handler: () => true });
|
||||
await document.getPermission().setReadOnly();
|
||||
|
||||
expect(commandService.syncExecuteCommand('docs-formula.mutation.set-last-values', {
|
||||
unitId: document.getId(),
|
||||
updates: [],
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the execution Unit instead of the active Document for external actions', async () => {
|
||||
const commandService = get(ICommandService);
|
||||
commandService.registerCommand({ id: 'docs.operation.print', type: CommandType.OPERATION, handler: () => true });
|
||||
await document.getPermission().setPoint(UnitAction.Print, false);
|
||||
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocumentData('other-doc', {
|
||||
dataStream: '\r\n',
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'other-paragraph' }],
|
||||
}));
|
||||
|
||||
expect(commandService.syncExecuteCommand(
|
||||
'docs.operation.print',
|
||||
undefined,
|
||||
{ unitId: document.getId() }
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears Unit and object permissions when the Document is disposed', async () => {
|
||||
const unitPermission = document.getPermission();
|
||||
const paragraphPermission = document.getParagraph('paragraph-one')!.getPermission();
|
||||
await unitPermission.setReadOnly();
|
||||
await paragraphPermission.setEditable(false);
|
||||
|
||||
expect(get(IUniverInstanceService).disposeUnit(document.getId())).toBe(true);
|
||||
expect(unitPermission.getPoint(UnitAction.Edit)).toBe(true);
|
||||
expect(paragraphPermission.canEdit()).toBe(true);
|
||||
});
|
||||
});
|
||||
87
packages/docs/src/controllers/custom-range.controller.ts
Normal file
87
packages/docs/src/controllers/custom-range.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { ISetTextSelectionsOperationParams } from '../commands/operations/text-selection.operation';
|
||||
import { BuildTextUtils, Disposable, ICommandService, Inject, IUniverInstanceService } from '@univerjs/core';
|
||||
import { SetTextSelectionsOperation } from '../commands/operations/text-selection.operation';
|
||||
import { DocSelectionManagerService } from '../services/doc-selection-manager.service';
|
||||
|
||||
export class DocCustomRangeController extends Disposable {
|
||||
constructor(
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
|
||||
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._initSelectionChange();
|
||||
}
|
||||
|
||||
private _transformCustomRange(doc: DocumentDataModel, selection: ITextRangeWithStyle) {
|
||||
const { startOffset, endOffset, collapsed } = selection;
|
||||
// find ranges intersect but not contain with selection
|
||||
const customRanges = doc.getCustomRanges()?.filter((range) => {
|
||||
if (!range.wholeEntity) {
|
||||
return false;
|
||||
}
|
||||
if (startOffset <= range.startIndex && endOffset > range.endIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (collapsed) {
|
||||
return range.startIndex < startOffset && range.endIndex >= endOffset;
|
||||
}
|
||||
return BuildTextUtils.range.isIntersects(startOffset, endOffset - 1, range.startIndex, range.endIndex);
|
||||
});
|
||||
|
||||
if (customRanges?.length) {
|
||||
let start = startOffset;
|
||||
let end = endOffset;
|
||||
customRanges.forEach((range) => {
|
||||
start = Math.min(range.startIndex, start);
|
||||
end = Math.max(range.endIndex + 1, end);
|
||||
});
|
||||
|
||||
return {
|
||||
...selection,
|
||||
startOffset: start,
|
||||
endOffset: end,
|
||||
collapsed: start === end,
|
||||
};
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
private _initSelectionChange() {
|
||||
this.disposeWithMe(this._commandService.onCommandExecuted((commandInfo) => {
|
||||
if (commandInfo.id === SetTextSelectionsOperation.id) {
|
||||
const params = commandInfo.params as ISetTextSelectionsOperationParams;
|
||||
const { unitId, ranges, isEditing } = params;
|
||||
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId);
|
||||
if (!doc) {
|
||||
return;
|
||||
}
|
||||
const transformedRanges = ranges.map((range) => this._transformCustomRange(doc, range));
|
||||
if (transformedRanges.some((range, i) => ranges[i] !== range)) {
|
||||
this._textSelectionManagerService.replaceDocRanges(transformedRanges, undefined, isEditing);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
376
packages/docs/src/controllers/doc-permission.controller.ts
Normal file
376
packages/docs/src/controllers/doc-permission.controller.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 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, ICommandInfo, IExecutionOptions } from '@univerjs/core';
|
||||
import type {
|
||||
IDeleteTextCommandParams,
|
||||
IInsertTextCommandParams,
|
||||
IUpdateTextCommandParams,
|
||||
} from '../commands/commands/core-editing.command';
|
||||
import type { ICreateHeaderFooterCommandParams } from '../commands/commands/create-header-footer.command';
|
||||
import type {
|
||||
ISetSectionHeaderFooterLinkCommandParams,
|
||||
} from '../commands/commands/set-section-header-footer-link.command';
|
||||
import type {
|
||||
IUpdateDocumentParagraphStyleCommandParams,
|
||||
} from '../commands/commands/update-document-paragraph-style.command';
|
||||
import type {
|
||||
IDeleteDocumentSectionBreakCommandParams,
|
||||
IInsertDocumentColumnBreakCommandParams,
|
||||
IInsertDocumentSectionBreakCommandParams,
|
||||
IUpdateDocumentSectionCommandParams,
|
||||
} from '../commands/commands/update-document-section.command';
|
||||
import type { IRichTextEditingMutationParams } from '../commands/mutations/core-editing.mutation';
|
||||
import {
|
||||
CustomCommandExecutionError,
|
||||
DeleteDirection,
|
||||
Disposable,
|
||||
ErrorService,
|
||||
ICommandService,
|
||||
Inject,
|
||||
Injector,
|
||||
IPermissionService,
|
||||
IUniverInstanceService,
|
||||
ObjectPermissionService,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
import { DeleteTextCommand, InsertTextCommand, UpdateTextCommand } from '../commands/commands/core-editing.command';
|
||||
import { CreateHeaderFooterCommand } from '../commands/commands/create-header-footer.command';
|
||||
import { SetDocumentPermissionCommand } from '../commands/commands/set-document-permission.command';
|
||||
import { SetDocumentPermissionsCommand } from '../commands/commands/set-document-permissions.command';
|
||||
import { SetSectionHeaderFooterLinkCommand } from '../commands/commands/set-section-header-footer-link.command';
|
||||
import { UpdateDocumentParagraphStyleCommand } from '../commands/commands/update-document-paragraph-style.command';
|
||||
import {
|
||||
DeleteDocumentSectionBreakCommand,
|
||||
InsertDocumentColumnBreakCommand,
|
||||
InsertDocumentSectionBreakCommand,
|
||||
UpdateDocumentSectionCommand,
|
||||
} from '../commands/commands/update-document-section.command';
|
||||
import { RichTextEditingMutation } from '../commands/mutations/core-editing.mutation';
|
||||
import { DocsRenameMutation } from '../commands/mutations/docs-rename.mutation';
|
||||
import {
|
||||
canEditDocumentTargets,
|
||||
clearDocumentPermissionValuesForUnit,
|
||||
createDocumentPermissionPoint,
|
||||
DOCUMENT_UNIT_PERMISSION_ACTIONS,
|
||||
getDocumentEntityPermissionObjectId,
|
||||
getDocumentPermissionValue,
|
||||
getDocumentSectionPermissionObjectId,
|
||||
} from '../services/permission/document-permission';
|
||||
import {
|
||||
getDocumentDrawingSegmentId,
|
||||
getDocumentEditTargetObjectIds,
|
||||
getDocumentEditTargetObjectIdsFromActions,
|
||||
getDocumentEntityParentPermissionObjectIds,
|
||||
getDocumentSectionIdsAtOffset,
|
||||
getDocumentSectionPermissionObjectIdsByIds,
|
||||
} from '../services/permission/document-permission-resolver';
|
||||
import { getTopLevelSectionBreaks } from '../utils/sections';
|
||||
|
||||
const NON_EDIT_DOCUMENT_COMMAND_IDS = new Set([
|
||||
SetDocumentPermissionCommand.id,
|
||||
SetDocumentPermissionsCommand.id,
|
||||
'doc.command.open-header-footer-panel',
|
||||
'doc.command.close-header-footer',
|
||||
'doc.command.select-all',
|
||||
'doc.command.set-zoom-ratio',
|
||||
]);
|
||||
|
||||
const DERIVED_DOCUMENT_MUTATION_IDS = new Set([
|
||||
'docs-formula.mutation.set-last-values',
|
||||
]);
|
||||
|
||||
export class DocPermissionController extends Disposable {
|
||||
constructor(
|
||||
@Inject(Injector) private readonly _injector: Injector,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IPermissionService private readonly _permissionService: IPermissionService,
|
||||
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
|
||||
) {
|
||||
super();
|
||||
this._univerInstanceService.getAllUnitsForType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
|
||||
.forEach((unit) => this._registerUnitPermissionPoints(unit.getUnitId()));
|
||||
this.disposeWithMe(this._univerInstanceService
|
||||
.getTypeOfUnitAdded$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
|
||||
.subscribe(({ unit }) => this._registerUnitPermissionPoints(unit.getUnitId())));
|
||||
this.disposeWithMe(this._commandService.beforeCommandExecuted((commandInfo, options) => {
|
||||
this._check(commandInfo, options);
|
||||
}));
|
||||
this.disposeWithMe(this._univerInstanceService
|
||||
.getTypeOfUnitDisposed$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
|
||||
.subscribe((unit) => {
|
||||
this._injector.get(ObjectPermissionService).clearUnit(unit.getUnitId());
|
||||
clearDocumentPermissionValuesForUnit(
|
||||
this._permissionService,
|
||||
unit.getUnitId()
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
private _registerUnitPermissionPoints(unitId: string): void {
|
||||
DOCUMENT_UNIT_PERMISSION_ACTIONS.forEach((action) => {
|
||||
const point = createDocumentPermissionPoint(unitId, unitId, action);
|
||||
if (!this._permissionService.getPermissionPoint(point.id)) {
|
||||
this._permissionService.addPermissionPoint(point);
|
||||
}
|
||||
});
|
||||
this._injector.get(ObjectPermissionService).initializeUnit({ unitId, objectId: unitId, objectType: UnitObject.Document });
|
||||
}
|
||||
|
||||
private _check(commandInfo: Readonly<ICommandInfo>, options?: IExecutionOptions): void {
|
||||
if (options?.fromCollab || options?.fromChangeset) {
|
||||
return;
|
||||
}
|
||||
const unitAction = getDocumentUnitAction(commandInfo.id);
|
||||
const unitId = getUnitId(commandInfo, options) ?? (unitAction
|
||||
? this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId()
|
||||
: undefined);
|
||||
if (!unitId || this._univerInstanceService.getUnitType(unitId) !== UniverInstanceType.UNIVER_DOC) {
|
||||
return;
|
||||
}
|
||||
if (unitAction) {
|
||||
if (!getDocumentPermissionValue(this._permissionService, unitId, unitId, unitAction)) {
|
||||
this._injector.get(ErrorService).emitPermissionDenied(unitId);
|
||||
throw new CustomCommandExecutionError(`Document ${UnitAction[unitAction]} permission denied.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(
|
||||
unitId,
|
||||
UniverInstanceType.UNIVER_DOC
|
||||
);
|
||||
if (!documentDataModel) {
|
||||
return;
|
||||
}
|
||||
const targetObjectIds = this._resolveTargetObjectIds(documentDataModel, commandInfo);
|
||||
if (targetObjectIds === null) {
|
||||
return;
|
||||
}
|
||||
if (!canEditDocumentTargets(this._permissionService, unitId, targetObjectIds)) {
|
||||
this._injector.get(ErrorService).emitPermissionDenied(unitId, targetObjectIds);
|
||||
throw new CustomCommandExecutionError('Document edit permission denied.');
|
||||
}
|
||||
}
|
||||
|
||||
private _resolveTargetObjectIds(
|
||||
documentDataModel: DocumentDataModel,
|
||||
commandInfo: Readonly<ICommandInfo>
|
||||
): string[] | null {
|
||||
if (commandInfo.id === RichTextEditingMutation.id) {
|
||||
const params = commandInfo.params as IRichTextEditingMutationParams;
|
||||
return getDocumentEditTargetObjectIdsFromActions(
|
||||
documentDataModel,
|
||||
params.segmentId ?? '',
|
||||
params.actions
|
||||
);
|
||||
}
|
||||
if (commandInfo.id === DocsRenameMutation.id) {
|
||||
return [];
|
||||
}
|
||||
if (isDerivedDocumentMutation(commandInfo)) {
|
||||
return null;
|
||||
}
|
||||
const knownTargets = resolveTextTargets(documentDataModel, commandInfo) ??
|
||||
resolveSectionTargets(documentDataModel, commandInfo);
|
||||
if (knownTargets) {
|
||||
return knownTargets;
|
||||
}
|
||||
const entityTargets = resolveEntityTargets(documentDataModel, commandInfo);
|
||||
if (entityTargets.length) {
|
||||
return entityTargets;
|
||||
}
|
||||
return isDocumentEditCommand(commandInfo.id) || isDocumentDataMutation(commandInfo)
|
||||
? []
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function isDocumentEditCommand(id: string): boolean {
|
||||
if (NON_EDIT_DOCUMENT_COMMAND_IDS.has(id)) {
|
||||
return false;
|
||||
}
|
||||
return id.startsWith('doc.command.') ||
|
||||
id.startsWith('docs.command.') ||
|
||||
id.startsWith('doc.command-') ||
|
||||
id.startsWith('doc.table.');
|
||||
}
|
||||
|
||||
function isDocumentDataMutation(commandInfo: Readonly<ICommandInfo>): boolean {
|
||||
const { id } = commandInfo;
|
||||
if (isDerivedDocumentMutation(commandInfo)) {
|
||||
return false;
|
||||
}
|
||||
return id.startsWith('doc.mutation.') ||
|
||||
id.startsWith('docs.mutation.') ||
|
||||
/^docs-[^.]+\.mutation\./.test(id);
|
||||
}
|
||||
|
||||
function isDerivedDocumentMutation(commandInfo: Readonly<ICommandInfo>): boolean {
|
||||
const params = isRecord(commandInfo.params) ? commandInfo.params : undefined;
|
||||
return DERIVED_DOCUMENT_MUTATION_IDS.has(commandInfo.id) ||
|
||||
(commandInfo.id === 'doc.mutation.update-shape-data' &&
|
||||
!!params?.formulaLastValueGuard);
|
||||
}
|
||||
|
||||
function resolveEntityTargets(
|
||||
documentDataModel: DocumentDataModel,
|
||||
commandInfo: Readonly<ICommandInfo>
|
||||
): string[] {
|
||||
const params = commandInfo.params;
|
||||
if (!isRecord(params)) {
|
||||
return [];
|
||||
}
|
||||
const result = new Set<string>();
|
||||
const add = (segmentId: string, entityType: string, entityId: string): void => {
|
||||
result.add(getDocumentEntityPermissionObjectId(segmentId, entityType, entityId));
|
||||
getDocumentEntityParentPermissionObjectIds(documentDataModel, segmentId, entityType, entityId)
|
||||
.forEach((objectId) => result.add(objectId));
|
||||
};
|
||||
collectEntityReferences(params).forEach(({ entityType, entityId }) => {
|
||||
const explicitSegmentId = params.segmentId;
|
||||
const segmentId = typeof explicitSegmentId === 'string'
|
||||
? explicitSegmentId
|
||||
: entityType === 'drawing' || entityType === 'custom-block'
|
||||
? getDocumentDrawingSegmentId(documentDataModel, entityId)
|
||||
: '';
|
||||
add(segmentId, entityType, entityId);
|
||||
});
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function collectEntityReferences(value: unknown): Array<{ entityType: string; entityId: string }> {
|
||||
const result = new Map<string, { entityType: string; entityId: string }>();
|
||||
const typeByKey: Record<string, string[]> = {
|
||||
blockId: ['custom-block'],
|
||||
drawingId: ['drawing'],
|
||||
shapeId: ['drawing', 'custom-block'],
|
||||
tableId: ['table'],
|
||||
rangeId: ['custom-range'],
|
||||
columnGroupId: ['column-group'],
|
||||
};
|
||||
const visit = (candidate: unknown, key = ''): void => {
|
||||
if (typeof candidate === 'string') {
|
||||
typeByKey[key]?.forEach((entityType) => {
|
||||
result.set(`${entityType}\u001F${candidate}`, { entityType, entityId: candidate });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(candidate)) {
|
||||
const singularKey = key.endsWith('Ids') ? `${key.slice(0, -3)}Id` : key;
|
||||
candidate.forEach((item) => visit(item, singularKey));
|
||||
return;
|
||||
}
|
||||
if (!isRecord(candidate)) {
|
||||
return;
|
||||
}
|
||||
Object.entries(candidate).forEach(([childKey, child]) => visit(child, childKey));
|
||||
};
|
||||
visit(value);
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
function getUnitId(commandInfo: Readonly<ICommandInfo>, options?: IExecutionOptions): string | undefined {
|
||||
const params = isRecord(commandInfo.params) ? commandInfo.params : undefined;
|
||||
return typeof params?.unitId === 'string'
|
||||
? params.unitId
|
||||
: typeof options?.unitId === 'string'
|
||||
? options.unitId
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getDocumentUnitAction(commandId: string): UnitAction | undefined {
|
||||
if (commandId === 'doc.command.copy-current-paragraph' || commandId === 'docs-table.command.copy-selection') {
|
||||
return UnitAction.Copy;
|
||||
}
|
||||
if (commandId === 'docs.operation.print') {
|
||||
return UnitAction.Print;
|
||||
}
|
||||
if (commandId === 'docs-exchange-client.operation.export-doc') {
|
||||
return UnitAction.Export;
|
||||
}
|
||||
if (commandId === 'docs.operation.start-add-comment' ||
|
||||
commandId === 'docs.operation.add-drawing-comment' ||
|
||||
(commandId.startsWith('docs.command.') && commandId.includes('comment')) ||
|
||||
commandId.startsWith('thread-comment.command.') ||
|
||||
commandId.startsWith('thread-comment.mutation.')) {
|
||||
return UnitAction.Comment;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTextTargets(
|
||||
documentDataModel: DocumentDataModel,
|
||||
commandInfo: Readonly<ICommandInfo>
|
||||
): string[] | null {
|
||||
const { id } = commandInfo;
|
||||
if (id === InsertTextCommand.id || id === UpdateTextCommand.id) {
|
||||
const params = commandInfo.params as IInsertTextCommandParams | IUpdateTextCommandParams;
|
||||
return getDocumentEditTargetObjectIds(documentDataModel, params.segmentId ?? '', params.range);
|
||||
}
|
||||
if (id === DeleteTextCommand.id) {
|
||||
const params = commandInfo.params as IDeleteTextCommandParams;
|
||||
const isDeleteLeft = params.direction === DeleteDirection.LEFT;
|
||||
return getDocumentEditTargetObjectIds(documentDataModel, params.segmentId ?? '', {
|
||||
startOffset: isDeleteLeft ? params.range.startOffset - (params.len ?? 1) : params.range.startOffset,
|
||||
endOffset: isDeleteLeft ? params.range.startOffset : params.range.startOffset + (params.len ?? 1),
|
||||
});
|
||||
}
|
||||
if (id === UpdateDocumentParagraphStyleCommand.id) {
|
||||
const params = commandInfo.params as IUpdateDocumentParagraphStyleCommandParams;
|
||||
return getDocumentEditTargetObjectIds(documentDataModel, params.segmentId ?? '', params);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSectionTargets(
|
||||
documentDataModel: DocumentDataModel,
|
||||
commandInfo: Readonly<ICommandInfo>
|
||||
): string[] | null {
|
||||
const { id } = commandInfo;
|
||||
if (id === UpdateDocumentSectionCommand.id) {
|
||||
const params = commandInfo.params as IUpdateDocumentSectionCommandParams;
|
||||
return getDocumentSectionPermissionObjectIdsByIds(params.updates.map((update) => update.sectionId));
|
||||
}
|
||||
if (id === InsertDocumentSectionBreakCommand.id || id === InsertDocumentColumnBreakCommand.id) {
|
||||
const params = commandInfo.params as IInsertDocumentSectionBreakCommandParams | IInsertDocumentColumnBreakCommandParams;
|
||||
const body = documentDataModel.getBody();
|
||||
return body ? getDocumentSectionPermissionObjectIdsByIds(getDocumentSectionIdsAtOffset(body, params.offset)) : [];
|
||||
}
|
||||
if (id === DeleteDocumentSectionBreakCommand.id) {
|
||||
const params = commandInfo.params as IDeleteDocumentSectionBreakCommandParams;
|
||||
const body = documentDataModel.getBody();
|
||||
const sections = body ? getTopLevelSectionBreaks(body) : [];
|
||||
const index = sections.findIndex((section) => section.sectionId === params.sectionId);
|
||||
return getDocumentSectionPermissionObjectIdsByIds([
|
||||
params.sectionId,
|
||||
...(index >= 0 && index + 1 < sections.length ? [sections[index + 1].sectionId] : []),
|
||||
]);
|
||||
}
|
||||
if (id === SetSectionHeaderFooterLinkCommand.id) {
|
||||
const params = commandInfo.params as ISetSectionHeaderFooterLinkCommandParams;
|
||||
return [getDocumentSectionPermissionObjectId('', params.sectionId)];
|
||||
}
|
||||
if (id === CreateHeaderFooterCommand.id) {
|
||||
const params = commandInfo.params as ICreateHeaderFooterCommandParams;
|
||||
return params.sectionId ? [getDocumentSectionPermissionObjectId('', params.sectionId)] : [];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
258
packages/docs/src/embed-host-anchor.ts
Normal file
258
packages/docs/src/embed-host-anchor.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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 { IDocDrawingBase, IMutationInfo, JSONXActions, Serializable } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from './commands/mutations/core-editing.mutation';
|
||||
import { AlignTypeH, DrawingTypeEnum, JSONX, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from './commands/mutations/core-editing.mutation';
|
||||
|
||||
export interface IDocsCustomBlockMutationParams {
|
||||
unitId: string;
|
||||
blockId: string;
|
||||
startIndex: number;
|
||||
segmentId?: string;
|
||||
drawingOrderIndex?: number;
|
||||
embedId?: string;
|
||||
childUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
componentKey?: string;
|
||||
interactionMode?: EmbedDocsCustomBlockInteractionMode;
|
||||
}
|
||||
|
||||
export const EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY = 'UniverEmbedDocsCustomBlock';
|
||||
export type EmbedDocsCustomBlockInteractionMode = 'block' | 'inline';
|
||||
|
||||
export interface IEmbedDocsCustomBlockData {
|
||||
version: 1;
|
||||
embedId: string;
|
||||
hostUnitId?: string;
|
||||
hostAnchorId: string;
|
||||
childUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
interactionMode?: EmbedDocsCustomBlockInteractionMode;
|
||||
}
|
||||
|
||||
const DEFAULT_CUSTOM_BLOCK_SIZE = { width: 720, height: 360 };
|
||||
const SHEET_LIKE_CUSTOM_BLOCK_SIZE = { width: 960, height: 480 };
|
||||
const SLIDE_CUSTOM_BLOCK_SIZE = { width: 720, height: 405 };
|
||||
|
||||
export function createDocsCustomBlockInsertMutation(params: IDocsCustomBlockMutationParams): IMutationInfo<IRichTextEditingMutationParams> {
|
||||
return createRichTextMutation(params.unitId, params.segmentId, createInsertCustomBlockActions(params));
|
||||
}
|
||||
|
||||
export function createDocsCustomBlockRemoveMutation(params: IDocsCustomBlockMutationParams): IMutationInfo<IRichTextEditingMutationParams> {
|
||||
return createRichTextMutation(params.unitId, params.segmentId, createRemoveCustomBlockActions(params));
|
||||
}
|
||||
|
||||
export function createInsertCustomBlockActions(params: IDocsCustomBlockMutationParams): JSONXActions {
|
||||
const textX = new TextX();
|
||||
if (params.startIndex > 0) {
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: params.startIndex,
|
||||
});
|
||||
}
|
||||
|
||||
textX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
body: {
|
||||
dataStream: '\b',
|
||||
customBlocks: [{
|
||||
startIndex: 0,
|
||||
blockId: params.blockId,
|
||||
}],
|
||||
},
|
||||
len: 1,
|
||||
});
|
||||
|
||||
return composeActions([
|
||||
toBodyEditActions(textX, params.segmentId),
|
||||
createDrawingInsertActions(params),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createRemoveCustomBlockActions(params: IDocsCustomBlockMutationParams): JSONXActions {
|
||||
const textX = new TextX();
|
||||
if (params.startIndex > 0) {
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
len: params.startIndex,
|
||||
});
|
||||
}
|
||||
|
||||
textX.push({
|
||||
t: TextXActionType.DELETE,
|
||||
len: 1,
|
||||
});
|
||||
|
||||
return composeActions([
|
||||
toBodyEditActions(textX, params.segmentId),
|
||||
createDrawingRemoveActions(params),
|
||||
]);
|
||||
}
|
||||
|
||||
export function createDocsCustomBlockDrawing(params: IDocsCustomBlockMutationParams): IDocDrawingBase {
|
||||
const size = resolveDocsCustomBlockSize(params.childType);
|
||||
const isInline = params.interactionMode === 'inline';
|
||||
const drawing: IDocDrawingBase & { componentKey: string; data: Serializable } = {
|
||||
unitId: params.unitId,
|
||||
subUnitId: params.unitId,
|
||||
drawingId: params.blockId,
|
||||
drawingType: DrawingTypeEnum.DRAWING_DOM,
|
||||
componentKey: params.componentKey ?? EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY,
|
||||
data: createEmbedDocsCustomBlockData(params) as unknown as Serializable,
|
||||
title: params.blockId,
|
||||
description: 'Univer embedded unit custom block',
|
||||
layoutType: isInline ? PositionedObjectLayoutType.INLINE : PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM,
|
||||
allowTransform: false,
|
||||
docTransform: {
|
||||
size: {
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
},
|
||||
positionH: {
|
||||
relativeFrom: isInline ? ObjectRelativeFromH.PAGE : ObjectRelativeFromH.COLUMN,
|
||||
...(isInline ? { posOffset: 0 } : { align: AlignTypeH.LEFT }),
|
||||
},
|
||||
positionV: {
|
||||
relativeFrom: isInline ? ObjectRelativeFromV.PAGE : ObjectRelativeFromV.PARAGRAPH,
|
||||
posOffset: 0,
|
||||
},
|
||||
angle: 0,
|
||||
},
|
||||
transform: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
},
|
||||
};
|
||||
|
||||
return drawing;
|
||||
}
|
||||
|
||||
export function resolveDocsCustomBlockSize(childType?: UniverInstanceType): { width: number; height: number } {
|
||||
if (childType === UniverInstanceType.UNIVER_SHEET || childType === UniverInstanceType.UNIVER_BASE) {
|
||||
return SHEET_LIKE_CUSTOM_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
if (childType === UniverInstanceType.UNIVER_SLIDE) {
|
||||
return SLIDE_CUSTOM_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
return DEFAULT_CUSTOM_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
export function isSheetLikeDocsCustomBlockChildType(childType?: UniverInstanceType): boolean {
|
||||
return childType === UniverInstanceType.UNIVER_SHEET || childType === UniverInstanceType.UNIVER_BASE;
|
||||
}
|
||||
|
||||
export function createEmbedDocsCustomBlockData(params: {
|
||||
blockId: string;
|
||||
embedId?: string;
|
||||
unitId?: string;
|
||||
childUnitId?: string;
|
||||
childType?: UniverInstanceType;
|
||||
interactionMode?: EmbedDocsCustomBlockInteractionMode;
|
||||
}): IEmbedDocsCustomBlockData {
|
||||
return {
|
||||
version: 1,
|
||||
embedId: params.embedId ?? params.blockId,
|
||||
hostUnitId: params.unitId,
|
||||
hostAnchorId: params.blockId,
|
||||
childUnitId: params.childUnitId,
|
||||
childType: params.childType,
|
||||
interactionMode: params.interactionMode ?? 'block',
|
||||
};
|
||||
}
|
||||
|
||||
export function isEmbedDocsCustomBlockData(data: unknown): data is IEmbedDocsCustomBlockData {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = data as Partial<IEmbedDocsCustomBlockData>;
|
||||
return candidate.version === 1 &&
|
||||
typeof candidate.embedId === 'string' &&
|
||||
typeof candidate.hostAnchorId === 'string';
|
||||
}
|
||||
|
||||
export function shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing: unknown): boolean {
|
||||
const data = drawing && typeof drawing === 'object' ? (drawing as { data?: unknown }).data : undefined;
|
||||
if (!isEmbedDocsCustomBlockData(data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return data.interactionMode === 'inline';
|
||||
}
|
||||
|
||||
function createRichTextMutation(unitId: string, segmentId: string | undefined, actions: JSONXActions): IMutationInfo<IRichTextEditingMutationParams> {
|
||||
return {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
segmentId,
|
||||
actions,
|
||||
textRanges: [],
|
||||
isEditing: false,
|
||||
noNeedSetTextRange: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toBodyEditActions(textX: TextX, segmentId?: string): JSONXActions {
|
||||
const action = JSONX.getInstance().editOp(textX.serialize(), segmentId ? ['headers', segmentId, 'body'] : ['body']);
|
||||
return action ?? [];
|
||||
}
|
||||
|
||||
function createDrawingInsertActions(params: IDocsCustomBlockMutationParams): JSONXActions {
|
||||
if (params.segmentId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const jsonX = JSONX.getInstance();
|
||||
const drawing = createDocsCustomBlockDrawing(params);
|
||||
return composeActions([
|
||||
jsonX.insertOp(['drawings', params.blockId], drawing) ?? [],
|
||||
jsonX.insertOp(['drawingsOrder', params.drawingOrderIndex ?? 0], params.blockId) ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
function createDrawingRemoveActions(params: IDocsCustomBlockMutationParams): JSONXActions {
|
||||
if (params.segmentId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const jsonX = JSONX.getInstance();
|
||||
const drawing = createDocsCustomBlockDrawing(params);
|
||||
return composeActions([
|
||||
jsonX.removeOp(['drawings', params.blockId], drawing) ?? [],
|
||||
jsonX.removeOp(['drawingsOrder', params.drawingOrderIndex ?? 0], params.blockId) ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
function composeActions(actions: Array<JSONXActions | null>): JSONXActions {
|
||||
return actions.reduce((composed, action) => {
|
||||
if (!action || JSONX.isNoop(action) || action.length === 0) {
|
||||
return composed;
|
||||
}
|
||||
if (!composed || JSONX.isNoop(composed) || composed.length === 0) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return JSONX.compose(composed, action) ?? [];
|
||||
}, [] as JSONXActions);
|
||||
}
|
||||
214
packages/docs/src/facade/__tests__/create-test-bed.ts
Normal file
214
packages/docs/src/facade/__tests__/create-test-bed.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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, IDocumentData, Injector } from '@univerjs/core';
|
||||
import {
|
||||
DisposableCollection,
|
||||
DocumentBlockRangeType,
|
||||
ILogService,
|
||||
IUniverInstanceService,
|
||||
LogLevel,
|
||||
PresetListType,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { FUniver } from '@univerjs/core/facade';
|
||||
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { UniverDocsPlugin } from '../../plugin';
|
||||
import '../index';
|
||||
|
||||
export const DOCUMENT_STYLE: IDocumentData['documentStyle'] = {
|
||||
pageSize: {
|
||||
width: 594.3,
|
||||
height: 840.51,
|
||||
},
|
||||
marginTop: 72,
|
||||
marginBottom: 72,
|
||||
marginRight: 90,
|
||||
marginLeft: 90,
|
||||
};
|
||||
|
||||
export function createDocumentData(id: string, body: NonNullable<IDocumentData['body']>): IDocumentData {
|
||||
return {
|
||||
id,
|
||||
body: {
|
||||
customBlocks: [],
|
||||
...body,
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
};
|
||||
}
|
||||
|
||||
function getTestDocumentDataDemo(): IDocumentData {
|
||||
return {
|
||||
id: 'test',
|
||||
body: {
|
||||
dataStream: 'Hello,\r\n',
|
||||
paragraphs: [{ startIndex: 6, paragraphId: 'para_fixture_19' }],
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSimpleDocument(id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: 'Alpha\rBeta\rGamma\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 5, paragraphId: 'para_alpha' },
|
||||
{ startIndex: 10, paragraphId: 'para_beta' },
|
||||
{ startIndex: 16, paragraphId: 'para_gamma' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_110', startIndex: 17 }],
|
||||
});
|
||||
}
|
||||
|
||||
export function createDuplicateDocument(id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: 'Same\rSame\rTail\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 4, paragraphId: 'para_same_1' },
|
||||
{ startIndex: 9, paragraphId: 'para_same_2' },
|
||||
{ startIndex: 14, paragraphId: 'para_tail' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_111', startIndex: 15 }],
|
||||
});
|
||||
}
|
||||
|
||||
export function createTaskDocument(id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: 'Todo\rDone\r\n',
|
||||
paragraphs: [
|
||||
{
|
||||
startIndex: 4,
|
||||
paragraphId: 'para_todo',
|
||||
bullet: {
|
||||
listId: 'task-list',
|
||||
listType: PresetListType.CHECK_LIST,
|
||||
nestingLevel: 0,
|
||||
},
|
||||
},
|
||||
{ startIndex: 9, paragraphId: 'para_done' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_112', startIndex: 10 }],
|
||||
});
|
||||
}
|
||||
|
||||
export function createBulletDocument(id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: 'Bullet\rTail\r\n',
|
||||
paragraphs: [
|
||||
{
|
||||
startIndex: 6,
|
||||
paragraphId: 'para_bullet',
|
||||
bullet: {
|
||||
listId: 'bullet-list',
|
||||
listType: PresetListType.BULLET_LIST,
|
||||
nestingLevel: 0,
|
||||
},
|
||||
},
|
||||
{ startIndex: 11, paragraphId: 'para_bullet_tail' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_113', startIndex: 12 }],
|
||||
});
|
||||
}
|
||||
|
||||
export function createBlockRangeDocument(blockType = DocumentBlockRangeType.QUOTE, id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: 'Block\rAfter\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'para_fixture_20' }, { startIndex: 11, paragraphId: 'para_after_block' }],
|
||||
blockRanges: [{
|
||||
blockId: `${blockType}-1`,
|
||||
blockType,
|
||||
startIndex: 0,
|
||||
endIndex: 5,
|
||||
}],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_114', startIndex: 12 }],
|
||||
});
|
||||
}
|
||||
|
||||
export function createTableDocument(id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: 'TT\raa\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'para_fixture_21' }, { startIndex: 5, paragraphId: 'para_after_table' }],
|
||||
tables: [{ tableId: 'table-1', startIndex: 0, endIndex: 2 }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_115', startIndex: 6 }],
|
||||
});
|
||||
}
|
||||
|
||||
export function createCustomBlockDocument(id = 'test'): IDocumentData {
|
||||
return createDocumentData(id, {
|
||||
dataStream: '\b\raa\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'para_fixture_22' }, { startIndex: 4, paragraphId: 'para_after_custom_block' }],
|
||||
customBlocks: [{ blockId: 'custom-1', blockType: 'custom' as never, startIndex: 0 }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_116', startIndex: 5 }],
|
||||
});
|
||||
}
|
||||
|
||||
function cloneDocumentData(documentData: IDocumentData): IDocumentData {
|
||||
return JSON.parse(JSON.stringify(documentData)) as IDocumentData;
|
||||
}
|
||||
|
||||
export interface ITestBed {
|
||||
univer: Univer;
|
||||
get: Injector['get'];
|
||||
doc: DocumentDataModel;
|
||||
univerAPI: FUniver;
|
||||
}
|
||||
|
||||
export function createTestBed(documentConfig?: IDocumentData): ITestBed {
|
||||
const univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
|
||||
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
const documentConfigWithIds = cloneDocumentData(documentConfig ?? getTestDocumentDataDemo());
|
||||
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
documentConfigWithIds
|
||||
);
|
||||
|
||||
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
|
||||
injector.get(IRenderManagerService).addRender(doc.getUnitId(), {
|
||||
unitId: doc.getUnitId(),
|
||||
type: UniverInstanceType.UNIVER_DOC,
|
||||
engine: new DisposableCollection() as never,
|
||||
scene: new DisposableCollection() as never,
|
||||
mainComponent: null,
|
||||
components: new Map(),
|
||||
isMainScene: true,
|
||||
activated$: new BehaviorSubject(true),
|
||||
with: <T>() => ({
|
||||
getViewModel: () => ({
|
||||
reset: () => undefined,
|
||||
resetByValidatedMetadataMutation: () => false,
|
||||
resetByValidatedTextMutation: () => false,
|
||||
}),
|
||||
}) as T,
|
||||
activate: () => undefined,
|
||||
deactivate: () => undefined,
|
||||
isDisposed: () => false,
|
||||
});
|
||||
|
||||
return {
|
||||
univer,
|
||||
get: injector.get.bind(injector),
|
||||
doc,
|
||||
univerAPI: FUniver.newAPI(injector),
|
||||
};
|
||||
}
|
||||
385
packages/docs/src/facade/__tests__/f-document-paragraph.spec.ts
Normal file
385
packages/docs/src/facade/__tests__/f-document-paragraph.spec.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* 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 { IDocumentData, Univer } from '@univerjs/core';
|
||||
import type { FDocument } from '../f-document';
|
||||
import { BooleanNumber, DataStreamTreeTokenType, DocumentBlockRangeType, ICommandService, IUndoRedoService, PresetListType } from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { UpdateDocumentParagraphStyleCommand } from '../../commands/commands/update-document-paragraph-style.command';
|
||||
import {
|
||||
createBulletDocument,
|
||||
createDocumentData,
|
||||
createDuplicateDocument,
|
||||
createSimpleDocument,
|
||||
createTaskDocument,
|
||||
createTestBed,
|
||||
} from './create-test-bed';
|
||||
|
||||
describe('FDocumentParagraph', () => {
|
||||
let univer: Univer | null = null;
|
||||
let document: FDocument;
|
||||
|
||||
function createDocumentFacade(docData: IDocumentData) {
|
||||
univer?.dispose();
|
||||
const testBed = createTestBed(docData);
|
||||
univer = testBed.univer;
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
univer?.dispose();
|
||||
univer = null;
|
||||
});
|
||||
|
||||
it('keeps paragraph handles stable when text is duplicated or inserted before them', () => {
|
||||
createDocumentFacade(createDuplicateDocument());
|
||||
|
||||
const secondSame = document.getParagraphs()[1];
|
||||
|
||||
expect(secondSame.getId()).toBe('para_same_2');
|
||||
expect(document.insertParagraph(0, 'X').getText()).toBe('X');
|
||||
expect(secondSame.getInfo().paragraphIndex).toBe(2);
|
||||
expect(secondSame.setText('Picked')).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('X\rSame\rPicked\rTail\r\n');
|
||||
expect(secondSame.remove()).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('X\rSame\rTail\r\n');
|
||||
});
|
||||
|
||||
it('marks paragraph handles as stale after their backing paragraph is removed', () => {
|
||||
createDocumentFacade(createSimpleDocument());
|
||||
|
||||
const paragraph = document.getParagraphs()[1];
|
||||
expect(paragraph.remove()).toBe(true);
|
||||
expect(() => paragraph.getText()).toThrow('Document paragraph with id para_beta not found');
|
||||
expect(() => paragraph.remove()).toThrow('Document paragraph with id para_beta not found');
|
||||
});
|
||||
|
||||
it('updates checklist paragraphs without changing non-task list items', () => {
|
||||
createDocumentFacade(createBulletDocument());
|
||||
|
||||
const listItem = document.getParagraphs()[0];
|
||||
expect(listItem.getId()).toMatch(/^para_/);
|
||||
expect(listItem.isListItem()).toBe(true);
|
||||
expect(listItem.isTask()).toBe(false);
|
||||
expect(listItem.setTaskChecked(true)).toBe(false);
|
||||
|
||||
createDocumentFacade(createTaskDocument());
|
||||
|
||||
const task = document.getParagraphs()[0];
|
||||
expect(task.isListItem()).toBe(true);
|
||||
expect(task.isTask()).toBe(true);
|
||||
expect(task.setTaskChecked(true)).toBe(true);
|
||||
expect(document.save().body?.paragraphs?.[0].bullet?.listType).toBe(PresetListType.CHECK_LIST_CHECKED);
|
||||
expect(task.setTaskChecked(false)).toBe(true);
|
||||
expect(document.save().body?.paragraphs?.[0].bullet?.listType).toBe(PresetListType.CHECK_LIST);
|
||||
});
|
||||
|
||||
it('applies paragraph text style to the paragraph text range', () => {
|
||||
createDocumentFacade(createSimpleDocument());
|
||||
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
|
||||
expect(paragraph.setStyle({
|
||||
textStyle: {
|
||||
cl: {
|
||||
rgb: '#FF0000',
|
||||
},
|
||||
fs: 14,
|
||||
},
|
||||
horizontalAlign: 2,
|
||||
})).toBe(true);
|
||||
|
||||
expect(document.save().body?.paragraphs?.[0].paragraphStyle).toMatchObject({
|
||||
horizontalAlign: 2,
|
||||
textStyle: {
|
||||
cl: {
|
||||
rgb: '#FF0000',
|
||||
},
|
||||
fs: 14,
|
||||
},
|
||||
});
|
||||
expect(document.save().body?.textRuns).toEqual([
|
||||
{
|
||||
st: 0,
|
||||
ed: 5,
|
||||
ts: {
|
||||
cl: {
|
||||
rgb: '#FF0000',
|
||||
},
|
||||
fs: 14,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates paragraph and text style as one undoable command and preserves explicit false', () => {
|
||||
const testBed = createTestBed(createSimpleDocument());
|
||||
univer = testBed.univer;
|
||||
testBed.get(IUndoRedoService);
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
|
||||
expect(paragraph.setStyle({
|
||||
textStyle: {
|
||||
bl: BooleanNumber.TRUE,
|
||||
},
|
||||
keepNext: BooleanNumber.FALSE,
|
||||
keepLines: BooleanNumber.TRUE,
|
||||
widowControl: BooleanNumber.FALSE,
|
||||
pageBreakBefore: BooleanNumber.TRUE,
|
||||
})).toBe(true);
|
||||
expect(paragraph.getInfo().paragraph.paragraphStyle).toMatchObject({
|
||||
keepNext: BooleanNumber.FALSE,
|
||||
keepLines: BooleanNumber.TRUE,
|
||||
widowControl: BooleanNumber.FALSE,
|
||||
pageBreakBefore: BooleanNumber.TRUE,
|
||||
});
|
||||
expect(document.save().body?.textRuns).toEqual([{
|
||||
st: 0,
|
||||
ed: 5,
|
||||
ts: { bl: BooleanNumber.TRUE },
|
||||
}]);
|
||||
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(paragraph.getInfo().paragraph.paragraphStyle).toBeUndefined();
|
||||
expect(document.save().body?.textRuns).toBeUndefined();
|
||||
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(paragraph.getInfo().paragraph.paragraphStyle).toMatchObject({
|
||||
keepNext: BooleanNumber.FALSE,
|
||||
keepLines: BooleanNumber.TRUE,
|
||||
widowControl: BooleanNumber.FALSE,
|
||||
pageBreakBefore: BooleanNumber.TRUE,
|
||||
});
|
||||
const saved = document.save();
|
||||
createDocumentFacade(saved);
|
||||
expect(document.getParagraphs()[0].getInfo().paragraph.paragraphStyle).toMatchObject({
|
||||
keepNext: BooleanNumber.FALSE,
|
||||
keepLines: BooleanNumber.TRUE,
|
||||
widowControl: BooleanNumber.FALSE,
|
||||
pageBreakBefore: BooleanNumber.TRUE,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a stale paragraph range without a partial style update', () => {
|
||||
const testBed = createTestBed(createSimpleDocument());
|
||||
univer = testBed.univer;
|
||||
document = testBed.univerAPI.getActiveDocument()!;
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
const { startOffset, endOffset } = paragraph.getInfo();
|
||||
|
||||
expect(document.insertParagraph(0, 'Before').getText()).toBe('Before');
|
||||
expect(testBed.get(ICommandService).syncExecuteCommand(UpdateDocumentParagraphStyleCommand.id, {
|
||||
unitId: document.getId(),
|
||||
paragraphId: paragraph.getId(),
|
||||
startOffset,
|
||||
endOffset,
|
||||
style: {
|
||||
keepNext: BooleanNumber.TRUE,
|
||||
},
|
||||
})).toBe(false);
|
||||
expect(paragraph.getInfo().paragraph.paragraphStyle?.keepNext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads and updates a text range across existing style-run boundaries', () => {
|
||||
const data = createSimpleDocument();
|
||||
data.body!.textRuns = [
|
||||
{ st: 0, ed: 5, ts: { cl: { rgb: '#FF0000' }, bl: 1 } },
|
||||
{ st: 5, ed: 10, ts: { cl: { rgb: '#FF0000' }, it: 1 } },
|
||||
];
|
||||
createDocumentFacade(data);
|
||||
|
||||
const range = document.getTextRange(2, 8);
|
||||
|
||||
expect(range.getText()).toBe('pha\rBe');
|
||||
expect(range.getExplicitTextStyleRuns()).toEqual([
|
||||
{ startOffset: 2, endOffset: 5, textStyle: { cl: { rgb: '#FF0000' }, bl: 1 } },
|
||||
{ startOffset: 5, endOffset: 8, textStyle: { cl: { rgb: '#FF0000' }, it: 1 } },
|
||||
]);
|
||||
expect(range.getCommonExplicitTextStyle()).toEqual({ cl: { rgb: '#FF0000' } });
|
||||
expect(range.describe()).toEqual({
|
||||
startOffset: 2,
|
||||
endOffset: 8,
|
||||
segmentId: '',
|
||||
length: 6,
|
||||
text: 'pha\rBe',
|
||||
explicitTextStyleRuns: range.getExplicitTextStyleRuns(),
|
||||
commonExplicitTextStyle: { cl: { rgb: '#FF0000' } },
|
||||
});
|
||||
|
||||
expect(range.setTextStyle({ fs: 20 })).toBe(true);
|
||||
expect(document.save().body?.textRuns).toEqual([
|
||||
{ st: 0, ed: 2, ts: { cl: { rgb: '#FF0000' }, bl: 1 } },
|
||||
{ st: 2, ed: 5, ts: { cl: { rgb: '#FF0000' }, bl: 1, fs: 20 } },
|
||||
{ st: 5, ed: 8, ts: { cl: { rgb: '#FF0000' }, it: 1, fs: 20 } },
|
||||
{ st: 8, ed: 10, ts: { cl: { rgb: '#FF0000' }, it: 1 } },
|
||||
]);
|
||||
expect(document.getParagraphs()[0].getTextRange().getRange()).toEqual({
|
||||
startOffset: 0,
|
||||
endOffset: 5,
|
||||
segmentId: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('finds literal text ranges inside a paragraph', () => {
|
||||
createDocumentFacade(createSimpleDocument());
|
||||
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
|
||||
expect(paragraph.findText('pha')?.describe()).toMatchObject({
|
||||
startOffset: 2,
|
||||
endOffset: 5,
|
||||
segmentId: '',
|
||||
text: 'pha',
|
||||
});
|
||||
expect(paragraph.findText('PHA')).toBeNull();
|
||||
expect(paragraph.findText('PHA', { matchCase: false })?.getText()).toBe('pha');
|
||||
expect(paragraph.findAllText('a').map((range) => range.getRange())).toEqual([
|
||||
{ startOffset: 4, endOffset: 5, segmentId: '' },
|
||||
]);
|
||||
expect(paragraph.findText('missing')).toBeNull();
|
||||
expect(() => paragraph.findText('', { occurrence: 0 })).toThrow(TypeError);
|
||||
expect(() => paragraph.findText('a', { occurrence: -1 })).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('resolves repeated text occurrences and keeps header matches scoped to their segment', () => {
|
||||
const headerId = 'header-find';
|
||||
createDocumentFacade({
|
||||
...createDocumentData('doc-find-repeated', {
|
||||
dataStream: 'x plus x\r\n',
|
||||
paragraphs: [{ startIndex: 8, paragraphId: 'para_body_repeated' }],
|
||||
sectionBreaks: [{ sectionId: 'section_body_repeated', startIndex: 9 }],
|
||||
}),
|
||||
headers: {
|
||||
[headerId]: {
|
||||
headerId,
|
||||
body: {
|
||||
dataStream: 'X and x\r\n',
|
||||
paragraphs: [{ startIndex: 7, paragraphId: 'para_header_repeated' }],
|
||||
sectionBreaks: [{ sectionId: 'section_header_repeated', startIndex: 8 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bodyParagraph = document.getParagraphs()[0];
|
||||
const headerParagraph = document.getParagraphs(headerId)[0];
|
||||
|
||||
expect(bodyParagraph.findAllText('x').map((range) => range.getRange())).toEqual([
|
||||
{ startOffset: 0, endOffset: 1, segmentId: '' },
|
||||
{ startOffset: 7, endOffset: 8, segmentId: '' },
|
||||
]);
|
||||
expect(bodyParagraph.findText('x', { occurrence: 1 })?.getText()).toBe('x');
|
||||
expect(headerParagraph.findAllText('x', { matchCase: false }).map((range) => range.getRange())).toEqual([
|
||||
{ startOffset: 0, endOffset: 1, segmentId: headerId },
|
||||
{ startOffset: 6, endOffset: 7, segmentId: headerId },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps original document offsets when case folding changes string length', () => {
|
||||
createDocumentFacade(createDocumentData('doc-find-unicode', {
|
||||
dataStream: 'İx and a.b\r\n',
|
||||
paragraphs: [{ startIndex: 10, paragraphId: 'para_unicode' }],
|
||||
sectionBreaks: [{ sectionId: 'section_unicode', startIndex: 11 }],
|
||||
}));
|
||||
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
|
||||
expect(paragraph.findText('x', { matchCase: false })?.describe()).toMatchObject({
|
||||
startOffset: 1,
|
||||
endOffset: 2,
|
||||
text: 'x',
|
||||
});
|
||||
expect(paragraph.findText('A.B', { matchCase: false })?.describe()).toMatchObject({
|
||||
startOffset: 7,
|
||||
endOffset: 10,
|
||||
text: 'a.b',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid document text ranges', () => {
|
||||
createDocumentFacade(createSimpleDocument());
|
||||
|
||||
expect(() => document.getTextRange(-1, 2)).toThrow(RangeError);
|
||||
expect(() => document.getTextRange(4, 2)).toThrow(RangeError);
|
||||
expect(() => document.getTextRange(0, 999)).toThrow(RangeError);
|
||||
});
|
||||
|
||||
it('keeps a paragraph after a block scoped outside the block end token', () => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
createDocumentFacade(createDocumentData('doc-after-block', {
|
||||
dataStream: `${T.BLOCK_START}Code${T.PARAGRAPH}${T.BLOCK_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
|
||||
paragraphs: [
|
||||
{ startIndex: 5, paragraphId: 'para_code' },
|
||||
{ startIndex: 12, paragraphId: 'para_after' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_117', startIndex: 13 }],
|
||||
blockRanges: [{
|
||||
blockId: 'code-1',
|
||||
blockType: DocumentBlockRangeType.CODE,
|
||||
startIndex: 0,
|
||||
endIndex: 6,
|
||||
}],
|
||||
}));
|
||||
|
||||
const paragraph = document.getParagraphs()[1];
|
||||
|
||||
expect(paragraph.getInfo()).toMatchObject({ startOffset: 7, endOffset: 12 });
|
||||
expect(paragraph.getText()).toBe('After');
|
||||
expect(document.insertParagraph(1, 'Between').getText()).toBe('Between');
|
||||
expect(document.save().body).toMatchObject({
|
||||
dataStream: `${T.BLOCK_START}Code${T.PARAGRAPH}${T.BLOCK_END}Between${T.PARAGRAPH}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
|
||||
blockRanges: [{ startIndex: 0, endIndex: 6 }],
|
||||
});
|
||||
const paragraphAfterInsert = document.getParagraphs().find((item) => item.getText() === 'After');
|
||||
expect(paragraphAfterInsert?.setText('Changed')).toBe(true);
|
||||
expect(document.save().body).toMatchObject({
|
||||
dataStream: `${T.BLOCK_START}Code${T.PARAGRAPH}${T.BLOCK_END}Between${T.PARAGRAPH}Changed${T.PARAGRAPH}${T.SECTION_BREAK}`,
|
||||
blockRanges: [{ startIndex: 0, endIndex: 6 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps paragraph edits scoped to their header segment', () => {
|
||||
const headerId = 'header-1';
|
||||
createDocumentFacade({
|
||||
...createDocumentData('doc-with-header', {
|
||||
dataStream: 'Body\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'para_body' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_118', startIndex: 5 }],
|
||||
}),
|
||||
headers: {
|
||||
[headerId]: {
|
||||
headerId,
|
||||
body: {
|
||||
dataStream: 'Head\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'para_header' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_119', startIndex: 5 }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headerParagraph = document.getParagraphs(headerId)[0];
|
||||
|
||||
expect(headerParagraph.getSegmentId()).toBe(headerId);
|
||||
expect(headerParagraph.appendText('er')).toBe(true);
|
||||
expect(document.appendParagraph('Tail', headerId).getText()).toBe('Tail');
|
||||
|
||||
expect(document.getBody().dataStream).toBe('Body\r\n');
|
||||
expect(document.getBody(headerId).dataStream).toBe('Header\rTail\r\n');
|
||||
expect(document.getBody(headerId).paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([6, 11]);
|
||||
});
|
||||
});
|
||||
108
packages/docs/src/facade/__tests__/f-document.node.spec.ts
Normal file
108
packages/docs/src/facade/__tests__/f-document.node.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment node
|
||||
|
||||
import type { Univer } from '@univerjs/core';
|
||||
import { ColumnSeparatorType, DataStreamTreeTokenType, DocumentFlavor, PageOrientType, SectionType } from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { createDocumentData, createTestBed } from './create-test-bed';
|
||||
|
||||
describe('FDocument in Node', () => {
|
||||
let univer: Univer | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
univer?.dispose();
|
||||
});
|
||||
|
||||
it('updates traditional sections without loading Docs UI or browser globals', () => {
|
||||
const data = createDocumentData('node-section-facade', {
|
||||
dataStream: 'Alpha\rBeta\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 5, paragraphId: 'alpha' },
|
||||
{ startIndex: 10, paragraphId: 'beta' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_node', startIndex: 11 }],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
const testBed = createTestBed(data);
|
||||
univer = testBed.univer;
|
||||
const document = testBed.univerAPI.getActiveDocument();
|
||||
const section = document?.getSection(0);
|
||||
|
||||
expect(globalThis).not.toHaveProperty('window');
|
||||
expect(section?.setColumns(2, {
|
||||
gap: 18,
|
||||
separator: ColumnSeparatorType.BETWEEN_EACH_COLUMN,
|
||||
})).toBe(true);
|
||||
expect(section?.setSectionType(SectionType.NEXT_PAGE)).toBe(true);
|
||||
expect(section?.setPageSetup({
|
||||
pageNumberStart: 7,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
marginTop: 48,
|
||||
marginBottom: 48,
|
||||
marginLeft: 64,
|
||||
marginRight: 64,
|
||||
})).toBe(true);
|
||||
expect(document?.insertColumnBreak(3)).toBe(true);
|
||||
|
||||
expect(section?.describe()).toMatchObject({
|
||||
columnCount: 2,
|
||||
columnSeparatorType: ColumnSeparatorType.BETWEEN_EACH_COLUMN,
|
||||
sectionType: SectionType.NEXT_PAGE,
|
||||
config: {
|
||||
pageNumberStart: 7,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
},
|
||||
});
|
||||
expect(section?.getEffectivePageSetup()).toEqual({
|
||||
pageNumberStart: 7,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
margins: {
|
||||
top: 48,
|
||||
bottom: 48,
|
||||
left: 64,
|
||||
right: 64,
|
||||
},
|
||||
contentSize: {
|
||||
width: 832,
|
||||
height: 624,
|
||||
},
|
||||
});
|
||||
expect(document?.save().body?.customRanges).toContainEqual(expect.objectContaining({
|
||||
startIndex: 3,
|
||||
endIndex: 3,
|
||||
properties: { breakType: 'column' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('reads custom block structure without rendering or pixel layout', () => {
|
||||
const data = createDocumentData('node-custom-block-layout', {
|
||||
dataStream: `A${DataStreamTreeTokenType.CUSTOM_BLOCK}B\r\n`,
|
||||
customBlocks: [{ blockId: 'embed-block', startIndex: 1 }],
|
||||
});
|
||||
const testBed = createTestBed(data);
|
||||
univer = testBed.univer;
|
||||
|
||||
expect(globalThis).not.toHaveProperty('window');
|
||||
expect(testBed.univerAPI.getActiveDocument()?.getCustomBlockLayout()).toEqual({
|
||||
blocks: [{ blockId: 'embed-block', startIndex: 1, index: 0 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
658
packages/docs/src/facade/__tests__/f-document.spec.ts
Normal file
658
packages/docs/src/facade/__tests__/f-document.spec.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
/**
|
||||
* 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 { IDocumentData, Univer } from '@univerjs/core';
|
||||
import type { FDocument } from '../f-document';
|
||||
import {
|
||||
BlockType,
|
||||
ColumnSeparatorType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
DrawingTypeEnum,
|
||||
ICommandService,
|
||||
IResourceManagerService,
|
||||
IUndoRedoService,
|
||||
PageOrientType,
|
||||
PositionedObjectLayoutType,
|
||||
SectionType,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { DocSelectionManagerService, InsertTextCommand } from '@univerjs/docs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createDocumentData, createSimpleDocument, createTestBed } from './create-test-bed';
|
||||
|
||||
describe('FDocument', () => {
|
||||
let univer: Univer;
|
||||
let document: FDocument;
|
||||
let get: ReturnType<typeof createTestBed>['get'];
|
||||
let univerAPI: ReturnType<typeof createTestBed>['univerAPI'];
|
||||
|
||||
function createDocumentFacade(docData?: IDocumentData) {
|
||||
const testBed = createTestBed(docData);
|
||||
univer = testBed.univer;
|
||||
get = testBed.get;
|
||||
univerAPI = testBed.univerAPI;
|
||||
document = univerAPI.getActiveDocument()!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createDocumentFacade();
|
||||
});
|
||||
|
||||
it('exposes document layout enums through univerAPI', () => {
|
||||
expect(univerAPI.Enum.DocumentFlavor).toBe(DocumentFlavor);
|
||||
expect(univerAPI.Enum.SectionType).toMatchObject({
|
||||
SECTION_TYPE_UNSPECIFIED: 0,
|
||||
CONTINUOUS: 1,
|
||||
NEXT_COLUMN: 2,
|
||||
NEXT_PAGE: 3,
|
||||
EVEN_PAGE: 4,
|
||||
ODD_PAGE: 5,
|
||||
});
|
||||
expect(univerAPI.Enum.ColumnSeparatorType.BETWEEN_EACH_COLUMN).toBe(ColumnSeparatorType.BETWEEN_EACH_COLUMN);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ flavor: DocumentFlavor.TRADITIONAL, traditional: true, modern: false },
|
||||
{ flavor: DocumentFlavor.MODERN, traditional: false, modern: true },
|
||||
{ flavor: DocumentFlavor.UNSPECIFIED, traditional: false, modern: false },
|
||||
])('reports the exact $flavor document flavor', ({ flavor, traditional, modern }) => {
|
||||
univer.dispose();
|
||||
const data = createSimpleDocument(`flavor-${flavor}`);
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: flavor };
|
||||
createDocumentFacade(data);
|
||||
|
||||
expect(document.getDocumentFlavor()).toBe(flavor);
|
||||
expect(document.isTraditional()).toBe(traditional);
|
||||
expect(document.isModern()).toBe(modern);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('edits document text through body and paragraph operations', () => {
|
||||
expect(document.insertText(6, 'Univer')).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('Hello,Univer\r\n');
|
||||
|
||||
univer.dispose();
|
||||
createDocumentFacade();
|
||||
expect(document.getParagraphs()[0].setText('HeDocso,')).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('HeDocso,\r\n');
|
||||
|
||||
univer.dispose();
|
||||
createDocumentFacade();
|
||||
expect(document.appendParagraph('Line 1').getText()).toBe('Line 1');
|
||||
expect(document.appendParagraph('Line 2').getText()).toBe('Line 2');
|
||||
expect(document.save().body?.dataStream).toBe('Hello,\rLine 1\rLine 2\r\n');
|
||||
});
|
||||
|
||||
it('includes current document resources in saved snapshots', () => {
|
||||
const resourceManagerService = get(IResourceManagerService);
|
||||
|
||||
resourceManagerService.registerPluginResource({
|
||||
pluginName: 'DOC_TEST_RESOURCE_PLUGIN',
|
||||
businesses: [UniverInstanceType.UNIVER_DOC],
|
||||
onLoad: () => undefined,
|
||||
onUnLoad: () => undefined,
|
||||
toJson: () => '{"value":1}',
|
||||
parseJson: (bytes: string) => JSON.parse(bytes),
|
||||
});
|
||||
|
||||
const resources = document.save().resources ?? [];
|
||||
expect(resources.filter((resource) => resource.name === 'DOC_TEST_RESOURCE_PLUGIN')).toEqual([
|
||||
{
|
||||
name: 'DOC_TEST_RESOURCE_PLUGIN',
|
||||
data: '{"value":1}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes document identity and snapshot data from the active model', () => {
|
||||
expect(document.getId()).toBe('test');
|
||||
expect(document.getName()).toBe('');
|
||||
expect(document.getDocumentDataModel().getUnitId()).toBe('test');
|
||||
expect(document.save().body?.dataStream).toBe('Hello,\r\n');
|
||||
});
|
||||
|
||||
it('sets the document name and supports undo and redo', () => {
|
||||
expect(document.setName('Renamed document')).toBe(document);
|
||||
expect(document.getName()).toBe('Renamed document');
|
||||
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(document.getName()).toBe('');
|
||||
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(document.getName()).toBe('Renamed document');
|
||||
});
|
||||
|
||||
it('runs undo and redo against the active document', () => {
|
||||
get(IUndoRedoService);
|
||||
|
||||
expect(get(ICommandService).syncExecuteCommand(InsertTextCommand.id, {
|
||||
unitId: document.getId(),
|
||||
body: { dataStream: 'One' },
|
||||
range: { startOffset: 6, endOffset: 6, collapsed: true, segmentId: '' },
|
||||
segmentId: '',
|
||||
})).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('Hello,One\r\n');
|
||||
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('Hello,\r\n');
|
||||
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(document.save().body?.dataStream).toBe('Hello,One\r\n');
|
||||
});
|
||||
|
||||
it('preserves paragraph ids in saved snapshots and paragraph facades', () => {
|
||||
univer.dispose();
|
||||
createDocumentFacade(createSimpleDocument());
|
||||
|
||||
const savedParagraphs = document.save().body?.paragraphs;
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
|
||||
expect(savedParagraphs?.map((item) => item.paragraphId)).toEqual(['para_alpha', 'para_beta', 'para_gamma']);
|
||||
expect(savedParagraphs?.map((item) => item.startIndex)).toEqual([5, 10, 16]);
|
||||
expect(paragraph.getId()).toBe('para_alpha');
|
||||
expect(paragraph.getText()).toBe('Alpha');
|
||||
});
|
||||
|
||||
it('keeps caller-provided paragraph ids when creating paragraph facades', () => {
|
||||
univer.dispose();
|
||||
createDocumentFacade(createDocumentData('doc-with-ids', {
|
||||
dataStream: 'Legacy\r\n',
|
||||
paragraphs: [{ startIndex: 6, paragraphId: 'para_fixture_26' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_120', startIndex: 7 }],
|
||||
}));
|
||||
|
||||
const paragraph = document.getParagraphs()[0];
|
||||
|
||||
expect(paragraph.getId()).toBe('para_fixture_26');
|
||||
expect(document.save().body?.paragraphs?.[0].paragraphId).toBe('para_fixture_26');
|
||||
});
|
||||
|
||||
it('preserves an editable empty paragraph when deleting past the end of an empty document', () => {
|
||||
univer.dispose();
|
||||
createDocumentFacade(createDocumentData('empty-doc', {
|
||||
dataStream: '\r\n',
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'para_empty' }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_121', startIndex: 1 }],
|
||||
}));
|
||||
|
||||
document.deleteRange({ startOffset: 0, endOffset: 5 });
|
||||
const paragraph = document.insertParagraph(0, 'Document title');
|
||||
expect(paragraph.appendText(' suffix')).toBe(true);
|
||||
|
||||
expect(document.save().body?.dataStream).toBe('Document title suffix\r\r\n');
|
||||
expect(document.save().body?.paragraphs?.map((item) => item.startIndex)).toEqual([21, 22]);
|
||||
expect(document.getParagraphs()[0].getText()).toBe('Document title suffix');
|
||||
});
|
||||
|
||||
it('deletes drawing references atomically without changing the interactive selection', async () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('drawing-delete-doc', {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'para-drawing' }],
|
||||
customBlocks: [{ blockId: 'drawing-1', blockType: BlockType.DRAWING, startIndex: 0 }],
|
||||
});
|
||||
data.drawings = { 'drawing-1': createDrawingData('drawing-delete-doc', 'drawing-1') };
|
||||
data.drawingsOrder = ['drawing-1'];
|
||||
createDocumentFacade(data);
|
||||
get(IUndoRedoService);
|
||||
const replaceDocRanges = vi.spyOn(get(DocSelectionManagerService), 'replaceDocRanges');
|
||||
|
||||
expect(document.deleteRange({ startOffset: 0, endOffset: 1 })).toBe(true);
|
||||
await Promise.resolve();
|
||||
expect(replaceDocRanges).not.toHaveBeenCalled();
|
||||
expect(document.save()).toMatchObject({
|
||||
body: { customBlocks: [] },
|
||||
drawings: {},
|
||||
drawingsOrder: [],
|
||||
});
|
||||
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(document.save()).toMatchObject({
|
||||
body: { customBlocks: [{ blockId: 'drawing-1', startIndex: 0 }] },
|
||||
drawings: { 'drawing-1': { drawingId: 'drawing-1' } },
|
||||
drawingsOrder: ['drawing-1'],
|
||||
});
|
||||
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(document.save()).toMatchObject({
|
||||
body: { customBlocks: [] },
|
||||
drawings: {},
|
||||
drawingsOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes header drawing references in the header segment history', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('header-drawing-delete-doc', {
|
||||
dataStream: 'Body\r\n',
|
||||
paragraphs: [{ startIndex: 4, paragraphId: 'body-paragraph' }],
|
||||
});
|
||||
data.headers = {
|
||||
'header-drawing': {
|
||||
headerId: 'header-drawing',
|
||||
body: {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'header-paragraph' }],
|
||||
customBlocks: [{ blockId: 'header-shape', blockType: BlockType.DRAWING, startIndex: 0 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
data.drawings = { 'header-shape': createDrawingData(data.id, 'header-shape') };
|
||||
data.drawingsOrder = ['header-shape'];
|
||||
createDocumentFacade(data);
|
||||
get(IUndoRedoService);
|
||||
|
||||
expect(document.deleteRange({ startOffset: 0, endOffset: 1, segmentId: 'header-drawing' })).toBe(true);
|
||||
expect(document.save()).toMatchObject({
|
||||
headers: { 'header-drawing': { body: { customBlocks: [] } } },
|
||||
drawings: {},
|
||||
drawingsOrder: [],
|
||||
});
|
||||
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(document.save()).toMatchObject({
|
||||
headers: {
|
||||
'header-drawing': {
|
||||
body: { customBlocks: [{ blockId: 'header-shape', startIndex: 0 }] },
|
||||
},
|
||||
},
|
||||
drawings: { 'header-shape': { drawingId: 'header-shape' } },
|
||||
drawingsOrder: ['header-shape'],
|
||||
});
|
||||
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(document.save()).toMatchObject({
|
||||
headers: { 'header-drawing': { body: { customBlocks: [] } } },
|
||||
drawings: {},
|
||||
drawingsOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('ensures header and footer segments independently', () => {
|
||||
univer.dispose();
|
||||
const documentData = createDocumentData('classic-doc', {
|
||||
dataStream: 'Hello,\r\n',
|
||||
paragraphs: [{ startIndex: 6, paragraphId: 'para_header_footer' }],
|
||||
});
|
||||
documentData.documentStyle = {
|
||||
...documentData.documentStyle,
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
};
|
||||
createDocumentFacade(documentData);
|
||||
|
||||
expect(document.setHeaderFooterOptions({ marginHeader: 36, marginFooter: 40 })).toBe(true);
|
||||
expect(document.getHeaderFooterOptions()).toMatchObject({ marginHeader: 36, marginFooter: 40 });
|
||||
|
||||
const headerId = document.ensurePageHeader();
|
||||
let snapshot = document.save();
|
||||
|
||||
expect(headerId).toEqual(expect.any(String));
|
||||
expect(snapshot.documentStyle?.defaultHeaderId).toBe(headerId);
|
||||
expect(snapshot.headers?.[headerId].body?.dataStream).toBe('\r\n');
|
||||
expect(snapshot.documentStyle?.defaultFooterId).toBeFalsy();
|
||||
expect(Object.keys(snapshot.footers ?? {})).toEqual([]);
|
||||
|
||||
const footerId = document.ensurePageFooter();
|
||||
snapshot = document.save();
|
||||
|
||||
expect(footerId).toEqual(expect.any(String));
|
||||
expect(footerId).not.toBe(headerId);
|
||||
expect(snapshot.documentStyle?.defaultHeaderId).toBe(headerId);
|
||||
expect(snapshot.documentStyle?.defaultFooterId).toBe(footerId);
|
||||
expect(snapshot.footers?.[footerId].body?.dataStream).toBe('\r\n');
|
||||
});
|
||||
|
||||
it('reads and updates OOXML-compatible columns on traditional document sections', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('section-doc', {
|
||||
dataStream: 'One\r\nTwo\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'para-one' },
|
||||
{ startIndex: 8, paragraphId: 'para-two' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section_fixture_122', startIndex: 4, sectionType: SectionType.CONTINUOUS },
|
||||
{ sectionId: 'section_fixture_123', startIndex: 9 },
|
||||
],
|
||||
});
|
||||
data.documentStyle = {
|
||||
...data.documentStyle,
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
};
|
||||
createDocumentFacade(data);
|
||||
|
||||
expect(document.getSections()).toHaveLength(2);
|
||||
expect(document.getSectionAt(6)?.getIndex()).toBe(1);
|
||||
expect(document.getSectionAt(4)).toBeNull();
|
||||
expect(document.getSectionAt(5)?.getIndex()).toBe(1);
|
||||
const first = document.getSection(0)!;
|
||||
expect(first.getRange()).toEqual({ startOffset: 0, endOffset: 4, segmentId: '' });
|
||||
expect(first.setColumns(2, { gap: 20, separator: true })).toBe(true);
|
||||
expect(first.setSectionType(SectionType.CONTINUOUS)).toBe(true);
|
||||
expect(first.describe()).toMatchObject({
|
||||
columnCount: 2,
|
||||
columnSeparatorType: ColumnSeparatorType.BETWEEN_EACH_COLUMN,
|
||||
sectionType: SectionType.CONTINUOUS,
|
||||
});
|
||||
expect(first.getColumns()[0].paddingEnd).toBe(20);
|
||||
const headerId = first.ensureHeader();
|
||||
expect(document.save().body?.sectionBreaks?.[0].defaultHeaderId).toBe(headerId);
|
||||
expect(document.save().documentStyle.defaultHeaderId).toBeFalsy();
|
||||
expect(document.insertColumnBreak(2)).toBe(true);
|
||||
const body = document.save().body;
|
||||
expect(body?.dataStream[2]).toBe(DataStreamTreeTokenType.COLUMN_BREAK);
|
||||
expect(body?.customRanges).toContainEqual(expect.objectContaining({
|
||||
startIndex: 2,
|
||||
endIndex: 2,
|
||||
wholeEntity: true,
|
||||
properties: { breakType: 'column' },
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects explicit columns that overflow the section content width', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('section-validation-doc', {
|
||||
dataStream: 'One\r\n',
|
||||
paragraphs: [{ startIndex: 3, paragraphId: 'para-section-validation' }],
|
||||
sectionBreaks: [{ sectionId: 'section_validation', startIndex: 4 }],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
const section = document.getSection(0)!;
|
||||
|
||||
expect(() => section.setColumns(2, { gap: 20, widths: [300, 300] })).toThrow('exceed the available page content width');
|
||||
expect(() => section.setColumnProperties([
|
||||
{ width: 300, paddingEnd: 20 },
|
||||
{ width: 300, paddingEnd: 0 },
|
||||
])).toThrow('exceed the available page content width');
|
||||
});
|
||||
|
||||
it('updates per-section page setup through commands with undo and redo', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('section-page-setup-doc', {
|
||||
dataStream: 'One\r\n',
|
||||
paragraphs: [{ startIndex: 3, paragraphId: 'para-page-setup' }],
|
||||
sectionBreaks: [{ sectionId: 'section_page_setup', startIndex: 4 }],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
get(IUndoRedoService);
|
||||
|
||||
const section = document.getSection(0)!;
|
||||
expect(() => section.setPageSetup({
|
||||
pageSize: { width: 100, height: 100 },
|
||||
marginLeft: 50,
|
||||
marginRight: 50,
|
||||
})).toThrow('must leave a positive content area');
|
||||
expect(() => section.setPageSetup({ marginTop: Number.NaN })).toThrow('must be finite');
|
||||
const pageSetup = {
|
||||
pageNumberStart: 3,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
marginTop: 48,
|
||||
marginBottom: 56,
|
||||
marginLeft: 64,
|
||||
marginRight: 72,
|
||||
};
|
||||
|
||||
expect(section.setPageSetup(pageSetup)).toBe(true);
|
||||
expect(section.getPageSetup()).toEqual(pageSetup);
|
||||
expect(section.getEffectivePageSetup()).toEqual({
|
||||
pageNumberStart: 3,
|
||||
pageSize: { width: 960, height: 720 },
|
||||
pageOrient: PageOrientType.LANDSCAPE,
|
||||
margins: {
|
||||
top: 48,
|
||||
bottom: 56,
|
||||
left: 64,
|
||||
right: 72,
|
||||
},
|
||||
contentSize: {
|
||||
width: 824,
|
||||
height: 616,
|
||||
},
|
||||
});
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(section.getPageSetup()).toEqual({
|
||||
pageNumberStart: undefined,
|
||||
pageSize: undefined,
|
||||
pageOrient: undefined,
|
||||
marginTop: undefined,
|
||||
marginBottom: undefined,
|
||||
marginLeft: undefined,
|
||||
marginRight: undefined,
|
||||
});
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(section.getPageSetup()).toEqual(pageSetup);
|
||||
});
|
||||
|
||||
it.each([DocumentFlavor.MODERN, DocumentFlavor.UNSPECIFIED])(
|
||||
'keeps traditional section mutation unavailable for document flavor %s',
|
||||
(documentFlavor) => {
|
||||
univer.dispose();
|
||||
const data = createSimpleDocument(`unsupported-sections-${documentFlavor}`);
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor };
|
||||
createDocumentFacade(data);
|
||||
|
||||
expect(document.getSections()).toEqual([]);
|
||||
expect(() => document.insertColumnBreak(0)).toThrow('Section column APIs are supported only in traditional documents');
|
||||
expect(() => document.insertSectionBreak(0)).toThrow('Section column APIs are supported only in traditional documents');
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps section facade identity stable when section order changes', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('stable-section-doc', {
|
||||
dataStream: 'One\r\nTwo\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'para-one' },
|
||||
{ startIndex: 8, paragraphId: 'para-two' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section_stable_one', startIndex: 4 },
|
||||
{ sectionId: 'section_stable_two', startIndex: 9 },
|
||||
],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
|
||||
const originalFirst = document.getSection(0)!;
|
||||
const originalId = originalFirst.getId();
|
||||
const inserted = document.insertSectionBreak(0);
|
||||
|
||||
expect(inserted).not.toBeNull();
|
||||
expect(originalFirst.getId()).toBe(originalId);
|
||||
expect(originalFirst.getIndex()).toBe(1);
|
||||
expect(originalFirst.getConfig().sectionId).toBe(originalId);
|
||||
});
|
||||
|
||||
it('reads and changes header links through stable section ids', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('section-header-facade-doc', {
|
||||
dataStream: 'One\r\nTwo\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'para_header_one' },
|
||||
{ startIndex: 8, paragraphId: 'para_header_two' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section_header_one', startIndex: 4, defaultHeaderId: 'header-one' },
|
||||
{ sectionId: 'section_header_two', startIndex: 9 },
|
||||
],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
data.headers = {
|
||||
'header-one': {
|
||||
headerId: 'header-one',
|
||||
body: {
|
||||
dataStream: 'Shared\r\n',
|
||||
paragraphs: [{ startIndex: 6, paragraphId: 'header_facade_para' }],
|
||||
sectionBreaks: [{ startIndex: 7, sectionId: 'header_facade_section' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
createDocumentFacade(data);
|
||||
const second = document.getSection(1)!;
|
||||
get(IUndoRedoService);
|
||||
|
||||
expect(second.isHeaderLinkedToPrevious()).toBe(true);
|
||||
expect(second.getHeaderId()).toBe('header-one');
|
||||
expect(second.describe().headerFooter.defaultHeader).toEqual({
|
||||
segmentId: 'header-one',
|
||||
linkedToPrevious: true,
|
||||
});
|
||||
expect(second.setHeaderLinkedToPrevious(false)).toBe(true);
|
||||
expect(second.isHeaderLinkedToPrevious()).toBe(false);
|
||||
expect(second.getHeaderId()).not.toBe('header-one');
|
||||
expect(document.save().headers?.[second.getHeaderId()!].body?.dataStream).toBe('Shared\r\n');
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(second.isHeaderLinkedToPrevious()).toBe(true);
|
||||
expect(second.getHeaderId()).toBe('header-one');
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(second.isHeaderLinkedToPrevious()).toBe(false);
|
||||
expect(second.setHeaderLinkedToPrevious(true)).toBe(true);
|
||||
expect(second.isHeaderLinkedToPrevious()).toBe(true);
|
||||
expect(second.getHeaderId()).toBe('header-one');
|
||||
});
|
||||
|
||||
it('rejects reads from a retained section facade after switching to modern mode', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('retained-section-doc', {
|
||||
dataStream: 'Alpha\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'para_retained_section' }],
|
||||
sectionBreaks: [{ startIndex: 6, sectionId: 'section_retained' }],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
const section = document.getSection(0)!;
|
||||
|
||||
document.getDocumentDataModel().updateDocumentStyle({
|
||||
...document.getDocumentDataModel().getDocumentStyle(),
|
||||
documentFlavor: DocumentFlavor.MODERN,
|
||||
});
|
||||
|
||||
expect(section.getId()).toBe('section_retained');
|
||||
expect(() => section.getConfig()).toThrow('Section column APIs are supported only in traditional documents');
|
||||
expect(() => section.getRange()).toThrow('Section column APIs are supported only in traditional documents');
|
||||
});
|
||||
|
||||
it('does not expose table-cell section sentinels as document sections', () => {
|
||||
univer.dispose();
|
||||
const T = DataStreamTreeTokenType;
|
||||
const dataStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`;
|
||||
const data = createDocumentData('top-level-section-doc', {
|
||||
dataStream,
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'para_cell' },
|
||||
{ startIndex: 8, paragraphId: 'para_root' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ startIndex: 4, sectionId: 'section_cell' },
|
||||
{ startIndex: 9, sectionId: 'section_root' },
|
||||
],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
|
||||
expect(document.getSections().map((section) => section.getId())).toEqual(['section_root']);
|
||||
expect(document.getSection(0)?.getRange()).toEqual({ startOffset: 0, endOffset: 9, segmentId: '' });
|
||||
});
|
||||
|
||||
it('inserts and removes traditional sections through stable ids', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('section-lifecycle-doc', {
|
||||
dataStream: 'Alpha\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'para_alpha_section' }],
|
||||
sectionBreaks: [{ startIndex: 6, sectionId: 'section_root' }],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
|
||||
const inserted = document.insertSectionBreak(6, { sectionType: SectionType.CONTINUOUS });
|
||||
expect(inserted?.getId()).toMatch(/^section_/u);
|
||||
expect(inserted?.getConfig().sectionType).toBe(SectionType.CONTINUOUS);
|
||||
expect(document.getSections()).toHaveLength(2);
|
||||
|
||||
expect(inserted?.remove()).toBe(true);
|
||||
expect(document.getSections().map((section) => section.getId())).toEqual(['section_root']);
|
||||
expect(document.getSection(0)?.remove()).toBe(false);
|
||||
});
|
||||
|
||||
it('atomically sets how the following section begins when inserting a section break', () => {
|
||||
univer.dispose();
|
||||
const data = createDocumentData('section-next-page-doc', {
|
||||
dataStream: 'Alpha\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'para_alpha_section' }],
|
||||
sectionBreaks: [{ startIndex: 6, sectionId: 'section_root' }],
|
||||
});
|
||||
data.documentStyle = { ...data.documentStyle, documentFlavor: DocumentFlavor.TRADITIONAL };
|
||||
createDocumentFacade(data);
|
||||
get(IUndoRedoService);
|
||||
|
||||
const inserted = document.insertSectionBreak(6, {
|
||||
sectionType: SectionType.CONTINUOUS,
|
||||
nextSectionType: SectionType.NEXT_PAGE,
|
||||
});
|
||||
|
||||
expect(inserted?.getConfig().sectionType).toBe(SectionType.CONTINUOUS);
|
||||
expect(document.getSection(1)?.getId()).toBe('section_root');
|
||||
expect(document.getSection(1)?.getConfig().sectionType).toBe(SectionType.NEXT_PAGE);
|
||||
|
||||
expect(document.undo()).toBe(true);
|
||||
expect(document.getSections().map((section) => section.getId())).toEqual(['section_root']);
|
||||
expect(document.getSection(0)?.getConfig().sectionType).toBeUndefined();
|
||||
|
||||
expect(document.redo()).toBe(true);
|
||||
expect(document.getSections()).toHaveLength(2);
|
||||
expect(document.getSection(1)?.getConfig().sectionType).toBe(SectionType.NEXT_PAGE);
|
||||
});
|
||||
|
||||
it('inserts a horizontal rule as a bordered paragraph', () => {
|
||||
univer.dispose();
|
||||
createDocumentFacade(createSimpleDocument());
|
||||
const offset = document.getBody().sectionBreaks![0].startIndex;
|
||||
|
||||
const rule = document.insertHorizontalRule(offset);
|
||||
|
||||
expect(rule).not.toBeNull();
|
||||
expect(rule?.getText()).toBe('');
|
||||
expect(rule?.getInfo().paragraph.paragraphStyle?.borderBottom).toMatchObject({
|
||||
padding: 5,
|
||||
width: 1,
|
||||
color: { rgb: '#CDD0D8' },
|
||||
});
|
||||
expect(rule?.getInfo().paragraph.paragraphStyle?.spaceBelow).toEqual({ v: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
function createDrawingData(unitId: string, drawingId: string) {
|
||||
return {
|
||||
drawingId,
|
||||
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
|
||||
docTransform: {
|
||||
angle: 0,
|
||||
positionH: { posOffset: 0, relativeFrom: 0 },
|
||||
positionV: { posOffset: 0, relativeFrom: 0 },
|
||||
size: { height: 40, width: 80 },
|
||||
},
|
||||
layoutType: PositionedObjectLayoutType.WRAP_SQUARE,
|
||||
subUnitId: unitId,
|
||||
unitId,
|
||||
};
|
||||
}
|
||||
70
packages/docs/src/facade/__tests__/f-univer.spec.ts
Normal file
70
packages/docs/src/facade/__tests__/f-univer.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { Univer } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createTestBed, DOCUMENT_STYLE } from './create-test-bed';
|
||||
|
||||
describe('FUniver docs facade', () => {
|
||||
let univer: Univer;
|
||||
let univerAPI: ReturnType<typeof createTestBed>['univerAPI'];
|
||||
|
||||
beforeEach(() => {
|
||||
const testBed = createTestBed();
|
||||
univer = testBed.univer;
|
||||
univerAPI = testBed.univerAPI;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('should create, focus and resolve document facades by document id', () => {
|
||||
expect(univerAPI.getActiveDocument()?.getId()).toBe('test');
|
||||
expect(univerAPI.getDocument('test')?.getId()).toBe('test');
|
||||
expect(univerAPI.getDocument('missing-doc')).toBeNull();
|
||||
|
||||
const created = univerAPI.createDocument({
|
||||
id: 'created-doc',
|
||||
title: 'Created',
|
||||
body: {
|
||||
dataStream: 'Created\r\n',
|
||||
paragraphs: [{ startIndex: 7, paragraphId: 'para_created' }],
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
});
|
||||
|
||||
expect(created.getId()).toBe('created-doc');
|
||||
expect(created.getName()).toBe('Created');
|
||||
expect(univerAPI.getDocument('created-doc')?.save().body?.dataStream).toBe('Created\r\n');
|
||||
});
|
||||
|
||||
it('creates an embedded document without changing the active document', () => {
|
||||
const created = univerAPI.createDocument({
|
||||
id: 'embedded-doc',
|
||||
title: 'Embedded',
|
||||
body: { dataStream: '\r\n' },
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
}, {
|
||||
embeddedRender: true,
|
||||
makeCurrent: false,
|
||||
skipAutoRender: true,
|
||||
});
|
||||
|
||||
expect(created.getId()).toBe('embedded-doc');
|
||||
expect(univerAPI.getActiveDocument()?.getId()).toBe('test');
|
||||
});
|
||||
});
|
||||
63
packages/docs/src/facade/__tests__/utils.spec.ts
Normal file
63
packages/docs/src/facade/__tests__/utils.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPlainTextInsertBody,
|
||||
getNormalizedPlainTextCursorOffset,
|
||||
getParagraphStyleAtOffset,
|
||||
getRemovedLeadingParagraphBreakLength,
|
||||
} from '../utils';
|
||||
|
||||
describe('doc facade utils', () => {
|
||||
it('should build normalized paragraph insert bodies with independent paragraph styles', () => {
|
||||
const paragraphStyle = { horizontalAlign: 2 };
|
||||
const body = buildPlainTextInsertBody('\nTitle\nBody\r\n', {
|
||||
paragraphStyle,
|
||||
removeLeadingParagraphBreak: true,
|
||||
});
|
||||
|
||||
paragraphStyle.horizontalAlign = 1;
|
||||
|
||||
expect(body.dataStream).toBe('Title\rBody\r');
|
||||
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([5, 10]);
|
||||
expect(body.paragraphs?.[0].paragraphId).toMatch(/^para_/);
|
||||
expect(body.paragraphs?.[1].paragraphId).toMatch(/^para_/);
|
||||
expect(body.paragraphs?.[0].paragraphId).not.toBe(body.paragraphs?.[1].paragraphId);
|
||||
expect(body.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(2);
|
||||
});
|
||||
|
||||
it('should calculate cursor offsets after line ending normalization and leading break removal', () => {
|
||||
expect(getRemovedLeadingParagraphBreakLength('\nTitle', true)).toBe(1);
|
||||
expect(getRemovedLeadingParagraphBreakLength('\nTitle', false)).toBe(0);
|
||||
expect(getNormalizedPlainTextCursorOffset('\nA\r\nB', 4, true)).toBe(2);
|
||||
});
|
||||
|
||||
it('should reuse the paragraph style surrounding an insertion offset', () => {
|
||||
const body: IDocumentBody = {
|
||||
dataStream: 'A\rB\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 1, paragraphId: 'para_a', paragraphStyle: { horizontalAlign: 1 } },
|
||||
{ startIndex: 3, paragraphId: 'para_b', paragraphStyle: { horizontalAlign: 2 } },
|
||||
],
|
||||
};
|
||||
|
||||
expect(getParagraphStyleAtOffset(body, 0)?.horizontalAlign).toBe(1);
|
||||
expect(getParagraphStyleAtOffset(body, 2)?.horizontalAlign).toBe(2);
|
||||
expect(getParagraphStyleAtOffset({ dataStream: '' }, 0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
521
packages/docs/src/facade/f-document-paragraph.ts
Normal file
521
packages/docs/src/facade/f-document-paragraph.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* 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, Injector, IParagraph, IParagraphStyle } from '@univerjs/core';
|
||||
import type { FDocument } from './f-document';
|
||||
import type { IFDocumentTextRange } from './utils';
|
||||
import { getParagraphContentStartOffset, ICommandService, IPermissionService, PresetListType, regexp, RESTORE_INSERTED_PARAGRAPH_IDS, UpdateDocsAttributeType } from '@univerjs/core';
|
||||
import { FBaseInitialable } from '@univerjs/core/facade';
|
||||
import { getDocumentParagraphParentPermissionObjectIds, getDocumentParagraphPermissionObjectId, UpdateDocumentParagraphStyleCommand } from '@univerjs/docs';
|
||||
import { FDocumentObjectPermission } from './f-document-permission';
|
||||
import { FDocumentTextRange } from './f-document-text-range';
|
||||
import { buildPlainTextInsertBody, replaceBodyRange, retainBodyRange } from './utils';
|
||||
|
||||
/**
|
||||
* Resolved paragraph metadata in the the document body.
|
||||
*/
|
||||
export interface IFDocumentParagraphInfo {
|
||||
/** The underlying paragraph snapshot object. */
|
||||
paragraph: IParagraph;
|
||||
/** The current paragraph index in the body paragraph list. */
|
||||
paragraphIndex: number;
|
||||
/** The inclusive start offset of the paragraph text. */
|
||||
startOffset: number;
|
||||
/** The exclusive end offset of the paragraph text, before the paragraph break. */
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for locating text inside a document paragraph.
|
||||
*/
|
||||
export interface IFDocumentFindTextOptions {
|
||||
/**
|
||||
* Whether matching is case-sensitive.
|
||||
* @default true
|
||||
*/
|
||||
matchCase?: boolean;
|
||||
|
||||
/**
|
||||
* Zero-based occurrence to return from {@link FDocumentParagraph.findText}.
|
||||
* @default 0
|
||||
*/
|
||||
occurrence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A paragraph facade wrapper.
|
||||
*
|
||||
* Paragraph identity is backed by the persisted `paragraphId`. The id is
|
||||
* re-resolved before each method call, so insertions before this paragraph do
|
||||
* not break the wrapper.
|
||||
*
|
||||
* @hideconstructor
|
||||
*/
|
||||
export class FDocumentParagraph extends FBaseInitialable {
|
||||
constructor(
|
||||
protected readonly _document: FDocument,
|
||||
protected readonly _paragraphId: string,
|
||||
protected readonly _segmentId = '',
|
||||
protected override readonly _injector: Injector,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IPermissionService private readonly _permissionService: IPermissionService
|
||||
) {
|
||||
super(_injector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the persisted paragraph id.
|
||||
* @returns {string} The paragraph id.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.getId());
|
||||
* ```
|
||||
*/
|
||||
getId(): string {
|
||||
return this._paragraphId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the segment id of this paragraph.
|
||||
* The main body paragraphs have an empty string segment id.
|
||||
* The header and footer paragraphs have a non-empty string segment id.
|
||||
* @returns {string} The segment id.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.getSegmentId());
|
||||
* ```
|
||||
*/
|
||||
getSegmentId(): string {
|
||||
return this._segmentId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this Paragraph's permission facade.
|
||||
* @returns {FDocumentObjectPermission} Permission facade combining Document, Section, and Paragraph Edit points.
|
||||
* @example
|
||||
* ```ts
|
||||
* const paragraph = univerAPI.getActiveDocument()?.getParagraphs()[0];
|
||||
* if (!paragraph) throw new Error('Paragraph not found.');
|
||||
* await paragraph.getPermission().setReadOnly();
|
||||
* ```
|
||||
*/
|
||||
getPermission(): FDocumentObjectPermission {
|
||||
return new FDocumentObjectPermission(
|
||||
this._document.getId(),
|
||||
getDocumentParagraphPermissionObjectId(this._segmentId, this._paragraphId),
|
||||
this._commandService,
|
||||
this._permissionService,
|
||||
() => getDocumentParagraphParentPermissionObjectIds(
|
||||
this._document.getDocumentDataModel(),
|
||||
this._segmentId,
|
||||
this._paragraphId
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this paragraph's metadata.
|
||||
* @returns {IFDocumentParagraphInfo} The paragraph info.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.getInfo());
|
||||
* ```
|
||||
*/
|
||||
getInfo(): IFDocumentParagraphInfo {
|
||||
const body = this._document.getBody(this._segmentId);
|
||||
const { paragraphs = [] } = body;
|
||||
const matches = paragraphs
|
||||
.map((paragraph, paragraphIndex) => ({ paragraph, paragraphIndex }))
|
||||
.filter(({ paragraph }) => paragraph.paragraphId === this._paragraphId);
|
||||
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`Document paragraph with id ${this._paragraphId} not found`);
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Multiple document paragraphs with id ${this._paragraphId} found`);
|
||||
}
|
||||
|
||||
const { paragraph, paragraphIndex } = matches[0];
|
||||
const startOffset = getParagraphContentStartOffset(body, paragraph);
|
||||
|
||||
return {
|
||||
paragraph,
|
||||
paragraphIndex,
|
||||
startOffset,
|
||||
endOffset: paragraph.startIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current text range occupied by this paragraph.
|
||||
* @returns {IFDocumentTextRange} The paragraph text range, excluding the trailing paragraph break.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.getRange());
|
||||
* ```
|
||||
*/
|
||||
getRange(): IFDocumentTextRange {
|
||||
const { startOffset, endOffset } = this.getInfo();
|
||||
return { startOffset, endOffset, segmentId: this._segmentId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an agent-friendly facade for reading and styling this paragraph's text.
|
||||
* @returns {FDocumentTextRange} The paragraph text range, excluding the trailing paragraph break.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument?.findParagraphByText('Launch');
|
||||
* const range = paragraph?.getTextRange();
|
||||
* console.log(range?.describe());
|
||||
* ```
|
||||
*/
|
||||
getTextRange(): FDocumentTextRange {
|
||||
const { startOffset, endOffset } = this.getInfo();
|
||||
return this._injector.createInstance(FDocumentTextRange, this._document, startOffset, endOffset, this._segmentId, this._injector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds one literal text occurrence inside this paragraph.
|
||||
*
|
||||
* The returned text range is fixed when it is created. Resolve a new range
|
||||
* after edits that insert or remove content before the match.
|
||||
*
|
||||
* @param {string} text Literal text to find. It must not be empty.
|
||||
* @param {IFDocumentFindTextOptions} [options] Case sensitivity and zero-based occurrence.
|
||||
* @returns {FDocumentTextRange | null} The matching fixed text range, or `null` when no such occurrence exists.
|
||||
* @example
|
||||
* ```ts
|
||||
* const univerAPI = FUniver.newAPI(univer);
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) throw new Error('No active document');
|
||||
*
|
||||
* const paragraph = document.findParagraphByText('Launch formula');
|
||||
* if (!paragraph) throw new Error('Target paragraph not found');
|
||||
*
|
||||
* const range = paragraph.findText('formula');
|
||||
* if (!range) throw new Error('Target text not found');
|
||||
*
|
||||
* console.log(range.describe());
|
||||
* ```
|
||||
*/
|
||||
findText(text: string, options: IFDocumentFindTextOptions = {}): FDocumentTextRange | null {
|
||||
const occurrence = options.occurrence ?? 0;
|
||||
if (!Number.isInteger(occurrence) || occurrence < 0) {
|
||||
throw new RangeError('Text occurrence must be a non-negative integer.');
|
||||
}
|
||||
|
||||
return this.findAllText(text, options)[occurrence] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every non-overlapping literal text occurrence inside this paragraph.
|
||||
*
|
||||
* Results are ordered from the start of the paragraph. The returned ranges
|
||||
* are fixed when created; use them immediately and resolve new ranges after
|
||||
* edits that change earlier document content.
|
||||
*
|
||||
* @param {string} text Literal text to find. It must not be empty.
|
||||
* @param {Omit<IFDocumentFindTextOptions, 'occurrence'>} [options] Case-sensitivity option.
|
||||
* @returns {FDocumentTextRange[]} All matching fixed text ranges, or an empty array when no matches exist.
|
||||
* @example
|
||||
* ```ts
|
||||
* const univerAPI = FUniver.newAPI(univer);
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) throw new Error('No active document');
|
||||
*
|
||||
* const paragraph = document.findParagraphByText('x plus x');
|
||||
* if (!paragraph) throw new Error('Target paragraph not found');
|
||||
*
|
||||
* const matches = paragraph.findAllText('x');
|
||||
* console.log(matches.map((range) => range.describe()));
|
||||
* ```
|
||||
*/
|
||||
findAllText(
|
||||
text: string,
|
||||
options: Omit<IFDocumentFindTextOptions, 'occurrence'> = {}
|
||||
): FDocumentTextRange[] {
|
||||
if (text.length === 0) {
|
||||
throw new TypeError('Text to find must not be empty.');
|
||||
}
|
||||
|
||||
const matchCase = options.matchCase ?? true;
|
||||
const paragraphText = this.getText();
|
||||
const matcher = regexp.createLiteralRegExp(text, matchCase ? 'gu' : 'giu');
|
||||
const { startOffset } = this.getInfo();
|
||||
const matches: FDocumentTextRange[] = [];
|
||||
|
||||
for (const match of paragraphText.matchAll(matcher)) {
|
||||
const matchStartOffset = startOffset + match.index;
|
||||
matches.push(this._injector.createInstance(
|
||||
FDocumentTextRange,
|
||||
this._document,
|
||||
matchStartOffset,
|
||||
matchStartOffset + match[0].length,
|
||||
this._segmentId,
|
||||
this._injector
|
||||
));
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this paragraph's plain text.
|
||||
* @returns {string} The paragraph text without the trailing paragraph break.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.getText());
|
||||
* ```
|
||||
*/
|
||||
getText(): string {
|
||||
const { dataStream } = this._document.getBody(this._segmentId);
|
||||
const { startOffset, endOffset } = this.getInfo();
|
||||
return dataStream.slice(startOffset, endOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace this paragraph's plain text.
|
||||
* @param {string} text The replacement text. Do not include the paragraph break.
|
||||
* @returns {boolean} `true` if the paragraph text was replaced.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* paragraph?.setText('New text');
|
||||
* console.log(paragraph?.getText());
|
||||
* ```
|
||||
*/
|
||||
setText(text: string): boolean {
|
||||
const { startOffset, endOffset } = this.getInfo();
|
||||
return replaceBodyRange(
|
||||
{
|
||||
startOffset,
|
||||
endOffset,
|
||||
segmentId: this._segmentId,
|
||||
},
|
||||
buildPlainTextInsertBody(text),
|
||||
this._document.getDocumentDataModel(),
|
||||
this._commandService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append plain text before this paragraph's trailing paragraph break.
|
||||
* @param {string} text The plain text to append.
|
||||
* @returns {boolean} `true` if the text was appended.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* paragraph?.appendText(' Appended text');
|
||||
* console.log(paragraph?.getText());
|
||||
* ```
|
||||
*/
|
||||
appendText(text: string): boolean {
|
||||
const { endOffset } = this.getInfo();
|
||||
return this._document.insertText(endOffset, text, this._segmentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a paragraph and optional text-style patch through one document command.
|
||||
*
|
||||
* Pagination values use `BooleanNumber.TRUE` or `BooleanNumber.FALSE`; explicit
|
||||
* false is preserved and overrides inherited true. The paragraph and text-style
|
||||
* changes share one undo/redo item. A stale paragraph handle returns `false`
|
||||
* without applying a partial update.
|
||||
*
|
||||
* The Traditional renderer applies these Word-compatible pagination rules:
|
||||
* use `pageBreakBefore` for a hard chapter-page boundary, `keepLines` for a
|
||||
* short paragraph that should stay intact, `keepNext` for a heading or caption
|
||||
* that should accompany the next paragraph, and `widowControl` for natural
|
||||
* multi-line body text. Do not enable every rule on every paragraph. Modern
|
||||
* and Unspecified Docs preserve the values in the model but do not apply them
|
||||
* to physical pages.
|
||||
*
|
||||
* `style.textStyle.fs` is a font size in points (pt), not CSS pixels.
|
||||
* @param {IParagraphStyle} style The Univer paragraph style patch.
|
||||
* @returns {boolean} `true` when the complete patch was applied; otherwise `false`.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) {
|
||||
* throw new Error('No active document');
|
||||
* }
|
||||
* if (!document.isTraditional()) {
|
||||
* throw new Error('Traditional document pagination is required');
|
||||
* }
|
||||
* const heading = document.findParagraphByText('Appendix');
|
||||
* const following = document.findParagraphByText('Supporting details');
|
||||
* if (!heading || !following) {
|
||||
* throw new Error('Expected paragraphs were not found');
|
||||
* }
|
||||
*
|
||||
* const headingUpdated = heading.setStyle({
|
||||
* pageBreakBefore: univerAPI.Enum.BooleanNumber.TRUE,
|
||||
* keepLines: univerAPI.Enum.BooleanNumber.TRUE,
|
||||
* keepNext: univerAPI.Enum.BooleanNumber.TRUE,
|
||||
* });
|
||||
* const followingUpdated = following.setStyle({
|
||||
* // Explicit FALSE terminates this authored keepNext chain even if a named
|
||||
* // style or document default enables it.
|
||||
* keepNext: univerAPI.Enum.BooleanNumber.FALSE,
|
||||
* widowControl: univerAPI.Enum.BooleanNumber.TRUE,
|
||||
* });
|
||||
* if (!headingUpdated || !followingUpdated) {
|
||||
* throw new Error('Failed to update paragraph pagination');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setStyle(style: IParagraphStyle): boolean {
|
||||
const { startOffset, endOffset } = this.getInfo();
|
||||
return this._commandService.syncExecuteCommand(UpdateDocumentParagraphStyleCommand.id, {
|
||||
unitId: this._document.getId(),
|
||||
segmentId: this._segmentId,
|
||||
paragraphId: this._paragraphId,
|
||||
startOffset,
|
||||
endOffset,
|
||||
style,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this paragraph is a bullet, ordered, or checklist item.
|
||||
* @returns {boolean} `true` if the paragraph has list metadata.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.isListItem());
|
||||
* ```
|
||||
*/
|
||||
isListItem(): boolean {
|
||||
const { paragraph } = this.getInfo();
|
||||
return Boolean(paragraph.bullet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether this paragraph is a task/checklist item.
|
||||
* @returns {boolean} `true` if this paragraph is an unchecked or checked task item.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* console.log(paragraph?.isTask());
|
||||
* ```
|
||||
*/
|
||||
isTask(): boolean {
|
||||
const { paragraph } = this.getInfo();
|
||||
const listType = paragraph.bullet?.listType;
|
||||
return listType === PresetListType.CHECK_LIST || listType === PresetListType.CHECK_LIST_CHECKED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the checked state of this task/checklist paragraph.
|
||||
* @param {boolean} checked Whether the task item should be checked.
|
||||
* @returns {boolean} `true` if the task state was updated, or `false` if this paragraph is not a task item.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
*
|
||||
* if (paragraph.isTask()) {
|
||||
* const success = paragraph.setTaskChecked(true);
|
||||
* console.log(success ? 'Task checked' : 'Failed to check task');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setTaskChecked(checked: boolean): boolean {
|
||||
if (!this.isTask()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { paragraph, endOffset } = this.getInfo();
|
||||
const bullet = paragraph.bullet!;
|
||||
|
||||
const updateBody: IDocumentBody = {
|
||||
dataStream: '',
|
||||
paragraphs: [{
|
||||
...paragraph,
|
||||
startIndex: 0,
|
||||
bullet: {
|
||||
...bullet,
|
||||
listType: checked ? PresetListType.CHECK_LIST_CHECKED : PresetListType.CHECK_LIST,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
this._preserveExplicitParagraphIds(updateBody);
|
||||
|
||||
return retainBodyRange(
|
||||
{
|
||||
startOffset: endOffset,
|
||||
endOffset: endOffset + 1,
|
||||
segmentId: this._segmentId,
|
||||
},
|
||||
updateBody,
|
||||
UpdateDocsAttributeType.REPLACE,
|
||||
this._document.getDocumentDataModel(),
|
||||
this._injector
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this paragraph.
|
||||
* @returns {boolean} `true` if the paragraph was removed.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const paragraph = fDocument.getParagraphs()[0];
|
||||
* const success = paragraph?.remove();
|
||||
* console.log(success ? 'Paragraph removed' : 'Failed to remove paragraph');
|
||||
* ```
|
||||
*/
|
||||
remove(): boolean {
|
||||
const { startOffset, endOffset } = this.getInfo();
|
||||
return this._document.deleteRange({
|
||||
startOffset,
|
||||
endOffset: endOffset + 1,
|
||||
segmentId: this._segmentId,
|
||||
});
|
||||
}
|
||||
|
||||
private _preserveExplicitParagraphIds(body: IDocumentBody): void {
|
||||
(body as IDocumentBody & Record<string, unknown>)[RESTORE_INSERTED_PARAGRAPH_IDS] = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function isParagraphFacade(value: unknown): value is FDocumentParagraph {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof (value as FDocumentParagraph).getId === 'function' &&
|
||||
typeof (value as FDocumentParagraph).getSegmentId === 'function' &&
|
||||
typeof (value as FDocumentParagraph).getInfo === 'function' &&
|
||||
typeof (value as FDocumentParagraph).getRange === 'function';
|
||||
}
|
||||
264
packages/docs/src/facade/f-document-permission.ts
Normal file
264
packages/docs/src/facade/f-document-permission.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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 {
|
||||
ICommandService,
|
||||
IObjectPermissionBatchResult,
|
||||
IObjectPermissionChange,
|
||||
IPermissionService,
|
||||
ISetObjectPermissionsCommandParams,
|
||||
} from '@univerjs/core';
|
||||
import type { DocumentUnitPermissionAction } from '@univerjs/docs';
|
||||
import {
|
||||
canEditDocumentTargets,
|
||||
getDocumentPermissionValue,
|
||||
SetDocumentPermissionCommand,
|
||||
SetDocumentPermissionsCommand,
|
||||
} from '@univerjs/docs';
|
||||
import { UnitAction } from '@univerjs/protocol';
|
||||
|
||||
/**
|
||||
* Command-backed permissions for one Document unit.
|
||||
* @hideconstructor
|
||||
*/
|
||||
export class FDocumentPermission {
|
||||
constructor(
|
||||
private readonly _unitId: string,
|
||||
private readonly _commandService: ICommandService,
|
||||
private readonly _permissionService: IPermissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates or updates child-object edit policies in this unit; policy: null removes protection and restores inheritance.
|
||||
*
|
||||
* Requires Authz support and objectPermissionTypes configured for every target type. File and parent restrictions
|
||||
* still apply. Use the exported permission object ID helpers, not raw object IDs or server permission IDs.
|
||||
* The batch must be nonempty, contain distinct objects, and belong to this unit; file-wide policies are excluded.
|
||||
* Other targets use getDocumentSectionPermissionObjectId and getDocumentEntityPermissionObjectId.
|
||||
*
|
||||
* edit: 'all' allows Unit editors, 'owner' restricts editing to the object owner, and 'members' selects existing
|
||||
* Unit collaborators. Pass their collaborator records from the member service; this does not invite new users.
|
||||
* Use strategies: [] for the default Edit strategy; child-object strategies support only UnitAction.Edit.
|
||||
*
|
||||
* Authz writes execute per object and can partially succeed. Inspect failed before retrying only those objects.
|
||||
* refreshError means writes finished but permission readback failed; do not retry succeeded objects for that error.
|
||||
* Successful binding changes share one undo entry; existing remote policy edits are not undoable.
|
||||
* @param {IObjectPermissionChange[]} changes Permission object IDs and policies to apply.
|
||||
* @returns {Promise<IObjectPermissionBatchResult>} Successful object IDs, per-object failures, and optional readback error.
|
||||
* @throws {Error} Invalid batches or unsupported object types are rejected before Authz writes.
|
||||
* @example Set owner/member editing and remove protection in one batch
|
||||
* ```ts
|
||||
* import type { ICollaborator } from '@univerjs/protocol';
|
||||
* import { getDocumentParagraphPermissionObjectId } from '@univerjs/docs';
|
||||
*
|
||||
* // selectedMembers comes from the existing Unit collaborator picker/service.
|
||||
* async function applyPermissions(selectedMembers: ICollaborator[]) {
|
||||
* if (!selectedMembers.length) throw new Error('Select at least one Unit collaborator.');
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) throw new Error('No active document.');
|
||||
* const objects = document.getParagraphs().slice(0, 3);
|
||||
* const objectIds = objects.map((paragraph) =>
|
||||
* getDocumentParagraphPermissionObjectId(paragraph.getSegmentId(), paragraph.getId()));
|
||||
* if (objectIds.length < 3) throw new Error('This example requires three paragraphs.');
|
||||
* const result = await document.getPermission().setObjectPermissions([
|
||||
* { objectId: objectIds[0], policy: { edit: 'owner', collaborators: [], strategies: [] } },
|
||||
* { objectId: objectIds[1], policy: { edit: 'members', collaborators: selectedMembers, strategies: [] } },
|
||||
* { objectId: objectIds[2], policy: null },
|
||||
* ]);
|
||||
* // A policy creates protection if absent, or updates the existing policy when already configured.
|
||||
* for (const failure of result.failed) {
|
||||
* console.error(failure.objectId, failure.error);
|
||||
* }
|
||||
* if (result.refreshError) {
|
||||
* console.error(result.refreshError);
|
||||
* }
|
||||
* return result;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async setObjectPermissions(changes: IObjectPermissionChange[]): Promise<IObjectPermissionBatchResult> {
|
||||
return this._commandService.executeCommand<ISetObjectPermissionsCommandParams, IObjectPermissionBatchResult>(
|
||||
SetDocumentPermissionsCommand.id,
|
||||
{ unitId: this._unitId, changes }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets one Document unit permission through the command system.
|
||||
*
|
||||
* Supported actions are Edit, Copy, Print, Export, and Comment. Await the returned promise
|
||||
* before reading the new value or performing an action that depends on it.
|
||||
*
|
||||
* @param {DocumentUnitPermissionAction} action Unit permission action to update.
|
||||
* @param {boolean} value Whether the action is allowed.
|
||||
* @returns {Promise<void>} Resolves after the permission command finishes.
|
||||
* @example Disable copying while keeping the Document editable
|
||||
* ```ts
|
||||
* import { UnitAction } from '@univerjs/protocol';
|
||||
*
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) throw new Error('No active Document.');
|
||||
* await document.getPermission().setPoint(UnitAction.Copy, false);
|
||||
* ```
|
||||
*/
|
||||
async setPoint(action: DocumentUnitPermissionAction, value: boolean): Promise<void> {
|
||||
await this._commandService.executeCommand(SetDocumentPermissionCommand.id, {
|
||||
unitId: this._unitId,
|
||||
objectId: this._unitId,
|
||||
action,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current value of one Document unit permission.
|
||||
* @param {DocumentUnitPermissionAction} action Unit permission action to query.
|
||||
* @returns {boolean} Whether the action is currently allowed.
|
||||
* @example
|
||||
* ```ts
|
||||
* import { UnitAction } from '@univerjs/protocol';
|
||||
*
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* const canPrint = document?.getPermission().getPoint(UnitAction.Print) ?? false;
|
||||
* console.log(canPrint);
|
||||
* ```
|
||||
*/
|
||||
getPoint(action: DocumentUnitPermissionAction): boolean {
|
||||
return getDocumentPermissionValue(
|
||||
this._permissionService,
|
||||
this._unitId,
|
||||
this._unitId,
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables or disables editing for the whole Document.
|
||||
* @param {boolean} [editable] Whether editing is allowed. Defaults to true.
|
||||
* @returns {Promise<void>} Resolves after the permission command finishes.
|
||||
*/
|
||||
async setEditable(editable = true): Promise<void> {
|
||||
await this._commandService.executeCommand(SetDocumentPermissionCommand.id, {
|
||||
unitId: this._unitId,
|
||||
objectId: this._unitId,
|
||||
action: UnitAction.Edit,
|
||||
value: editable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the whole Document read-only.
|
||||
* @returns {Promise<void>} Resolves after the permission command finishes.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) throw new Error('No active Document.');
|
||||
* await document.getPermission().setReadOnly();
|
||||
* ```
|
||||
*/
|
||||
async setReadOnly(): Promise<void> {
|
||||
await this._commandService.executeCommand(SetDocumentPermissionCommand.id, {
|
||||
unitId: this._unitId,
|
||||
objectId: this._unitId,
|
||||
action: UnitAction.Edit,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the whole Document is currently editable.
|
||||
* @returns {boolean} Whether Document editing is allowed.
|
||||
*/
|
||||
canEdit(): boolean {
|
||||
return getDocumentPermissionValue(
|
||||
this._permissionService,
|
||||
this._unitId,
|
||||
this._unitId,
|
||||
UnitAction.Edit
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Command-backed Edit permission for one stable Document object.
|
||||
* @hideconstructor
|
||||
*/
|
||||
export class FDocumentObjectPermission {
|
||||
constructor(
|
||||
private readonly _unitId: string,
|
||||
private readonly _objectId: string,
|
||||
private readonly _commandService: ICommandService,
|
||||
private readonly _permissionService: IPermissionService,
|
||||
private readonly _getParentObjectIds: () => string[] = () => []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Enables or disables editing for this stable Document object.
|
||||
*
|
||||
* This changes only the object's Edit point. `canEdit()` also applies the Document unit and
|
||||
* parent Section or Paragraph ceilings.
|
||||
*
|
||||
* @param {boolean} [editable] Whether object editing is allowed. Defaults to true.
|
||||
* @returns {Promise<void>} Resolves after the permission command finishes.
|
||||
* @example Restore editing for a paragraph
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* const paragraph = document?.getParagraphs()[0];
|
||||
* if (!paragraph) throw new Error('Paragraph not found.');
|
||||
* await paragraph.getPermission().setEditable();
|
||||
* ```
|
||||
*/
|
||||
async setEditable(editable = true): Promise<void> {
|
||||
await this._commandService.executeCommand(SetDocumentPermissionCommand.id, {
|
||||
unitId: this._unitId,
|
||||
objectId: this._objectId,
|
||||
action: UnitAction.Edit,
|
||||
value: editable,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes this stable Document object read-only.
|
||||
* @returns {Promise<void>} Resolves after the permission command finishes.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* const section = document?.getSection(0);
|
||||
* if (!section) throw new Error('Section not found.');
|
||||
* await section.getPermission().setReadOnly();
|
||||
* ```
|
||||
*/
|
||||
async setReadOnly(): Promise<void> {
|
||||
await this._commandService.executeCommand(SetDocumentPermissionCommand.id, {
|
||||
unitId: this._unitId,
|
||||
objectId: this._objectId,
|
||||
action: UnitAction.Edit,
|
||||
value: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective Edit result after applying the Document, parent, and object permissions.
|
||||
* @returns {boolean} Whether the object is currently editable.
|
||||
*/
|
||||
canEdit(): boolean {
|
||||
return canEditDocumentTargets(
|
||||
this._permissionService,
|
||||
this._unitId,
|
||||
[...this._getParentObjectIds(), this._objectId]
|
||||
);
|
||||
}
|
||||
}
|
||||
749
packages/docs/src/facade/f-document-section.ts
Normal file
749
packages/docs/src/facade/f-document-section.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* 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 { IResolvedSectionHeaderFooterReference, ISectionBreak, ISectionColumnProperties, SectionHeaderFooterKind, SectionHeaderFooterVariant } from '@univerjs/core';
|
||||
import type { IEffectiveSectionPageSetup, IHeaderFooterProps } from '@univerjs/docs';
|
||||
import type { FDocument } from './f-document';
|
||||
import type { IFDocumentTextRange } from './utils';
|
||||
import { ColumnSeparatorType, DocumentFlavor, generateRandomId, getSectionHeaderFooterReferenceKey, ICommandService, IPermissionService, PageOrientType, resolveSectionHeaderFooterReference, SectionType, Tools } from '@univerjs/core';
|
||||
import { CreateHeaderFooterCommand, createSectionColumnProperties, DeleteDocumentSectionBreakCommand, getDocumentSectionPermissionObjectId, getEffectiveSectionPageSetup, getSectionContentWidth, getTopLevelSectionBreaks, HeaderFooterType, SetSectionHeaderFooterLinkCommand, UpdateDocumentSectionCommand } from '@univerjs/docs';
|
||||
import { FDocumentObjectPermission } from './f-document-permission';
|
||||
|
||||
export interface IFDocumentSectionColumnOptions {
|
||||
/** Gap after each column except the last, in 96-DPI layout pixels. */
|
||||
gap?: number;
|
||||
/** Optional explicit column widths in 96-DPI layout pixels. Length must equal `columnCount`. */
|
||||
widths?: number[];
|
||||
/** Whether to draw separators, or the exact separator enum value. */
|
||||
separator?: boolean | ColumnSeparatorType;
|
||||
}
|
||||
|
||||
export type FDocumentSectionPageSetup = Pick<
|
||||
ISectionBreak,
|
||||
'pageNumberStart' | 'pageSize' | 'pageOrient' | 'marginTop' | 'marginBottom' | 'marginLeft' | 'marginRight'
|
||||
>;
|
||||
|
||||
export interface IFDocumentSectionDescription {
|
||||
sectionId: string;
|
||||
index: number;
|
||||
range: IFDocumentTextRange;
|
||||
columnCount: number;
|
||||
columns: ISectionColumnProperties[];
|
||||
columnSeparatorType: ColumnSeparatorType;
|
||||
sectionType: SectionType;
|
||||
headerFooter: Record<`${SectionHeaderFooterVariant}${Capitalize<SectionHeaderFooterKind>}`, {
|
||||
segmentId: string | null;
|
||||
linkedToPrevious: boolean;
|
||||
}>;
|
||||
config: ISectionBreak;
|
||||
}
|
||||
|
||||
function validatePageSetup(pageSetup: FDocumentSectionPageSetup): void {
|
||||
const { pageNumberStart, pageSize, pageOrient, marginTop, marginBottom, marginLeft, marginRight } = pageSetup;
|
||||
if (pageNumberStart != null && (!Number.isInteger(pageNumberStart) || pageNumberStart < 1)) {
|
||||
throw new RangeError('Section page number start must be a positive integer.');
|
||||
}
|
||||
if (pageSize && [pageSize.width, pageSize.height]
|
||||
.some((size) => size != null && (!Number.isFinite(size) || size <= 0))) {
|
||||
throw new RangeError('Section page size must be finite and positive.');
|
||||
}
|
||||
if (pageOrient != null && !Object.values(PageOrientType).includes(pageOrient)) {
|
||||
throw new RangeError('Invalid section page orientation.');
|
||||
}
|
||||
if ([marginTop, marginBottom, marginLeft, marginRight]
|
||||
.some((margin) => margin != null && (!Number.isFinite(margin) || margin < 0))) {
|
||||
throw new RangeError('Section page margins must be finite and non-negative.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Error thrown when a Traditional-only section API is used with another document flavor. */
|
||||
export class DocsSectionUnsupportedDocumentFlavorError extends Error {
|
||||
constructor() {
|
||||
super('Section column APIs are supported only in traditional documents. Use ColumnGroup APIs for modern documents, or resolve an unspecified document flavor first.');
|
||||
this.name = 'DocsSectionUnsupportedDocumentFlavorError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Facade wrapper for an OOXML-compatible traditional document section.
|
||||
* Modern documents use ColumnGroup APIs. Unspecified documents must resolve
|
||||
* their flavor before using this facade.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* console.log(fDocument.getSection(0)?.describe());
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class FDocumentSection {
|
||||
constructor(
|
||||
private readonly _document: FDocument,
|
||||
private readonly _sectionId: string,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IPermissionService private readonly _permissionService: IPermissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the persisted section id.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getId());
|
||||
* ```
|
||||
*/
|
||||
getId(): string {
|
||||
return this._sectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this Section's permission facade.
|
||||
* @returns {FDocumentObjectPermission} Permission facade combining Document and Section Edit points.
|
||||
* @example
|
||||
* ```ts
|
||||
* const section = univerAPI.getActiveDocument()?.getSection(0);
|
||||
* if (!section) throw new Error('Section not found.');
|
||||
* await section.getPermission().setReadOnly();
|
||||
* ```
|
||||
*/
|
||||
getPermission(): FDocumentObjectPermission {
|
||||
return new FDocumentObjectPermission(
|
||||
this._document.getId(),
|
||||
getDocumentSectionPermissionObjectId('', this._sectionId),
|
||||
this._commandService,
|
||||
this._permissionService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current zero-based section index.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getIndex());
|
||||
* ```
|
||||
*/
|
||||
getIndex(): number {
|
||||
return this._resolve().index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the section break snapshot that terminates this section.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getConfig());
|
||||
* ```
|
||||
*/
|
||||
getConfig(): ISectionBreak {
|
||||
return this._getConfigSnapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the section content range, excluding its terminating section-break token.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getRange());
|
||||
* ```
|
||||
*/
|
||||
getRange(): IFDocumentTextRange {
|
||||
return this._getRange(this._resolve().index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the explicit columns. An empty array means the normal single-column layout.
|
||||
* Column widths and trailing spaces are in 96-DPI layout pixels.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getColumns());
|
||||
* ```
|
||||
*/
|
||||
getColumns(): ISectionColumnProperties[] {
|
||||
return Tools.deepClone(this._getConfigSnapshot().columnProperties ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a compact serializable section summary.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.describe());
|
||||
* ```
|
||||
*/
|
||||
describe(): IFDocumentSectionDescription {
|
||||
const { index } = this._resolve();
|
||||
const config = this._getConfigSnapshot();
|
||||
const columns = config.columnProperties ?? [];
|
||||
const headerFooter = {
|
||||
defaultHeader: this._describeHeaderFooterReference('header', 'default'),
|
||||
defaultFooter: this._describeHeaderFooterReference('footer', 'default'),
|
||||
firstHeader: this._describeHeaderFooterReference('header', 'first'),
|
||||
firstFooter: this._describeHeaderFooterReference('footer', 'first'),
|
||||
evenHeader: this._describeHeaderFooterReference('header', 'even'),
|
||||
evenFooter: this._describeHeaderFooterReference('footer', 'even'),
|
||||
};
|
||||
return {
|
||||
sectionId: this._sectionId,
|
||||
index,
|
||||
range: this._getRange(index),
|
||||
columnCount: columns.length || 1,
|
||||
columns: Tools.deepClone(columns),
|
||||
columnSeparatorType: config.columnSeparatorType ?? ColumnSeparatorType.NONE,
|
||||
sectionType: config.sectionType ?? SectionType.SECTION_TYPE_UNSPECIFIED,
|
||||
headerFooter,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets equal or explicitly sized columns for this traditional section.
|
||||
* Use `columnCount = 1` to restore normal single-column layout.
|
||||
* `gap` and `widths` are in 96-DPI layout pixels.
|
||||
* @param {number} columnCount Positive integer column count.
|
||||
* @param {IFDocumentSectionColumnOptions} [options] Column widths, gap (default 18 pixels), and separator (default none).
|
||||
* @returns {boolean} Whether the section update succeeded.
|
||||
* @throws {RangeError} If column dimensions or the separator are invalid, or columns exceed the available width.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* fDocument.getSection(0)?.setColumns(2, { gap: 18, separator: true });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setColumns(columnCount: number, options: IFDocumentSectionColumnOptions = {}): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
if (!Number.isInteger(columnCount) || columnCount < 1) {
|
||||
throw new RangeError('Section column count must be a positive integer.');
|
||||
}
|
||||
if (options.widths && options.widths.length !== columnCount) {
|
||||
throw new RangeError('Section column widths must match the column count.');
|
||||
}
|
||||
if (options.gap != null && (!Number.isFinite(options.gap) || options.gap < 0)) {
|
||||
throw new RangeError('Section column gap must be finite and non-negative.');
|
||||
}
|
||||
|
||||
const gap = Math.max(0, options.gap ?? 18);
|
||||
const config = this._getConfigSnapshot();
|
||||
const columns = createSectionColumnProperties(
|
||||
this._document.getDocumentDataModel().getSnapshot().documentStyle,
|
||||
config,
|
||||
columnCount,
|
||||
gap,
|
||||
options.widths
|
||||
);
|
||||
const separator = typeof options.separator === 'boolean'
|
||||
? options.separator ? ColumnSeparatorType.BETWEEN_EACH_COLUMN : ColumnSeparatorType.NONE
|
||||
: options.separator ?? ColumnSeparatorType.NONE;
|
||||
if (!Object.values(ColumnSeparatorType).includes(separator)) {
|
||||
throw new RangeError('Invalid section column separator type.');
|
||||
}
|
||||
|
||||
return this._update({
|
||||
columnProperties: columns,
|
||||
columnSeparatorType: separator,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets explicit OOXML-compatible column width and trailing-space values in 96-DPI layout pixels.
|
||||
* @param {ISectionColumnProperties[]} columns Explicit widths and trailing spaces; an empty array restores a single column.
|
||||
* @param {ColumnSeparatorType} [separator] Column separator style. Defaults to `ColumnSeparatorType.NONE`.
|
||||
* @returns {boolean} Whether the section update succeeded.
|
||||
* @throws {RangeError} If column dimensions or the separator are invalid, or columns exceed the available width.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* fDocument.getSection(0)?.setColumnProperties([
|
||||
* { width: 240, paddingEnd: 18 },
|
||||
* { width: 240, paddingEnd: 0 },
|
||||
* ], univerAPI.Enum.ColumnSeparatorType.BETWEEN_EACH_COLUMN);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setColumnProperties(columns: ISectionColumnProperties[], separator = ColumnSeparatorType.NONE): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
if (!Object.values(ColumnSeparatorType).includes(separator)) {
|
||||
throw new RangeError('Invalid section column separator type.');
|
||||
}
|
||||
if (columns.some(({ width, paddingEnd }) => !Number.isFinite(width) || !Number.isFinite(paddingEnd) || width < 0 || paddingEnd < 0)) {
|
||||
throw new RangeError('Section column widths and padding must be finite and non-negative.');
|
||||
}
|
||||
const contentWidth = getSectionContentWidth(
|
||||
this._document.getDocumentDataModel().getSnapshot().documentStyle,
|
||||
this._getConfigSnapshot()
|
||||
);
|
||||
if (columns.reduce((sum, { width, paddingEnd }) => sum + width + paddingEnd, 0) > contentWidth) {
|
||||
throw new RangeError('Section columns exceed the available page content width.');
|
||||
}
|
||||
return this._update({
|
||||
columnProperties: Tools.deepClone(columns),
|
||||
columnSeparatorType: separator,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets how this section begins relative to the previous section.
|
||||
*
|
||||
* The first section has no preceding boundary, so setting its type does not
|
||||
* create an initial blank page. Prefer `FDocument.insertSectionBreak` with
|
||||
* `nextSectionType` when creating a new boundary; use this method when
|
||||
* updating an existing section after resolving it again from the document.
|
||||
*
|
||||
* @param {SectionType} sectionType How this section begins.
|
||||
* @returns {boolean} `true` when the section command was applied.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document?.isTraditional()) {
|
||||
* throw new Error('A Traditional document is required');
|
||||
* }
|
||||
*
|
||||
* const secondSection = document.getSection(1);
|
||||
* if (!secondSection) {
|
||||
* throw new Error('The second section does not exist');
|
||||
* }
|
||||
* if (!secondSection.setSectionType(univerAPI.Enum.SectionType.NEXT_PAGE)) {
|
||||
* throw new Error('Failed to update the second section');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setSectionType(sectionType: SectionType): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
if (!Object.values(SectionType).includes(sectionType)) {
|
||||
throw new RangeError('Invalid section type.');
|
||||
}
|
||||
return this._update({ sectionType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns this section's explicit page setup overrides.
|
||||
* Missing values inherit from the document style. Geometry values use 96-DPI layout pixels.
|
||||
*
|
||||
* Use `getEffectivePageSetup()` when an agent needs resolved page and content
|
||||
* dimensions rather than only the overrides stored on this section.
|
||||
*
|
||||
* @returns {FDocumentSectionPageSetup} A cloned object containing only explicit section overrides.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* const section = document?.getSection(0);
|
||||
* console.log(section?.getPageSetup());
|
||||
* ```
|
||||
*/
|
||||
getPageSetup(): FDocumentSectionPageSetup {
|
||||
const {
|
||||
pageNumberStart,
|
||||
pageSize,
|
||||
pageOrient,
|
||||
marginTop,
|
||||
marginBottom,
|
||||
marginLeft,
|
||||
marginRight,
|
||||
} = this._getConfigSnapshot();
|
||||
return Tools.deepClone({
|
||||
pageNumberStart,
|
||||
pageSize,
|
||||
pageOrient,
|
||||
marginTop,
|
||||
marginBottom,
|
||||
marginLeft,
|
||||
marginRight,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns nominal page geometry after resolving this section's overrides
|
||||
* against document defaults. All geometry values use 96-DPI layout pixels.
|
||||
*
|
||||
* This synchronous model-only API works without `engine-render`. It does not
|
||||
* report physical page count, remaining page space, or final coordinates.
|
||||
*
|
||||
* @returns {IEffectiveSectionPageSetup} A cloned, serializable page setup.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document) {
|
||||
* throw new Error('No active document');
|
||||
* }
|
||||
* if (!document.isTraditional()) {
|
||||
* throw new Error('Traditional document sections are required');
|
||||
* }
|
||||
*
|
||||
* const section = document.getSection(0);
|
||||
* if (!section) {
|
||||
* throw new Error('The document has no traditional section');
|
||||
* }
|
||||
*
|
||||
* const layout = section.getEffectivePageSetup();
|
||||
* console.log({
|
||||
* pageWidth: layout.pageSize.width,
|
||||
* pageHeight: layout.pageSize.height,
|
||||
* contentWidth: layout.contentSize.width,
|
||||
* contentHeight: layout.contentSize.height,
|
||||
* margins: layout.margins,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
getEffectivePageSetup(): IEffectiveSectionPageSetup {
|
||||
this._assertTraditionalDocument();
|
||||
const documentStyle = this._document.getDocumentDataModel().getSnapshot().documentStyle;
|
||||
return Tools.deepClone(getEffectiveSectionPageSetup(documentStyle, this._getConfigSnapshot()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates this section's page setup through the document section command.
|
||||
* Geometry values use 96-DPI layout pixels.
|
||||
*
|
||||
* This method changes static page geometry; it does not choose where the
|
||||
* section begins. Use `setSectionType()` for an existing boundary, or
|
||||
* `insertSectionBreak(..., { nextSectionType })` while creating one.
|
||||
*
|
||||
* @param {FDocumentSectionPageSetup} pageSetup Explicit section overrides to patch.
|
||||
* @returns {boolean} `true` when the section command was applied.
|
||||
* @example
|
||||
* ```ts
|
||||
* const document = univerAPI.getActiveDocument();
|
||||
* if (!document?.isTraditional()) {
|
||||
* throw new Error('A Traditional document is required');
|
||||
* }
|
||||
*
|
||||
* const section = document.getSection(1);
|
||||
* if (!section) {
|
||||
* throw new Error('The second section does not exist');
|
||||
* }
|
||||
* const updated = section.setPageSetup({
|
||||
* pageSize: { width: 816, height: 1056 },
|
||||
* marginTop: 96,
|
||||
* marginBottom: 96,
|
||||
* marginLeft: 96,
|
||||
* marginRight: 96,
|
||||
* });
|
||||
* if (!updated) {
|
||||
* throw new Error('Failed to update section page setup');
|
||||
* }
|
||||
* console.log(section.getEffectivePageSetup());
|
||||
* ```
|
||||
*/
|
||||
setPageSetup(pageSetup: FDocumentSectionPageSetup): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
validatePageSetup(pageSetup);
|
||||
const definedPageSetup = Tools.deepClone(pageSetup);
|
||||
Tools.removeNull(definedPageSetup);
|
||||
const documentStyle = this._document.getDocumentDataModel().getSnapshot().documentStyle;
|
||||
getEffectiveSectionPageSetup(documentStyle, {
|
||||
...this._getConfigSnapshot(),
|
||||
...definedPageSetup,
|
||||
});
|
||||
return this._update(definedPageSetup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a header segment linked specifically to this section.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {string} The existing or newly created section-specific segment ID.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* const segmentId = fDocument.getSection(0)?.ensureHeader();
|
||||
* if (segmentId) {
|
||||
* fDocument.insertText(0, 'Quarterly report', segmentId);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
ensureHeader(variant: SectionHeaderFooterVariant = 'default'): string {
|
||||
return this._ensureHeaderFooter('header', variant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a footer segment linked specifically to this section.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {string} The existing or newly created section-specific segment ID.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* const segmentId = fDocument.getSection(0)?.ensureFooter('first');
|
||||
* if (segmentId) {
|
||||
* fDocument.insertText(0, 'Confidential', segmentId);
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
ensureFooter(variant: SectionHeaderFooterVariant = 'default'): string {
|
||||
return this._ensureHeaderFooter('footer', variant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective header id after resolving links to previous sections.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {string | null} The effective segment ID, or `null` if no segment is available.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getHeaderId('default'));
|
||||
* ```
|
||||
*/
|
||||
getHeaderId(variant: SectionHeaderFooterVariant = 'default'): string | null {
|
||||
return this._getHeaderFooterReference('header', variant).segmentId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective footer id after resolving links to previous sections.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {string | null} The effective segment ID, or `null` if no segment is available.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(0)?.getFooterId('first'));
|
||||
* ```
|
||||
*/
|
||||
getFooterId(variant: SectionHeaderFooterVariant = 'default'): string | null {
|
||||
return this._getHeaderFooterReference('footer', variant).segmentId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this header variant inherits the previous section's reference.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {boolean} Whether this variant inherits from the previous section.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(1)?.isHeaderLinkedToPrevious());
|
||||
* ```
|
||||
*/
|
||||
isHeaderLinkedToPrevious(variant: SectionHeaderFooterVariant = 'default'): boolean {
|
||||
return this._getHeaderFooterReference('header', variant).linkedToPrevious;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this footer variant inherits the previous section's reference.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {boolean} Whether this variant inherits from the previous section.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument?.getSection(1)?.isFooterLinkedToPrevious('even'));
|
||||
* ```
|
||||
*/
|
||||
isFooterLinkedToPrevious(variant: SectionHeaderFooterVariant = 'default'): boolean {
|
||||
return this._getHeaderFooterReference('footer', variant).linkedToPrevious;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links or unlinks this header variant. Unlinking clones the inherited header.
|
||||
* @param {boolean} linkedToPrevious Whether to inherit the previous section's header/footer.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {boolean} Whether the link update succeeded.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* fDocument.getSection(1)?.setHeaderLinkedToPrevious(false, 'default');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setHeaderLinkedToPrevious(linkedToPrevious: boolean, variant: SectionHeaderFooterVariant = 'default'): boolean {
|
||||
return this._setHeaderFooterLinkedToPrevious('header', variant, linkedToPrevious);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links or unlinks this footer variant. Unlinking clones the inherited footer.
|
||||
* @param {boolean} linkedToPrevious Whether to inherit the previous section's header/footer.
|
||||
* @param {SectionHeaderFooterVariant} [variant] The `'default'`, `'first'`, or `'even'` variant. Defaults to `'default'`.
|
||||
* @returns {boolean} Whether the link update succeeded.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* fDocument.getSection(1)?.setFooterLinkedToPrevious(true, 'even');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setFooterLinkedToPrevious(linkedToPrevious: boolean, variant: SectionHeaderFooterVariant = 'default'): boolean {
|
||||
return this._setHeaderFooterLinkedToPrevious('footer', variant, linkedToPrevious);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates header/footer switches and margins on this section break.
|
||||
* `marginHeader` and `marginFooter` are in 96-DPI layout pixels.
|
||||
* @param {IHeaderFooterProps} options Header/footer switches and margins to update. Omitted properties are preserved.
|
||||
* @returns {boolean} Whether the update command succeeded.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* fDocument.getSection(0)?.setHeaderFooterOptions({
|
||||
* marginHeader: 36,
|
||||
* marginFooter: 36,
|
||||
* useFirstPageHeaderFooter: univerAPI.Enum.BooleanNumber.TRUE,
|
||||
* });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
setHeaderFooterOptions(options: IHeaderFooterProps): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
return this._update(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes this section break. The final top-level section break cannot be removed.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* if (fDocument?.isTraditional()) {
|
||||
* const sections = fDocument.getSections();
|
||||
* if (sections.length > 1) {
|
||||
* sections[0].remove();
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
remove(): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
return this._commandService.syncExecuteCommand(DeleteDocumentSectionBreakCommand.id, {
|
||||
unitId: this._document.getId(),
|
||||
sectionId: this._sectionId,
|
||||
});
|
||||
}
|
||||
|
||||
private _update(patch: Partial<ISectionBreak>): boolean {
|
||||
const { sectionId: _sectionId, startIndex: _startIndex, ...config } = patch;
|
||||
return this._commandService.syncExecuteCommand(UpdateDocumentSectionCommand.id, {
|
||||
unitId: this._document.getId(),
|
||||
updates: [{ sectionId: this._sectionId, config }],
|
||||
});
|
||||
}
|
||||
|
||||
private _ensureHeaderFooter(kind: SectionHeaderFooterKind, variant: SectionHeaderFooterVariant): string {
|
||||
this._assertTraditionalDocument();
|
||||
const { index } = this._resolve();
|
||||
const config = this._getConfigSnapshot();
|
||||
const key = getSectionHeaderFooterReferenceKey(kind, variant);
|
||||
const existing = config[key];
|
||||
if (typeof existing === 'string' && existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
const segmentId = generateRandomId(6);
|
||||
const success = this._commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: this._document.getId(),
|
||||
sectionId: this._sectionId,
|
||||
kind,
|
||||
variant,
|
||||
linkedToPrevious: false,
|
||||
segmentId,
|
||||
});
|
||||
if (!success) {
|
||||
throw new Error(`Failed to create section ${kind}.`);
|
||||
}
|
||||
return segmentId;
|
||||
}
|
||||
|
||||
const types = {
|
||||
default: kind === 'header' ? HeaderFooterType.DEFAULT_HEADER : HeaderFooterType.DEFAULT_FOOTER,
|
||||
first: kind === 'header' ? HeaderFooterType.FIRST_PAGE_HEADER : HeaderFooterType.FIRST_PAGE_FOOTER,
|
||||
even: kind === 'header' ? HeaderFooterType.EVEN_PAGE_HEADER : HeaderFooterType.EVEN_PAGE_FOOTER,
|
||||
};
|
||||
const segmentId = generateRandomId(6);
|
||||
const success = this._commandService.syncExecuteCommand(CreateHeaderFooterCommand.id, {
|
||||
unitId: this._document.getId(),
|
||||
segmentId,
|
||||
createType: types[variant],
|
||||
sectionId: this._sectionId,
|
||||
});
|
||||
if (!success) {
|
||||
throw new Error(`Failed to create section ${kind}.`);
|
||||
}
|
||||
return segmentId;
|
||||
}
|
||||
|
||||
private _getHeaderFooterReference(
|
||||
kind: SectionHeaderFooterKind,
|
||||
variant: SectionHeaderFooterVariant
|
||||
): IResolvedSectionHeaderFooterReference {
|
||||
const { index } = this._resolve();
|
||||
const snapshot = this._document.getDocumentDataModel().getSnapshot();
|
||||
return resolveSectionHeaderFooterReference(
|
||||
snapshot.documentStyle,
|
||||
getTopLevelSectionBreaks(this._document.getBody()),
|
||||
index,
|
||||
getSectionHeaderFooterReferenceKey(kind, variant)
|
||||
);
|
||||
}
|
||||
|
||||
private _describeHeaderFooterReference(
|
||||
kind: SectionHeaderFooterKind,
|
||||
variant: SectionHeaderFooterVariant
|
||||
): { segmentId: string | null; linkedToPrevious: boolean } {
|
||||
const reference = this._getHeaderFooterReference(kind, variant);
|
||||
return {
|
||||
segmentId: reference.segmentId ?? null,
|
||||
linkedToPrevious: reference.linkedToPrevious,
|
||||
};
|
||||
}
|
||||
|
||||
private _setHeaderFooterLinkedToPrevious(
|
||||
kind: SectionHeaderFooterKind,
|
||||
variant: SectionHeaderFooterVariant,
|
||||
linkedToPrevious: boolean
|
||||
): boolean {
|
||||
this._assertTraditionalDocument();
|
||||
return this._commandService.syncExecuteCommand(SetSectionHeaderFooterLinkCommand.id, {
|
||||
unitId: this._document.getId(),
|
||||
sectionId: this._sectionId,
|
||||
kind,
|
||||
variant,
|
||||
linkedToPrevious,
|
||||
...(linkedToPrevious ? {} : { segmentId: generateRandomId(6) }),
|
||||
});
|
||||
}
|
||||
|
||||
private _assertTraditionalDocument(): void {
|
||||
if (this._document.getDocumentDataModel().getSnapshot().documentStyle.documentFlavor !== DocumentFlavor.TRADITIONAL) {
|
||||
throw new DocsSectionUnsupportedDocumentFlavorError();
|
||||
}
|
||||
}
|
||||
|
||||
private _getConfigSnapshot(): ISectionBreak {
|
||||
return Tools.deepClone(this._resolve().sectionBreak);
|
||||
}
|
||||
|
||||
private _getRange(index: number): IFDocumentTextRange {
|
||||
const sectionBreaks = getTopLevelSectionBreaks(this._document.getBody());
|
||||
return {
|
||||
startOffset: index === 0 ? 0 : sectionBreaks[index - 1].startIndex + 1,
|
||||
endOffset: sectionBreaks[index].startIndex,
|
||||
segmentId: '',
|
||||
};
|
||||
}
|
||||
|
||||
private _resolve(): { index: number; sectionBreak: ISectionBreak } {
|
||||
this._assertTraditionalDocument();
|
||||
const sectionBreaks = getTopLevelSectionBreaks(this._document.getBody());
|
||||
const index = sectionBreaks.findIndex((section) => section.sectionId === this._sectionId);
|
||||
if (index < 0) {
|
||||
throw new Error(`Document section with id ${this._sectionId} not found.`);
|
||||
}
|
||||
return { index, sectionBreak: sectionBreaks[index] };
|
||||
}
|
||||
}
|
||||
255
packages/docs/src/facade/f-document-text-range.ts
Normal file
255
packages/docs/src/facade/f-document-text-range.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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, ITextStyle } from '@univerjs/core';
|
||||
import type { FDocument } from './f-document';
|
||||
import type { IFDocumentTextRange } from './utils';
|
||||
import { ICommandService, Tools, UpdateDocsAttributeType } from '@univerjs/core';
|
||||
import { FBaseInitialable } from '@univerjs/core/facade';
|
||||
import { buildPlainTextInsertBody, replaceBodyRange, retainBodyRange } from './utils';
|
||||
|
||||
/** A clipped text-style run in document offsets. */
|
||||
export interface IFDocumentTextStyleRun {
|
||||
/** Inclusive start offset in the document segment. */
|
||||
startOffset: number;
|
||||
/** Exclusive end offset in the document segment. */
|
||||
endOffset: number;
|
||||
/** Explicit text style stored on this run. */
|
||||
textStyle: ITextStyle;
|
||||
}
|
||||
|
||||
/** Agent-friendly summary of a document text range. */
|
||||
export interface IFDocumentTextRangeDescription extends IFDocumentTextRange {
|
||||
text: string;
|
||||
length: number;
|
||||
/** Explicit styles stored directly in text runs. */
|
||||
explicitTextStyleRuns: IFDocumentTextStyleRun[];
|
||||
/** Top-level explicit style properties common to the complete range. */
|
||||
commonExplicitTextStyle: ITextStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Facade wrapper for reading and styling a fixed document text range.
|
||||
*
|
||||
* Offsets are fixed when the wrapper is created. Create a new range after edits
|
||||
* that insert or remove content before it.
|
||||
* @hideconstructor
|
||||
*/
|
||||
export class FDocumentTextRange extends FBaseInitialable {
|
||||
constructor(
|
||||
protected readonly _document: FDocument,
|
||||
protected readonly _startOffset: number,
|
||||
protected readonly _endOffset: number,
|
||||
protected readonly _segmentId: string,
|
||||
protected override readonly _injector: Injector,
|
||||
@ICommandService private readonly _commandService: ICommandService
|
||||
) {
|
||||
super(_injector);
|
||||
this._validateRange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the serializable document range.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Launch')?.getTextRange();
|
||||
* console.log(range?.getRange());
|
||||
* ```
|
||||
*/
|
||||
getRange(): IFDocumentTextRange {
|
||||
return {
|
||||
startOffset: this._startOffset,
|
||||
endOffset: this._endOffset,
|
||||
segmentId: this._segmentId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the plain data-stream text in this range.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Launch')?.getTextRange();
|
||||
* console.log(range?.getText());
|
||||
* ```
|
||||
*/
|
||||
getText(): string {
|
||||
return this._document.getBody(this._segmentId).dataStream.slice(this._startOffset, this._endOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns explicit text-style runs intersecting this range.
|
||||
* Returned offsets are clipped to the range and remain document-relative.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Launch')?.getTextRange();
|
||||
* console.log(range?.getExplicitTextStyleRuns());
|
||||
* ```
|
||||
*/
|
||||
getExplicitTextStyleRuns(): IFDocumentTextStyleRun[] {
|
||||
const { textRuns = [] } = this._document.getBody(this._segmentId);
|
||||
return textRuns
|
||||
.filter((run) => run.st < this._endOffset && run.ed > this._startOffset)
|
||||
.map((run) => ({
|
||||
startOffset: Math.max(run.st, this._startOffset),
|
||||
endOffset: Math.min(run.ed, this._endOffset),
|
||||
textStyle: Tools.deepClone(run.ts ?? {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns top-level style properties that have the same explicit value
|
||||
* across the complete range. Unstyled gaps make a property non-common.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Launch')?.getTextRange();
|
||||
* console.log(range?.getCommonExplicitTextStyle());
|
||||
* ```
|
||||
*/
|
||||
getCommonExplicitTextStyle(): ITextStyle {
|
||||
if (this._startOffset === this._endOffset) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const runs = this._getStyleSegments();
|
||||
const [first, ...rest] = runs;
|
||||
const common = Tools.deepClone(first.textStyle);
|
||||
|
||||
for (const key of Object.keys(common) as Array<keyof ITextStyle>) {
|
||||
if (rest.some((run) => !isDeepEqual(run.textStyle[key], common[key]))) {
|
||||
delete common[key];
|
||||
}
|
||||
}
|
||||
|
||||
return common;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a serializable summary suitable for an agent/tool response.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Launch')?.getTextRange();
|
||||
* console.log(range?.describe());
|
||||
* ```
|
||||
*/
|
||||
describe(): IFDocumentTextRangeDescription {
|
||||
const explicitTextStyleRuns = this.getExplicitTextStyleRuns();
|
||||
const commonExplicitTextStyle = this.getCommonExplicitTextStyle();
|
||||
return {
|
||||
...this.getRange(),
|
||||
text: this.getText(),
|
||||
length: this._endOffset - this._startOffset,
|
||||
explicitTextStyleRuns,
|
||||
commonExplicitTextStyle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a text-style patch into every character in the range.
|
||||
* Existing text-run splitting, merging, and normalization are handled by
|
||||
* the document mutation pipeline.
|
||||
* `style.fs` is a font size in points (pt), not CSS pixels.
|
||||
* @param {ITextStyle} style Text-style properties to merge into the range.
|
||||
* @returns {boolean} Whether the style update succeeded; `false` for an empty range.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Launch')?.getTextRange();
|
||||
* range?.setTextStyle({ fs: 10.5, bl: univerAPI.Enum.BooleanNumber.TRUE });
|
||||
* ```
|
||||
*/
|
||||
setTextStyle(style: ITextStyle): boolean {
|
||||
if (this._startOffset === this._endOffset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return retainBodyRange(
|
||||
this.getRange(),
|
||||
{
|
||||
dataStream: '',
|
||||
textRuns: [{
|
||||
st: 0,
|
||||
ed: this._endOffset - this._startOffset,
|
||||
ts: Tools.deepClone(style),
|
||||
}],
|
||||
},
|
||||
UpdateDocsAttributeType.COVER,
|
||||
this._document.getDocumentDataModel(),
|
||||
this._injector
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the range with plain text while preserving document mutation semantics.
|
||||
* @param {string} text Replacement plain text. An empty string deletes the range.
|
||||
* @returns {boolean} Whether the replacement succeeded.
|
||||
* @example
|
||||
* ```ts
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* const range = fDocument?.findParagraphByText('Draft')?.getTextRange();
|
||||
* range?.setText('Final');
|
||||
* ```
|
||||
*/
|
||||
setText(text: string): boolean {
|
||||
return replaceBodyRange(
|
||||
this.getRange(),
|
||||
buildPlainTextInsertBody(text),
|
||||
this._document.getDocumentDataModel(),
|
||||
this._commandService
|
||||
);
|
||||
}
|
||||
|
||||
private _validateRange(): void {
|
||||
const bodyLength = this._document.getBody(this._segmentId).dataStream.length;
|
||||
if (
|
||||
!Number.isInteger(this._startOffset) ||
|
||||
!Number.isInteger(this._endOffset) ||
|
||||
this._startOffset < 0 ||
|
||||
this._endOffset < this._startOffset ||
|
||||
this._endOffset > bodyLength
|
||||
) {
|
||||
throw new RangeError(`Invalid document text range [${this._startOffset}, ${this._endOffset}) for body length ${bodyLength}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private _getStyleSegments(): IFDocumentTextStyleRun[] {
|
||||
const explicitRuns = this.getExplicitTextStyleRuns();
|
||||
const segments: IFDocumentTextStyleRun[] = [];
|
||||
let offset = this._startOffset;
|
||||
|
||||
for (const run of explicitRuns) {
|
||||
if (offset < run.startOffset) {
|
||||
segments.push({ startOffset: offset, endOffset: run.startOffset, textStyle: {} });
|
||||
}
|
||||
segments.push(run);
|
||||
offset = run.endOffset;
|
||||
}
|
||||
|
||||
if (offset < this._endOffset) {
|
||||
segments.push({ startOffset: offset, endOffset: this._endOffset, textStyle: {} });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
}
|
||||
|
||||
function isDeepEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
1002
packages/docs/src/facade/f-document.ts
Normal file
1002
packages/docs/src/facade/f-document.ts
Normal file
File diff suppressed because it is too large
Load Diff
49
packages/docs/src/facade/f-enum.ts
Normal file
49
packages/docs/src/facade/f-enum.ts
Normal file
@@ -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 { ColumnSeparatorType, DocumentFlavor, SectionType } from '@univerjs/core';
|
||||
import { FEnum } from '@univerjs/core/facade';
|
||||
|
||||
/** @ignore */
|
||||
export interface IFDocsEnumMixin {
|
||||
/** Document layout flavors. */
|
||||
DocumentFlavor: typeof DocumentFlavor;
|
||||
/** OOXML-compatible section start types. */
|
||||
SectionType: typeof SectionType;
|
||||
/** Section column separator types. */
|
||||
ColumnSeparatorType: typeof ColumnSeparatorType;
|
||||
}
|
||||
|
||||
export class FDocsEnumMixin extends FEnum implements IFDocsEnumMixin {
|
||||
override get DocumentFlavor(): typeof DocumentFlavor {
|
||||
return DocumentFlavor;
|
||||
}
|
||||
|
||||
override get SectionType(): typeof SectionType {
|
||||
return SectionType;
|
||||
}
|
||||
|
||||
override get ColumnSeparatorType(): typeof ColumnSeparatorType {
|
||||
return ColumnSeparatorType;
|
||||
}
|
||||
}
|
||||
|
||||
FEnum.extend(FDocsEnumMixin);
|
||||
|
||||
declare module '@univerjs/core/facade' {
|
||||
// eslint-disable-next-line ts/naming-convention
|
||||
export interface FEnum extends IFDocsEnumMixin {}
|
||||
}
|
||||
96
packages/docs/src/facade/f-univer.ts
Normal file
96
packages/docs/src/facade/f-univer.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 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, ICreateUnitOptions, IDocumentData } from '@univerjs/core';
|
||||
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
|
||||
import { FUniver } from '@univerjs/core/facade';
|
||||
import { FDocument } from './f-document';
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
export interface IFUniverDocsMixin {
|
||||
/**
|
||||
* Create a new document and get the API handler of that document.
|
||||
* @param {Partial<IDocumentData>} data The snapshot of the document.
|
||||
* @param {ICreateUnitOptions} [options] The options of creating the document.
|
||||
* @returns {FDocument} The document API instance.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fDocument = univerAPI.createDocument({ id: 'document-01', title: 'Document1' });
|
||||
* console.log(fDocument);
|
||||
* ```
|
||||
*/
|
||||
createDocument(data: Partial<IDocumentData>, options?: ICreateUnitOptions): FDocument;
|
||||
|
||||
/**
|
||||
* Get the currently focused Univer document.
|
||||
* @returns {FDocument | null} The currently focused Univer document API instance, or null if there is no focused Univer document.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fDocument = univerAPI.getActiveDocument();
|
||||
* console.log(fDocument);
|
||||
* ```
|
||||
*/
|
||||
getActiveDocument(): FDocument | null;
|
||||
|
||||
/**
|
||||
* Get the document API handler by the document id.
|
||||
* @param {string} id The document id.
|
||||
* @returns {FDocument | null} The document API instance corresponding to the document id, or null if not found.
|
||||
* @example
|
||||
* ```typescript
|
||||
* const fDocument = univerAPI.getDocument('document-01');
|
||||
* console.log(fDocument);
|
||||
* ```
|
||||
*/
|
||||
getDocument(id: string): FDocument | null;
|
||||
}
|
||||
|
||||
export class FUniverDocsMixin extends FUniver implements IFUniverDocsMixin {
|
||||
override createDocument(data: Partial<IDocumentData>, options?: ICreateUnitOptions): FDocument {
|
||||
const instanceService = this._injector.get(IUniverInstanceService);
|
||||
const document = instanceService.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
data,
|
||||
options
|
||||
);
|
||||
return this._injector.createInstance(FDocument, document);
|
||||
}
|
||||
|
||||
override getActiveDocument(): FDocument | null {
|
||||
const document = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._injector.createInstance(FDocument, document);
|
||||
}
|
||||
|
||||
override getDocument(id: string): FDocument | null {
|
||||
const document = this._univerInstanceService.getUnit<DocumentDataModel>(id, UniverInstanceType.UNIVER_DOC);
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._injector.createInstance(FDocument, document);
|
||||
}
|
||||
}
|
||||
|
||||
FUniver.extend(FUniverDocsMixin);
|
||||
declare module '@univerjs/core/facade' {
|
||||
interface FUniver extends IFUniverDocsMixin {}
|
||||
}
|
||||
34
packages/docs/src/facade/index.ts
Normal file
34
packages/docs/src/facade/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import './f-univer';
|
||||
import './f-enum';
|
||||
|
||||
export {
|
||||
FDocument,
|
||||
type IDocumentCustomBlockLayout,
|
||||
type IDocumentCustomBlockLayoutItem,
|
||||
} from './f-document';
|
||||
export { FDocumentParagraph, isParagraphFacade } from './f-document-paragraph';
|
||||
export type { IFDocumentFindTextOptions, IFDocumentParagraphInfo } from './f-document-paragraph';
|
||||
export { FDocumentObjectPermission, FDocumentPermission } from './f-document-permission';
|
||||
export { DocsSectionUnsupportedDocumentFlavorError, FDocumentSection } from './f-document-section';
|
||||
export type { FDocumentSectionPageSetup, IFDocumentSectionColumnOptions, IFDocumentSectionDescription } from './f-document-section';
|
||||
export { FDocumentTextRange } from './f-document-text-range';
|
||||
export type { IFDocumentTextRangeDescription, IFDocumentTextStyleRun } from './f-document-text-range';
|
||||
export * from './f-enum';
|
||||
export type { IFDocumentTextRange } from './utils';
|
||||
export { stripBlockTokens } from './utils';
|
||||
221
packages/docs/src/facade/utils.ts
Normal file
221
packages/docs/src/facade/utils.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 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, IDocumentBody, Injector, IParagraphStyle, UpdateDocsAttributeType } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
|
||||
import {
|
||||
createParagraphId,
|
||||
DataStreamTreeTokenType,
|
||||
getRichTextEditPath,
|
||||
ICommandService,
|
||||
JSONX,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
} from '@univerjs/core';
|
||||
import { InsertTextCommand, RichTextEditingMutation } from '@univerjs/docs';
|
||||
|
||||
export interface IBuildPlainTextInsertBodyOptions {
|
||||
paragraphStyle?: IParagraphStyle;
|
||||
removeLeadingParagraphBreak?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A text range in a document segment. Offsets are zero-based positions in the segment data stream.
|
||||
*/
|
||||
export interface IFDocumentTextRange {
|
||||
/** The inclusive start offset of the range. */
|
||||
startOffset: number;
|
||||
/** The exclusive end offset of the range. */
|
||||
endOffset: number;
|
||||
/** The header/footer segment id. Omit or use an empty string for the main body. */
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
function cloneParagraphStyle(paragraphStyle: IParagraphStyle): IParagraphStyle {
|
||||
return paragraphStyle == null ? paragraphStyle : JSON.parse(JSON.stringify(paragraphStyle));
|
||||
}
|
||||
|
||||
function normalizePlainTextDataStream(dataStream: string): string {
|
||||
return dataStream.replace(/\r\n/g, '\r').replace(/\n/g, '\r');
|
||||
}
|
||||
|
||||
export function getRemovedLeadingParagraphBreakLength(
|
||||
dataStream: string,
|
||||
removeLeadingParagraphBreak?: boolean
|
||||
): number {
|
||||
const normalized = normalizePlainTextDataStream(dataStream);
|
||||
|
||||
if (removeLeadingParagraphBreak && normalized.length > 1 && normalized.startsWith('\r')) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getNormalizedPlainTextCursorOffset(
|
||||
dataStream: string,
|
||||
cursorOffset: number,
|
||||
removeLeadingParagraphBreak?: boolean
|
||||
): number {
|
||||
const normalizedPrefixLength = normalizePlainTextDataStream(dataStream.slice(0, cursorOffset)).length;
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
normalizedPrefixLength - getRemovedLeadingParagraphBreakLength(dataStream, removeLeadingParagraphBreak)
|
||||
);
|
||||
}
|
||||
|
||||
export function getParagraphStyleAtOffset(body: IDocumentBody, offset: number): IParagraphStyle | undefined {
|
||||
const paragraphs = body.paragraphs ?? [];
|
||||
const paragraph = paragraphs.find((item) => item.startIndex >= offset) ?? paragraphs[paragraphs.length - 1];
|
||||
|
||||
return paragraph?.paragraphStyle;
|
||||
}
|
||||
|
||||
export function buildPlainTextInsertBody(
|
||||
dataStream: string,
|
||||
options: IBuildPlainTextInsertBodyOptions = {}
|
||||
): IDocumentBody {
|
||||
const normalizedDataStream = normalizePlainTextDataStream(dataStream).slice(
|
||||
getRemovedLeadingParagraphBreakLength(dataStream, options.removeLeadingParagraphBreak)
|
||||
);
|
||||
const body: IDocumentBody = {
|
||||
dataStream: normalizedDataStream,
|
||||
customDecorations: [],
|
||||
customRanges: [],
|
||||
textRuns: [],
|
||||
};
|
||||
|
||||
const paragraphs = [];
|
||||
const existingParagraphIds = new Set<string>();
|
||||
for (let index = 0; index < normalizedDataStream.length; index++) {
|
||||
if (normalizedDataStream[index] === '\r') {
|
||||
paragraphs.push({
|
||||
startIndex: index,
|
||||
paragraphId: createParagraphId(existingParagraphIds),
|
||||
...(options.paragraphStyle == null ? {} : { paragraphStyle: cloneParagraphStyle(options.paragraphStyle) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (paragraphs.length > 0) {
|
||||
body.paragraphs = paragraphs;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export function replaceBodyRange(
|
||||
range: IFDocumentTextRange,
|
||||
insertBody: IDocumentBody,
|
||||
docDataModel: DocumentDataModel,
|
||||
commandService: ICommandService
|
||||
): boolean {
|
||||
const { startOffset, endOffset, segmentId } = range;
|
||||
const result = commandService.syncExecuteCommand(
|
||||
InsertTextCommand.id,
|
||||
{
|
||||
unitId: docDataModel.getUnitId(),
|
||||
body: insertBody,
|
||||
range: {
|
||||
startOffset,
|
||||
endOffset,
|
||||
collapsed: startOffset === endOffset,
|
||||
segmentId,
|
||||
},
|
||||
segmentId,
|
||||
debounce: false,
|
||||
textRanges: [],
|
||||
noNeedSetTextRange: true,
|
||||
isEditing: false,
|
||||
}
|
||||
);
|
||||
|
||||
return Boolean(result);
|
||||
}
|
||||
|
||||
export function retainBodyRange(
|
||||
range: IFDocumentTextRange,
|
||||
updateBody: IDocumentBody,
|
||||
coverType: UpdateDocsAttributeType,
|
||||
docDataModel: DocumentDataModel,
|
||||
injector: Injector
|
||||
): boolean {
|
||||
const { startOffset, endOffset, segmentId } = range;
|
||||
const commandService = injector.get(ICommandService);
|
||||
|
||||
if (updateBody.textRuns?.length && docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.textRuns == null) {
|
||||
const jsonX = JSONX.getInstance();
|
||||
const actions = jsonX.replaceOp(
|
||||
[...getRichTextEditPath(docDataModel, segmentId), 'textRuns'],
|
||||
undefined,
|
||||
[]
|
||||
);
|
||||
|
||||
commandService.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(
|
||||
RichTextEditingMutation.id,
|
||||
{
|
||||
unitId: docDataModel.getUnitId(),
|
||||
segmentId,
|
||||
actions,
|
||||
textRanges: [],
|
||||
isEditing: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const textX = new TextX();
|
||||
|
||||
if (startOffset > 0) {
|
||||
textX.push({ t: TextXActionType.RETAIN, len: startOffset });
|
||||
}
|
||||
|
||||
textX.push({
|
||||
t: TextXActionType.RETAIN,
|
||||
body: updateBody,
|
||||
coverType,
|
||||
len: endOffset - startOffset,
|
||||
});
|
||||
|
||||
const jsonX = JSONX.getInstance();
|
||||
const actions = jsonX.editOp(textX.serialize(), getRichTextEditPath(docDataModel, segmentId));
|
||||
|
||||
const result = commandService.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(
|
||||
RichTextEditingMutation.id,
|
||||
{
|
||||
unitId: docDataModel.getUnitId(),
|
||||
segmentId,
|
||||
actions,
|
||||
textRanges: [],
|
||||
isEditing: false,
|
||||
}
|
||||
);
|
||||
|
||||
return Boolean(result?.actions && result.actions.length > 0);
|
||||
}
|
||||
|
||||
export function stripBlockTokens(text: string): string {
|
||||
return Array.from(text)
|
||||
.map((char) => char === DataStreamTreeTokenType.PARAGRAPH ? '\n' : char)
|
||||
.filter(
|
||||
(char) =>
|
||||
char !== DataStreamTreeTokenType.BLOCK_START &&
|
||||
char !== DataStreamTreeTokenType.BLOCK_END &&
|
||||
char !== DataStreamTreeTokenType.SECTION_BREAK
|
||||
)
|
||||
.join('')
|
||||
.replace(/\n$/, '');
|
||||
}
|
||||
159
packages/docs/src/index.ts
Normal file
159
packages/docs/src/index.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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 { DeleteTextCommand, InsertTextCommand, UpdateTextCommand } from './commands/commands/core-editing.command';
|
||||
export type {
|
||||
IDeleteTextCommandParams,
|
||||
IInsertTextCommandParams,
|
||||
IUpdateTextCommandParams,
|
||||
} from './commands/commands/core-editing.command';
|
||||
export { CreateHeaderFooterCommand, HeaderFooterType } from './commands/commands/create-header-footer.command';
|
||||
export type {
|
||||
HeaderFooterCreateMode,
|
||||
ICreateHeaderFooterCommandParams,
|
||||
IHeaderFooterProps,
|
||||
} from './commands/commands/create-header-footer.command';
|
||||
export { SetDocumentDefaultParagraphStyleCommand } from './commands/commands/set-document-default-paragraph-style.command';
|
||||
export type {
|
||||
IDocumentDefaultParagraphStylePatch,
|
||||
ISetDocumentDefaultParagraphStyleCommandParams,
|
||||
} from './commands/commands/set-document-default-paragraph-style.command';
|
||||
export { SetDocumentNameCommand } from './commands/commands/set-document-name.command';
|
||||
export type { ISetDocumentNameCommandParams } from './commands/commands/set-document-name.command';
|
||||
export { SetDocumentPermissionCommand } from './commands/commands/set-document-permission.command';
|
||||
export type { ISetDocumentPermissionCommandParams } from './commands/commands/set-document-permission.command';
|
||||
export { SetDocumentPermissionsCommand } from './commands/commands/set-document-permissions.command';
|
||||
export { SetSectionHeaderFooterLinkCommand } from './commands/commands/set-section-header-footer-link.command';
|
||||
export type { ISetSectionHeaderFooterLinkCommandParams } from './commands/commands/set-section-header-footer-link.command';
|
||||
export { UpdateDocumentParagraphStyleCommand } from './commands/commands/update-document-paragraph-style.command';
|
||||
export { DeleteDocumentSectionBreakCommand, InsertDocumentColumnBreakCommand, InsertDocumentSectionBreakCommand, UpdateDocumentSectionCommand } from './commands/commands/update-document-section.command';
|
||||
export type { IDeleteDocumentSectionBreakCommandParams, IDocumentSectionConfig, IDocumentSectionUpdate, IInsertDocumentColumnBreakCommandParams, IInsertDocumentSectionBreakCommandParams, IUpdateDocumentSectionCommandParams } from './commands/commands/update-document-section.command';
|
||||
export { DocHistoryAction, RichTextEditingMutation, transformDocumentTextRanges } from './commands/mutations/core-editing.mutation';
|
||||
export type { IRichTextEditingMutationParams } from './commands/mutations/core-editing.mutation';
|
||||
export { SetDocumentPermissionRuleMutation } from './commands/mutations/set-document-permission-rule.mutation';
|
||||
export { SetDocumentPermissionRulesMutation } from './commands/mutations/set-document-permission-rules.mutation';
|
||||
export { SetTextSelectionsOperation } from './commands/operations/text-selection.operation';
|
||||
export type { ISetTextSelectionsOperationParams } from './commands/operations/text-selection.operation';
|
||||
export type { IUniverDocsConfig } from './config/config';
|
||||
export {
|
||||
createDocsCustomBlockDrawing,
|
||||
createDocsCustomBlockInsertMutation,
|
||||
createDocsCustomBlockRemoveMutation,
|
||||
createEmbedDocsCustomBlockData,
|
||||
createInsertCustomBlockActions,
|
||||
createRemoveCustomBlockActions,
|
||||
EMBED_DOCS_CUSTOM_BLOCK_DEFAULT_COMPONENT_KEY,
|
||||
isEmbedDocsCustomBlockData,
|
||||
isSheetLikeDocsCustomBlockChildType,
|
||||
resolveDocsCustomBlockSize,
|
||||
shouldUseInlineTextSelectionForDocsCustomBlockDrawing,
|
||||
} from './embed-host-anchor';
|
||||
export type {
|
||||
EmbedDocsCustomBlockInteractionMode,
|
||||
IDocsCustomBlockMutationParams,
|
||||
IEmbedDocsCustomBlockData,
|
||||
} from './embed-host-anchor';
|
||||
export { DocsLayoutWorkerCapabilityError, DocsLayoutWorkerClientService, startDocsLayoutWorker, UniverDocsLayoutWorkerPlugin } from './layout-worker';
|
||||
export type { IDocsLayoutWorkerCapabilities, IDocsLayoutWorkerFontProbe, IDocsLayoutWorkerRuntime, IUniverDocsLayoutWorkerConfig } from './layout-worker';
|
||||
export { UniverDocsPlugin } from './plugin';
|
||||
export { DocBlockMoveValidatorService } from './services/doc-block-move-validator.service';
|
||||
export type {
|
||||
DocBlockMoveTransformer,
|
||||
DocBlockMoveValidator,
|
||||
IDocBlockMoveResult,
|
||||
IDocBlockMoveTransformContext,
|
||||
IDocBlockMoveValidationContext,
|
||||
} from './services/doc-block-move-validator.service';
|
||||
export { DocContentInsertService } from './services/doc-content-insert.service';
|
||||
export type { IDocContentInsertRange } from './services/doc-content-insert.service';
|
||||
export { DocInterceptorService } from './services/doc-interceptor/doc-interceptor.service';
|
||||
export { DOC_INTERCEPTOR_POINT } from './services/doc-interceptor/interceptor-const';
|
||||
export {
|
||||
DocLayoutExecutorService,
|
||||
DocLayoutExecutorState,
|
||||
DocLayoutExecutorType,
|
||||
DocLayoutSessionStatus,
|
||||
} from './services/doc-layout-executor.service';
|
||||
export type {
|
||||
IDocLayoutCancelRequest,
|
||||
IDocLayoutCreateSessionRequest,
|
||||
IDocLayoutCustomRangePresentation,
|
||||
IDocLayoutCustomRangePresentationContext,
|
||||
IDocLayoutCustomRangePresentationEntry,
|
||||
IDocLayoutCustomRangePresentationPatch,
|
||||
IDocLayoutDisposeMountRequest,
|
||||
IDocLayoutDisposeSessionRequest,
|
||||
IDocLayoutExecutor,
|
||||
IDocLayoutExecutorStatus,
|
||||
IDocLayoutMountIdentity,
|
||||
IDocLayoutMutationProjection,
|
||||
IDocLayoutPageRequest,
|
||||
IDocLayoutPageResult,
|
||||
IDocLayoutPerformanceMetrics,
|
||||
IDocLayoutRecordPatch,
|
||||
IDocLayoutStartOptions,
|
||||
IDocLayoutStartRequest,
|
||||
IDocLayoutStartResult,
|
||||
IDocLayoutStepRequest,
|
||||
IDocLayoutStepResult,
|
||||
} from './services/doc-layout-executor.service';
|
||||
export {
|
||||
DOC_SELECTION_OPTION_PRESERVE_CARET,
|
||||
DocSelectionManagerService,
|
||||
} from './services/doc-selection-manager.service';
|
||||
export { DocSkeletonManagerService } from './services/doc-skeleton-manager.service';
|
||||
export {
|
||||
DocStateChangeManagerService,
|
||||
IDocStateChangeInterceptorService,
|
||||
} from './services/doc-state-change-manager.service';
|
||||
export type { IDocStateChangeInfo, IDocStateChangeParams } from './services/doc-state-emit.service';
|
||||
export { DocStateEmitService } from './services/doc-state-emit.service';
|
||||
export { DocTextResolverService } from './services/doc-text-resolver.service';
|
||||
export type {
|
||||
IDocTextReplacement,
|
||||
IDocTextResolver,
|
||||
IResolvedDocText,
|
||||
IResolvedDocTextCharacter,
|
||||
} from './services/doc-text-resolver.service';
|
||||
export {
|
||||
canEditDocumentTargets,
|
||||
createDocumentPermissionPoint,
|
||||
DOCUMENT_UNIT_PERMISSION_ACTIONS,
|
||||
getDocumentEntityPermissionObjectId,
|
||||
getDocumentParagraphPermissionObjectId,
|
||||
getDocumentPermissionValue,
|
||||
getDocumentSectionPermissionObjectId,
|
||||
setDocumentPermissionValue,
|
||||
} from './services/permission/document-permission';
|
||||
export type { DocumentUnitPermissionAction } from './services/permission/document-permission';
|
||||
export {
|
||||
getDocumentDrawingSegmentId,
|
||||
getDocumentEditTargetObjectIds,
|
||||
getDocumentEntityParentPermissionObjectIds,
|
||||
getDocumentParagraphParentPermissionObjectIds,
|
||||
} from './services/permission/document-permission-resolver';
|
||||
export {
|
||||
addCustomRangeBySelectionFactory,
|
||||
addCustomRangeFactory,
|
||||
deleteCustomRangeFactory,
|
||||
} from './utils/custom-range-factory';
|
||||
export { generateParagraphs } from './utils/paragraphs';
|
||||
export { replaceSelectionFactory } from './utils/replace-selection-factory';
|
||||
export { createSectionColumnProperties, getEffectiveSectionPageSetup, getSectionContentWidth } from './utils/section-columns';
|
||||
export type { IEffectiveSectionPageSetup } from './utils/section-columns';
|
||||
export { getTopLevelSectionBreaks } from './utils/sections';
|
||||
export { buildDocTransform, docDrawingPositionToTransform, transformToDocDrawingPosition } from './utils/transform-position';
|
||||
export { consumeContentInsertRange, getContentInsertRange, isHeaderFooterSelection, normalizeTextRange } from './utils/util';
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { JSONX } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectDocumentFontFamilies } from '../document-font-metrics';
|
||||
|
||||
describe('document font metrics', () => {
|
||||
it('collects inherited, header and footnote families and font changes without inspecting text content', () => {
|
||||
const families = new Set<string>();
|
||||
collectDocumentFontFamilies({
|
||||
documentStyle: { textStyle: { ff: 'Body Font' } },
|
||||
headers: { first: { body: { textRuns: [{ ts: { ff: 'Header Font' } }] } } },
|
||||
notes: { first: { body: { textRuns: [{ ts: { ff: 'Note Font' } }] } } },
|
||||
body: { dataStream: '{"ff":"Not a font"}', textRuns: [{ ts: { ff: 'Body Font' } }] },
|
||||
}, families);
|
||||
collectDocumentFontFamilies(JSONX.getInstance().replaceOp(
|
||||
['documentStyle', 'textStyle', 'ff'],
|
||||
'Body Font',
|
||||
'Replacement Font'
|
||||
), families);
|
||||
collectDocumentFontFamilies([{ actions: ['body', {
|
||||
et: 'textX',
|
||||
e: [{ t: 'retain', len: 1, body: { textRuns: [{ ts: { ff: 'Pasted Font' } }] } }],
|
||||
}] }], families);
|
||||
expect([...families].sort()).toEqual([
|
||||
'Body Font',
|
||||
'Header Font',
|
||||
'Note Font',
|
||||
'Pasted Font',
|
||||
'Replacement Font',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 { describe, expect, it } from 'vitest';
|
||||
import { DocsLayoutWorkerPerformanceTracker } from '../performance-tracker';
|
||||
|
||||
describe('DocsLayoutWorkerPerformanceTracker', () => {
|
||||
it('separates snapshot, mutation, and published patch transfer samples by unit', () => {
|
||||
const tracker = new DocsLayoutWorkerPerformanceTracker();
|
||||
|
||||
tracker.recordRequest({
|
||||
method: 'createSession',
|
||||
args: [{ unitId: 'doc-1', snapshot: { id: 'doc-1' } }],
|
||||
}, 4);
|
||||
tracker.recordRequest({
|
||||
method: 'startLayout',
|
||||
args: [{ unitId: 'doc-1', mutations: [] }],
|
||||
}, 5);
|
||||
tracker.recordRequest({
|
||||
method: 'startLayout',
|
||||
args: [{ unitId: 'doc-1', mutations: [{ modelRevision: 1 }] }],
|
||||
}, 6);
|
||||
tracker.recordResponse({
|
||||
data: {
|
||||
unitId: 'doc-1',
|
||||
publication: { pages: [] },
|
||||
},
|
||||
}, 7);
|
||||
tracker.recordResponse({
|
||||
data: {
|
||||
status: 'accepted',
|
||||
step: {
|
||||
unitId: 'doc-1',
|
||||
publication: { pages: [] },
|
||||
},
|
||||
},
|
||||
}, 8);
|
||||
tracker.recordResponse({
|
||||
data: {
|
||||
unitId: 'doc-2',
|
||||
publication: { pages: [] },
|
||||
},
|
||||
}, 9);
|
||||
|
||||
expect(tracker.getMetrics('doc-1')).toEqual({
|
||||
mutationTransferMs: [6],
|
||||
patchTransferMs: [7, 8],
|
||||
snapshotTransferMs: [4],
|
||||
});
|
||||
expect(tracker.getMetrics('doc-2').patchTransferMs).toEqual([9]);
|
||||
|
||||
tracker.reset('doc-1');
|
||||
expect(tracker.getMetrics('doc-1')).toEqual({
|
||||
mutationTransferMs: [],
|
||||
patchTransferMs: [],
|
||||
snapshotTransferMs: [],
|
||||
});
|
||||
|
||||
tracker.resetAll();
|
||||
expect(tracker.getMetrics('doc-2')).toEqual({
|
||||
mutationTransferMs: [],
|
||||
patchTransferMs: [],
|
||||
snapshotTransferMs: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
655
packages/docs/src/layout-worker/__tests__/worker.spec.ts
Normal file
655
packages/docs/src/layout-worker/__tests__/worker.spec.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* 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 {
|
||||
createDocumentModelWithStyle,
|
||||
CustomRangeType,
|
||||
DocumentFlavor,
|
||||
JSONX,
|
||||
LocaleType,
|
||||
ObjectRelativeFromH,
|
||||
ObjectRelativeFromV,
|
||||
PositionedObjectLayoutType,
|
||||
} from '@univerjs/core';
|
||||
import { getFontStyleString, invalidateDocumentFontMetrics } from '@univerjs/engine-render';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocLayoutSessionStatus } from '../../services/doc-layout-executor.service';
|
||||
import { DocsLayoutWorkerPerformanceTracker } from '../performance-tracker';
|
||||
import { DocsLayoutWorkerRuntime } from '../worker';
|
||||
|
||||
function stubOffscreenCanvas(): void {
|
||||
vi.stubGlobal('OffscreenCanvas', class {
|
||||
getContext() {
|
||||
return {
|
||||
font: '',
|
||||
textBaseline: 'alphabetic',
|
||||
measureText(content: string) {
|
||||
return {
|
||||
width: content.length * 7,
|
||||
fontBoundingBoxAscent: 9,
|
||||
fontBoundingBoxDescent: 3,
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('DocsLayoutWorkerRuntime', () => {
|
||||
afterEach(() => {
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses browser normal font spacing for initial layout and a newly introduced font during editing', async () => {
|
||||
stubOffscreenCanvas();
|
||||
vi.stubGlobal('document', undefined);
|
||||
const source = createDocumentModelWithStyle('Normal spacing', { ff: 'Initial Font', fs: 12 });
|
||||
source.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
source.updateDocumentDataPageSize(300, 300);
|
||||
source.updateDocumentDataMargin({ t: 0, r: 0, b: 0, l: 0 });
|
||||
const snapshot = structuredClone(source.getSnapshot());
|
||||
const runtime = new DocsLayoutWorkerRuntime();
|
||||
const identity = { unitId: source.getUnitId(), mountId: 'font-mount', mountEpoch: 1, viewportEpoch: 1 };
|
||||
const fontKey = (ff: string) => getFontStyleString({ ff, fs: 1000 }).fontString.trim();
|
||||
invalidateDocumentFontMetrics(() => true);
|
||||
await runtime.createSession({
|
||||
unitId: identity.unitId,
|
||||
sessionEpoch: 1,
|
||||
snapshot,
|
||||
modelRevision: 0,
|
||||
locale: LocaleType.EN_US,
|
||||
direction: 'ltr',
|
||||
normalFontLineHeights: { [fontKey('Initial Font')]: 1.5 },
|
||||
});
|
||||
try {
|
||||
for (const revision of [0, 1]) {
|
||||
const start = await runtime.startLayout({
|
||||
...identity,
|
||||
metricsRevision: revision + 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: revision,
|
||||
mutations: revision === 0
|
||||
? []
|
||||
: [{
|
||||
baseRevision: 0,
|
||||
modelRevision: 1,
|
||||
actions: JSONX.getInstance().replaceOp(
|
||||
['body', 'textRuns', 0, 'ts', 'ff'],
|
||||
'Initial Font',
|
||||
'Edited Font'
|
||||
),
|
||||
}],
|
||||
normalFontLineHeights: revision === 0 ? undefined : { [fontKey('Edited Font')]: 1.75 },
|
||||
reason: revision === 0 ? 'initial' : 'edit',
|
||||
budgetMs: 8,
|
||||
});
|
||||
if (start.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected layout to accept the font measurement update.');
|
||||
}
|
||||
let step = start.step;
|
||||
await expect.poll(async () => {
|
||||
if (!step.progress.complete) {
|
||||
step = await runtime.stepLayout({ ...identity, generation: step.progress.generation, budgetMs: 8 });
|
||||
}
|
||||
return step.progress.complete;
|
||||
}).toBe(true);
|
||||
const result = await runtime.getLayoutPage({ ...identity, pageIndex: 0 });
|
||||
const line = result.page?.page.sections[0].columns[0].lines[0];
|
||||
expect(line?.lineHeight).toBeCloseTo(revision === 0 ? 18 : 21);
|
||||
expect(line?.divides.flatMap((divide) => divide.glyphGroup).map((glyph) => glyph.raw).join(''))
|
||||
.toBe(snapshot.body!.dataStream);
|
||||
}
|
||||
} finally {
|
||||
await runtime.disposeSession({ unitId: identity.unitId, sessionEpoch: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
it('applies custom range presentations to the matching body, header, and footer segments', async () => {
|
||||
stubOffscreenCanvas();
|
||||
const sourceModel = createDocumentModelWithStyle('Body\r', {});
|
||||
sourceModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
const snapshot = structuredClone(sourceModel.getSnapshot());
|
||||
const createRange = (startIndex: number) => ({
|
||||
startIndex,
|
||||
endIndex: startIndex,
|
||||
rangeId: 'shared-range',
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
});
|
||||
snapshot.body!.customRanges = [createRange(0)];
|
||||
snapshot.headers = {
|
||||
'header-1': {
|
||||
headerId: 'header-1',
|
||||
body: {
|
||||
dataStream: 'Header\r\n',
|
||||
paragraphs: [{ paragraphId: 'header-paragraph', startIndex: 6 }],
|
||||
customRanges: [createRange(1)],
|
||||
},
|
||||
},
|
||||
};
|
||||
snapshot.footers = {
|
||||
'footer-1': {
|
||||
footerId: 'footer-1',
|
||||
body: {
|
||||
dataStream: 'Footer\r\n',
|
||||
paragraphs: [{ paragraphId: 'footer-paragraph', startIndex: 6 }],
|
||||
customRanges: [createRange(2)],
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtime = new DocsLayoutWorkerRuntime();
|
||||
const unitId = sourceModel.getUnitId();
|
||||
await runtime.createSession({
|
||||
unitId,
|
||||
sessionEpoch: 1,
|
||||
snapshot,
|
||||
modelRevision: 0,
|
||||
locale: LocaleType.EN_US,
|
||||
localeData: {},
|
||||
direction: 'ltr',
|
||||
});
|
||||
|
||||
await runtime.startLayout({
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
mountEpoch: 1,
|
||||
viewportEpoch: 1,
|
||||
metricsRevision: 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
customRangePresentations: [
|
||||
{ segmentId: '', rangeId: 'shared-range', presentation: { glyphWidthEm: 1 } },
|
||||
{ segmentId: '', rangeId: 'shared-range', presentation: { glyphWidthEm: 99 } },
|
||||
{ segmentId: 'header-1', rangeId: 'shared-range', presentation: { glyphWidthEm: 2 } },
|
||||
{ segmentId: 'footer-1', rangeId: 'shared-range', presentation: { glyphWidthEm: 3 } },
|
||||
],
|
||||
reason: 'initial',
|
||||
budgetMs: 0,
|
||||
});
|
||||
|
||||
const dataModel = [...((runtime as unknown as {
|
||||
_sessions: Map<string, { dataModel: ReturnType<typeof createDocumentModelWithStyle> }>;
|
||||
})._sessions.values())][0].dataModel;
|
||||
expect(dataModel.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 1 });
|
||||
expect(dataModel.getSelfOrHeaderFooterModel('header-1')?.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 2 });
|
||||
expect(dataModel.getSelfOrHeaderFooterModel('footer-1')?.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 3 });
|
||||
|
||||
await runtime.startLayout({
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
mountEpoch: 1,
|
||||
viewportEpoch: 1,
|
||||
metricsRevision: 2,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewportPatch: { removals: [], upserts: {} },
|
||||
customRangePresentationPatch: {
|
||||
removals: [{ segmentId: '', rangeId: 'shared-range' }],
|
||||
upserts: [{
|
||||
segmentId: 'header-1',
|
||||
rangeId: 'shared-range',
|
||||
presentation: { glyphWidthEm: 4 },
|
||||
}],
|
||||
},
|
||||
reason: 'edit',
|
||||
budgetMs: 0,
|
||||
});
|
||||
|
||||
expect(dataModel.getBody()?.customRanges?.[0]).not.toHaveProperty('glyphWidthEm');
|
||||
expect(dataModel.getSelfOrHeaderFooterModel('header-1')?.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 4 });
|
||||
expect(dataModel.getSelfOrHeaderFooterModel('footer-1')?.getBody()?.customRanges?.[0]).toMatchObject({ glyphWidthEm: 3 });
|
||||
});
|
||||
|
||||
it('applies many presentations with one bounded pass over each segment range list', () => {
|
||||
const rangeCount = 100;
|
||||
let indexedRangeReads = 0;
|
||||
const ranges = new Proxy(Array.from({ length: rangeCount }, (_, index) => ({
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
rangeId: `range-${index}`,
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
})), {
|
||||
get(target, property, receiver) {
|
||||
if (typeof property === 'string' && /^\d+$/.test(property)) {
|
||||
indexedRangeReads++;
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
const body = { dataStream: 'Body\r\n', customRanges: ranges };
|
||||
const dataModel = {
|
||||
getBody: () => body,
|
||||
headerModelMap: new Map(),
|
||||
footerModelMap: new Map(),
|
||||
getSelfOrHeaderFooterModel: () => ({ getBody: () => body }),
|
||||
};
|
||||
const presentations = ranges.map((range, index) => ({
|
||||
segmentId: '',
|
||||
rangeId: range.rangeId,
|
||||
presentation: { glyphWidthEm: index + 1 },
|
||||
}));
|
||||
indexedRangeReads = 0;
|
||||
|
||||
(new DocsLayoutWorkerRuntime() as unknown as {
|
||||
_applyCustomRangePresentations(dataModel: unknown, presentations: unknown): void;
|
||||
})._applyCustomRangePresentations(dataModel, presentations);
|
||||
|
||||
expect(indexedRangeReads).toBeLessThanOrEqual(rangeCount * 2);
|
||||
expect(ranges[0]).toMatchObject({ glyphWidthEm: 1 });
|
||||
expect(ranges[rangeCount - 1]).toMatchObject({ glyphWidthEm: rangeCount });
|
||||
});
|
||||
|
||||
it('lays out a snapshot and enforces sequential model revisions', async () => {
|
||||
stubOffscreenCanvas();
|
||||
|
||||
const sourceModel = createDocumentModelWithStyle('First page\fSecond page\fThird page\r', {});
|
||||
sourceModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
sourceModel.updateDocumentDataPageSize(240, 180);
|
||||
sourceModel.updateDocumentDataMargin({ t: 10, r: 10, b: 10, l: 10 });
|
||||
const performanceTracker = new DocsLayoutWorkerPerformanceTracker();
|
||||
const runtime = new DocsLayoutWorkerRuntime(performanceTracker);
|
||||
const unitId = sourceModel.getUnitId();
|
||||
const mountIdentity = { mountEpoch: 1, viewportEpoch: 1 };
|
||||
|
||||
await runtime.createSession({
|
||||
unitId,
|
||||
sessionEpoch: 1,
|
||||
snapshot: structuredClone(sourceModel.getSnapshot()),
|
||||
modelRevision: 1,
|
||||
locale: LocaleType.EN_US,
|
||||
localeData: {},
|
||||
direction: 'ltr',
|
||||
});
|
||||
|
||||
await expect(runtime.getCapabilities()).resolves.toMatchObject({
|
||||
protocolVersion: 5,
|
||||
executor: 'worker',
|
||||
offscreenCanvas: true,
|
||||
structuredClone: true,
|
||||
fontProbe: {
|
||||
font: '16px Arial',
|
||||
width: expect.any(Number),
|
||||
},
|
||||
});
|
||||
|
||||
const start = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
metricsRevision: 1,
|
||||
baseRevision: 1,
|
||||
modelRevision: 1,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 0,
|
||||
});
|
||||
if (start.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the Worker layout session to start.');
|
||||
}
|
||||
|
||||
let result = start.step;
|
||||
const publications = [result.publication];
|
||||
// Hyphenation patterns load asynchronously; slice count is not a loading deadline.
|
||||
await expect.poll(async () => {
|
||||
if (!result.progress.complete) {
|
||||
result = await runtime.stepLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
generation: start.step.progress.generation,
|
||||
budgetMs: 0,
|
||||
});
|
||||
publications.push(result.publication);
|
||||
}
|
||||
return result.progress.complete;
|
||||
}, { timeout: 5_000, interval: 5 }).toBe(true);
|
||||
expect(result.modelRevision).toBe(1);
|
||||
expect(publications.flatMap((publication) =>
|
||||
publication?.kind === 'page' ? publication.pages : []
|
||||
).length).toBeGreaterThan(1);
|
||||
expect(() => structuredClone(publications)).not.toThrow();
|
||||
const firstPage = await runtime.getLayoutPage({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
pageIndex: 0,
|
||||
});
|
||||
expect(firstPage).toMatchObject({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
page: { pageIndex: 0 },
|
||||
});
|
||||
expect(() => structuredClone(firstPage)).not.toThrow();
|
||||
|
||||
const customBlockModel = createDocumentModelWithStyle('\b\r\n', {});
|
||||
customBlockModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
const customBlockSnapshot = structuredClone(customBlockModel.getSnapshot());
|
||||
customBlockSnapshot.id = 'custom-block-doc';
|
||||
if (customBlockSnapshot.body == null) {
|
||||
throw new Error('Expected a document body.');
|
||||
}
|
||||
customBlockSnapshot.body.customBlocks = [{ startIndex: 0, blockId: 'embed-1' }];
|
||||
customBlockSnapshot.drawings = {
|
||||
'embed-1': {
|
||||
unitId: customBlockSnapshot.id,
|
||||
subUnitId: customBlockSnapshot.id,
|
||||
drawingId: 'embed-1',
|
||||
drawingType: 1,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: {
|
||||
angle: 0,
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
|
||||
size: { width: 100, height: 80 },
|
||||
},
|
||||
},
|
||||
};
|
||||
await runtime.createSession({
|
||||
unitId: customBlockSnapshot.id,
|
||||
sessionEpoch: 1,
|
||||
snapshot: customBlockSnapshot,
|
||||
modelRevision: 0,
|
||||
locale: LocaleType.EN_US,
|
||||
localeData: {},
|
||||
direction: 'ltr',
|
||||
});
|
||||
const customBlockStart = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId: customBlockSnapshot.id,
|
||||
mountId: 'custom-block-mount',
|
||||
metricsRevision: 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewports: {
|
||||
'embed-1': { width: 222, height: 111, layoutWidth: 222 },
|
||||
},
|
||||
reason: 'initial',
|
||||
budgetMs: 32,
|
||||
});
|
||||
if (customBlockStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the custom block layout session to start.');
|
||||
}
|
||||
expect(JSON.stringify(customBlockStart.step.publication)).toContain('"width":222');
|
||||
|
||||
const updatedCustomBlockStart = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId: customBlockSnapshot.id,
|
||||
mountId: 'custom-block-mount',
|
||||
metricsRevision: 2,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewportPatch: {
|
||||
removals: [],
|
||||
upserts: { 'embed-1': { width: 333, height: 111, layoutWidth: 333 } },
|
||||
},
|
||||
reason: 'edit',
|
||||
budgetMs: 32,
|
||||
});
|
||||
if (updatedCustomBlockStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the updated custom block layout session to start.');
|
||||
}
|
||||
expect(JSON.stringify(updatedCustomBlockStart.step.publication)).toContain('"width":333');
|
||||
|
||||
const resetCustomBlockStart = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId: customBlockSnapshot.id,
|
||||
mountId: 'custom-block-mount',
|
||||
metricsRevision: 3,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewportPatch: {
|
||||
removals: ['embed-1'],
|
||||
upserts: {},
|
||||
},
|
||||
reason: 'edit',
|
||||
budgetMs: 32,
|
||||
});
|
||||
if (resetCustomBlockStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the reset custom block layout session to start.');
|
||||
}
|
||||
expect(JSON.stringify(resetCustomBlockStart.step.publication)).toContain('"width":100');
|
||||
|
||||
const secondMountStart = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-2',
|
||||
metricsRevision: 1,
|
||||
baseRevision: 1,
|
||||
modelRevision: 1,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 0,
|
||||
});
|
||||
if (secondMountStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected a second mounted layout session to start.');
|
||||
}
|
||||
const newerViewportStart = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
viewportEpoch: 2,
|
||||
unitId,
|
||||
mountId: 'mount-2',
|
||||
metricsRevision: 2,
|
||||
baseRevision: 1,
|
||||
modelRevision: 1,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 0,
|
||||
});
|
||||
if (newerViewportStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the newer viewport layout to start.');
|
||||
}
|
||||
await expect(runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-2',
|
||||
metricsRevision: 1,
|
||||
baseRevision: 1,
|
||||
modelRevision: 1,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 0,
|
||||
})).resolves.toEqual({ status: DocLayoutSessionStatus.SUPERSEDED });
|
||||
await runtime.disposeLayoutMount({ ...mountIdentity, unitId, mountId: 'mount-2' });
|
||||
await expect(runtime.stepLayout({
|
||||
...mountIdentity,
|
||||
viewportEpoch: 2,
|
||||
unitId,
|
||||
mountId: 'mount-2',
|
||||
generation: newerViewportStart.step.progress.generation,
|
||||
budgetMs: 0,
|
||||
})).resolves.toEqual(expect.objectContaining({ viewportEpoch: 2 }));
|
||||
await runtime.disposeLayoutMount({ ...mountIdentity, unitId, mountId: 'mount-1' });
|
||||
await expect(runtime.stepLayout({
|
||||
...mountIdentity,
|
||||
viewportEpoch: 2,
|
||||
unitId,
|
||||
mountId: 'mount-2',
|
||||
generation: newerViewportStart.step.progress.generation,
|
||||
budgetMs: 0,
|
||||
})).resolves.toEqual(expect.objectContaining({ modelRevision: 1 }));
|
||||
await expect(runtime.stepLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
generation: start.step.progress.generation,
|
||||
budgetMs: 0,
|
||||
})).rejects.toThrow('Document layout Worker mount not found');
|
||||
|
||||
const editedStart = await runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
metricsRevision: 2,
|
||||
baseRevision: 1,
|
||||
modelRevision: 2,
|
||||
mutations: [{ baseRevision: 1, modelRevision: 2, actions: null }],
|
||||
customBlockViewports: {},
|
||||
reason: 'edit',
|
||||
budgetMs: 0,
|
||||
});
|
||||
expect(editedStart.status).toBe(DocLayoutSessionStatus.ACCEPTED);
|
||||
await expect(runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
metricsRevision: 3,
|
||||
baseRevision: 3,
|
||||
modelRevision: 4,
|
||||
mutations: [{ baseRevision: 3, modelRevision: 4, actions: null }],
|
||||
customBlockViewports: {},
|
||||
reason: 'edit',
|
||||
budgetMs: 0,
|
||||
})).resolves.toEqual({
|
||||
status: DocLayoutSessionStatus.RESNAPSHOT_REQUIRED,
|
||||
modelRevision: 2,
|
||||
});
|
||||
|
||||
performanceTracker.recordResponse({
|
||||
data: { unitId, publication: { pages: [] } },
|
||||
}, 5);
|
||||
expect((await runtime.getPerformanceMetrics(unitId)).patchTransferMs).toEqual([5]);
|
||||
await runtime.disposeSession({ unitId, sessionEpoch: 1 });
|
||||
expect((await runtime.getPerformanceMetrics(unitId)).patchTransferMs).toEqual([]);
|
||||
await expect(runtime.startLayout({
|
||||
...mountIdentity,
|
||||
unitId,
|
||||
mountId: 'mount-1',
|
||||
metricsRevision: 4,
|
||||
baseRevision: 2,
|
||||
modelRevision: 2,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'edit',
|
||||
budgetMs: 0,
|
||||
})).resolves.toEqual({ status: DocLayoutSessionStatus.NOT_FOUND });
|
||||
|
||||
await runtime.createSession({
|
||||
unitId,
|
||||
sessionEpoch: 2,
|
||||
snapshot: structuredClone(sourceModel.getSnapshot()),
|
||||
modelRevision: 2,
|
||||
locale: LocaleType.EN_US,
|
||||
localeData: {},
|
||||
direction: 'ltr',
|
||||
});
|
||||
await runtime.disposeSession({ unitId, sessionEpoch: 1 });
|
||||
await runtime.createSession({
|
||||
unitId,
|
||||
sessionEpoch: 1,
|
||||
snapshot: structuredClone(sourceModel.getSnapshot()),
|
||||
modelRevision: 1,
|
||||
locale: LocaleType.EN_US,
|
||||
localeData: {},
|
||||
direction: 'ltr',
|
||||
});
|
||||
await expect(runtime.startLayout({
|
||||
...mountIdentity,
|
||||
mountEpoch: 2,
|
||||
unitId,
|
||||
mountId: 'mount-after-recreate',
|
||||
metricsRevision: 1,
|
||||
baseRevision: 2,
|
||||
modelRevision: 2,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 0,
|
||||
})).resolves.toEqual(expect.objectContaining({ status: DocLayoutSessionStatus.ACCEPTED }));
|
||||
await runtime.disposeSession({ unitId, sessionEpoch: 2 });
|
||||
|
||||
runtime.dispose();
|
||||
sourceModel.dispose();
|
||||
customBlockModel.dispose();
|
||||
});
|
||||
|
||||
it('yields after a bounded paragraph batch and observes cancellation before publishing', async () => {
|
||||
stubOffscreenCanvas();
|
||||
const sourceModel = createDocumentModelWithStyle(
|
||||
Array.from({ length: 80 }, (_, index) => `Worker paragraph ${index}\r`).join(''),
|
||||
{}
|
||||
);
|
||||
sourceModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
sourceModel.updateDocumentDataPageSize(240, 180);
|
||||
const runtime = new DocsLayoutWorkerRuntime();
|
||||
const unitId = sourceModel.getUnitId();
|
||||
const identity = {
|
||||
unitId,
|
||||
mountId: 'bounded-worker-layout',
|
||||
mountEpoch: 1,
|
||||
viewportEpoch: 1,
|
||||
};
|
||||
await runtime.createSession({
|
||||
unitId,
|
||||
sessionEpoch: 1,
|
||||
snapshot: structuredClone(sourceModel.getSnapshot()),
|
||||
modelRevision: 0,
|
||||
locale: LocaleType.EN_US,
|
||||
localeData: {},
|
||||
direction: 'ltr',
|
||||
});
|
||||
|
||||
const boundedStart = await runtime.startLayout({
|
||||
...identity,
|
||||
metricsRevision: 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 10_000,
|
||||
});
|
||||
if (boundedStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the bounded Worker layout session to start.');
|
||||
}
|
||||
expect(boundedStart.step.progress.complete).toBe(false);
|
||||
expect(boundedStart.step.progress.processedBlockCount).toBeLessThanOrEqual(8);
|
||||
|
||||
const cancellingIdentity = { ...identity, mountId: 'cancelled-worker-layout' };
|
||||
const cancellingStart = runtime.startLayout({
|
||||
...cancellingIdentity,
|
||||
metricsRevision: 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
reason: 'initial',
|
||||
budgetMs: 10_000,
|
||||
});
|
||||
await runtime.cancelLayout(cancellingIdentity);
|
||||
const cancelledStart = await cancellingStart;
|
||||
if (cancelledStart.status !== DocLayoutSessionStatus.ACCEPTED) {
|
||||
throw new Error('Expected the cancelled Worker layout session to start.');
|
||||
}
|
||||
expect(cancelledStart.step.progress.cancelled).toBe(true);
|
||||
expect(cancelledStart.step.publication).toBeNull();
|
||||
|
||||
runtime.dispose();
|
||||
sourceModel.dispose();
|
||||
});
|
||||
});
|
||||
31
packages/docs/src/layout-worker/config/config.ts
Normal file
31
packages/docs/src/layout-worker/config/config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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_LAYOUT_WORKER_PLUGIN_CONFIG_KEY = 'docs-layout-worker.config';
|
||||
|
||||
export const configSymbol = Symbol(DOCS_LAYOUT_WORKER_PLUGIN_CONFIG_KEY);
|
||||
|
||||
export const DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface IUniverDocsLayoutWorkerConfig {
|
||||
workerFactory?: () => Worker;
|
||||
/** Maximum time allowed for one Worker RPC request before recovery starts. */
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export const defaultPluginDocsLayoutWorkerConfig: IUniverDocsLayoutWorkerConfig = {
|
||||
requestTimeoutMs: DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS,
|
||||
};
|
||||
55
packages/docs/src/layout-worker/document-font-metrics.ts
Normal file
55
packages/docs/src/layout-worker/document-font-metrics.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { BooleanNumber } from '@univerjs/core';
|
||||
import { FontCache, getFontStyleString } from '@univerjs/engine-render';
|
||||
|
||||
/** Collect once from the snapshot, then only from mutation payloads (including JSON1 font replacements). */
|
||||
export function collectDocumentFontFamilies(value: unknown, families: Set<string>): void {
|
||||
if (value == null || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const item = value[index];
|
||||
if (value[index - 1] === 'ff' && item != null && typeof item === 'object' && typeof item.i === 'string') {
|
||||
families.add(item.i);
|
||||
}
|
||||
collectDocumentFontFamilies(item, families);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (key === 'ff' && typeof item === 'string') {
|
||||
families.add(item);
|
||||
} else {
|
||||
collectDocumentFontFamilies(item, families);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function measureDocumentFontFamilies(families: Set<string>): Record<string, number> {
|
||||
FontCache.getNormalLineHeight(getFontStyleString());
|
||||
// Bold/italic may be inherited separately from the family in paragraph or run styles.
|
||||
for (const ff of ['', ...families]) {
|
||||
for (const bl of [BooleanNumber.FALSE, BooleanNumber.TRUE]) {
|
||||
for (const it of [BooleanNumber.FALSE, BooleanNumber.TRUE]) {
|
||||
FontCache.getNormalLineHeight(getFontStyleString({ ff, bl, it }));
|
||||
}
|
||||
}
|
||||
}
|
||||
return FontCache.getNormalLineHeightCache();
|
||||
}
|
||||
298
packages/docs/src/layout-worker/index.ts
Normal file
298
packages/docs/src/layout-worker/index.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 { IMessageProtocol } from '@univerjs/rpc';
|
||||
import type { IDocLayoutCancelRequest, IDocLayoutCreateSessionRequest, IDocLayoutDisposeMountRequest, IDocLayoutDisposeSessionRequest, IDocLayoutExecutor, IDocLayoutPageRequest, IDocLayoutPageResult, IDocLayoutPerformanceMetrics, IDocLayoutStartRequest, IDocLayoutStartResult, IDocLayoutStepRequest, IDocLayoutStepResult } from '../services/doc-layout-executor.service';
|
||||
import type { IUniverDocsLayoutWorkerConfig } from './config/config';
|
||||
import type { IDocsLayoutWorkerCapabilities, IDocsLayoutWorkerRuntime } from './protocol';
|
||||
import { DependentOn, Disposable, IConfigService, Inject, Injector, merge, Plugin, Tools } from '@univerjs/core';
|
||||
import { FontCache } from '@univerjs/engine-render';
|
||||
import { ChannelService, toModule } from '@univerjs/rpc';
|
||||
import { Observable, shareReplay } from 'rxjs';
|
||||
import pkg from '../../package.json';
|
||||
import { UniverDocsPlugin } from '../plugin';
|
||||
import { DocLayoutExecutorService, DocLayoutExecutorType } from '../services/doc-layout-executor.service';
|
||||
import { DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS, defaultPluginDocsLayoutWorkerConfig, DOCS_LAYOUT_WORKER_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { collectDocumentFontFamilies, measureDocumentFontFamilies } from './document-font-metrics';
|
||||
import { DocsLayoutWorkerPerformanceTracker } from './performance-tracker';
|
||||
import { DOCS_LAYOUT_WORKER_CHANNEL, DOCS_LAYOUT_WORKER_PROTOCOL_VERSION } from './protocol';
|
||||
import { startDocsLayoutWorker } from './worker';
|
||||
|
||||
const FONT_METRICS_TOLERANCE = 1;
|
||||
|
||||
export class DocsLayoutWorkerCapabilityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'DocsLayoutWorkerCapabilityError';
|
||||
}
|
||||
}
|
||||
|
||||
function createDocsLayoutWorkerMessageProtocol(
|
||||
worker: Worker,
|
||||
performanceTracker: DocsLayoutWorkerPerformanceTracker
|
||||
): IMessageProtocol {
|
||||
return {
|
||||
send(message: unknown): void {
|
||||
const startedAt = Tools.now();
|
||||
worker.postMessage(message);
|
||||
performanceTracker.recordRequest(message, Tools.now() - startedAt);
|
||||
},
|
||||
onMessage: new Observable<unknown>((subscriber) => {
|
||||
const handler = (event: MessageEvent) => subscriber.next(event.data);
|
||||
worker.addEventListener('message', handler);
|
||||
return () => worker.removeEventListener('message', handler);
|
||||
}).pipe(shareReplay({ bufferSize: 1, refCount: true })),
|
||||
};
|
||||
}
|
||||
|
||||
export class DocsLayoutWorkerClientService extends Disposable implements IDocLayoutExecutor {
|
||||
readonly type = DocLayoutExecutorType.WORKER;
|
||||
private _worker: Worker | null = null;
|
||||
private _channelService: ChannelService | null = null;
|
||||
private _runtime: IDocsLayoutWorkerRuntime | null = null;
|
||||
private _initialization: Promise<void>;
|
||||
private _initialized = false;
|
||||
private readonly _performanceTracker = new DocsLayoutWorkerPerformanceTracker();
|
||||
private readonly _fontFamilies = new Map<string, Set<string>>();
|
||||
|
||||
constructor(
|
||||
private readonly _workerFactory: () => Worker,
|
||||
private readonly _requestTimeoutMs = DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS
|
||||
) {
|
||||
super();
|
||||
this._initialization = this._replaceRuntime();
|
||||
}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
return this._initialization;
|
||||
}
|
||||
|
||||
getCapabilities(): Promise<IDocsLayoutWorkerCapabilities> {
|
||||
return this._getRuntime().getCapabilities();
|
||||
}
|
||||
|
||||
async recover(): Promise<void> {
|
||||
this._initialization = this._replaceRuntime();
|
||||
await this._initialization;
|
||||
}
|
||||
|
||||
createSession(request: IDocLayoutCreateSessionRequest): Promise<void> {
|
||||
const families = new Set<string>();
|
||||
collectDocumentFontFamilies(request.snapshot, families);
|
||||
this._fontFamilies.set(request.unitId, families);
|
||||
const measuredRequest = { ...request, normalFontLineHeights: measureDocumentFontFamilies(families) };
|
||||
if (this._initialized) {
|
||||
return this._withTimeout(this._getRuntime().createSession(measuredRequest), 'create session');
|
||||
}
|
||||
|
||||
const capturedRequest = Tools.deepClone(measuredRequest);
|
||||
return this._initialization.then(() =>
|
||||
this._withTimeout(this._getRuntime().createSession(capturedRequest), 'create session'));
|
||||
}
|
||||
|
||||
async startLayout(request: IDocLayoutStartRequest): Promise<IDocLayoutStartResult> {
|
||||
await this._initialization;
|
||||
const families = this._fontFamilies.get(request.unitId);
|
||||
if (families != null) {
|
||||
collectDocumentFontFamilies(request.mutations, families);
|
||||
}
|
||||
const measuredRequest = families == null
|
||||
? request
|
||||
: { ...request, normalFontLineHeights: measureDocumentFontFamilies(families) };
|
||||
return this._withTimeout(this._getRuntime().startLayout(measuredRequest), 'start layout');
|
||||
}
|
||||
|
||||
async stepLayout(request: IDocLayoutStepRequest): Promise<IDocLayoutStepResult> {
|
||||
await this._initialization;
|
||||
return this._withTimeout(this._getRuntime().stepLayout(request), 'step layout');
|
||||
}
|
||||
|
||||
async publishBacklog(request: Omit<IDocLayoutStepRequest, 'budgetMs'>): Promise<IDocLayoutStepResult> {
|
||||
await this._initialization;
|
||||
return this._withTimeout(this._getRuntime().publishBacklog(request), 'publish layout backlog');
|
||||
}
|
||||
|
||||
async getLayoutPage(request: IDocLayoutPageRequest): Promise<IDocLayoutPageResult> {
|
||||
await this._initialization;
|
||||
return this._withTimeout(this._getRuntime().getLayoutPage(request), 'get layout page');
|
||||
}
|
||||
|
||||
async cancelLayout(request: IDocLayoutCancelRequest): Promise<void> {
|
||||
await this._initialization;
|
||||
return this._withTimeout(this._getRuntime().cancelLayout(request), 'cancel layout');
|
||||
}
|
||||
|
||||
async getPerformanceMetrics(
|
||||
unitId: string
|
||||
): Promise<Omit<IDocLayoutPerformanceMetrics, 'hydrationMs'>> {
|
||||
await this._initialization;
|
||||
const workerMetrics = await this._withTimeout(
|
||||
this._getRuntime().getPerformanceMetrics(unitId),
|
||||
'read performance metrics'
|
||||
);
|
||||
const clientMetrics = this._performanceTracker.getMetrics(unitId);
|
||||
return {
|
||||
mutationTransferMs: clientMetrics.mutationTransferMs,
|
||||
patchTransferMs: workerMetrics.patchTransferMs,
|
||||
snapshotTransferMs: clientMetrics.snapshotTransferMs,
|
||||
};
|
||||
}
|
||||
|
||||
async resetPerformanceMetrics(unitId: string): Promise<void> {
|
||||
await this._initialization;
|
||||
await this._withTimeout(
|
||||
this._getRuntime().resetPerformanceMetrics(unitId),
|
||||
'reset performance metrics'
|
||||
);
|
||||
this._performanceTracker.reset(unitId);
|
||||
}
|
||||
|
||||
async disposeLayoutMount(request: IDocLayoutDisposeMountRequest): Promise<void> {
|
||||
await this._initialization;
|
||||
return this._withTimeout(this._getRuntime().disposeLayoutMount(request), 'dispose layout mount');
|
||||
}
|
||||
|
||||
async disposeSession(request: IDocLayoutDisposeSessionRequest): Promise<void> {
|
||||
await this._initialization;
|
||||
await this._withTimeout(this._getRuntime().disposeSession(request), 'dispose session');
|
||||
this._performanceTracker.reset(request.unitId);
|
||||
this._fontFamilies.delete(request.unitId);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._disposeRuntime();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private async _replaceRuntime(): Promise<void> {
|
||||
this._initialized = false;
|
||||
this._disposeRuntime();
|
||||
const worker = this._workerFactory();
|
||||
const channelService = new ChannelService(createDocsLayoutWorkerMessageProtocol(worker, this._performanceTracker));
|
||||
this._worker = worker;
|
||||
this._channelService = channelService;
|
||||
this._runtime = toModule<IDocsLayoutWorkerRuntime>(
|
||||
channelService.requestChannel(DOCS_LAYOUT_WORKER_CHANNEL)
|
||||
);
|
||||
await this._withTimeout(this._verifyCapabilities(), 'verify capabilities');
|
||||
this._initialized = true;
|
||||
}
|
||||
|
||||
private _disposeRuntime(): void {
|
||||
this._fontFamilies.clear();
|
||||
this._initialized = false;
|
||||
this._channelService?.dispose();
|
||||
this._channelService = null;
|
||||
this._runtime = null;
|
||||
this._worker?.terminate();
|
||||
this._worker = null;
|
||||
this._performanceTracker.resetAll();
|
||||
}
|
||||
|
||||
private _getRuntime(): IDocsLayoutWorkerRuntime {
|
||||
if (this._runtime == null) {
|
||||
throw new Error('Document layout Worker runtime is unavailable.');
|
||||
}
|
||||
return this._runtime;
|
||||
}
|
||||
|
||||
private _withTimeout<T>(task: Promise<T>, operation: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Document layout Worker timed out while attempting to ${operation}.`));
|
||||
}, this._requestTimeoutMs);
|
||||
});
|
||||
return Promise.race([task, timeout]).finally(() => {
|
||||
if (timeoutId != null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _verifyCapabilities(): Promise<void> {
|
||||
const capabilities = await this.getCapabilities();
|
||||
if (capabilities.protocolVersion !== DOCS_LAYOUT_WORKER_PROTOCOL_VERSION) {
|
||||
throw new DocsLayoutWorkerCapabilityError('Document layout Worker protocol version mismatch.');
|
||||
}
|
||||
if (!capabilities.offscreenCanvas || !capabilities.structuredClone) {
|
||||
throw new DocsLayoutWorkerCapabilityError(
|
||||
'Document layout Worker requires OffscreenCanvas and structured clone support.'
|
||||
);
|
||||
}
|
||||
|
||||
const mainMetrics = FontCache.getMeasureText(
|
||||
capabilities.fontProbe.content,
|
||||
capabilities.fontProbe.font
|
||||
);
|
||||
const metricPairs = [
|
||||
[mainMetrics.width, capabilities.fontProbe.width],
|
||||
[mainMetrics.actualBoundingBoxAscent, capabilities.fontProbe.actualBoundingBoxAscent],
|
||||
[mainMetrics.actualBoundingBoxDescent, capabilities.fontProbe.actualBoundingBoxDescent],
|
||||
];
|
||||
if (metricPairs.some(([mainValue, workerValue]) =>
|
||||
!Number.isFinite(mainValue) ||
|
||||
!Number.isFinite(workerValue) ||
|
||||
Math.abs(mainValue - workerValue) > FONT_METRICS_TOLERANCE
|
||||
)) {
|
||||
throw new DocsLayoutWorkerCapabilityError(
|
||||
'Document layout Worker font metrics differ from the main rendering context.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DependentOn(UniverDocsPlugin)
|
||||
export class UniverDocsLayoutWorkerPlugin extends Plugin {
|
||||
static override pluginName = 'DOCS_LAYOUT_WORKER_PLUGIN';
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverDocsLayoutWorkerConfig> = defaultPluginDocsLayoutWorkerConfig,
|
||||
@Inject(Injector) override readonly _injector: Injector,
|
||||
@Inject(DocLayoutExecutorService) private readonly _layoutExecutorService: DocLayoutExecutorService,
|
||||
@IConfigService private readonly _configService: IConfigService
|
||||
) {
|
||||
super();
|
||||
|
||||
const { ...rest } = merge(
|
||||
{},
|
||||
defaultPluginDocsLayoutWorkerConfig,
|
||||
this._config
|
||||
);
|
||||
this._configService.setConfig(DOCS_LAYOUT_WORKER_PLUGIN_CONFIG_KEY, rest);
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
if (typeof this._config.workerFactory !== 'function') {
|
||||
throw new TypeError('[UniverDocsLayoutWorkerPlugin]: workerFactory is required.');
|
||||
}
|
||||
const requestTimeoutMs = this._config.requestTimeoutMs ?? DEFAULT_DOCS_LAYOUT_WORKER_REQUEST_TIMEOUT_MS;
|
||||
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
||||
throw new RangeError('[UniverDocsLayoutWorkerPlugin]: requestTimeoutMs must be a positive finite number.');
|
||||
}
|
||||
|
||||
const client = new DocsLayoutWorkerClientService(this._config.workerFactory, requestTimeoutMs);
|
||||
this._injector.add([DocsLayoutWorkerClientService, { useValue: client }]);
|
||||
this.disposeWithMe(client);
|
||||
this.disposeWithMe(this._layoutExecutorService.register(client));
|
||||
}
|
||||
}
|
||||
|
||||
export type { IUniverDocsLayoutWorkerConfig } from './config/config';
|
||||
export type { IDocsLayoutWorkerCapabilities, IDocsLayoutWorkerFontProbe, IDocsLayoutWorkerRuntime } from './protocol';
|
||||
export { startDocsLayoutWorker };
|
||||
115
packages/docs/src/layout-worker/performance-tracker.ts
Normal file
115
packages/docs/src/layout-worker/performance-tracker.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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 { IDocLayoutPerformanceMetrics } from '../services/doc-layout-executor.service';
|
||||
|
||||
const PERFORMANCE_SAMPLE_LIMIT = 2048;
|
||||
|
||||
type WorkerTransferMetrics = Omit<IDocLayoutPerformanceMetrics, 'hydrationMs'>;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value != null;
|
||||
}
|
||||
|
||||
function getRequest(message: unknown): { method: string; request: Record<string, unknown> } | null {
|
||||
if (!isRecord(message) || typeof message.method !== 'string' || !Array.isArray(message.args)) {
|
||||
return null;
|
||||
}
|
||||
const request = message.args[0];
|
||||
return isRecord(request) ? { method: message.method, request } : null;
|
||||
}
|
||||
|
||||
function getPublicationResult(message: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(message) || !isRecord(message.data)) {
|
||||
return null;
|
||||
}
|
||||
const result = message.data;
|
||||
if (isRecord(result.step)) {
|
||||
return result.step;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createTransferMetrics(): WorkerTransferMetrics {
|
||||
return {
|
||||
mutationTransferMs: [],
|
||||
patchTransferMs: [],
|
||||
snapshotTransferMs: [],
|
||||
};
|
||||
}
|
||||
|
||||
export class DocsLayoutWorkerPerformanceTracker {
|
||||
private readonly _metrics = new Map<string, WorkerTransferMetrics>();
|
||||
|
||||
recordRequest(message: unknown, durationMs: number): void {
|
||||
const requestInfo = getRequest(message);
|
||||
const unitId = requestInfo?.request.unitId;
|
||||
if (requestInfo == null || typeof unitId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestInfo.method === 'createSession') {
|
||||
this._append(unitId, 'snapshotTransferMs', durationMs);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
requestInfo.method === 'startLayout' &&
|
||||
Array.isArray(requestInfo.request.mutations) &&
|
||||
requestInfo.request.mutations.length > 0
|
||||
) {
|
||||
this._append(unitId, 'mutationTransferMs', durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
recordResponse(message: unknown, durationMs: number): void {
|
||||
const result = getPublicationResult(message);
|
||||
if (
|
||||
result == null ||
|
||||
typeof result.unitId !== 'string' ||
|
||||
result.publication == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._append(result.unitId, 'patchTransferMs', durationMs);
|
||||
}
|
||||
|
||||
getMetrics(unitId: string): WorkerTransferMetrics {
|
||||
const metrics = this._metrics.get(unitId) ?? createTransferMetrics();
|
||||
return {
|
||||
mutationTransferMs: [...metrics.mutationTransferMs],
|
||||
patchTransferMs: [...metrics.patchTransferMs],
|
||||
snapshotTransferMs: [...metrics.snapshotTransferMs],
|
||||
};
|
||||
}
|
||||
|
||||
reset(unitId: string): void {
|
||||
this._metrics.delete(unitId);
|
||||
}
|
||||
|
||||
resetAll(): void {
|
||||
this._metrics.clear();
|
||||
}
|
||||
|
||||
private _append(unitId: string, metric: keyof WorkerTransferMetrics, durationMs: number): void {
|
||||
const metrics = this._metrics.get(unitId) ?? createTransferMetrics();
|
||||
const samples = metrics[metric];
|
||||
samples.push(durationMs);
|
||||
if (samples.length > PERFORMANCE_SAMPLE_LIMIT) {
|
||||
samples.splice(0, samples.length - PERFORMANCE_SAMPLE_LIMIT);
|
||||
}
|
||||
this._metrics.set(unitId, metrics);
|
||||
}
|
||||
}
|
||||
40
packages/docs/src/layout-worker/protocol.ts
Normal file
40
packages/docs/src/layout-worker/protocol.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 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 { DocLayoutExecutorType, IDocLayoutExecutor } from '../services/doc-layout-executor.service';
|
||||
|
||||
export const DOCS_LAYOUT_WORKER_CHANNEL = 'univer.docs-layout-worker';
|
||||
export const DOCS_LAYOUT_WORKER_PROTOCOL_VERSION = 5;
|
||||
|
||||
export interface IDocsLayoutWorkerFontProbe {
|
||||
content: string;
|
||||
font: string;
|
||||
width: number;
|
||||
actualBoundingBoxAscent: number;
|
||||
actualBoundingBoxDescent: number;
|
||||
}
|
||||
|
||||
export interface IDocsLayoutWorkerCapabilities {
|
||||
protocolVersion: typeof DOCS_LAYOUT_WORKER_PROTOCOL_VERSION;
|
||||
executor: DocLayoutExecutorType.WORKER;
|
||||
offscreenCanvas: boolean;
|
||||
structuredClone: boolean;
|
||||
fontProbe: IDocsLayoutWorkerFontProbe;
|
||||
}
|
||||
|
||||
export interface IDocsLayoutWorkerRuntime extends IDocLayoutExecutor {
|
||||
getCapabilities(): Promise<IDocsLayoutWorkerCapabilities>;
|
||||
}
|
||||
510
packages/docs/src/layout-worker/worker.ts
Normal file
510
packages/docs/src/layout-worker/worker.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* 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 { ICustomRangeForInterceptor } from '@univerjs/core';
|
||||
import type { IDocsCustomBlockRenderViewport } from '@univerjs/engine-render';
|
||||
import type { IMessageProtocol } from '@univerjs/rpc';
|
||||
import type { IDocLayoutCancelRequest, IDocLayoutCreateSessionRequest, IDocLayoutCustomRangePresentationEntry, IDocLayoutDisposeMountRequest, IDocLayoutDisposeSessionRequest, IDocLayoutMountIdentity, IDocLayoutPageRequest, IDocLayoutPageResult, IDocLayoutPerformanceMetrics, IDocLayoutStartRequest, IDocLayoutStartResult, IDocLayoutStepRequest, IDocLayoutStepResult } from '../services/doc-layout-executor.service';
|
||||
import type { IDocsLayoutWorkerCapabilities, IDocsLayoutWorkerRuntime } from './protocol';
|
||||
import { DocumentDataModel, LocaleService, requestImmediateMacroTask, Tools } from '@univerjs/core';
|
||||
import { DocumentLayoutSession, FontCache, setDocsCustomBlockRenderViewportProvider } from '@univerjs/engine-render';
|
||||
import { ChannelService, fromModule } from '@univerjs/rpc';
|
||||
import { Observable, shareReplay } from 'rxjs';
|
||||
import { DocLayoutExecutorType, DocLayoutSessionStatus } from '../services/doc-layout-executor.service';
|
||||
import { DocsLayoutWorkerPerformanceTracker } from './performance-tracker';
|
||||
import { DOCS_LAYOUT_WORKER_CHANNEL, DOCS_LAYOUT_WORKER_PROTOCOL_VERSION } from './protocol';
|
||||
|
||||
const FONT_PROBE_CONTENT = 'Univer 文档 Worker 0123456789';
|
||||
const FONT_PROBE_STYLE = '16px Arial';
|
||||
const WORKER_LAYOUT_INTERRUPT_INTERVAL = 8;
|
||||
|
||||
function yieldForWorkerMessage(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestImmediateMacroTask(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
interface IDocsLayoutWorkerSession {
|
||||
sessionEpoch: number;
|
||||
dataModel: DocumentDataModel;
|
||||
layoutSessions: Map<string, IDocsLayoutWorkerMount>;
|
||||
localeService: LocaleService;
|
||||
modelRevision: number;
|
||||
customRangePresentations: Map<string, IDocLayoutCustomRangePresentationEntry>;
|
||||
}
|
||||
|
||||
interface IDocsLayoutWorkerMount {
|
||||
mountId: string;
|
||||
layoutSession: DocumentLayoutSession;
|
||||
customBlockViewports: Record<string, IDocsCustomBlockRenderViewport>;
|
||||
metricsRevision: number;
|
||||
mountEpoch: number;
|
||||
viewportEpoch: number;
|
||||
activeGeneration: number | null;
|
||||
cancelledGeneration: number | null;
|
||||
}
|
||||
|
||||
function createDocsLayoutWorkerMessageProtocol(
|
||||
performanceTracker: DocsLayoutWorkerPerformanceTracker
|
||||
): IMessageProtocol {
|
||||
return {
|
||||
send(message: unknown): void {
|
||||
const startedAt = Tools.now();
|
||||
postMessage(message);
|
||||
performanceTracker.recordResponse(message, Tools.now() - startedAt);
|
||||
},
|
||||
onMessage: new Observable<unknown>((subscriber) => {
|
||||
const handler = (event: MessageEvent) => subscriber.next(event.data);
|
||||
addEventListener('message', handler);
|
||||
return () => removeEventListener('message', handler);
|
||||
}).pipe(shareReplay({ bufferSize: 1, refCount: true })),
|
||||
};
|
||||
}
|
||||
|
||||
function getCustomRangePresentationKey(
|
||||
entry: Pick<IDocLayoutCustomRangePresentationEntry, 'segmentId' | 'rangeId'>
|
||||
): string {
|
||||
return JSON.stringify([entry.segmentId, entry.rangeId]);
|
||||
}
|
||||
|
||||
function applyRecordPatch<T>(
|
||||
target: Record<string, T>,
|
||||
patch: { removals: string[]; upserts: Record<string, T> } | undefined
|
||||
): void {
|
||||
if (patch == null) {
|
||||
return;
|
||||
}
|
||||
for (const key of patch.removals) {
|
||||
delete target[key];
|
||||
}
|
||||
Object.assign(target, patch.upserts);
|
||||
}
|
||||
|
||||
export class DocsLayoutWorkerRuntime implements IDocsLayoutWorkerRuntime {
|
||||
private readonly _sessions = new Map<string, IDocsLayoutWorkerSession>();
|
||||
readonly type = DocLayoutExecutorType.WORKER;
|
||||
|
||||
constructor(
|
||||
private readonly _performanceTracker = new DocsLayoutWorkerPerformanceTracker()
|
||||
) {}
|
||||
|
||||
initialize(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async getCapabilities(): Promise<IDocsLayoutWorkerCapabilities> {
|
||||
const metrics = FontCache.getMeasureText(FONT_PROBE_CONTENT, FONT_PROBE_STYLE);
|
||||
return {
|
||||
protocolVersion: DOCS_LAYOUT_WORKER_PROTOCOL_VERSION,
|
||||
executor: DocLayoutExecutorType.WORKER,
|
||||
offscreenCanvas: typeof OffscreenCanvas !== 'undefined',
|
||||
structuredClone: typeof structuredClone === 'function',
|
||||
fontProbe: {
|
||||
content: FONT_PROBE_CONTENT,
|
||||
font: FONT_PROBE_STYLE,
|
||||
width: metrics.width,
|
||||
actualBoundingBoxAscent: metrics.actualBoundingBoxAscent,
|
||||
actualBoundingBoxDescent: metrics.actualBoundingBoxDescent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async recover(): Promise<void> {
|
||||
for (const session of this._sessions.values()) {
|
||||
for (const mount of session.layoutSessions.values()) {
|
||||
mount.layoutSession.dispose();
|
||||
}
|
||||
}
|
||||
this._sessions.clear();
|
||||
}
|
||||
|
||||
async createSession(request: IDocLayoutCreateSessionRequest): Promise<void> {
|
||||
if (typeof OffscreenCanvas === 'undefined') {
|
||||
throw new Error('Document layout Worker requires OffscreenCanvas text measurement support.');
|
||||
}
|
||||
|
||||
const currentSession = this._sessions.get(request.unitId);
|
||||
if (currentSession != null && request.sessionEpoch < currentSession.sessionEpoch) {
|
||||
return;
|
||||
}
|
||||
this._disposeSession(request.unitId);
|
||||
|
||||
FontCache.setNormalLineHeightCache(request.normalFontLineHeights ?? {});
|
||||
const dataModel = new DocumentDataModel(request.snapshot);
|
||||
const localeService = new LocaleService();
|
||||
if (request.localeData != null) {
|
||||
localeService.load({ [request.locale]: request.localeData });
|
||||
}
|
||||
localeService.setLocale(request.locale);
|
||||
localeService.setDirection(request.direction);
|
||||
this._sessions.set(request.unitId, {
|
||||
sessionEpoch: request.sessionEpoch,
|
||||
dataModel,
|
||||
layoutSessions: new Map(),
|
||||
localeService,
|
||||
modelRevision: request.modelRevision,
|
||||
customRangePresentations: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async startLayout(request: IDocLayoutStartRequest): Promise<IDocLayoutStartResult> {
|
||||
const session = this._sessions.get(request.unitId);
|
||||
if (session == null) {
|
||||
return { status: DocLayoutSessionStatus.NOT_FOUND };
|
||||
}
|
||||
|
||||
if (session.modelRevision !== request.baseRevision) {
|
||||
return {
|
||||
status: DocLayoutSessionStatus.RESNAPSHOT_REQUIRED,
|
||||
modelRevision: session.modelRevision,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this._applyMutations(session, request)) {
|
||||
return {
|
||||
status: DocLayoutSessionStatus.RESNAPSHOT_REQUIRED,
|
||||
modelRevision: session.modelRevision,
|
||||
};
|
||||
}
|
||||
|
||||
FontCache.setNormalLineHeightCache(request.normalFontLineHeights ?? {});
|
||||
|
||||
this._updateCustomRangePresentations(session, request);
|
||||
this._applyCustomRangePresentations(session.dataModel, Array.from(session.customRangePresentations.values()));
|
||||
|
||||
let mount = session.layoutSessions.get(request.mountId);
|
||||
if (
|
||||
mount != null && (
|
||||
request.mountEpoch < mount.mountEpoch ||
|
||||
(request.mountEpoch === mount.mountEpoch && request.viewportEpoch < mount.viewportEpoch)
|
||||
)
|
||||
) {
|
||||
return { status: DocLayoutSessionStatus.SUPERSEDED };
|
||||
}
|
||||
if (mount != null && request.mountEpoch > mount.mountEpoch) {
|
||||
mount.layoutSession.dispose();
|
||||
session.layoutSessions.delete(request.mountId);
|
||||
mount = undefined;
|
||||
}
|
||||
if (mount == null) {
|
||||
const customBlockViewports = { ...request.customBlockViewports };
|
||||
applyRecordPatch(customBlockViewports, request.customBlockViewportPatch);
|
||||
mount = {
|
||||
mountId: request.mountId,
|
||||
layoutSession: new DocumentLayoutSession(session.dataModel, session.localeService),
|
||||
customBlockViewports,
|
||||
metricsRevision: request.metricsRevision,
|
||||
mountEpoch: request.mountEpoch,
|
||||
viewportEpoch: request.viewportEpoch,
|
||||
activeGeneration: null,
|
||||
cancelledGeneration: null,
|
||||
};
|
||||
session.layoutSessions.set(request.mountId, mount);
|
||||
} else {
|
||||
if (request.customBlockViewports != null) {
|
||||
mount.customBlockViewports = { ...request.customBlockViewports };
|
||||
}
|
||||
applyRecordPatch(mount.customBlockViewports, request.customBlockViewportPatch);
|
||||
mount.metricsRevision = request.metricsRevision;
|
||||
mount.viewportEpoch = request.viewportEpoch;
|
||||
}
|
||||
const generation = mount.layoutSession.start({
|
||||
waitForHyphenationPatterns: true,
|
||||
reason: request.reason,
|
||||
anchor: request.anchor,
|
||||
priorityAnchor: request.priorityAnchor,
|
||||
invalidation: request.invalidation,
|
||||
modernPageWidth: request.modernPageWidth,
|
||||
modernHorizontalMargin: request.modernHorizontalMargin,
|
||||
});
|
||||
mount.activeGeneration = generation;
|
||||
mount.cancelledGeneration = null;
|
||||
return {
|
||||
status: DocLayoutSessionStatus.ACCEPTED,
|
||||
step: await this._runLayoutStep(session, mount, generation, request.budgetMs),
|
||||
};
|
||||
}
|
||||
|
||||
private _applyMutations(
|
||||
session: IDocsLayoutWorkerSession,
|
||||
request: IDocLayoutStartRequest
|
||||
): boolean {
|
||||
for (const mutation of request.mutations) {
|
||||
if (
|
||||
mutation.baseRevision !== session.modelRevision ||
|
||||
mutation.modelRevision !== mutation.baseRevision + 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
session.dataModel.apply(mutation.actions);
|
||||
session.modelRevision = mutation.modelRevision;
|
||||
}
|
||||
if (session.modelRevision !== request.modelRevision) {
|
||||
return false;
|
||||
}
|
||||
if (request.mutations.length > 0) {
|
||||
for (const mount of session.layoutSessions.values()) {
|
||||
mount.activeGeneration = null;
|
||||
mount.cancelledGeneration = null;
|
||||
mount.layoutSession.resetDataModel(session.dataModel);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _applyCustomRangePresentations(
|
||||
dataModel: DocumentDataModel,
|
||||
presentations: IDocLayoutStartRequest['customRangePresentations']
|
||||
): void {
|
||||
const rangeMaps = new Map<string, Map<string, ICustomRangeForInterceptor>>();
|
||||
const segmentModels = [
|
||||
['', dataModel] as const,
|
||||
...dataModel.headerModelMap,
|
||||
...dataModel.footerModelMap,
|
||||
];
|
||||
for (const [segmentId, segmentModel] of segmentModels) {
|
||||
const rangeMap = new Map<string, ICustomRangeForInterceptor>();
|
||||
for (const range of segmentModel.getBody()?.customRanges ?? []) {
|
||||
const workerRange = range as ICustomRangeForInterceptor;
|
||||
delete workerRange.active;
|
||||
delete workerRange.glyphAscentEm;
|
||||
delete workerRange.glyphDescentEm;
|
||||
delete workerRange.glyphWidthEm;
|
||||
delete workerRange.show;
|
||||
if (!rangeMap.has(range.rangeId)) {
|
||||
rangeMap.set(range.rangeId, workerRange);
|
||||
}
|
||||
}
|
||||
rangeMaps.set(segmentId, rangeMap);
|
||||
}
|
||||
for (const { segmentId, rangeId, presentation } of presentations ?? []) {
|
||||
const rangeMap = rangeMaps.get(segmentId);
|
||||
const range = rangeMap?.get(rangeId);
|
||||
if (range != null) {
|
||||
Object.assign(range, presentation);
|
||||
rangeMap?.delete(rangeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateCustomRangePresentations(
|
||||
session: IDocsLayoutWorkerSession,
|
||||
request: IDocLayoutStartRequest
|
||||
): void {
|
||||
if (request.customRangePresentations != null) {
|
||||
session.customRangePresentations.clear();
|
||||
for (const entry of request.customRangePresentations) {
|
||||
const key = getCustomRangePresentationKey(entry);
|
||||
if (!session.customRangePresentations.has(key)) {
|
||||
session.customRangePresentations.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const removal of request.customRangePresentationPatch?.removals ?? []) {
|
||||
session.customRangePresentations.delete(getCustomRangePresentationKey(removal));
|
||||
}
|
||||
for (const entry of request.customRangePresentationPatch?.upserts ?? []) {
|
||||
session.customRangePresentations.set(getCustomRangePresentationKey(entry), entry);
|
||||
}
|
||||
}
|
||||
|
||||
async stepLayout(request: IDocLayoutStepRequest): Promise<IDocLayoutStepResult> {
|
||||
const session = this._getSession(request.unitId);
|
||||
const mount = this._getLayoutMount(session, request);
|
||||
return await this._runLayoutStep(session, mount, request.generation, request.budgetMs);
|
||||
}
|
||||
|
||||
async publishBacklog(request: Omit<IDocLayoutStepRequest, 'budgetMs'>): Promise<IDocLayoutStepResult> {
|
||||
const session = this._getSession(request.unitId);
|
||||
const mount = this._getLayoutMount(session, request);
|
||||
return this._runWithCustomBlockViewports(session, mount, () => ({
|
||||
...mount.layoutSession.publishBacklog(request.generation),
|
||||
unitId: request.unitId,
|
||||
mountId: request.mountId,
|
||||
mountEpoch: mount.mountEpoch,
|
||||
viewportEpoch: mount.viewportEpoch,
|
||||
modelRevision: session.modelRevision,
|
||||
metricsRevision: mount.metricsRevision,
|
||||
}));
|
||||
}
|
||||
|
||||
async getLayoutPage(request: IDocLayoutPageRequest): Promise<IDocLayoutPageResult> {
|
||||
const session = this._getSession(request.unitId);
|
||||
const mount = this._getLayoutMount(session, request);
|
||||
return {
|
||||
...request,
|
||||
modelRevision: session.modelRevision,
|
||||
metricsRevision: mount.metricsRevision,
|
||||
page: mount.layoutSession.getPage(request.pageIndex),
|
||||
};
|
||||
}
|
||||
|
||||
async cancelLayout(request: IDocLayoutCancelRequest): Promise<void> {
|
||||
const mount = this._sessions.get(request.unitId)?.layoutSessions.get(request.mountId);
|
||||
if (
|
||||
mount?.mountEpoch === request.mountEpoch &&
|
||||
mount.viewportEpoch === request.viewportEpoch
|
||||
) {
|
||||
if (request.generation == null || request.generation === mount.activeGeneration) {
|
||||
mount.cancelledGeneration = request.generation ?? mount.activeGeneration;
|
||||
}
|
||||
mount.layoutSession.cancel(request.generation);
|
||||
}
|
||||
}
|
||||
|
||||
async getPerformanceMetrics(
|
||||
unitId: string
|
||||
): Promise<Omit<IDocLayoutPerformanceMetrics, 'hydrationMs'>> {
|
||||
return this._performanceTracker.getMetrics(unitId);
|
||||
}
|
||||
|
||||
async resetPerformanceMetrics(unitId: string): Promise<void> {
|
||||
this._performanceTracker.reset(unitId);
|
||||
}
|
||||
|
||||
async disposeLayoutMount(request: IDocLayoutDisposeMountRequest): Promise<void> {
|
||||
const session = this._sessions.get(request.unitId);
|
||||
const mount = session?.layoutSessions.get(request.mountId);
|
||||
if (
|
||||
session == null ||
|
||||
mount == null ||
|
||||
mount.mountEpoch !== request.mountEpoch ||
|
||||
mount.viewportEpoch !== request.viewportEpoch
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.layoutSessions.delete(request.mountId);
|
||||
mount.activeGeneration = null;
|
||||
mount.layoutSession.dispose();
|
||||
}
|
||||
|
||||
async disposeSession(request: IDocLayoutDisposeSessionRequest): Promise<void> {
|
||||
const session = this._sessions.get(request.unitId);
|
||||
if (session?.sessionEpoch !== request.sessionEpoch) {
|
||||
return;
|
||||
}
|
||||
this._disposeSession(request.unitId);
|
||||
this._performanceTracker.reset(request.unitId);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const unitId of this._sessions.keys()) {
|
||||
this._disposeSession(unitId);
|
||||
}
|
||||
this._performanceTracker.resetAll();
|
||||
}
|
||||
|
||||
private _getSession(unitId: string): IDocsLayoutWorkerSession {
|
||||
const session = this._sessions.get(unitId);
|
||||
if (session == null) {
|
||||
throw new Error(`Document layout Worker session not found for unit "${unitId}".`);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private _getLayoutMount(
|
||||
session: IDocsLayoutWorkerSession,
|
||||
request: IDocLayoutMountIdentity
|
||||
): IDocsLayoutWorkerMount {
|
||||
const mount = session.layoutSessions.get(request.mountId);
|
||||
if (mount == null) {
|
||||
throw new Error(`Document layout Worker mount not found: "${request.mountId}".`);
|
||||
}
|
||||
if (
|
||||
mount.mountEpoch !== request.mountEpoch ||
|
||||
mount.viewportEpoch !== request.viewportEpoch
|
||||
) {
|
||||
throw new Error(`Document layout Worker rejected a stale mount publication: "${request.mountId}".`);
|
||||
}
|
||||
return mount;
|
||||
}
|
||||
|
||||
private async _runLayoutStep(
|
||||
session: IDocsLayoutWorkerSession,
|
||||
mount: IDocsLayoutWorkerMount,
|
||||
generation: number,
|
||||
budgetMs: number
|
||||
): Promise<IDocLayoutStepResult> {
|
||||
const result = this._runWithCustomBlockViewports(session, mount, () => ({
|
||||
...mount.layoutSession.step(generation, budgetMs, WORKER_LAYOUT_INTERRUPT_INTERVAL),
|
||||
unitId: session.dataModel.getUnitId(),
|
||||
mountId: mount.mountId,
|
||||
mountEpoch: mount.mountEpoch,
|
||||
viewportEpoch: mount.viewportEpoch,
|
||||
modelRevision: session.modelRevision,
|
||||
metricsRevision: mount.metricsRevision,
|
||||
}));
|
||||
await yieldForWorkerMessage();
|
||||
if (
|
||||
mount.activeGeneration === generation &&
|
||||
mount.cancelledGeneration !== generation
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return this._runWithCustomBlockViewports(session, mount, () => ({
|
||||
...mount.layoutSession.step(generation, 0, 1),
|
||||
unitId: session.dataModel.getUnitId(),
|
||||
mountId: mount.mountId,
|
||||
mountEpoch: mount.mountEpoch,
|
||||
viewportEpoch: mount.viewportEpoch,
|
||||
modelRevision: session.modelRevision,
|
||||
metricsRevision: mount.metricsRevision,
|
||||
}));
|
||||
}
|
||||
|
||||
private _runWithCustomBlockViewports<T>(
|
||||
session: IDocsLayoutWorkerSession,
|
||||
mount: IDocsLayoutWorkerMount,
|
||||
run: () => T
|
||||
): T {
|
||||
const unregister = setDocsCustomBlockRenderViewportProvider((unitId, blockId) =>
|
||||
unitId === session.dataModel.getUnitId() ? mount.customBlockViewports[blockId] : null
|
||||
);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
}
|
||||
|
||||
private _disposeSession(unitId: string): void {
|
||||
const session = this._sessions.get(unitId);
|
||||
if (session == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._sessions.delete(unitId);
|
||||
for (const mount of session.layoutSessions.values()) {
|
||||
mount.activeGeneration = null;
|
||||
mount.layoutSession.dispose();
|
||||
}
|
||||
session.layoutSessions.clear();
|
||||
session.dataModel.dispose();
|
||||
session.localeService.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export function startDocsLayoutWorker(): ChannelService {
|
||||
const performanceTracker = new DocsLayoutWorkerPerformanceTracker();
|
||||
const channelService = new ChannelService(createDocsLayoutWorkerMessageProtocol(performanceTracker));
|
||||
channelService.registerChannel(
|
||||
DOCS_LAYOUT_WORKER_CHANNEL,
|
||||
fromModule(new DocsLayoutWorkerRuntime(performanceTracker))
|
||||
);
|
||||
return channelService;
|
||||
}
|
||||
146
packages/docs/src/plugin.ts
Normal file
146
packages/docs/src/plugin.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 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, ICommand } from '@univerjs/core';
|
||||
import type { IUniverDocsConfig } from './config/config';
|
||||
import {
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
Inject,
|
||||
Injector,
|
||||
merge,
|
||||
ObjectPermissionService,
|
||||
Plugin,
|
||||
} from '@univerjs/core';
|
||||
import { UnitObject } from '@univerjs/protocol';
|
||||
import pkg from '../package.json';
|
||||
import { DeleteTextCommand, InsertTextCommand, UpdateTextCommand } from './commands/commands/core-editing.command';
|
||||
import { CreateHeaderFooterCommand } from './commands/commands/create-header-footer.command';
|
||||
import {
|
||||
SetDocumentDefaultParagraphStyleCommand,
|
||||
} from './commands/commands/set-document-default-paragraph-style.command';
|
||||
import { SetDocumentNameCommand } from './commands/commands/set-document-name.command';
|
||||
import { SetDocumentPermissionCommand } from './commands/commands/set-document-permission.command';
|
||||
import { SetDocumentPermissionsCommand } from './commands/commands/set-document-permissions.command';
|
||||
import { SetSectionHeaderFooterLinkCommand } from './commands/commands/set-section-header-footer-link.command';
|
||||
import { UpdateDocumentParagraphStyleCommand } from './commands/commands/update-document-paragraph-style.command';
|
||||
import {
|
||||
DeleteDocumentSectionBreakCommand,
|
||||
InsertDocumentColumnBreakCommand,
|
||||
InsertDocumentSectionBreakCommand,
|
||||
UpdateDocumentSectionCommand,
|
||||
} from './commands/commands/update-document-section.command';
|
||||
import { RichTextEditingMutation } from './commands/mutations/core-editing.mutation';
|
||||
import { DocsRenameMutation } from './commands/mutations/docs-rename.mutation';
|
||||
import { SetDocumentPermissionRuleMutation } from './commands/mutations/set-document-permission-rule.mutation';
|
||||
import { SetDocumentPermissionRulesMutation } from './commands/mutations/set-document-permission-rules.mutation';
|
||||
import { SetTextSelectionsOperation } from './commands/operations/text-selection.operation';
|
||||
import { defaultPluginConfig, DOCS_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { DocCustomRangeController } from './controllers/custom-range.controller';
|
||||
import { DocPermissionController } from './controllers/doc-permission.controller';
|
||||
import { DocBlockMoveValidatorService } from './services/doc-block-move-validator.service';
|
||||
import { DocContentInsertService } from './services/doc-content-insert.service';
|
||||
import { DocLayoutExecutorService } from './services/doc-layout-executor.service';
|
||||
import { DocSelectionManagerService } from './services/doc-selection-manager.service';
|
||||
import { DocStateChangeManagerService } from './services/doc-state-change-manager.service';
|
||||
import { DocStateEmitService } from './services/doc-state-emit.service';
|
||||
import { DocTextResolverService } from './services/doc-text-resolver.service';
|
||||
import { DocumentPermissionRuleModel } from './services/permission/document-permission-rule.model';
|
||||
|
||||
export class UniverDocsPlugin extends Plugin {
|
||||
static override pluginName = 'DOCS_PLUGIN';
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
// static override type = UniverInstanceType.UNIVER_DOC;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverDocsConfig> = defaultPluginConfig,
|
||||
@Inject(Injector) override _injector: Injector,
|
||||
@IConfigService private readonly _configService: IConfigService
|
||||
) {
|
||||
super();
|
||||
|
||||
// Manage the plugin configuration.
|
||||
const { ...rest } = merge(
|
||||
{},
|
||||
defaultPluginConfig,
|
||||
this._config
|
||||
);
|
||||
this._configService.setConfig(DOCS_PLUGIN_CONFIG_KEY, rest);
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
this._injector.add([DocumentPermissionRuleModel]);
|
||||
this.disposeWithMe(this._injector.get(ICommandService).registerCommand(SetDocumentPermissionRuleMutation));
|
||||
this.disposeWithMe(this._injector.get(ICommandService).registerCommand(SetDocumentPermissionRulesMutation));
|
||||
this.disposeWithMe(this._injector.get(ObjectPermissionService).registerRuleModel(
|
||||
UnitObject.Document,
|
||||
this._injector.get(DocumentPermissionRuleModel),
|
||||
SetDocumentPermissionRuleMutation.id,
|
||||
SetDocumentPermissionRulesMutation.id
|
||||
));
|
||||
this._initializeDependencies();
|
||||
this._initializeCommands();
|
||||
}
|
||||
|
||||
private _initializeCommands(): void {
|
||||
(
|
||||
[
|
||||
InsertTextCommand,
|
||||
DeleteTextCommand,
|
||||
UpdateTextCommand,
|
||||
CreateHeaderFooterCommand,
|
||||
SetDocumentPermissionCommand,
|
||||
SetDocumentPermissionsCommand,
|
||||
SetDocumentDefaultParagraphStyleCommand,
|
||||
SetDocumentNameCommand,
|
||||
SetSectionHeaderFooterLinkCommand,
|
||||
UpdateDocumentParagraphStyleCommand,
|
||||
UpdateDocumentSectionCommand,
|
||||
InsertDocumentSectionBreakCommand,
|
||||
InsertDocumentColumnBreakCommand,
|
||||
DeleteDocumentSectionBreakCommand,
|
||||
RichTextEditingMutation,
|
||||
DocsRenameMutation,
|
||||
SetTextSelectionsOperation,
|
||||
] as ICommand[]
|
||||
).forEach((command) => {
|
||||
this._injector.get(ICommandService).registerCommand(command);
|
||||
});
|
||||
}
|
||||
|
||||
private _initializeDependencies() {
|
||||
(
|
||||
[
|
||||
[DocSelectionManagerService],
|
||||
[DocStateEmitService],
|
||||
[DocStateChangeManagerService],
|
||||
[DocBlockMoveValidatorService],
|
||||
[DocContentInsertService],
|
||||
[DocLayoutExecutorService],
|
||||
[DocTextResolverService],
|
||||
[DocCustomRangeController],
|
||||
[DocPermissionController],
|
||||
] as Dependency[]
|
||||
).forEach((d) => this._injector.add(d));
|
||||
}
|
||||
|
||||
override onReady(): void {
|
||||
this._injector.get(DocStateChangeManagerService);
|
||||
this._injector.get(DocCustomRangeController);
|
||||
this._injector.get(DocPermissionController);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { DocBlockMoveValidatorService } from '../doc-block-move-validator.service';
|
||||
|
||||
const DOCUMENT_STYLE = {
|
||||
pageSize: {
|
||||
width: 594,
|
||||
height: 840,
|
||||
},
|
||||
marginTop: 72,
|
||||
marginBottom: 72,
|
||||
marginLeft: 90,
|
||||
marginRight: 90,
|
||||
};
|
||||
|
||||
describe('DocBlockMoveValidatorService', () => {
|
||||
it('allows a move when no validator rejects it', () => {
|
||||
const service = new DocBlockMoveValidatorService();
|
||||
|
||||
const first = service.registerValidator(() => true);
|
||||
service.registerValidator(() => true);
|
||||
|
||||
expect(service.canMoveBlock({
|
||||
unitId: 'doc-1',
|
||||
sourceRange: {
|
||||
startOffset: 2,
|
||||
endOffset: 8,
|
||||
},
|
||||
targetOffset: 20,
|
||||
})).toBe(true);
|
||||
|
||||
first.dispose();
|
||||
expect(service.canMoveBlock({
|
||||
unitId: 'doc-1',
|
||||
sourceRange: {
|
||||
startOffset: 2,
|
||||
endOffset: 8,
|
||||
},
|
||||
targetOffset: 20,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks a move when any validator rejects it and stops checking later validators', () => {
|
||||
const service = new DocBlockMoveValidatorService();
|
||||
let laterValidatorCalls = 0;
|
||||
|
||||
service.registerValidator(() => false);
|
||||
service.registerValidator(() => {
|
||||
laterValidatorCalls++;
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(service.canMoveBlock({
|
||||
unitId: 'doc-1',
|
||||
sourceRange: {
|
||||
startOffset: 2,
|
||||
endOffset: 8,
|
||||
},
|
||||
targetOffset: 20,
|
||||
})).toBe(false);
|
||||
expect(laterValidatorCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('applies registered move transformers in order and supports disposal', () => {
|
||||
const service = new DocBlockMoveValidatorService();
|
||||
const first = service.registerTransformer((context) => ({
|
||||
nextDocumentData: {
|
||||
...context.result.nextDocumentData,
|
||||
body: {
|
||||
dataStream: `${context.result.nextDocumentData.body?.dataStream ?? ''}!`,
|
||||
},
|
||||
},
|
||||
movedRange: context.result.movedRange,
|
||||
}));
|
||||
service.registerTransformer((context) => ({
|
||||
nextDocumentData: context.result.nextDocumentData,
|
||||
movedRange: {
|
||||
startOffset: context.result.movedRange.startOffset + 1,
|
||||
endOffset: context.result.movedRange.endOffset + 1,
|
||||
},
|
||||
}));
|
||||
|
||||
const transformed = service.transformMoveResult({
|
||||
unitId: 'doc-1',
|
||||
sourceRange: {
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
},
|
||||
targetOffset: 2,
|
||||
previousDocumentData: {
|
||||
id: 'doc-1',
|
||||
body: {
|
||||
dataStream: 'A',
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
},
|
||||
result: {
|
||||
nextDocumentData: {
|
||||
id: 'doc-1',
|
||||
body: {
|
||||
dataStream: 'B',
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
},
|
||||
movedRange: {
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(transformed.nextDocumentData.body?.dataStream).toBe('B!');
|
||||
expect(transformed.movedRange).toEqual({
|
||||
startOffset: 1,
|
||||
endOffset: 2,
|
||||
});
|
||||
|
||||
first.dispose();
|
||||
const transformedAfterDispose = service.transformMoveResult({
|
||||
unitId: 'doc-1',
|
||||
sourceRange: {
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
},
|
||||
targetOffset: 2,
|
||||
previousDocumentData: {
|
||||
id: 'doc-1',
|
||||
body: {
|
||||
dataStream: 'A',
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
},
|
||||
result: {
|
||||
nextDocumentData: {
|
||||
id: 'doc-1',
|
||||
body: {
|
||||
dataStream: 'B',
|
||||
},
|
||||
documentStyle: DOCUMENT_STYLE,
|
||||
},
|
||||
movedRange: {
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(transformedAfterDispose.nextDocumentData.body?.dataStream).toBe('B');
|
||||
expect(transformedAfterDispose.movedRange).toEqual({
|
||||
startOffset: 1,
|
||||
endOffset: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector } from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DocContentInsertService } from '../doc-content-insert.service';
|
||||
|
||||
describe('DocContentInsertService', () => {
|
||||
let service: DocContentInsertService;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = new Injector();
|
||||
injector.add([DocContentInsertService]);
|
||||
service = injector.get(DocContentInsertService);
|
||||
});
|
||||
|
||||
it('consumes a pending insert range only for the matching document', () => {
|
||||
const range = { unitId: 'doc-1', startOffset: 2, endOffset: 5, segmentId: 'header' };
|
||||
|
||||
service.setInsertRange(range);
|
||||
|
||||
expect(service.consumeInsertRange('doc-2')).toBeNull();
|
||||
expect(service.consumeInsertRange('doc-1')).toEqual(range);
|
||||
expect(service.consumeInsertRange('doc-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears stale insert ranges when editing is cancelled', () => {
|
||||
service.setInsertRange({ unitId: 'doc-1', startOffset: 2, endOffset: 5 });
|
||||
service.clearInsertRange();
|
||||
|
||||
expect(service.consumeInsertRange()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,876 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import type { IDocLayoutExecutor, IDocLayoutMountIdentity, IDocLayoutStartResult } from '../doc-layout-executor.service';
|
||||
import {
|
||||
CustomRangeType,
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
IUniverInstanceService,
|
||||
JSONX,
|
||||
LifecycleService,
|
||||
LifecycleStages,
|
||||
ObjectRelativeFromH,
|
||||
ObjectRelativeFromV,
|
||||
PositionedObjectLayoutType,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
Univer,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
IRenderManagerService,
|
||||
RenderManagerService,
|
||||
setDocsCustomBlockRenderViewportProvider,
|
||||
} from '@univerjs/engine-render';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RichTextEditingMutation } from '../../commands/mutations/core-editing.mutation';
|
||||
import { UniverDocsPlugin } from '../../plugin';
|
||||
import {
|
||||
DocLayoutExecutorService,
|
||||
DocLayoutExecutorState,
|
||||
DocLayoutExecutorType,
|
||||
DocLayoutSessionStatus,
|
||||
|
||||
} from '../doc-layout-executor.service';
|
||||
|
||||
function createDocumentData(id: string, documentFlavor: DocumentFlavor): IDocumentData {
|
||||
return {
|
||||
id,
|
||||
body: {
|
||||
dataStream: 'Hello\r\n',
|
||||
paragraphs: [{ paragraphId: 'paragraph-1', startIndex: 5 }],
|
||||
},
|
||||
documentStyle: {
|
||||
documentFlavor,
|
||||
pageSize: { width: 594, height: 840 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMountIdentity(mountId = 'mount-1', viewportEpoch = 1): IDocLayoutMountIdentity {
|
||||
return { unitId: 'traditional-doc', mountId, mountEpoch: 1, viewportEpoch };
|
||||
}
|
||||
|
||||
function createStartResult(
|
||||
modelRevision: number,
|
||||
metricsRevision = 1,
|
||||
identity = createMountIdentity()
|
||||
): IDocLayoutStartResult {
|
||||
return {
|
||||
status: DocLayoutSessionStatus.ACCEPTED,
|
||||
step: {
|
||||
...identity,
|
||||
progress: {
|
||||
generation: 1,
|
||||
publicationRevision: 1,
|
||||
didPublish: true,
|
||||
didPublishAnchor: true,
|
||||
publishedPageCount: 1,
|
||||
reason: 'initial',
|
||||
mode: 'paginated',
|
||||
complete: false,
|
||||
cancelled: false,
|
||||
anchorReady: true,
|
||||
laidOutThrough: 5,
|
||||
stableLaidOutThrough: 4,
|
||||
pageCount: 1,
|
||||
processedBlockCount: 1,
|
||||
totalBlockCount: 1,
|
||||
estimatedPageCount: 1,
|
||||
estimatedHeight: 840,
|
||||
elapsedTime: 1,
|
||||
maxBlockDuration: 1,
|
||||
interactionWindowComplete: false,
|
||||
},
|
||||
publication: null,
|
||||
modelRevision,
|
||||
metricsRevision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getRequestIdentity(request: IDocLayoutMountIdentity): IDocLayoutMountIdentity {
|
||||
return {
|
||||
unitId: request.unitId,
|
||||
mountId: request.mountId,
|
||||
mountEpoch: request.mountEpoch,
|
||||
viewportEpoch: request.viewportEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutor(): IDocLayoutExecutor {
|
||||
return {
|
||||
type: DocLayoutExecutorType.WORKER,
|
||||
initialize: vi.fn(async () => {}),
|
||||
recover: vi.fn(async () => {}),
|
||||
createSession: vi.fn(async () => {}),
|
||||
startLayout: vi.fn(async (request) =>
|
||||
createStartResult(request.modelRevision, request.metricsRevision, getRequestIdentity(request))),
|
||||
stepLayout: vi.fn(async () => {
|
||||
throw new Error('not used');
|
||||
}),
|
||||
publishBacklog: vi.fn(async () => {
|
||||
throw new Error('not used');
|
||||
}),
|
||||
getLayoutPage: vi.fn(async () => {
|
||||
throw new Error('not used');
|
||||
}),
|
||||
cancelLayout: vi.fn(async () => {}),
|
||||
getPerformanceMetrics: vi.fn(async () => ({
|
||||
mutationTransferMs: [],
|
||||
patchTransferMs: [],
|
||||
snapshotTransferMs: [],
|
||||
})),
|
||||
resetPerformanceMetrics: vi.fn(async () => {}),
|
||||
disposeLayoutMount: vi.fn(async () => {}),
|
||||
disposeSession: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocLayoutExecutorService', () => {
|
||||
let univer: Univer;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.get(LifecycleService).stage = LifecycleStages.Starting;
|
||||
univer.registerPlugin(UniverDocsPlugin);
|
||||
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('owns Worker sessions only for paginated and modern documents', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
const registration = service.register(executor);
|
||||
await vi.waitFor(() => expect(service.getExecutorStatus()).toEqual({
|
||||
state: DocLayoutExecutorState.ACTIVE,
|
||||
executor: DocLayoutExecutorType.WORKER,
|
||||
diagnostic: null,
|
||||
recoveryUnitId: null,
|
||||
}));
|
||||
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('modern-doc', DocumentFlavor.MODERN)
|
||||
);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('editor-doc', DocumentFlavor.UNSPECIFIED)
|
||||
);
|
||||
await vi.waitFor(() => expect(executor.createSession).toHaveBeenCalledTimes(2));
|
||||
expect(executor.createSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
unitId: 'traditional-doc',
|
||||
sessionEpoch: expect.any(Number),
|
||||
modelRevision: 0,
|
||||
}));
|
||||
expect(executor.createSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
unitId: 'modern-doc',
|
||||
modelRevision: 0,
|
||||
}));
|
||||
|
||||
const result = await service.startLayout(createMountIdentity(), { reason: 'initial' }, 32);
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
status: DocLayoutSessionStatus.ACCEPTED,
|
||||
step: expect.objectContaining({ modelRevision: 0 }),
|
||||
}));
|
||||
expect(executor.startLayout).toHaveBeenCalledWith({
|
||||
unitId: 'traditional-doc',
|
||||
mountId: 'mount-1',
|
||||
mountEpoch: 1,
|
||||
viewportEpoch: 1,
|
||||
metricsRevision: 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
customBlockViewports: {},
|
||||
customRangePresentations: [],
|
||||
reason: 'initial',
|
||||
budgetMs: 32,
|
||||
});
|
||||
expect(await service.startLayout(
|
||||
{ ...createMountIdentity(), unitId: 'editor-doc' },
|
||||
{ reason: 'initial' },
|
||||
32
|
||||
)).toBeNull();
|
||||
|
||||
injector.get(IUniverInstanceService).disposeUnit('traditional-doc');
|
||||
expect(executor.disposeSession).toHaveBeenCalledWith({
|
||||
unitId: 'traditional-doc',
|
||||
sessionEpoch: expect.any(Number),
|
||||
});
|
||||
registration.dispose();
|
||||
});
|
||||
|
||||
it('cancels a deferred snapshot transfer when the document is disposed in the same task', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
injector.get(IUniverInstanceService).disposeUnit('traditional-doc');
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(executor.createSession).not.toHaveBeenCalled();
|
||||
expect(executor.disposeSession).toHaveBeenCalledWith({
|
||||
unitId: 'traditional-doc',
|
||||
sessionEpoch: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it('folds mutations committed before a deferred snapshot transfer into one consistent Worker baseline', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
|
||||
const textX = new TextX();
|
||||
textX.push({ t: TextXActionType.RETAIN, len: 5 });
|
||||
textX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: 1,
|
||||
body: { dataStream: 'A' },
|
||||
});
|
||||
injector.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: 'traditional-doc',
|
||||
actions: JSONX.getInstance().editOp(textX.serialize(), ['body']),
|
||||
textRanges: [],
|
||||
});
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', anchor: 6 }, 32);
|
||||
|
||||
expect(executor.createSession).toHaveBeenCalledTimes(1);
|
||||
expect(executor.createSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
modelRevision: 1,
|
||||
snapshot: expect.objectContaining({
|
||||
body: expect.objectContaining({ dataStream: 'HelloA\r\n' }),
|
||||
}),
|
||||
}));
|
||||
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
baseRevision: 1,
|
||||
modelRevision: 1,
|
||||
mutations: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('rebases the deferred Worker snapshot after executor initialization completes', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
let resolveInitialization: () => void = () => {};
|
||||
vi.mocked(executor.initialize).mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveInitialization = resolve;
|
||||
}));
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
expect(executor.createSession).not.toHaveBeenCalled();
|
||||
|
||||
const textX = new TextX();
|
||||
textX.push({ t: TextXActionType.RETAIN, len: 5 });
|
||||
textX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: 1,
|
||||
body: { dataStream: 'A' },
|
||||
});
|
||||
injector.get(ICommandService).syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: 'traditional-doc',
|
||||
actions: JSONX.getInstance().editOp(textX.serialize(), ['body']),
|
||||
textRanges: [],
|
||||
});
|
||||
resolveInitialization();
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', anchor: 6 }, 32);
|
||||
|
||||
expect(executor.createSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
modelRevision: 1,
|
||||
snapshot: expect.objectContaining({
|
||||
body: expect.objectContaining({ dataStream: 'HelloA\r\n' }),
|
||||
}),
|
||||
}));
|
||||
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
baseRevision: 1,
|
||||
modelRevision: 1,
|
||||
mutations: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('reports capability initialization failures and unregisters the executor', async () => {
|
||||
const service = univer.__getInjector().get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
vi.mocked(executor.initialize).mockRejectedValue(new Error('font metrics mismatch'));
|
||||
|
||||
service.register(executor);
|
||||
|
||||
await vi.waitFor(() => expect(service.getExecutor()).toBeNull());
|
||||
expect(service.getExecutorStatus()).toEqual({
|
||||
state: DocLayoutExecutorState.FAILED,
|
||||
executor: DocLayoutExecutorType.WORKER,
|
||||
diagnostic: 'font metrics mismatch',
|
||||
recoveryUnitId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('collects bounded transfer and hydration diagnostics without changing executor results', async () => {
|
||||
const service = univer.__getInjector().get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
vi.mocked(executor.getPerformanceMetrics).mockResolvedValue({
|
||||
mutationTransferMs: [2],
|
||||
patchTransferMs: [3, 4],
|
||||
snapshotTransferMs: [5],
|
||||
});
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
expect(executor.createSession).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(executor.createSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
service.recordHydrationDuration('traditional-doc', 6);
|
||||
const metrics = await service.getPerformanceMetrics('traditional-doc');
|
||||
|
||||
expect(metrics).toMatchObject({
|
||||
hydrationMs: [6],
|
||||
mutationTransferMs: [2],
|
||||
patchTransferMs: [3, 4],
|
||||
snapshotTransferMs: [5],
|
||||
});
|
||||
await service.resetPerformanceMetrics('traditional-doc');
|
||||
expect(executor.resetPerformanceMetrics).toHaveBeenCalledWith('traditional-doc');
|
||||
expect((await service.getPerformanceMetrics('traditional-doc')).hydrationMs).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the affected document in recovery until a canonical layout completes', async () => {
|
||||
const service = univer.__getInjector().get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
await vi.waitFor(() => expect(service.getExecutorStatus().state).toBe(DocLayoutExecutorState.ACTIVE));
|
||||
|
||||
await service.recoverExecutor('doc-1', 'layout convergence failed');
|
||||
|
||||
expect(executor.recover).toHaveBeenCalledTimes(1);
|
||||
expect(service.getExecutorStatus()).toEqual({
|
||||
state: DocLayoutExecutorState.RECOVERING,
|
||||
executor: DocLayoutExecutorType.WORKER,
|
||||
diagnostic: 'layout convergence failed',
|
||||
recoveryUnitId: 'doc-1',
|
||||
});
|
||||
|
||||
service.completeRecovery('another-doc');
|
||||
expect(service.getExecutorStatus().state).toBe(DocLayoutExecutorState.RECOVERING);
|
||||
|
||||
service.completeRecovery('doc-1');
|
||||
expect(service.getExecutorStatus()).toEqual({
|
||||
state: DocLayoutExecutorState.ACTIVE,
|
||||
executor: DocLayoutExecutorType.WORKER,
|
||||
diagnostic: null,
|
||||
recoveryUnitId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('projects committed mutations to the Worker in revision order before layout starts', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await vi.waitFor(() => expect(executor.createSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
for (const [offset, text] of [[5, 'A'], [6, 'B']] as const) {
|
||||
const textX = new TextX();
|
||||
textX.push({ t: TextXActionType.RETAIN, len: offset });
|
||||
textX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: text.length,
|
||||
body: { dataStream: text },
|
||||
});
|
||||
commandService.syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: 'traditional-doc',
|
||||
actions: JSONX.getInstance().editOp(textX.serialize(), ['body']),
|
||||
textRanges: [],
|
||||
});
|
||||
}
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', anchor: 5 }, 32);
|
||||
|
||||
expect(executor.startLayout).toHaveBeenLastCalledWith({
|
||||
unitId: 'traditional-doc',
|
||||
mountId: 'mount-1',
|
||||
mountEpoch: 1,
|
||||
viewportEpoch: 1,
|
||||
metricsRevision: 1,
|
||||
baseRevision: 0,
|
||||
modelRevision: 2,
|
||||
mutations: [
|
||||
expect.objectContaining({ baseRevision: 0, modelRevision: 1 }),
|
||||
expect.objectContaining({ baseRevision: 1, modelRevision: 2 }),
|
||||
],
|
||||
customBlockViewports: {},
|
||||
customRangePresentations: [],
|
||||
reason: 'edit',
|
||||
anchor: 5,
|
||||
budgetMs: 32,
|
||||
});
|
||||
expect(executor.createSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-snapshots before layout when a direct document model mutation bypasses the projected command stream', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await vi.waitFor(() => expect(executor.createSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
model.updateDocumentStyle({
|
||||
...model.getDocumentStyle(),
|
||||
marginTop: 123,
|
||||
});
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', anchor: 5 }, 32);
|
||||
|
||||
expect(executor.createSession).toHaveBeenCalledTimes(2);
|
||||
expect(executor.createSession).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
unitId: 'traditional-doc',
|
||||
modelRevision: 0,
|
||||
snapshot: expect.objectContaining({
|
||||
documentStyle: expect.objectContaining({ marginTop: 123 }),
|
||||
}),
|
||||
}));
|
||||
expect(executor.startLayout).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('re-snapshots when a direct model mutation lands while a queued Worker start is waiting', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
let resolveInitialSession: () => void = () => {};
|
||||
vi.mocked(executor.createSession)
|
||||
.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
resolveInitialSession = resolve;
|
||||
}))
|
||||
.mockResolvedValue(undefined);
|
||||
service.register(executor);
|
||||
const model = univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await vi.waitFor(() => expect(executor.createSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
const start = service.startLayout(createMountIdentity(), { reason: 'initial' }, 32);
|
||||
model.updateDocumentStyle({
|
||||
...model.getDocumentStyle(),
|
||||
marginBottom: 456,
|
||||
});
|
||||
resolveInitialSession();
|
||||
await start;
|
||||
|
||||
expect(executor.createSession).toHaveBeenCalledTimes(2);
|
||||
expect(executor.createSession).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
snapshot: expect.objectContaining({
|
||||
documentStyle: expect.objectContaining({ marginBottom: 456 }),
|
||||
}),
|
||||
}));
|
||||
expect(executor.startLayout).toHaveBeenCalledTimes(1);
|
||||
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
baseRevision: 0,
|
||||
modelRevision: 0,
|
||||
mutations: [],
|
||||
}));
|
||||
});
|
||||
|
||||
it('preserves paste, undo, redo, remote, and table-format actions in one ordered Worker batch', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await vi.waitFor(() => expect(executor.createSession).toHaveBeenCalledTimes(1));
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
const pastedText = '大段粘贴内容'.repeat(1_024);
|
||||
const pasteTextX = new TextX();
|
||||
pasteTextX.push({ t: TextXActionType.RETAIN, len: 5 });
|
||||
pasteTextX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: pastedText.length,
|
||||
body: { dataStream: pastedText },
|
||||
});
|
||||
const pasteActions = JSONX.getInstance().editOp(pasteTextX.serialize(), ['body']);
|
||||
|
||||
const undoTextX = new TextX();
|
||||
undoTextX.push({ t: TextXActionType.RETAIN, len: 5 });
|
||||
undoTextX.push({ t: TextXActionType.DELETE, len: pastedText.length });
|
||||
const undoActions = JSONX.getInstance().editOp(undoTextX.serialize(), ['body']);
|
||||
|
||||
const redoTextX = new TextX();
|
||||
redoTextX.push({ t: TextXActionType.RETAIN, len: 5 });
|
||||
redoTextX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: pastedText.length,
|
||||
body: { dataStream: pastedText },
|
||||
});
|
||||
const redoActions = JSONX.getInstance().editOp(redoTextX.serialize(), ['body']);
|
||||
|
||||
const remoteTextX = new TextX();
|
||||
remoteTextX.push({ t: TextXActionType.RETAIN, len: 5 });
|
||||
remoteTextX.push({
|
||||
t: TextXActionType.INSERT,
|
||||
len: 2,
|
||||
body: { dataStream: '远端' },
|
||||
});
|
||||
const remoteActions = JSONX.getInstance().editOp(remoteTextX.serialize(), ['body']);
|
||||
const tableFormatActions = JSONX.getInstance().insertOp(['tableSource', 'table-1'], {
|
||||
tableId: 'table-1',
|
||||
tableRows: [{ tableCells: [{ backgroundColor: { rgb: '#fff4b9' } }] }],
|
||||
tableColumns: [{}],
|
||||
});
|
||||
|
||||
for (const [actions, textRanges] of [
|
||||
[pasteActions, []],
|
||||
[undoActions, []],
|
||||
[redoActions, []],
|
||||
[remoteActions, null],
|
||||
[tableFormatActions, []],
|
||||
] as const) {
|
||||
commandService.syncExecuteCommand(RichTextEditingMutation.id, {
|
||||
unitId: 'traditional-doc',
|
||||
actions,
|
||||
textRanges,
|
||||
});
|
||||
}
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit', anchor: 5 }, 32);
|
||||
|
||||
const request = vi.mocked(executor.startLayout).mock.lastCall?.[0];
|
||||
expect(request).toEqual(expect.objectContaining({
|
||||
baseRevision: 0,
|
||||
modelRevision: 5,
|
||||
reason: 'edit',
|
||||
anchor: 5,
|
||||
}));
|
||||
expect(request?.mutations).toEqual([
|
||||
{ baseRevision: 0, modelRevision: 1, actions: pasteActions },
|
||||
{ baseRevision: 1, modelRevision: 2, actions: undoActions },
|
||||
{ baseRevision: 2, modelRevision: 3, actions: redoActions },
|
||||
{ baseRevision: 3, modelRevision: 4, actions: remoteActions },
|
||||
{ baseRevision: 4, modelRevision: 5, actions: tableFormatActions },
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes main-thread custom block viewport measurements with a layout start', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
let extraWidth = 10;
|
||||
let viewportEnabled = true;
|
||||
const unregister = setDocsCustomBlockRenderViewportProvider((unitId, blockId, input) => {
|
||||
if (!viewportEnabled || unitId !== 'traditional-doc' || blockId !== 'embed-1') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: input.fallbackWidth + extraWidth,
|
||||
height: input.fallbackHeight + 20,
|
||||
contentWidth: 640,
|
||||
contentHeight: 480,
|
||||
};
|
||||
});
|
||||
const documentData = createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL);
|
||||
documentData.body = {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ paragraphId: 'paragraph-1', startIndex: 1 }],
|
||||
customBlocks: [
|
||||
{ startIndex: 0, blockId: 'embed-1' },
|
||||
{ startIndex: 0, blockId: 'missing-drawing' },
|
||||
],
|
||||
};
|
||||
documentData.drawings = {
|
||||
'embed-1': {
|
||||
unitId: 'traditional-doc',
|
||||
subUnitId: 'traditional-doc',
|
||||
drawingId: 'embed-1',
|
||||
drawingType: 1,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: {
|
||||
angle: 0,
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PARAGRAPH, posOffset: 0 },
|
||||
size: { width: 100, height: 80 },
|
||||
},
|
||||
},
|
||||
};
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, documentData);
|
||||
await Promise.resolve();
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'initial' }, 32);
|
||||
|
||||
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customBlockViewports: {
|
||||
'embed-1': {
|
||||
width: 110,
|
||||
height: 100,
|
||||
contentWidth: 640,
|
||||
contentHeight: 480,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).not.toHaveProperty('customBlockViewportPatch');
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).not.toHaveProperty('customBlockViewports');
|
||||
|
||||
extraWidth = 20;
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).toEqual(expect.objectContaining({
|
||||
customBlockViewportPatch: {
|
||||
removals: [],
|
||||
upserts: {
|
||||
'embed-1': {
|
||||
width: 120,
|
||||
height: 100,
|
||||
contentWidth: 640,
|
||||
contentHeight: 480,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
viewportEnabled = false;
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).toEqual(expect.objectContaining({
|
||||
customBlockViewportPatch: {
|
||||
removals: ['embed-1'],
|
||||
upserts: {},
|
||||
},
|
||||
}));
|
||||
unregister();
|
||||
});
|
||||
|
||||
it('serializes custom range presentations for the body, headers, and footers without range id collisions', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
service.register(executor);
|
||||
let glyphWidthOffset = 1;
|
||||
const registration = service.registerCustomRangePresentationProvider((_unitId, range) => ({
|
||||
glyphWidthEm: range.startIndex + glyphWidthOffset,
|
||||
}));
|
||||
const documentData = createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL);
|
||||
documentData.body!.customRanges = [
|
||||
{
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
rangeId: 'shared-range',
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
},
|
||||
{
|
||||
startIndex: 3,
|
||||
endIndex: 3,
|
||||
rangeId: 'shared-range',
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
},
|
||||
];
|
||||
documentData.headers = {
|
||||
'header-1': {
|
||||
headerId: 'header-1',
|
||||
body: {
|
||||
dataStream: 'Header\r\n',
|
||||
paragraphs: [{ paragraphId: 'header-paragraph', startIndex: 6 }],
|
||||
customRanges: [{
|
||||
startIndex: 1,
|
||||
endIndex: 1,
|
||||
rangeId: 'shared-range',
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
documentData.footers = {
|
||||
'footer-1': {
|
||||
footerId: 'footer-1',
|
||||
body: {
|
||||
dataStream: 'Footer\r\n',
|
||||
paragraphs: [{ paragraphId: 'footer-paragraph', startIndex: 6 }],
|
||||
customRanges: [{
|
||||
startIndex: 2,
|
||||
endIndex: 2,
|
||||
rangeId: 'shared-range',
|
||||
rangeType: CustomRangeType.CUSTOM,
|
||||
}],
|
||||
},
|
||||
},
|
||||
};
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, documentData);
|
||||
await Promise.resolve();
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'initial' }, 32);
|
||||
|
||||
expect(executor.startLayout).toHaveBeenCalledWith(expect.objectContaining({
|
||||
customRangePresentations: [
|
||||
{
|
||||
segmentId: '',
|
||||
rangeId: 'shared-range',
|
||||
presentation: { glyphWidthEm: 1 },
|
||||
},
|
||||
{
|
||||
segmentId: 'header-1',
|
||||
rangeId: 'shared-range',
|
||||
presentation: { glyphWidthEm: 2 },
|
||||
},
|
||||
{
|
||||
segmentId: 'footer-1',
|
||||
rangeId: 'shared-range',
|
||||
presentation: { glyphWidthEm: 3 },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).not.toHaveProperty('customRangePresentationPatch');
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]).not.toHaveProperty('customRangePresentations');
|
||||
|
||||
glyphWidthOffset = 10;
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]?.customRangePresentationPatch?.upserts).toEqual([
|
||||
{ segmentId: '', rangeId: 'shared-range', presentation: { glyphWidthEm: 10 } },
|
||||
{ segmentId: 'header-1', rangeId: 'shared-range', presentation: { glyphWidthEm: 11 } },
|
||||
{ segmentId: 'footer-1', rangeId: 'shared-range', presentation: { glyphWidthEm: 12 } },
|
||||
]);
|
||||
|
||||
registration.dispose();
|
||||
await service.startLayout(createMountIdentity(), { reason: 'edit' }, 32);
|
||||
expect(vi.mocked(executor.startLayout).mock.lastCall?.[0]?.customRangePresentationPatch?.removals).toEqual([
|
||||
{ segmentId: '', rangeId: 'shared-range' },
|
||||
{ segmentId: 'header-1', rangeId: 'shared-range' },
|
||||
{ segmentId: 'footer-1', rangeId: 'shared-range' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips queued layout starts that a newer edit superseded', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
let resolveFirstStart: (result: IDocLayoutStartResult) => void = () => {};
|
||||
vi.mocked(executor.startLayout)
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFirstStart = resolve;
|
||||
}))
|
||||
.mockImplementation(async (request) =>
|
||||
createStartResult(request.modelRevision, request.metricsRevision, getRequestIdentity(request)));
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const firstStart = service.startLayout(createMountIdentity(), { reason: 'edit', anchor: 5 }, 32);
|
||||
await vi.waitFor(() => expect(executor.startLayout).toHaveBeenCalledTimes(1));
|
||||
const supersededStart = service.startLayout(createMountIdentity('mount-1', 2), { reason: 'edit', anchor: 6 }, 32);
|
||||
const latestStart = service.startLayout(createMountIdentity('mount-1', 3), { reason: 'edit', anchor: 7 }, 32);
|
||||
resolveFirstStart(createStartResult(0));
|
||||
|
||||
await expect(firstStart).resolves.toEqual(createStartResult(0));
|
||||
await expect(supersededStart).resolves.toEqual({ status: DocLayoutSessionStatus.SUPERSEDED });
|
||||
await expect(latestStart).resolves.toEqual(createStartResult(0, 3, createMountIdentity('mount-1', 3)));
|
||||
expect(executor.startLayout).toHaveBeenCalledTimes(2);
|
||||
expect(executor.startLayout).toHaveBeenLastCalledWith(expect.objectContaining({ anchor: 7 }));
|
||||
});
|
||||
|
||||
it('keeps queued starts for different document mounts independent', async () => {
|
||||
const injector = univer.__getInjector();
|
||||
const service = injector.get(DocLayoutExecutorService);
|
||||
const executor = createExecutor();
|
||||
let resolveFirstStart: (result: IDocLayoutStartResult) => void = () => {};
|
||||
vi.mocked(executor.startLayout)
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveFirstStart = resolve;
|
||||
}))
|
||||
.mockImplementation(async (request) =>
|
||||
createStartResult(request.modelRevision, request.metricsRevision, getRequestIdentity(request)));
|
||||
service.register(executor);
|
||||
univer.createUnit<IDocumentData, DocumentDataModel>(
|
||||
UniverInstanceType.UNIVER_DOC,
|
||||
createDocumentData('traditional-doc', DocumentFlavor.TRADITIONAL)
|
||||
);
|
||||
await Promise.resolve();
|
||||
|
||||
const firstMountStart = service.startLayout(
|
||||
createMountIdentity(),
|
||||
{ reason: 'edit', anchor: 5 },
|
||||
32
|
||||
);
|
||||
await vi.waitFor(() => expect(executor.startLayout).toHaveBeenCalledTimes(1));
|
||||
const secondMountStart = service.startLayout(
|
||||
createMountIdentity('mount-2'),
|
||||
{ reason: 'edit', anchor: 6 },
|
||||
32
|
||||
);
|
||||
resolveFirstStart(createStartResult(0));
|
||||
|
||||
await expect(firstMountStart).resolves.toEqual(createStartResult(0));
|
||||
await expect(secondMountStart).resolves.toEqual(createStartResult(0, 1, createMountIdentity('mount-2')));
|
||||
expect(executor.startLayout).toHaveBeenCalledTimes(2);
|
||||
expect(executor.startLayout).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
mountId: 'mount-2',
|
||||
anchor: 6,
|
||||
}));
|
||||
|
||||
await service.disposeLayoutMount(createMountIdentity());
|
||||
expect(executor.disposeLayoutMount).toHaveBeenCalledWith({
|
||||
unitId: 'traditional-doc',
|
||||
mountId: 'mount-1',
|
||||
mountEpoch: 1,
|
||||
viewportEpoch: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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 {
|
||||
CommandService,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
UniverInstanceService,
|
||||
} from '@univerjs/core';
|
||||
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SetTextSelectionsOperation } from '../../commands/operations/text-selection.operation';
|
||||
import { DocSelectionManagerService } from '../doc-selection-manager.service';
|
||||
|
||||
function createService() {
|
||||
const injector = new Injector();
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
|
||||
injector.add([DocSelectionManagerService]);
|
||||
injector.get(ICommandService).registerCommand(SetTextSelectionsOperation);
|
||||
return injector.get(DocSelectionManagerService);
|
||||
}
|
||||
|
||||
describe('DocSelectionManagerService', () => {
|
||||
it('retains explicitly owned render selections before a global Doc becomes current', () => {
|
||||
const service = createService();
|
||||
const target = { unitId: 'embedded-doc', subUnitId: 'embedded-doc' };
|
||||
const ranges = [{ startOffset: 4, endOffset: 14, collapsed: false, isActive: true }];
|
||||
try {
|
||||
service.__replaceTextRangesWithNoRefresh({
|
||||
textRanges: ranges,
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
isEditing: false,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
}, target);
|
||||
|
||||
expect(service.getDocRanges(target)).toEqual(ranges);
|
||||
expect(service.__getCurrentSelection()).toBeNull();
|
||||
expect(service.getDocRanges()).toEqual([]);
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'peer-doc', subUnitId: 'peer-doc' });
|
||||
expect(service.getDocRanges()).toEqual([]);
|
||||
expect(service.getDocRanges(target)).toEqual(ranges);
|
||||
service.__TEST_ONLY_setCurrentSelection(target);
|
||||
expect(service.getActiveTextRange()).toEqual(ranges[0]);
|
||||
} finally {
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('stores document text selections and exposes active/doc range ordering', () => {
|
||||
const service = createService();
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
service.__TEST_ONLY_add([{
|
||||
startOffset: 5,
|
||||
endOffset: 8,
|
||||
collapsed: false,
|
||||
isActive: false,
|
||||
}, {
|
||||
startOffset: 1,
|
||||
endOffset: 2,
|
||||
collapsed: false,
|
||||
isActive: true,
|
||||
}] as never);
|
||||
|
||||
expect(service.getTextRanges()?.length).toBe(2);
|
||||
expect(service.getActiveTextRange()?.startOffset).toBe(1);
|
||||
expect(service.getDocRanges().map((range) => range.startOffset)).toEqual([1, 5]);
|
||||
});
|
||||
|
||||
it('keeps empty selections inert until a document selection is current', () => {
|
||||
const service = createService();
|
||||
|
||||
service.__TEST_ONLY_add([{ startOffset: 1, endOffset: 1, collapsed: true }] as never);
|
||||
service.refreshSelection();
|
||||
service.replaceDocRanges([{ startOffset: 2, endOffset: 2 }]);
|
||||
|
||||
expect(service.__getCurrentSelection()).toBeNull();
|
||||
expect(service.getSelectionInfo()).toBeUndefined();
|
||||
expect(service.getTextRanges()).toBeUndefined();
|
||||
expect(service.getRectRanges()).toBeUndefined();
|
||||
expect(service.getActiveTextRange()).toBeUndefined();
|
||||
expect(service.getDocRanges()).toEqual([]);
|
||||
});
|
||||
|
||||
it('publishes refresh selection requests for current document ranges', () => {
|
||||
const service = createService();
|
||||
const refreshes: unknown[] = [];
|
||||
const sub = service.refreshSelection$.subscribe((value) => refreshes.push(value));
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
service.replaceDocRanges([{ startOffset: 2, endOffset: 4 }], undefined, false, { keepVisible: true });
|
||||
|
||||
expect(refreshes.at(-1)).toEqual({
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
docRanges: [{ startOffset: 2, endOffset: 4 }],
|
||||
isEditing: false,
|
||||
options: { keepVisible: true },
|
||||
});
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('preserves selection options when refreshing an existing render selection', () => {
|
||||
const service = createService();
|
||||
const refreshes: unknown[] = [];
|
||||
const sub = service.refreshSelection$.subscribe((value) => refreshes.push(value));
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
service.__replaceTextRangesWithNoRefresh({
|
||||
textRanges: [{ startOffset: 2, endOffset: 2, collapsed: true }],
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
isEditing: false,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
options: { preserveCaret: true },
|
||||
}, { unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
service.refreshSelection();
|
||||
|
||||
expect(refreshes.at(-1)).toEqual(expect.objectContaining({
|
||||
docRanges: [expect.objectContaining({
|
||||
startOffset: 2,
|
||||
endOffset: 2,
|
||||
collapsed: true,
|
||||
})],
|
||||
options: { preserveCaret: true },
|
||||
}));
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('does not replay a previous forced focus when refreshing an inactive document selection', () => {
|
||||
const service = createService();
|
||||
const target = { unitId: 'doc-1', subUnitId: 'doc-1' };
|
||||
const refreshes: unknown[] = [];
|
||||
const sub = service.refreshSelection$.subscribe((value) => refreshes.push(value));
|
||||
try {
|
||||
service.__replaceTextRangesWithNoRefresh({
|
||||
textRanges: [{ startOffset: 2, endOffset: 5, collapsed: false, isActive: true }],
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
isEditing: false,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
options: { forceFocus: true, preserveCaret: true },
|
||||
}, target);
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-2', subUnitId: 'doc-2' });
|
||||
|
||||
service.refreshSelection(target);
|
||||
|
||||
expect(refreshes.at(-1)).toEqual(expect.objectContaining({
|
||||
unitId: 'doc-1',
|
||||
docRanges: [expect.objectContaining({ startOffset: 2, endOffset: 5 })],
|
||||
options: { forceFocus: false, preserveCaret: true },
|
||||
}));
|
||||
expect(service.__getCurrentSelection()?.unitId).toBe('doc-2');
|
||||
service.replaceDocRanges([{ startOffset: 3, endOffset: 3 }], target, false, { forceFocus: true });
|
||||
expect(refreshes.at(-1)).toEqual(expect.objectContaining({ options: { forceFocus: true } }));
|
||||
} finally {
|
||||
sub.unsubscribe();
|
||||
service.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves active editing state when refreshed layout geometry replaces the selection', () => {
|
||||
const service = createService();
|
||||
const refreshes: unknown[] = [];
|
||||
const sub = service.refreshSelection$.subscribe((value) => refreshes.push(value));
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
service.__TEST_ONLY_add([{
|
||||
startOffset: 2,
|
||||
endOffset: 2,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
}]);
|
||||
|
||||
service.refreshSelection(undefined, true);
|
||||
|
||||
expect(refreshes.at(-1)).toEqual({
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
docRanges: [expect.objectContaining({ startOffset: 2, endOffset: 2 })],
|
||||
isEditing: true,
|
||||
options: undefined,
|
||||
});
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('replaces logical selection state without publishing a render refresh', () => {
|
||||
const service = createService();
|
||||
const refreshes: unknown[] = [];
|
||||
const sub = service.refreshSelection$.subscribe((value) => refreshes.push(value));
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
service.replaceSelectionInfoWithoutRefresh({
|
||||
textRanges: [{
|
||||
startOffset: 20,
|
||||
endOffset: 20,
|
||||
collapsed: true,
|
||||
isActive: true,
|
||||
}],
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
isEditing: true,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
|
||||
options: { keepVisible: true },
|
||||
}, { unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
expect(service.getActiveTextRange()?.endOffset).toBe(20);
|
||||
expect(service.getSelectionInfo()?.isEditing).toBe(true);
|
||||
expect(refreshes).toEqual([null]);
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('replaces render selections, publishes them, and sorts text and rect ranges together', async () => {
|
||||
const service = createService();
|
||||
const selections: unknown[] = [];
|
||||
const sub = service.textSelection$.subscribe((value) => selections.push(value));
|
||||
service.__TEST_ONLY_setCurrentSelection({ unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
service.__replaceTextRangesWithNoRefresh({
|
||||
textRanges: [{ startOffset: 8, endOffset: 9, collapsed: false, isActive: false }],
|
||||
rectRanges: [{ startOffset: 2, endOffset: 4, isActive: true }],
|
||||
segmentId: 'header-1',
|
||||
segmentPage: 0,
|
||||
isEditing: true,
|
||||
style: { stroke: 'red' },
|
||||
} as never, { unitId: 'doc-1', subUnitId: 'doc-1' });
|
||||
|
||||
expect(service.getTextRanges()?.map((range) => range.startOffset)).toEqual([8]);
|
||||
expect(service.getRectRanges()?.map((range) => range.startOffset)).toEqual([2]);
|
||||
expect(service.getRectRanges()?.find((range) => range.isActive)?.startOffset).toBe(2);
|
||||
expect(service.getDocRanges().map((range) => range.startOffset)).toEqual([2, 8]);
|
||||
expect(selections.at(-1)).toMatchObject({
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
segmentId: 'header-1',
|
||||
isEditing: true,
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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 { IDocLayoutExecutor } from '../doc-layout-executor.service';
|
||||
import {
|
||||
CommandService,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
DocumentDataModel,
|
||||
DocumentFlavor,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
UniverInstanceService,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocLayoutExecutorService, DocLayoutExecutorType, DocLayoutSessionStatus } from '../doc-layout-executor.service';
|
||||
import { DocSkeletonManagerService } from '../doc-skeleton-manager.service';
|
||||
|
||||
function createDocument(options?: { documentFlavor?: DocumentFlavor; id?: string }) {
|
||||
return new DocumentDataModel({
|
||||
id: options?.id ?? 'doc-1',
|
||||
body: {
|
||||
dataStream: 'Hello\r\n',
|
||||
paragraphs: [{ paragraphId: 'p1', startIndex: 5 }],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_126', startIndex: 7 }],
|
||||
customRanges: [],
|
||||
customDecorations: [],
|
||||
customBlocks: [],
|
||||
tables: [],
|
||||
},
|
||||
documentStyle: {
|
||||
pageSize: { width: 594, height: 840 },
|
||||
documentFlavor: options?.documentFlavor ?? DocumentFlavor.TRADITIONAL,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createExecutor(): IDocLayoutExecutor {
|
||||
return {
|
||||
type: DocLayoutExecutorType.WORKER,
|
||||
initialize: async () => {},
|
||||
recover: async () => {},
|
||||
createSession: async () => {},
|
||||
startLayout: async () => ({ status: DocLayoutSessionStatus.NOT_FOUND }),
|
||||
stepLayout: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
publishBacklog: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
getLayoutPage: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
cancelLayout: async () => {},
|
||||
getPerformanceMetrics: async () => ({
|
||||
mutationTransferMs: [],
|
||||
patchTransferMs: [],
|
||||
snapshotTransferMs: [],
|
||||
}),
|
||||
resetPerformanceMetrics: async () => {},
|
||||
disposeLayoutMount: async () => {},
|
||||
disposeSession: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function createService(document: DocumentDataModel, registerExecutor = false) {
|
||||
const injector = new Injector();
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([LocaleService]);
|
||||
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
|
||||
injector.add([DocLayoutExecutorService]);
|
||||
const univerInstanceService = injector.get(IUniverInstanceService) as UniverInstanceService;
|
||||
univerInstanceService.__addUnit(document);
|
||||
if (registerExecutor) {
|
||||
injector.get(DocLayoutExecutorService).register(createExecutor());
|
||||
}
|
||||
|
||||
return injector.createInstance(DocSkeletonManagerService, {
|
||||
unit: document,
|
||||
unitId: document.getUnitId(),
|
||||
type: UniverInstanceType.UNIVER_DOC,
|
||||
});
|
||||
}
|
||||
|
||||
describe('DocSkeletonManagerService', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('builds and publishes a document skeleton for the render context document', async () => {
|
||||
vi.stubGlobal('document', {
|
||||
getElementById: () => null,
|
||||
getElementsByTagName: () => [{ appendChild: vi.fn() }],
|
||||
createElement: () => ({
|
||||
id: '',
|
||||
style: { cssText: '' },
|
||||
textContent: '',
|
||||
getBoundingClientRect: () => ({ width: 40, height: 10 }),
|
||||
getContext: () => ({
|
||||
font: '',
|
||||
measureText: (content: string) => ({
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
width: content.length * 8,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const document = createDocument();
|
||||
const service = createService(document);
|
||||
|
||||
expect(service.getViewModel().getDataModel().getUnitId()).toBe('doc-1');
|
||||
expect(service.getSkeleton()).toBeTruthy();
|
||||
|
||||
const publishedSkeletons: unknown[] = [];
|
||||
const subscription = service.currentSkeleton$.subscribe((skeleton) => publishedSkeletons.push(skeleton));
|
||||
const skeleton = service.recalculate();
|
||||
|
||||
expect(skeleton).toBe(service.getSkeleton());
|
||||
expect(publishedSkeletons).toEqual([skeleton, skeleton]);
|
||||
await expect(service.getLayoutPerformanceMetrics()).resolves.toEqual({
|
||||
hydrationMs: [],
|
||||
mutationTransferMs: [],
|
||||
patchTransferMs: [],
|
||||
snapshotTransferMs: [],
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['internal editor', createDocument({ id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY, documentFlavor: DocumentFlavor.UNSPECIFIED })],
|
||||
['unspecified document', createDocument({ id: 'unspecified-doc', documentFlavor: DocumentFlavor.UNSPECIFIED })],
|
||||
])('calculates %s synchronously before publishing', (_label, document) => {
|
||||
const service = createService(document);
|
||||
|
||||
expect(service.getSkeleton().getSkeletonData()).not.toBeNull();
|
||||
expect(service.supportsIncrementalLayout()).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
DocumentFlavor.TRADITIONAL,
|
||||
DocumentFlavor.MODERN,
|
||||
])('keeps an interactive document synchronous without a registered executor: %s', (documentFlavor) => {
|
||||
const service = createService(createDocument({ documentFlavor }));
|
||||
|
||||
expect(service.supportsIncrementalLayout()).toBe(false);
|
||||
expect(service.getSkeleton().getSkeletonData()).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
DocumentFlavor.TRADITIONAL,
|
||||
DocumentFlavor.MODERN,
|
||||
])('enables incremental layout for a registered executor and interactive flavor: %s', (documentFlavor) => {
|
||||
const service = createService(createDocument({ documentFlavor }), true);
|
||||
|
||||
expect(service.supportsIncrementalLayout()).toBe(true);
|
||||
expect(service.getSkeleton().getSkeletonData()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps an internal editor synchronous even if its flavor is paginated', () => {
|
||||
const service = createService(createDocument({
|
||||
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
documentFlavor: DocumentFlavor.TRADITIONAL,
|
||||
}));
|
||||
|
||||
expect(service.supportsIncrementalLayout()).toBe(false);
|
||||
expect(service.getSkeleton().getSkeletonData()).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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 {
|
||||
CommandService,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
DocumentDataModel,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUndoRedoService,
|
||||
IUniverInstanceService,
|
||||
LocalUndoRedoService,
|
||||
UniverInstanceService,
|
||||
} from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocStateChangeManagerService, IDocStateChangeInterceptorService } from '../doc-state-change-manager.service';
|
||||
import { DocStateEmitService } from '../doc-state-emit.service';
|
||||
|
||||
class DropDocStateChangeInterceptorService {
|
||||
transformChangeStateInfo() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createService(intercept = false) {
|
||||
const injector = new Injector();
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
|
||||
injector.add([IUndoRedoService, { useClass: LocalUndoRedoService }]);
|
||||
injector.add([DocStateEmitService]);
|
||||
if (intercept) {
|
||||
injector.add([IDocStateChangeInterceptorService, { useClass: DropDocStateChangeInterceptorService }]);
|
||||
}
|
||||
injector.add([DocStateChangeManagerService]);
|
||||
return {
|
||||
service: injector.get(DocStateChangeManagerService),
|
||||
emitter: injector.get(DocStateEmitService),
|
||||
undoRedoService: injector.get(IUndoRedoService),
|
||||
univerInstanceService: injector.get(IUniverInstanceService) as UniverInstanceService,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocStateChangeManagerService', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function createChange(overrides = {}) {
|
||||
return {
|
||||
commandId: 'doc.rich-text-editing',
|
||||
unitId: 'doc-1',
|
||||
trigger: 'keyboard',
|
||||
redoState: { actions: null as never, textRanges: [{ startOffset: 1, endOffset: 1, collapsed: true }] },
|
||||
undoState: { actions: null as never, textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true }] },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('turns emitted document edits into history items and collaboration change events', () => {
|
||||
const { service, emitter, undoRedoService, univerInstanceService } = createService();
|
||||
const changes: unknown[] = [];
|
||||
const sub = service.docStateChange$.subscribe((value) => changes.push(value));
|
||||
univerInstanceService.__addUnit(new DocumentDataModel({ id: 'doc-1' }));
|
||||
univerInstanceService.focusUnit('doc-1');
|
||||
const change = createChange();
|
||||
|
||||
emitter.emitStateChangeInfo(change);
|
||||
|
||||
expect(undoRedoService.pitchTopUndoElement()).toMatchObject({
|
||||
unitID: 'doc-1',
|
||||
undoMutations: [{ id: 'doc.rich-text-editing' }],
|
||||
redoMutations: [{ id: 'doc.rich-text-editing' }],
|
||||
});
|
||||
expect(changes.at(-1)).toMatchObject({
|
||||
commandId: 'doc.rich-text-editing',
|
||||
unitId: 'doc-1',
|
||||
trigger: 'keyboard',
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('filters sync or intercepted state changes without changing caches', () => {
|
||||
const synced = createService();
|
||||
synced.emitter.emitStateChangeInfo(createChange({ isSync: true }));
|
||||
expect(synced.service.getStateCache('doc-1')).toEqual({ history: [], collaboration: [] });
|
||||
|
||||
const intercepted = createService(true);
|
||||
intercepted.emitter.emitStateChangeInfo(createChange());
|
||||
expect(intercepted.service.getStateCache('doc-1')).toEqual({ history: [], collaboration: [] });
|
||||
});
|
||||
|
||||
it('keeps no-history changes out of undo and collaboration streams', () => {
|
||||
const { service, emitter, undoRedoService } = createService();
|
||||
const changes: unknown[] = [];
|
||||
const sub = service.docStateChange$.subscribe((value) => changes.push(value));
|
||||
|
||||
emitter.emitStateChangeInfo(createChange({ noHistory: true }));
|
||||
|
||||
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
|
||||
expect(changes.at(-1)).toBeNull();
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('debounces history and collaboration flushing for composed edits', () => {
|
||||
vi.useFakeTimers();
|
||||
const { service, emitter } = createService();
|
||||
const changes: unknown[] = [];
|
||||
const sub = service.docStateChange$.subscribe((value) => changes.push(value));
|
||||
|
||||
emitter.emitStateChangeInfo(createChange({ debounce: true }));
|
||||
expect(service.getStateCache('doc-1').history).toHaveLength(1);
|
||||
expect(changes.at(-1)).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
expect(service.getStateCache('doc-1')).toEqual({ history: [], collaboration: [] });
|
||||
expect(changes.at(-1)).toMatchObject({ unitId: 'doc-1', debounce: true });
|
||||
sub.unsubscribe();
|
||||
});
|
||||
|
||||
it('preserves segment and whole-document selection state in undo and redo mutations', () => {
|
||||
const { emitter, undoRedoService, univerInstanceService } = createService();
|
||||
univerInstanceService.__addUnit(new DocumentDataModel({ id: 'doc-1' }));
|
||||
univerInstanceService.focusUnit('doc-1');
|
||||
|
||||
emitter.emitStateChangeInfo(createChange({
|
||||
segmentId: 'header-1',
|
||||
redoState: {
|
||||
actions: null as never,
|
||||
textRanges: [{ startOffset: 2, endOffset: 2, collapsed: true }],
|
||||
options: { wholeDocument: false },
|
||||
isEditing: true,
|
||||
},
|
||||
undoState: {
|
||||
actions: null as never,
|
||||
textRanges: [{ startOffset: 0, endOffset: 4, collapsed: false }],
|
||||
options: { wholeDocument: true },
|
||||
isEditing: false,
|
||||
},
|
||||
}));
|
||||
|
||||
expect(undoRedoService.pitchTopUndoElement()).toMatchObject({
|
||||
undoMutations: [{
|
||||
params: {
|
||||
segmentId: 'header-1',
|
||||
options: { wholeDocument: true },
|
||||
isEditing: false,
|
||||
},
|
||||
}],
|
||||
redoMutations: [{
|
||||
params: {
|
||||
segmentId: 'header-1',
|
||||
options: { wholeDocument: false },
|
||||
isEditing: true,
|
||||
},
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('flushes debounced history before switching document segments', () => {
|
||||
vi.useFakeTimers();
|
||||
const { service, emitter, undoRedoService, univerInstanceService } = createService();
|
||||
univerInstanceService.__addUnit(new DocumentDataModel({ id: 'doc-1' }));
|
||||
univerInstanceService.focusUnit('doc-1');
|
||||
|
||||
emitter.emitStateChangeInfo(createChange({ segmentId: 'header-1', debounce: true }));
|
||||
emitter.emitStateChangeInfo(createChange({ segmentId: '', debounce: true }));
|
||||
|
||||
expect(undoRedoService.pitchTopUndoElement()).toMatchObject({
|
||||
undoMutations: [{ params: { segmentId: 'header-1' } }],
|
||||
redoMutations: [{ params: { segmentId: 'header-1' } }],
|
||||
});
|
||||
expect(service.getStateCache('doc-1').history).toEqual([
|
||||
expect.objectContaining({ segmentId: '' }),
|
||||
]);
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(service.getStateCache('doc-1')).toEqual({ history: [], collaboration: [] });
|
||||
});
|
||||
|
||||
it('can restore pre-existing state caches for a document', () => {
|
||||
const { service } = createService();
|
||||
const state = createChange();
|
||||
|
||||
service.setStateCache('doc-1', {
|
||||
history: [state],
|
||||
collaboration: [state],
|
||||
});
|
||||
|
||||
expect(service.getStateCache('doc-1')).toEqual({
|
||||
history: [state],
|
||||
collaboration: [state],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DocStateEmitService } from '../doc-state-emit.service';
|
||||
|
||||
function createService(): DocStateEmitService {
|
||||
const injector = new Injector();
|
||||
injector.add([DocStateEmitService]);
|
||||
return injector.get(DocStateEmitService);
|
||||
}
|
||||
|
||||
describe('DocStateEmitService', () => {
|
||||
it('publishes document change payloads to collaboration/history consumers', () => {
|
||||
const service = createService();
|
||||
const emitted: unknown[] = [];
|
||||
const sub = service.docStateChangeParams$.subscribe((value) => emitted.push(value));
|
||||
const change = {
|
||||
commandId: 'doc.command',
|
||||
unitId: 'doc-1',
|
||||
trigger: 'keyboard',
|
||||
redoState: { actions: [], textRanges: null },
|
||||
undoState: { actions: [], textRanges: null },
|
||||
isSync: true,
|
||||
};
|
||||
|
||||
service.emitStateChangeInfo(change);
|
||||
|
||||
expect(emitted).toEqual([null, change]);
|
||||
sub.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { DocTextResolverService } from '../doc-text-resolver.service';
|
||||
|
||||
describe('DocTextResolverService', () => {
|
||||
it('projects replacement text and maps every visible character to native offsets', () => {
|
||||
const service = new DocTextResolverService();
|
||||
service.register({
|
||||
resolve: () => [{
|
||||
startOffset: 1,
|
||||
endOffset: 2,
|
||||
text: '42',
|
||||
}],
|
||||
});
|
||||
|
||||
expect(service.resolve('doc-1', { dataStream: 'A\uFFFCC' })).toEqual({
|
||||
text: 'A42C',
|
||||
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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores invalid and overlapping replacements deterministically', () => {
|
||||
const service = new DocTextResolverService();
|
||||
service.register({
|
||||
resolve: () => [
|
||||
{ startOffset: 1, endOffset: 3, text: 'first' },
|
||||
{ startOffset: 2, endOffset: 3, text: 'overlap' },
|
||||
{ startOffset: -1, endOffset: 1, text: 'invalid' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(service.resolve('doc-1', { dataStream: 'ABCD' }).text).toBe('AfirstD');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
DocumentDataModel,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
UniverInstanceService,
|
||||
} from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DocViewModelManagerService } from '../doc-view-model-manager.service';
|
||||
|
||||
describe('DocViewModelManagerService', () => {
|
||||
it('creates view models for existing documents and removes them when the document unit is disposed', () => {
|
||||
const injector = new Injector();
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
|
||||
const univerInstanceService = injector.get(IUniverInstanceService) as UniverInstanceService;
|
||||
univerInstanceService.__addUnit(new DocumentDataModel({
|
||||
id: 'doc-1',
|
||||
body: {
|
||||
dataStream: '\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-1' }],
|
||||
sectionBreaks: [],
|
||||
tables: [],
|
||||
customRanges: [],
|
||||
customDecorations: [],
|
||||
customBlocks: [],
|
||||
},
|
||||
}));
|
||||
|
||||
const service = injector.createInstance(DocViewModelManagerService, { unitId: 'doc-1' } as never);
|
||||
expect(service.getViewModel('doc-1')?.getDataModel().getUnitId()).toBe('doc-1');
|
||||
|
||||
univerInstanceService.disposeUnit('doc-1');
|
||||
expect(service.getAllModel().has('doc-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createDocumentLayoutSnapshot } from '../document-layout-snapshot';
|
||||
|
||||
describe('createDocumentLayoutSnapshot', () => {
|
||||
it('excludes exchange-only resources while preserving layout metadata', () => {
|
||||
const snapshot: IDocumentData = {
|
||||
id: 'layout-snapshot',
|
||||
documentStyle: {},
|
||||
resources: [{ name: 'DOC_RESOURCE', data: 'large-resource-payload' }],
|
||||
body: {
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-1' }],
|
||||
customBlocks: [{
|
||||
startIndex: 0,
|
||||
blockId: 'drawing-1',
|
||||
docxRawXml: '<w:r>large raw XML</w:r>',
|
||||
docxExportTs: { fs: 20 },
|
||||
}],
|
||||
docxRawCustomBlocks: [{
|
||||
startIndex: 0,
|
||||
blockId: 'raw-1',
|
||||
docxRawXml: '<w:r>opaque</w:r>',
|
||||
}],
|
||||
docxRawBlocks: [{ startIndex: 0, xml: '<w:proofErr />' }],
|
||||
docxExportExcludedRanges: [{ start: 0, end: 1 }],
|
||||
payloads: { clipboard: 'large clipboard payload' },
|
||||
},
|
||||
headers: {
|
||||
header: {
|
||||
headerId: 'header',
|
||||
body: {
|
||||
dataStream: '\r\n',
|
||||
docxRawBlocks: [{ startIndex: 0, xml: '<w:header />' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const layoutSnapshot = createDocumentLayoutSnapshot(snapshot);
|
||||
|
||||
expect(layoutSnapshot.resources).toBeUndefined();
|
||||
expect(layoutSnapshot.body).toMatchObject({
|
||||
dataStream: '\b\r\n',
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'paragraph-1' }],
|
||||
customBlocks: [{ startIndex: 0, blockId: 'drawing-1' }],
|
||||
});
|
||||
expect(layoutSnapshot.body?.customBlocks?.[0]).not.toHaveProperty('docxRawXml');
|
||||
expect(layoutSnapshot.body?.customBlocks?.[0]).not.toHaveProperty('docxExportTs');
|
||||
expect(layoutSnapshot.body?.docxRawCustomBlocks).toBeUndefined();
|
||||
expect(layoutSnapshot.body?.docxRawBlocks).toBeUndefined();
|
||||
expect(layoutSnapshot.body?.docxExportExcludedRanges).toBeUndefined();
|
||||
expect(layoutSnapshot.body?.payloads).toBeUndefined();
|
||||
expect(layoutSnapshot.headers?.header.body.docxRawBlocks).toBeUndefined();
|
||||
expect(snapshot.resources).toHaveLength(1);
|
||||
expect(snapshot.body?.customBlocks?.[0].docxRawXml).toContain('large raw XML');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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, IDocumentData } from '@univerjs/core';
|
||||
import { Disposable, remove, toDisposable } from '@univerjs/core';
|
||||
|
||||
export interface IDocBlockMoveValidationContext {
|
||||
unitId: string;
|
||||
sourceRange: {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
};
|
||||
targetOffset: number;
|
||||
}
|
||||
|
||||
export type DocBlockMoveValidator = (context: IDocBlockMoveValidationContext) => boolean;
|
||||
|
||||
export interface IDocBlockMoveResult {
|
||||
nextDocumentData: IDocumentData;
|
||||
movedRange: {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDocBlockMoveTransformContext extends IDocBlockMoveValidationContext {
|
||||
previousDocumentData: IDocumentData;
|
||||
result: IDocBlockMoveResult;
|
||||
}
|
||||
|
||||
export type DocBlockMoveTransformer = (context: IDocBlockMoveTransformContext) => IDocBlockMoveResult;
|
||||
|
||||
export class DocBlockMoveValidatorService extends Disposable {
|
||||
private readonly _validators: DocBlockMoveValidator[] = [];
|
||||
private readonly _transformers: DocBlockMoveTransformer[] = [];
|
||||
|
||||
registerValidator(validator: DocBlockMoveValidator): IDisposable {
|
||||
this._validators.push(validator);
|
||||
|
||||
return this.disposeWithMe(toDisposable(() => remove(this._validators, validator)));
|
||||
}
|
||||
|
||||
registerTransformer(transformer: DocBlockMoveTransformer): IDisposable {
|
||||
this._transformers.push(transformer);
|
||||
|
||||
return this.disposeWithMe(toDisposable(() => remove(this._transformers, transformer)));
|
||||
}
|
||||
|
||||
canMoveBlock(context: IDocBlockMoveValidationContext): boolean {
|
||||
return this._validators.every((validator) => validator(context));
|
||||
}
|
||||
|
||||
transformMoveResult(context: IDocBlockMoveTransformContext): IDocBlockMoveResult {
|
||||
return this._transformers.reduce((result, transformer) => transformer({
|
||||
...context,
|
||||
result,
|
||||
}), context.result);
|
||||
}
|
||||
}
|
||||
50
packages/docs/src/services/doc-content-insert.service.ts
Normal file
50
packages/docs/src/services/doc-content-insert.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
|
||||
export interface IDocContentInsertRange {
|
||||
unitId: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export class DocContentInsertService extends Disposable {
|
||||
private _range: IDocContentInsertRange | null = null;
|
||||
|
||||
setInsertRange(range: IDocContentInsertRange): void {
|
||||
this._range = range;
|
||||
}
|
||||
|
||||
consumeInsertRange(unitId?: string): IDocContentInsertRange | null {
|
||||
if (!this._range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (unitId && this._range.unitId !== unitId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const range = this._range;
|
||||
this._range = null;
|
||||
return range;
|
||||
}
|
||||
|
||||
clearInsertRange(): void {
|
||||
this._range = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 { ICustomRangeForInterceptor } from '@univerjs/core';
|
||||
import { Injector } from '@univerjs/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DocSkeletonManagerService } from '../../doc-skeleton-manager.service';
|
||||
import { DocInterceptorService } from '../doc-interceptor.service';
|
||||
import { DOC_INTERCEPTOR_POINT } from '../interceptor-const';
|
||||
|
||||
function createViewModel(unitId = 'doc-1') {
|
||||
let interceptor: { getCustomRange: (index: number) => ICustomRangeForInterceptor | null } | null = null;
|
||||
const body = { dataStream: 'Body\r\n' };
|
||||
return {
|
||||
segmentViewModels$: new BehaviorSubject([]),
|
||||
registerCustomRangeInterceptor: (value: typeof interceptor) => {
|
||||
interceptor = value;
|
||||
return {
|
||||
dispose: () => {
|
||||
interceptor = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
getCustomRangeRaw: (index: number): ICustomRangeForInterceptor => ({
|
||||
startIndex: index,
|
||||
endIndex: index + 1,
|
||||
rangeId: `range-${index}`,
|
||||
rangeType: 1,
|
||||
}),
|
||||
getCustomDecorationRaw: () => null,
|
||||
getDataModel: () => ({
|
||||
getUnitId: () => unitId,
|
||||
getBody: () => body,
|
||||
getCustomRanges: () => [],
|
||||
getCustomDecorations: () => [],
|
||||
}),
|
||||
readRange: (index: number) => interceptor?.getCustomRange(index),
|
||||
};
|
||||
}
|
||||
|
||||
class TestDocSkeletonManagerService {
|
||||
readonly viewModel = createViewModel();
|
||||
|
||||
getViewModel() {
|
||||
return this.viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
const TestDocSkeletonManagerServiceCtor = TestDocSkeletonManagerService as unknown as typeof DocSkeletonManagerService;
|
||||
|
||||
describe('DocInterceptorService', () => {
|
||||
it('composes document view-model interceptors before custom ranges are rendered', () => {
|
||||
const injector = new Injector();
|
||||
injector.add([DocSkeletonManagerService, { useClass: TestDocSkeletonManagerServiceCtor }]);
|
||||
const viewModel = (injector.get(DocSkeletonManagerService) as unknown as TestDocSkeletonManagerService).viewModel;
|
||||
|
||||
const service = injector.createInstance(DocInterceptorService, {} as never);
|
||||
let interceptedBody: unknown;
|
||||
service.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_RANGE, {
|
||||
priority: 1,
|
||||
handler: (range, context, next) => {
|
||||
interceptedBody = context.body;
|
||||
const baseRange = next(range);
|
||||
return baseRange == null ? baseRange : { ...baseRange, user: 'u1' };
|
||||
},
|
||||
});
|
||||
|
||||
expect(viewModel.readRange(3)).toEqual({
|
||||
startIndex: 3,
|
||||
endIndex: 4,
|
||||
rangeId: 'range-3',
|
||||
rangeType: 1,
|
||||
user: 'u1',
|
||||
});
|
||||
expect(interceptedBody).toEqual({ dataStream: 'Body\r\n' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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, ICustomDecorationForInterceptor, ICustomRangeForInterceptor, IInterceptor, Nullable } from '@univerjs/core';
|
||||
import type { DocumentViewModel, IRenderContext, IRenderModule } from '@univerjs/engine-render';
|
||||
import { composeInterceptors, Disposable, DisposableCollection, DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, Inject, remove, toDisposable } from '@univerjs/core';
|
||||
import { DocSkeletonManagerService } from '../doc-skeleton-manager.service';
|
||||
import { DOC_INTERCEPTOR_POINT } from './interceptor-const';
|
||||
|
||||
export class DocInterceptorService extends Disposable implements IRenderModule {
|
||||
private _interceptorsByName: Map<string, Array<IInterceptor<unknown, unknown>>> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly _context: IRenderContext<DocumentDataModel>,
|
||||
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService
|
||||
) {
|
||||
super();
|
||||
|
||||
const viewModel = this._docSkeletonManagerService.getViewModel();
|
||||
|
||||
const unitId = viewModel.getDataModel().getUnitId();
|
||||
if (unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY || unitId === DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposeWithMe(this.interceptDocumentViewModel(viewModel));
|
||||
|
||||
this.disposeWithMe(this.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_RANGE, {
|
||||
priority: -1,
|
||||
handler: (data, pos, next) => {
|
||||
return next(data);
|
||||
},
|
||||
}));
|
||||
|
||||
let disposableCollection = new DisposableCollection();
|
||||
viewModel.segmentViewModels$.subscribe((segmentViewModels) => {
|
||||
disposableCollection.dispose();
|
||||
disposableCollection = new DisposableCollection();
|
||||
segmentViewModels.forEach((segmentViewModel) => {
|
||||
disposableCollection.add(this.interceptDocumentViewModel(segmentViewModel));
|
||||
});
|
||||
// this._docSkeletonManagerService.dispose();
|
||||
});
|
||||
|
||||
this.disposeWithMe(disposableCollection);
|
||||
}
|
||||
|
||||
intercept<T extends IInterceptor<any, any>>(name: T, interceptor: T) {
|
||||
const key = name as unknown as string;
|
||||
if (!this._interceptorsByName.has(key)) {
|
||||
this._interceptorsByName.set(key, []);
|
||||
}
|
||||
const interceptors = this._interceptorsByName.get(key)!;
|
||||
interceptors.push(interceptor);
|
||||
|
||||
this._interceptorsByName.set(
|
||||
key,
|
||||
interceptors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
|
||||
);
|
||||
|
||||
return this.disposeWithMe(toDisposable(() => remove(this._interceptorsByName.get(key)!, interceptor)));
|
||||
}
|
||||
|
||||
fetchThroughInterceptors<T, C>(name: IInterceptor<T, C>) {
|
||||
const key = name as unknown as string;
|
||||
const interceptors = this._interceptorsByName.get(key) as unknown as Array<typeof name>;
|
||||
return composeInterceptors<T, C>(interceptors || []);
|
||||
}
|
||||
|
||||
interceptDocumentViewModel(viewModel: DocumentViewModel) {
|
||||
const disposableCollection = new DisposableCollection();
|
||||
disposableCollection.add(viewModel.registerCustomRangeInterceptor({
|
||||
getCustomRange: (index: number): Nullable<ICustomRangeForInterceptor> => {
|
||||
return this.fetchThroughInterceptors(DOC_INTERCEPTOR_POINT.CUSTOM_RANGE)(
|
||||
viewModel.getCustomRangeRaw(index),
|
||||
{
|
||||
index,
|
||||
unitId: viewModel.getDataModel().getUnitId(),
|
||||
body: viewModel.getDataModel().getBody(),
|
||||
customRanges: viewModel.getDataModel().getCustomRanges() ?? [],
|
||||
}
|
||||
);
|
||||
},
|
||||
getCustomDecoration: (index: number): Nullable<ICustomDecorationForInterceptor> => {
|
||||
return this.fetchThroughInterceptors(DOC_INTERCEPTOR_POINT.CUSTOM_DECORATION)(
|
||||
viewModel.getCustomDecorationRaw(index),
|
||||
{
|
||||
index,
|
||||
unitId: viewModel.getDataModel().getUnitId(),
|
||||
customDecorations: viewModel.getDataModel().getCustomDecorations() ?? [],
|
||||
}
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
return disposableCollection;
|
||||
}
|
||||
}
|
||||
@@ -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 type {
|
||||
ICustomDecoration,
|
||||
ICustomDecorationForInterceptor,
|
||||
ICustomRange,
|
||||
ICustomRangeForInterceptor,
|
||||
IDocumentBody,
|
||||
} from '@univerjs/core';
|
||||
import { createInterceptorKey } from '@univerjs/core';
|
||||
|
||||
const CUSTOM_RANGE = createInterceptorKey<
|
||||
ICustomRangeForInterceptor,
|
||||
{ index: number; unitId: string; body: IDocumentBody | undefined; customRanges: ICustomRange[] }
|
||||
>('CUSTOM_RANGE');
|
||||
const CUSTOM_DECORATION = createInterceptorKey<
|
||||
ICustomDecorationForInterceptor,
|
||||
{ index: number; unitId: string; customDecorations: ICustomDecoration[] }
|
||||
>('CUSTOM_DECORATION');
|
||||
|
||||
export const DOC_INTERCEPTOR_POINT = {
|
||||
CUSTOM_RANGE,
|
||||
CUSTOM_DECORATION,
|
||||
};
|
||||
1009
packages/docs/src/services/doc-layout-executor.service.ts
Normal file
1009
packages/docs/src/services/doc-layout-executor.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
330
packages/docs/src/services/doc-selection-manager.service.ts
Normal file
330
packages/docs/src/services/doc-selection-manager.service.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import type {
|
||||
IDocSelectionInnerParam,
|
||||
IRectRangeWithStyle,
|
||||
ISuccinctDocRangeParam,
|
||||
ITextRangeWithStyle,
|
||||
} from '@univerjs/engine-render';
|
||||
import { ICommandService, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
|
||||
import { NORMAL_TEXT_SELECTION_PLUGIN_STYLE } from '@univerjs/engine-render';
|
||||
import { BehaviorSubject, Subject, takeUntil } from 'rxjs';
|
||||
import { SetTextSelectionsOperation } from '../commands/operations/text-selection.operation';
|
||||
|
||||
interface IDocSelectionManagerSearchParam {
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a programmatically restored collapsed caret from being promoted back
|
||||
* into a neighboring whole-entity selection by feature-specific UI.
|
||||
*/
|
||||
export const DOC_SELECTION_OPTION_PRESERVE_CARET = 'preserveCaret';
|
||||
|
||||
export interface IRefreshSelectionParam extends IDocSelectionManagerSearchParam {
|
||||
docRanges: ISuccinctDocRangeParam[];
|
||||
isEditing: boolean;
|
||||
options?: {
|
||||
[key: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ITextSelectionManagerInsertParam extends IDocSelectionManagerSearchParam, IDocSelectionInnerParam {}
|
||||
|
||||
type ITextSelectionInfo = Map<string, Map<string, IDocSelectionInnerParam>>;
|
||||
|
||||
/**
|
||||
* This service is for text selection.
|
||||
*/
|
||||
export class DocSelectionManagerService extends RxDisposable {
|
||||
private _currentSelection: Nullable<IDocSelectionManagerSearchParam> = null;
|
||||
|
||||
private readonly _textSelectionInfo: ITextSelectionInfo = new Map();
|
||||
|
||||
private readonly _textSelection$ = new Subject<ITextSelectionManagerInsertParam>();
|
||||
readonly textSelection$ = this._textSelection$.asObservable();
|
||||
|
||||
private readonly _refreshSelection$ = new BehaviorSubject<Nullable<IRefreshSelectionParam>>(null);
|
||||
readonly refreshSelection$ = this._refreshSelection$.asObservable();
|
||||
|
||||
constructor(
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._listenCurrentUnit();
|
||||
}
|
||||
|
||||
private _listenCurrentUnit() {
|
||||
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
|
||||
.pipe(takeUntil(this.dispose$))
|
||||
.subscribe((documentModel) => {
|
||||
if (documentModel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unitId = documentModel.getUnitId();
|
||||
|
||||
this._setCurrentSelectionNotRefresh({
|
||||
unitId,
|
||||
subUnitId: unitId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
__getCurrentSelection() {
|
||||
return this._currentSelection;
|
||||
}
|
||||
|
||||
getSelectionInfo(params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection) {
|
||||
return this._getTextRanges(params);
|
||||
}
|
||||
|
||||
refreshSelection(
|
||||
params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection,
|
||||
isEditing = false
|
||||
) {
|
||||
if (params == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._refresh(params, isEditing);
|
||||
}
|
||||
|
||||
// **Only used in test case** because this does not go through the render layer.
|
||||
__TEST_ONLY_setCurrentSelection(param: IDocSelectionManagerSearchParam) {
|
||||
this._currentSelection = param;
|
||||
|
||||
this._refresh(param);
|
||||
}
|
||||
|
||||
getTextRanges(
|
||||
params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection
|
||||
): Readonly<Nullable<ITextRangeWithStyle[]>> {
|
||||
return this._getTextRanges(params)?.textRanges;
|
||||
}
|
||||
|
||||
getRectRanges(
|
||||
params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection
|
||||
): Readonly<Nullable<IRectRangeWithStyle[]>> {
|
||||
return this._getTextRanges(params)?.rectRanges;
|
||||
}
|
||||
|
||||
getDocRanges(
|
||||
params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection
|
||||
) {
|
||||
const textRanges = this.getTextRanges(params) ?? [];
|
||||
const rectRanges = this.getRectRanges(params) ?? [];
|
||||
// Sort ranges by startOffset in ascending order.
|
||||
const allRanges = [...textRanges, ...rectRanges]
|
||||
.filter((range) => range.startOffset != null && range.endOffset != null)
|
||||
.sort((a, b) => {
|
||||
if (a.startOffset! > b.startOffset!) {
|
||||
return 1;
|
||||
} else if (a.startOffset! < b.startOffset!) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return allRanges;
|
||||
}
|
||||
|
||||
getActiveTextRange(): Nullable<ITextRangeWithStyle> {
|
||||
const selectionInfo = this._getTextRanges(this._currentSelection);
|
||||
if (selectionInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { textRanges } = selectionInfo;
|
||||
|
||||
return textRanges.find((textRange) => textRange.isActive);
|
||||
}
|
||||
|
||||
// **Only used in test case** because this does not go through the render layer.
|
||||
__TEST_ONLY_add(textRanges: ITextRangeWithStyle[], isEditing = true) {
|
||||
if (this._currentSelection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._addByParam({
|
||||
...this._currentSelection,
|
||||
textRanges,
|
||||
rectRanges: [],
|
||||
segmentId: '',
|
||||
segmentPage: -1,
|
||||
isEditing,
|
||||
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE, // mock style.
|
||||
});
|
||||
}
|
||||
|
||||
replaceDocRanges(
|
||||
docRanges: ISuccinctDocRangeParam[],
|
||||
params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection,
|
||||
isEditing = true,
|
||||
options?: { [key: string]: boolean }
|
||||
) {
|
||||
if (params == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove all textRanges.
|
||||
// Add new textRanges.
|
||||
const { unitId, subUnitId } = params;
|
||||
|
||||
this._refreshSelection$.next({
|
||||
unitId,
|
||||
subUnitId,
|
||||
docRanges,
|
||||
isEditing,
|
||||
options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces logical selection state without rebuilding render ranges. Atomic
|
||||
* external mutations use this before applying document actions so layout can
|
||||
* follow transformed offsets, then refresh after new geometry is available.
|
||||
*/
|
||||
replaceSelectionInfoWithoutRefresh(
|
||||
selectionInfo: IDocSelectionInnerParam,
|
||||
params: Nullable<IDocSelectionManagerSearchParam> = this._currentSelection
|
||||
): void {
|
||||
if (params == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._replaceByParam({
|
||||
...selectionInfo,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
// Only use in doc-selection-render.controller.ts
|
||||
__replaceTextRangesWithNoRefresh(textSelectionInfo: IDocSelectionInnerParam, search: IDocSelectionManagerSearchParam) {
|
||||
// Embedded render selections have an explicit owner even while the host retains global focus.
|
||||
const params = {
|
||||
...textSelectionInfo,
|
||||
...search,
|
||||
};
|
||||
|
||||
// Store the textSelectionInfo.
|
||||
this._replaceByParam(params);
|
||||
|
||||
// Broadcast textSelection changes, this should be used within the application.
|
||||
this._textSelection$.next(params);
|
||||
|
||||
const { unitId, subUnitId, segmentId, style, textRanges, rectRanges, isEditing } = params;
|
||||
|
||||
const ranges = [...textRanges, ...rectRanges]
|
||||
.filter((range) => range.startOffset != null && range.endOffset != null)
|
||||
.sort((a, b) => {
|
||||
if (a.startOffset! > b.startOffset!) {
|
||||
return 1;
|
||||
} else if (a.startOffset! < b.startOffset!) {
|
||||
return -1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// For menu status.
|
||||
this._commandService.executeCommand(SetTextSelectionsOperation.id, {
|
||||
unitId,
|
||||
subUnitId,
|
||||
segmentId,
|
||||
style,
|
||||
isEditing,
|
||||
ranges,
|
||||
});
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._textSelection$.complete();
|
||||
this._refreshSelection$.complete();
|
||||
}
|
||||
|
||||
private _setCurrentSelectionNotRefresh(param: IDocSelectionManagerSearchParam) {
|
||||
this._currentSelection = param;
|
||||
}
|
||||
|
||||
private _getTextRanges(param: Nullable<IDocSelectionManagerSearchParam>) {
|
||||
if (param == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { unitId, subUnitId = '' } = param;
|
||||
|
||||
return this._textSelectionInfo.get(unitId)?.get(subUnitId);
|
||||
}
|
||||
|
||||
private _refresh(param: IDocSelectionManagerSearchParam, isEditing = false): void {
|
||||
const allTextSelectionInfo = this._getTextRanges(param);
|
||||
|
||||
if (allTextSelectionInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { textRanges, rectRanges, options } = allTextSelectionInfo;
|
||||
|
||||
const docRanges = [...textRanges, ...rectRanges];
|
||||
|
||||
const { unitId, subUnitId } = param;
|
||||
|
||||
this._refreshSelection$.next({
|
||||
unitId,
|
||||
subUnitId,
|
||||
docRanges,
|
||||
isEditing,
|
||||
// Forced focus belongs to the original interaction, not later layout refreshes.
|
||||
options: options?.forceFocus ? { ...options, forceFocus: false } : options,
|
||||
});
|
||||
}
|
||||
|
||||
private _replaceByParam(insertParam: ITextSelectionManagerInsertParam) {
|
||||
const { unitId, subUnitId, ...selectionInsertParam } = insertParam;
|
||||
|
||||
if (!this._textSelectionInfo.has(unitId)) {
|
||||
this._textSelectionInfo.set(unitId, new Map());
|
||||
}
|
||||
|
||||
const unitTextRange = this._textSelectionInfo.get(unitId)!;
|
||||
|
||||
unitTextRange.set(subUnitId, { ...selectionInsertParam });
|
||||
}
|
||||
|
||||
private _addByParam(insertParam: ITextSelectionManagerInsertParam): void {
|
||||
const { unitId, subUnitId, ...selectionInsertParam } = insertParam;
|
||||
|
||||
if (!this._textSelectionInfo.has(unitId)) {
|
||||
this._textSelectionInfo.set(unitId, new Map());
|
||||
}
|
||||
|
||||
const unitTextRange = this._textSelectionInfo.get(unitId)!;
|
||||
|
||||
if (!unitTextRange.has(subUnitId)) {
|
||||
unitTextRange.set(subUnitId, { ...selectionInsertParam });
|
||||
} else {
|
||||
const OldTextRanges = unitTextRange.get(subUnitId)!;
|
||||
OldTextRanges.textRanges.push(...insertParam.textRanges);
|
||||
}
|
||||
}
|
||||
}
|
||||
153
packages/docs/src/services/doc-skeleton-manager.service.ts
Normal file
153
packages/docs/src/services/doc-skeleton-manager.service.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
|
||||
import { DocumentFlavor, Inject, isInternalEditorID, IUniverInstanceService, LocaleService, RxDisposable, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocumentSkeleton, DocumentViewModel } from '@univerjs/engine-render';
|
||||
import { BehaviorSubject, takeUntil } from 'rxjs';
|
||||
import { DocLayoutExecutorService } from './doc-layout-executor.service';
|
||||
|
||||
/**
|
||||
* This service is for document build and manage doc skeletons. It also manages
|
||||
* DocumentViewModels.
|
||||
*/
|
||||
export class DocSkeletonManagerService extends RxDisposable implements IRenderModule {
|
||||
private _skeleton: DocumentSkeleton;
|
||||
private _docViewModel: DocumentViewModel;
|
||||
|
||||
private readonly _currentSkeleton$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
|
||||
readonly currentSkeleton$ = this._currentSkeleton$.asObservable();
|
||||
|
||||
// CurrentSkeletonBefore for pre-triggered logic during registration
|
||||
private readonly _currentSkeletonBefore$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
|
||||
readonly currentSkeletonBefore$ = this._currentSkeletonBefore$.asObservable();
|
||||
|
||||
private readonly _currentViewModel$ = new BehaviorSubject<Nullable<DocumentViewModel>>(null);
|
||||
readonly currentViewModel$ = this._currentViewModel$.asObservable();
|
||||
|
||||
constructor(
|
||||
private readonly _context: Pick<IRenderContext<DocumentDataModel>, 'type' | 'unit' | 'unitId'>,
|
||||
@Inject(LocaleService) private readonly _localeService: LocaleService,
|
||||
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
|
||||
@Inject(DocLayoutExecutorService) private readonly _docLayoutExecutorService: DocLayoutExecutorService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._init();
|
||||
|
||||
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
|
||||
.pipe(takeUntil(this.dispose$))
|
||||
.subscribe((documentModel) => {
|
||||
if (documentModel && documentModel.getUnitId() === this._context.unitId) {
|
||||
this._update(documentModel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
this._currentSkeletonBefore$.complete();
|
||||
this._currentSkeleton$.complete();
|
||||
// this._currentViewModel$.complete();
|
||||
|
||||
// this._docViewModel?.dispose();
|
||||
|
||||
//@ts-ignore
|
||||
// delete this._docViewModel;
|
||||
}
|
||||
|
||||
getSkeleton(): DocumentSkeleton {
|
||||
return this._skeleton;
|
||||
}
|
||||
|
||||
getViewModel(): DocumentViewModel {
|
||||
return this._docViewModel;
|
||||
}
|
||||
|
||||
getLayoutPerformanceMetrics(): ReturnType<DocLayoutExecutorService['getPerformanceMetrics']> {
|
||||
return this._docLayoutExecutorService.getPerformanceMetrics(this._context.unitId);
|
||||
}
|
||||
|
||||
supportsIncrementalLayout(): boolean {
|
||||
const documentFlavor = this._context.unit.getSnapshot().documentStyle?.documentFlavor;
|
||||
|
||||
return this._docLayoutExecutorService.getExecutor() != null &&
|
||||
!isInternalEditorID(this._context.unitId) &&
|
||||
(documentFlavor === DocumentFlavor.TRADITIONAL || documentFlavor === DocumentFlavor.MODERN);
|
||||
}
|
||||
|
||||
recalculate(): DocumentSkeleton {
|
||||
const skeleton = this._skeleton;
|
||||
skeleton.calculate();
|
||||
this._publishSkeleton(skeleton);
|
||||
return skeleton;
|
||||
}
|
||||
|
||||
private _publishSkeleton(skeleton: DocumentSkeleton): void {
|
||||
this._currentSkeletonBefore$.next(skeleton);
|
||||
this._currentSkeleton$.next(skeleton);
|
||||
}
|
||||
|
||||
private _init() {
|
||||
const documentDataModel = this._context.unit;
|
||||
this._update(documentDataModel);
|
||||
}
|
||||
|
||||
private _update(documentDataModel: DocumentDataModel) {
|
||||
const unitId = this._context.unitId;
|
||||
|
||||
// No need to build view model, if data model has no body.
|
||||
if (documentDataModel.getBody() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always need to reset document data model, because cell editor change doc instance every time.
|
||||
if (this._docViewModel && isInternalEditorID(unitId)) {
|
||||
this._docViewModel.reset(documentDataModel);
|
||||
|
||||
this._context.unit = documentDataModel;
|
||||
} else if (!this._docViewModel) {
|
||||
this._docViewModel = this._buildDocViewModel(documentDataModel);
|
||||
}
|
||||
|
||||
if (!this._skeleton) {
|
||||
this._skeleton = this._buildSkeleton(this._docViewModel);
|
||||
}
|
||||
|
||||
if (this.supportsIncrementalLayout()) {
|
||||
// The optional layout executor owns interactive progressive layout. Consumers that
|
||||
// do not register it keep the historical synchronous initialization and editing path.
|
||||
this._publishSkeleton(this._skeleton);
|
||||
} else {
|
||||
// Internal editors, UNSPECIFIED documents, and applications without the optional
|
||||
// executor depend on a complete skeleton immediately for caret/input geometry.
|
||||
this.recalculate();
|
||||
}
|
||||
|
||||
// sub: packages/docs/src/services/doc-interceptor/doc-interceptor.service.ts
|
||||
this._currentViewModel$.next(this._docViewModel);
|
||||
}
|
||||
|
||||
private _buildSkeleton(documentViewModel: DocumentViewModel) {
|
||||
return DocumentSkeleton.create(documentViewModel, this._localeService);
|
||||
}
|
||||
|
||||
private _buildDocViewModel(documentDataModel: DocumentDataModel) {
|
||||
return new DocumentViewModel(documentDataModel);
|
||||
}
|
||||
}
|
||||
274
packages/docs/src/services/doc-state-change-manager.service.ts
Normal file
274
packages/docs/src/services/doc-state-change-manager.service.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* 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, JSONXActions, Nullable } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../commands/mutations/core-editing.mutation';
|
||||
import type { IDocStateChangeInfo, IDocStateChangeParams } from './doc-state-emit.service';
|
||||
import { createIdentifier, ICommandService, Inject, IUndoRedoService, IUniverInstanceService, JSONX, Optional, RedoCommandId, RxDisposable, UndoCommandId, UniverInstanceType } from '@univerjs/core';
|
||||
import { BehaviorSubject, takeUntil } from 'rxjs';
|
||||
import { DocStateEmitService } from './doc-state-emit.service';
|
||||
|
||||
type ChangeStateCacheType = 'history' | 'collaboration';
|
||||
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
interface IStateCache {
|
||||
history: IDocStateChangeParams[];
|
||||
collaboration: IDocStateChangeParams[];
|
||||
}
|
||||
|
||||
export interface IDocStateChangeInterceptorService {
|
||||
transformChangeStateInfo(changeStateInfo: IDocStateChangeInfo): Nullable<IDocStateChangeInfo>;
|
||||
}
|
||||
|
||||
export const IDocStateChangeInterceptorService = createIdentifier<IDocStateChangeInterceptorService>('doc.state-change-interceptor-service');
|
||||
|
||||
// This class sends out state-changing events, what the state is, the data model,
|
||||
// and cursor & selection information. It mainly serves history and collaboration.
|
||||
export class DocStateChangeManagerService extends RxDisposable {
|
||||
private readonly _docStateChange$ = new BehaviorSubject<Nullable<IDocStateChangeParams>>(null);
|
||||
readonly docStateChange$ = this._docStateChange$.asObservable();
|
||||
// This cache used for history compose.
|
||||
private _historyStateCache: Map<string, IDocStateChangeParams[]> = new Map();
|
||||
// This cache used for collaboration state compose.
|
||||
private _changeStateCache: Map<string, IDocStateChangeParams[]> = new Map();
|
||||
private _historyTimer: Nullable<ReturnType<typeof setTimeout>> = null;
|
||||
private _changeStateCacheTimer: Nullable<ReturnType<typeof setTimeout>> = null;
|
||||
|
||||
constructor(
|
||||
@Optional(IUndoRedoService) private _undoRedoService: Nullable<IUndoRedoService>,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
|
||||
@Inject(DocStateEmitService) private readonly _docStateEmitService: DocStateEmitService,
|
||||
@Optional(IDocStateChangeInterceptorService) private readonly _docStateChangeInterceptorService?: IDocStateChangeInterceptorService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._initialize();
|
||||
this._listenDocStateChange();
|
||||
}
|
||||
|
||||
getStateCache(unitId: string) {
|
||||
return {
|
||||
history: this._historyStateCache.get(unitId) ?? [],
|
||||
collaboration: this._changeStateCache.get(unitId) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
setStateCache(unitId: string, cache: IStateCache) {
|
||||
this._historyStateCache.set(unitId, cache.history);
|
||||
this._changeStateCache.set(unitId, cache.collaboration);
|
||||
}
|
||||
|
||||
clearHistory(unitId: string): void {
|
||||
this._historyStateCache.delete(unitId);
|
||||
}
|
||||
|
||||
private _setChangeState(changeState: IDocStateChangeParams) {
|
||||
this._cacheChangeState(changeState, 'history');
|
||||
// Mutations by user or historyService need collaboration.
|
||||
this._cacheChangeState(changeState, 'collaboration');
|
||||
}
|
||||
|
||||
private _initialize() {
|
||||
this.disposeWithMe(
|
||||
this._commandService.beforeCommandExecuted((command) => {
|
||||
if (command.id === UndoCommandId || command.id === RedoCommandId) {
|
||||
const univerDoc = this._univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
|
||||
if (univerDoc == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unitId = univerDoc.getUnitId();
|
||||
|
||||
this._pushHistory(unitId);
|
||||
this._emitChangeState(unitId);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _listenDocStateChange() {
|
||||
this._docStateEmitService.docStateChangeParams$.pipe(takeUntil(this.dispose$)).subscribe((changeStateInfo) => {
|
||||
if (changeStateInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interceptedChangeStateInfo = this._docStateChangeInterceptorService?.transformChangeStateInfo(changeStateInfo) ?? changeStateInfo;
|
||||
if (interceptedChangeStateInfo == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (interceptedChangeStateInfo.isSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isCompositionEnd: _isCompositionEnd, isSync: _isSync, syncer: _syncer, ...changeState } = interceptedChangeStateInfo;
|
||||
|
||||
this._setChangeState(changeState);
|
||||
});
|
||||
}
|
||||
|
||||
private _cacheChangeState(changeState: IDocStateChangeParams, type: ChangeStateCacheType = 'history') {
|
||||
const { trigger, unitId, noHistory, debounce = false } = changeState;
|
||||
|
||||
if (noHistory || (type === 'history' && trigger == null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'history' && (trigger === RedoCommandId || trigger === UndoCommandId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stateCache = type === 'history'
|
||||
? this._historyStateCache
|
||||
: this._changeStateCache;
|
||||
|
||||
const cb = type === 'history'
|
||||
? this._pushHistory.bind(this)
|
||||
: this._emitChangeState.bind(this);
|
||||
|
||||
const pendingStates = stateCache.get(unitId);
|
||||
// Switching segments ends the current debounce group so body, header, and footer edits keep separate history entries.
|
||||
if (pendingStates?.length && pendingStates[pendingStates.length - 1].segmentId !== changeState.segmentId) {
|
||||
cb(unitId);
|
||||
}
|
||||
|
||||
if (stateCache.has(unitId)) {
|
||||
const cacheStates = stateCache.get(unitId);
|
||||
|
||||
cacheStates?.push(changeState);
|
||||
} else {
|
||||
stateCache.set(unitId, [changeState]);
|
||||
}
|
||||
|
||||
if (debounce) {
|
||||
if (type === 'history') {
|
||||
if (this._historyTimer) {
|
||||
clearTimeout(this._historyTimer);
|
||||
}
|
||||
|
||||
this._historyTimer = setTimeout(() => {
|
||||
cb(unitId);
|
||||
}, DEBOUNCE_DELAY);
|
||||
} else {
|
||||
if (this._changeStateCacheTimer) {
|
||||
clearTimeout(this._changeStateCacheTimer);
|
||||
}
|
||||
|
||||
this._changeStateCacheTimer = setTimeout(() => {
|
||||
cb(unitId);
|
||||
}, DEBOUNCE_DELAY);
|
||||
}
|
||||
} else {
|
||||
cb(unitId);
|
||||
}
|
||||
}
|
||||
|
||||
private _pushHistory(unitId: string) {
|
||||
const undoRedoService = this._undoRedoService;
|
||||
const cacheStates = this._historyStateCache.get(unitId);
|
||||
|
||||
if (undoRedoService == null || !Array.isArray(cacheStates) || cacheStates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const len = cacheStates.length;
|
||||
// Use the first state.commandId as commandId, because we will only have one core mutation type.
|
||||
const commandId = cacheStates[0].commandId;
|
||||
|
||||
const firstState = cacheStates[0];
|
||||
const lastState = cacheStates[len - 1];
|
||||
|
||||
const redoParams: IRichTextEditingMutationParams = {
|
||||
unitId,
|
||||
actions: cacheStates.reduce((acc, cur) => JSONX.compose(acc, cur.redoState.actions), null as JSONXActions),
|
||||
textRanges: lastState.redoState.textRanges,
|
||||
segmentId: lastState.segmentId,
|
||||
options: lastState.redoState.options,
|
||||
isEditing: lastState.redoState.isEditing,
|
||||
};
|
||||
|
||||
const undoParams: IRichTextEditingMutationParams = {
|
||||
unitId,
|
||||
// Always need to put undoParams after redoParams, because `reverse` will change the `cacheStates` order.
|
||||
actions: cacheStates.reverse().reduce((acc, cur) => JSONX.compose(acc, cur.undoState.actions), null as JSONXActions),
|
||||
textRanges: firstState.undoState.textRanges,
|
||||
segmentId: firstState.segmentId,
|
||||
options: firstState.undoState.options,
|
||||
isEditing: firstState.undoState.isEditing,
|
||||
};
|
||||
|
||||
undoRedoService.pushUndoRedo({
|
||||
unitID: unitId,
|
||||
undoMutations: [{ id: commandId, params: undoParams }],
|
||||
redoMutations: [{ id: commandId, params: redoParams }],
|
||||
});
|
||||
|
||||
// Empty the cacheState.
|
||||
cacheStates.length = 0;
|
||||
}
|
||||
|
||||
private _emitChangeState(unitId: string) {
|
||||
const cacheStates = this._changeStateCache.get(unitId);
|
||||
|
||||
if (!Array.isArray(cacheStates) || cacheStates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const len = cacheStates.length;
|
||||
// Use the first state.commandId as commandId, because we will only have one core mutation type.
|
||||
const { commandId, trigger, segmentId, noHistory, debounce } = cacheStates[0];
|
||||
|
||||
const firstState = cacheStates[0];
|
||||
const lastState = cacheStates[len - 1];
|
||||
|
||||
const redoState: IRichTextEditingMutationParams = {
|
||||
unitId,
|
||||
actions: cacheStates.reduce((acc, cur) => JSONX.compose(acc, cur.redoState.actions), null as JSONXActions),
|
||||
textRanges: lastState.redoState.textRanges,
|
||||
segmentId: lastState.segmentId,
|
||||
options: lastState.redoState.options,
|
||||
isEditing: lastState.redoState.isEditing,
|
||||
};
|
||||
|
||||
const undoState: IRichTextEditingMutationParams = {
|
||||
unitId,
|
||||
// Always need to put undoParams after redoParams, because `reverse` will change the `cacheStates` order.
|
||||
actions: cacheStates.reverse().reduce((acc, cur) => JSONX.compose(acc, cur.undoState.actions), null as JSONXActions),
|
||||
textRanges: firstState.undoState.textRanges,
|
||||
segmentId: firstState.segmentId,
|
||||
options: firstState.undoState.options,
|
||||
isEditing: firstState.undoState.isEditing,
|
||||
};
|
||||
|
||||
const changeState: IDocStateChangeParams = {
|
||||
commandId,
|
||||
unitId,
|
||||
trigger,
|
||||
redoState,
|
||||
undoState,
|
||||
segmentId,
|
||||
noHistory,
|
||||
debounce,
|
||||
};
|
||||
|
||||
// Empty the cacheState.
|
||||
cacheStates.length = 0;
|
||||
|
||||
this._docStateChange$.next(changeState);
|
||||
}
|
||||
}
|
||||
63
packages/docs/src/services/doc-state-emit.service.ts
Normal file
63
packages/docs/src/services/doc-state-emit.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { JSONXActions, Nullable } from '@univerjs/core';
|
||||
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import { RxDisposable } from '@univerjs/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
interface IDocChangeState {
|
||||
actions: JSONXActions;
|
||||
textRanges: Nullable<ITextRangeWithStyle[]>;
|
||||
options?: { [key: string]: boolean };
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export interface IDocStateChangeParams {
|
||||
commandId: string;
|
||||
unitId: string;
|
||||
trigger: Nullable<string>;
|
||||
redoState: IDocChangeState;
|
||||
undoState: IDocChangeState;
|
||||
segmentId?: string;
|
||||
noHistory?: boolean;
|
||||
debounce?: boolean;
|
||||
}
|
||||
|
||||
export interface IDocStateChangeInfo extends IDocStateChangeParams {
|
||||
isCompositionEnd?: boolean;
|
||||
isSync?: boolean;
|
||||
syncer?: string;
|
||||
}
|
||||
|
||||
export class DocStateEmitService extends RxDisposable {
|
||||
private readonly _docStateChangeParams$ = new BehaviorSubject<Nullable<IDocStateChangeInfo>>(null);
|
||||
readonly docStateChangeParams$ = this._docStateChangeParams$.asObservable();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
emitStateChangeInfo(params: IDocStateChangeInfo) {
|
||||
this._docStateChangeParams$.next(params);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
this._docStateChangeParams$.complete();
|
||||
}
|
||||
}
|
||||
139
packages/docs/src/services/doc-text-resolver.service.ts
Normal file
139
packages/docs/src/services/doc-text-resolver.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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, IDocumentBody } from '@univerjs/core';
|
||||
import { Disposable, toDisposable } from '@univerjs/core';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
export interface IDocTextReplacement {
|
||||
endOffset: number;
|
||||
replaceable?: boolean;
|
||||
startOffset: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IDocTextResolver {
|
||||
resolve(unitId: string, body: IDocumentBody): readonly IDocTextReplacement[];
|
||||
}
|
||||
|
||||
export interface IResolvedDocTextCharacter {
|
||||
endOffset: number;
|
||||
replaceable: boolean;
|
||||
startOffset: number;
|
||||
}
|
||||
|
||||
export interface IResolvedDocText {
|
||||
characters: readonly IResolvedDocTextCharacter[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a consumer-facing text projection while retaining a mapping back to
|
||||
* the native document offsets.
|
||||
*
|
||||
* Consumers such as find/replace can search the projected text and then use
|
||||
* `characters` to focus the native object that supplied a matching character.
|
||||
* Resolvers must return half-open, non-overlapping replacements.
|
||||
*/
|
||||
export class DocTextResolverService extends Disposable {
|
||||
private readonly _resolvers = new Set<IDocTextResolver>();
|
||||
private readonly _textChanged$ = new Subject<string>();
|
||||
|
||||
readonly textChanged$ = this._textChanged$.asObservable();
|
||||
|
||||
register(resolver: IDocTextResolver): IDisposable {
|
||||
this._resolvers.add(resolver);
|
||||
return toDisposable(() => this._resolvers.delete(resolver));
|
||||
}
|
||||
|
||||
notifyTextChanged(unitId: string): void {
|
||||
this._textChanged$.next(unitId);
|
||||
}
|
||||
|
||||
resolve(unitId: string, body: IDocumentBody): IResolvedDocText {
|
||||
const replacements = this._collectReplacements(unitId, body);
|
||||
const characters: IResolvedDocTextCharacter[] = [];
|
||||
let text = '';
|
||||
let sourceOffset = 0;
|
||||
|
||||
for (const replacement of replacements) {
|
||||
const sourceText = body.dataStream.slice(sourceOffset, replacement.startOffset);
|
||||
text += sourceText;
|
||||
for (let index = sourceOffset; index < replacement.startOffset; index += 1) {
|
||||
characters.push({
|
||||
startOffset: index,
|
||||
endOffset: index + 1,
|
||||
replaceable: true,
|
||||
});
|
||||
}
|
||||
|
||||
text += replacement.text;
|
||||
for (let index = 0; index < replacement.text.length; index += 1) {
|
||||
characters.push({
|
||||
startOffset: replacement.startOffset,
|
||||
endOffset: replacement.endOffset,
|
||||
replaceable: replacement.replaceable ?? false,
|
||||
});
|
||||
}
|
||||
sourceOffset = replacement.endOffset;
|
||||
}
|
||||
|
||||
text += body.dataStream.slice(sourceOffset);
|
||||
for (let index = sourceOffset; index < body.dataStream.length; index += 1) {
|
||||
characters.push({
|
||||
startOffset: index,
|
||||
endOffset: index + 1,
|
||||
replaceable: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { characters, text };
|
||||
}
|
||||
|
||||
private _collectReplacements(
|
||||
unitId: string,
|
||||
body: IDocumentBody
|
||||
): IDocTextReplacement[] {
|
||||
const candidates = [...this._resolvers]
|
||||
.flatMap((resolver) => resolver.resolve(unitId, body))
|
||||
.filter((replacement) =>
|
||||
Number.isInteger(replacement.startOffset) &&
|
||||
Number.isInteger(replacement.endOffset) &&
|
||||
replacement.startOffset >= 0 &&
|
||||
replacement.endOffset > replacement.startOffset &&
|
||||
replacement.endOffset <= body.dataStream.length
|
||||
)
|
||||
.sort((left, right) =>
|
||||
left.startOffset - right.startOffset ||
|
||||
right.endOffset - left.endOffset
|
||||
);
|
||||
const replacements: IDocTextReplacement[] = [];
|
||||
let previousEnd = -1;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.startOffset < previousEnd) {
|
||||
continue;
|
||||
}
|
||||
replacements.push(candidate);
|
||||
previousEnd = candidate.endOffset;
|
||||
}
|
||||
return replacements;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._textChanged$.complete();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
135
packages/docs/src/services/doc-view-model-manager.service.ts
Normal file
135
packages/docs/src/services/doc-view-model-manager.service.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 } from '@univerjs/core';
|
||||
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
|
||||
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
|
||||
import { DocumentViewModel } from '@univerjs/engine-render';
|
||||
import { BehaviorSubject, Subject, takeUntil } from 'rxjs';
|
||||
|
||||
export interface IDocumentViewModelManagerParam {
|
||||
unitId: string;
|
||||
docViewModel: DocumentViewModel;
|
||||
}
|
||||
|
||||
// TODO@wzhudev: move this manager service into render unit
|
||||
|
||||
/**
|
||||
* The view model manager is used to manage Doc view model. Each view model has a one-to-one correspondence
|
||||
* with the doc skeleton.
|
||||
*/
|
||||
export class DocViewModelManagerService extends RxDisposable implements IRenderModule {
|
||||
private _docViewModelMap: Map<string, IDocumentViewModelManagerParam> = new Map();
|
||||
|
||||
private readonly _currentDocViewModel$ = new BehaviorSubject<Nullable<IDocumentViewModelManagerParam>>(null);
|
||||
readonly currentDocViewModel$ = this._currentDocViewModel$.asObservable();
|
||||
|
||||
private readonly _docViewModelAdd$ = new Subject<DocumentViewModel>();
|
||||
readonly docViewModelAdd$ = this._docViewModelAdd$.asObservable();
|
||||
|
||||
constructor(
|
||||
private readonly _context: IRenderContext<DocumentDataModel>,
|
||||
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
|
||||
) {
|
||||
super();
|
||||
this._initialize();
|
||||
}
|
||||
|
||||
private _initialize() {
|
||||
this._init();
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._currentDocViewModel$.complete();
|
||||
this._docViewModelMap.clear();
|
||||
}
|
||||
|
||||
private _init() {
|
||||
this._univerInstanceService
|
||||
.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
|
||||
.pipe(takeUntil(this.dispose$))
|
||||
.subscribe((documentModel) => {
|
||||
this._create(documentModel);
|
||||
});
|
||||
|
||||
this._univerInstanceService.getAllUnitsForType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).forEach((documentModel) => {
|
||||
this._create(documentModel);
|
||||
});
|
||||
|
||||
this._univerInstanceService.getTypeOfUnitDisposed$(UniverInstanceType.UNIVER_DOC).pipe(takeUntil(this.dispose$)).subscribe((documentModel) => {
|
||||
this._docViewModelMap.delete(documentModel.getUnitId());
|
||||
});
|
||||
}
|
||||
|
||||
private _create(documentModel: Nullable<DocumentDataModel>) {
|
||||
if (documentModel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unitId = documentModel.getUnitId();
|
||||
// Build the view model and notify the skeleton manager to create the skeleton.
|
||||
this._setCurrent(unitId);
|
||||
}
|
||||
|
||||
getAllModel() {
|
||||
return this._docViewModelMap;
|
||||
}
|
||||
|
||||
getViewModel(unitId: string) {
|
||||
return this._docViewModelMap.get(unitId)?.docViewModel;
|
||||
}
|
||||
|
||||
private _setCurrent(unitId: string) {
|
||||
const documentDataModel = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (documentDataModel == null) {
|
||||
throw new Error(`Document data model with id ${unitId} not found when build view model.`);
|
||||
}
|
||||
|
||||
// No need to build view model, if data model has no body.
|
||||
if (documentDataModel.getBody() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._docViewModelMap.has(unitId)) {
|
||||
const docViewModel = this._buildDocViewModel(documentDataModel);
|
||||
|
||||
this._docViewModelMap.set(unitId, {
|
||||
unitId,
|
||||
docViewModel,
|
||||
});
|
||||
}
|
||||
|
||||
// Always need to reset document data model, because cell editor change doc instance every time.
|
||||
if (unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
|
||||
const docViewModel = this._docViewModelMap.get(unitId)?.docViewModel;
|
||||
|
||||
if (docViewModel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
docViewModel.reset(documentDataModel);
|
||||
}
|
||||
|
||||
this._currentDocViewModel$.next(this._docViewModelMap.get(unitId));
|
||||
}
|
||||
|
||||
private _buildDocViewModel(documentDataModel: DocumentDataModel) {
|
||||
const model = new DocumentViewModel(documentDataModel);
|
||||
this._docViewModelAdd$.next(model);
|
||||
|
||||
return model;
|
||||
}
|
||||
}
|
||||
82
packages/docs/src/services/document-layout-snapshot.ts
Normal file
82
packages/docs/src/services/document-layout-snapshot.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 { ICustomBlock, IDocumentBody, IDocumentData, IFooterData, IHeaderData } from '@univerjs/core';
|
||||
|
||||
function projectCustomBlock(block: ICustomBlock): ICustomBlock {
|
||||
const {
|
||||
docxRawXml: _docxRawXml,
|
||||
docxExportTs: _docxExportTs,
|
||||
...layoutBlock
|
||||
} = block;
|
||||
return layoutBlock;
|
||||
}
|
||||
|
||||
function projectDocumentBody(body: IDocumentBody): IDocumentBody {
|
||||
const {
|
||||
docxRawCustomBlocks: _docxRawCustomBlocks,
|
||||
docxRawBlocks: _docxRawBlocks,
|
||||
docxExportExcludedRanges: _docxExportExcludedRanges,
|
||||
payloads: _payloads,
|
||||
customBlocks,
|
||||
...layoutBody
|
||||
} = body;
|
||||
return {
|
||||
...layoutBody,
|
||||
...(customBlocks == null ? {} : { customBlocks: customBlocks.map(projectCustomBlock) }),
|
||||
};
|
||||
}
|
||||
|
||||
function projectHeader(header: IHeaderData): IHeaderData {
|
||||
return {
|
||||
...header,
|
||||
body: projectDocumentBody(header.body),
|
||||
};
|
||||
}
|
||||
|
||||
function projectFooter(footer: IFooterData): IFooterData {
|
||||
return {
|
||||
...footer,
|
||||
body: projectDocumentBody(footer.body),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the model snapshot owned by the layout Worker. Exchange-only payloads
|
||||
* remain in the authoritative Main model and are deliberately excluded from
|
||||
* the structured-clone boundary because they do not affect document geometry.
|
||||
*/
|
||||
export function createDocumentLayoutSnapshot(snapshot: IDocumentData): IDocumentData {
|
||||
const { resources: _resources, body, headers, footers, ...layoutSnapshot } = snapshot;
|
||||
return {
|
||||
...layoutSnapshot,
|
||||
...(body == null ? {} : { body: projectDocumentBody(body) }),
|
||||
...(headers == null
|
||||
? {}
|
||||
: {
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(headers).map(([headerId, header]) => [headerId, projectHeader(header)])
|
||||
),
|
||||
}),
|
||||
...(footers == null
|
||||
? {}
|
||||
: {
|
||||
footers: Object.fromEntries(
|
||||
Object.entries(footers).map(([footerId, footer]) => [footerId, projectFooter(footer)])
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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 { CustomRangeType, DataStreamTreeTokenType, DocumentDataModel, DrawingTypeEnum, ObjectRelativeFromH, ObjectRelativeFromV, PositionedObjectLayoutType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getDocumentEntityPermissionObjectId, getDocumentParagraphPermissionObjectId, getDocumentSectionPermissionObjectId } from '../document-permission';
|
||||
import { getDocumentEditTargetObjectIds } from '../document-permission-resolver';
|
||||
|
||||
describe('document permission resolver', () => {
|
||||
it('uses note-owned drawing permissions while retaining the body reference restrictions', () => {
|
||||
const document = new DocumentDataModel({
|
||||
id: 'note-permission',
|
||||
body: {
|
||||
dataStream: 'A\uFFFC\r\n',
|
||||
paragraphs: [{ startIndex: 2, paragraphId: 'body-paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 3, sectionId: 'body-section' }],
|
||||
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'note-ref', rangeType: CustomRangeType.FOOTNOTE, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: {
|
||||
note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: '\b\r\n',
|
||||
customBlocks: [{ startIndex: 0, blockId: 'image' }],
|
||||
paragraphs: [{ startIndex: 1, paragraphId: 'note-paragraph' }],
|
||||
sectionBreaks: [{ startIndex: 2, sectionId: 'note-section' }],
|
||||
}, drawings: {
|
||||
image: {
|
||||
unitId: 'note-permission',
|
||||
subUnitId: 'note-permission',
|
||||
drawingId: 'image',
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
layoutType: PositionedObjectLayoutType.INLINE,
|
||||
docTransform: {
|
||||
size: { width: 100, height: 40 },
|
||||
angle: 0,
|
||||
positionH: { relativeFrom: ObjectRelativeFromH.PAGE, posOffset: 0 },
|
||||
positionV: { relativeFrom: ObjectRelativeFromV.PAGE, posOffset: 0 },
|
||||
},
|
||||
},
|
||||
} },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const targets = getDocumentEditTargetObjectIds(document, 'note', { startOffset: 0, endOffset: 1 });
|
||||
expect(targets).toContain(getDocumentEntityPermissionObjectId('note', 'drawing', 'image'));
|
||||
expect(targets).not.toContain(getDocumentEntityPermissionObjectId('note', 'custom-block', 'image'));
|
||||
expect(targets).toContain(getDocumentSectionPermissionObjectId('', 'body-section'));
|
||||
expect(targets).toContain(getDocumentParagraphPermissionObjectId('', 'body-paragraph'));
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('indexes paragraph content starts once per document revision', () => {
|
||||
const paragraphCount = 1_000;
|
||||
const paragraphs = Array.from({ length: paragraphCount }, (_, index) => ({
|
||||
startIndex: index * 2 + 1,
|
||||
paragraphId: `paragraph-${index}`,
|
||||
}));
|
||||
const dataStream = `${Array.from({ length: paragraphCount }, () => `A${DataStreamTreeTokenType.PARAGRAPH}`).join('')}${DataStreamTreeTokenType.SECTION_BREAK}`;
|
||||
const document = new DocumentDataModel({
|
||||
id: 'permission-index-document',
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs,
|
||||
sectionBreaks: [{
|
||||
sectionId: 'permission-index-section',
|
||||
startIndex: dataStream.length - 1,
|
||||
}],
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
const modelParagraphs = document.getBody()!.paragraphs!;
|
||||
const originalIterator = modelParagraphs[Symbol.iterator].bind(modelParagraphs);
|
||||
let iteratorCount = 0;
|
||||
Object.defineProperty(modelParagraphs, Symbol.iterator, {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
iteratorCount++;
|
||||
return originalIterator();
|
||||
},
|
||||
});
|
||||
|
||||
const targetOffset = paragraphs[500].startIndex - 1;
|
||||
const objectIds = getDocumentEditTargetObjectIds(document, '', {
|
||||
startOffset: targetOffset,
|
||||
endOffset: targetOffset,
|
||||
});
|
||||
const iteratorCountAfterFirstLookup = iteratorCount;
|
||||
getDocumentEditTargetObjectIds(document, '', {
|
||||
startOffset: targetOffset,
|
||||
endOffset: targetOffset,
|
||||
});
|
||||
|
||||
expect(objectIds).toContain(getDocumentParagraphPermissionObjectId('', 'paragraph-500'));
|
||||
expect(iteratorCountAfterFirstLookup).toBeLessThanOrEqual(2);
|
||||
expect(iteratorCount).toBe(iteratorCountAfterFirstLookup);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* 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,
|
||||
IDocumentBody,
|
||||
ISectionBreak,
|
||||
JSONXActions,
|
||||
SectionHeaderFooterReferenceKey,
|
||||
TextXAction,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
CustomRangeType,
|
||||
getParagraphContentStartOffset,
|
||||
getParagraphContentStartOffsets,
|
||||
JSON1,
|
||||
resolveSectionHeaderFooterReference,
|
||||
TextX,
|
||||
TextXActionType,
|
||||
} from '@univerjs/core';
|
||||
import { getTopLevelSectionBreaks } from '../../utils/sections';
|
||||
import {
|
||||
getDocumentEntityPermissionObjectId,
|
||||
getDocumentParagraphPermissionObjectId,
|
||||
getDocumentSectionPermissionObjectId,
|
||||
} from './document-permission';
|
||||
|
||||
const HEADER_FOOTER_REFERENCE_KEYS: SectionHeaderFooterReferenceKey[] = [
|
||||
'defaultHeaderId',
|
||||
'defaultFooterId',
|
||||
'firstPageHeaderId',
|
||||
'firstPageFooterId',
|
||||
'evenPageHeaderId',
|
||||
'evenPageFooterId',
|
||||
];
|
||||
|
||||
interface IDocumentPermissionEntityRange extends IDocumentPermissionRange {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface IDocumentPermissionSegmentIndex {
|
||||
body: IDocumentBody | null;
|
||||
entities: IDocumentPermissionEntityRange[];
|
||||
entityRanges: Map<string, IDocumentPermissionRange>;
|
||||
paragraphContentStartOffsets: Map<number, number>;
|
||||
paragraphsById: Map<string, NonNullable<IDocumentBody['paragraphs']>[number]>;
|
||||
}
|
||||
|
||||
interface IDocumentPermissionResolverIndex {
|
||||
drawingSegmentIds: Map<string, string>;
|
||||
footnoteReferenceRanges: Map<string, IDocumentPermissionRange>;
|
||||
mutationRevision: number;
|
||||
segments: Map<string, IDocumentPermissionSegmentIndex>;
|
||||
topLevelSections: ISectionBreak[];
|
||||
}
|
||||
|
||||
const documentPermissionResolverIndexCache = new WeakMap<DocumentDataModel, IDocumentPermissionResolverIndex>();
|
||||
|
||||
export interface IDocumentPermissionRange {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
function getDocumentPermissionResolverIndex(documentDataModel: DocumentDataModel): IDocumentPermissionResolverIndex {
|
||||
const mutationRevision = (documentDataModel as Partial<Pick<DocumentDataModel, 'getMutationRevision'>>)
|
||||
.getMutationRevision?.() ?? Number.NaN;
|
||||
const cached = documentPermissionResolverIndexCache.get(documentDataModel);
|
||||
if (cached?.mutationRevision === mutationRevision) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const topLevelSections = snapshot.body ? getTopLevelSectionBreaks(snapshot.body) : [];
|
||||
const drawingSegmentIds = new Map<string, string>();
|
||||
const addDrawingSegments = (body: IDocumentBody | undefined, segmentId: string): void => {
|
||||
for (const block of body?.customBlocks ?? []) {
|
||||
if (!drawingSegmentIds.has(block.blockId)) {
|
||||
drawingSegmentIds.set(block.blockId, segmentId);
|
||||
}
|
||||
}
|
||||
};
|
||||
addDrawingSegments(snapshot.body, '');
|
||||
Object.entries(snapshot.headers ?? {}).forEach(([segmentId, header]) => addDrawingSegments(header.body, segmentId));
|
||||
Object.entries(snapshot.footers ?? {}).forEach(([segmentId, footer]) => addDrawingSegments(footer.body, segmentId));
|
||||
Object.entries(snapshot.notes ?? {}).forEach(([segmentId, footnote]) => addDrawingSegments(footnote.body, segmentId));
|
||||
const footnoteReferenceRanges = new Map<string, IDocumentPermissionRange>();
|
||||
for (const reference of snapshot.body?.customRanges ?? []) {
|
||||
if ((reference.rangeType === CustomRangeType.FOOTNOTE || reference.rangeType === CustomRangeType.ENDNOTE) && typeof reference.properties?.noteId === 'string') {
|
||||
footnoteReferenceRanges.set(reference.properties.noteId, {
|
||||
startOffset: reference.startIndex,
|
||||
endOffset: reference.endIndex + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const index: IDocumentPermissionResolverIndex = {
|
||||
drawingSegmentIds,
|
||||
footnoteReferenceRanges,
|
||||
mutationRevision,
|
||||
segments: new Map(),
|
||||
topLevelSections,
|
||||
};
|
||||
documentPermissionResolverIndexCache.set(documentDataModel, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
function getDocumentPermissionSegmentIndex(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
resolverIndex = getDocumentPermissionResolverIndex(documentDataModel)
|
||||
): IDocumentPermissionSegmentIndex {
|
||||
const cached = resolverIndex.segments.get(segmentId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody() ?? null;
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const note = snapshot.notes?.[segmentId];
|
||||
const drawings = (note ? note.drawings : snapshot.drawings) ?? {};
|
||||
const entities: IDocumentPermissionEntityRange[] = body == null
|
||||
? []
|
||||
: [
|
||||
...(body.tables ?? []).map((item) => ({ type: 'table', id: item.tableId, startOffset: item.startIndex, endOffset: item.endIndex })),
|
||||
...(body.customBlocks ?? []).map((item) => ({
|
||||
type: drawings[item.blockId] ? 'drawing' : 'custom-block',
|
||||
id: item.blockId,
|
||||
startOffset: item.startIndex,
|
||||
endOffset: item.startIndex + 1,
|
||||
})),
|
||||
...(body.blockRanges ?? []).map((item) => ({ type: 'block-range', id: item.blockId, startOffset: item.startIndex, endOffset: item.endIndex + 1 })),
|
||||
...(body.customRanges ?? []).map((item) => ({ type: 'custom-range', id: item.rangeId, startOffset: item.startIndex, endOffset: item.endIndex + 1 })),
|
||||
...(body.columnGroups ?? []).map((item) => ({ type: 'column-group', id: item.columnGroupId, startOffset: item.startIndex, endOffset: item.endIndex + 1 })),
|
||||
];
|
||||
const entityRanges = new Map(entities.map((entity) => [
|
||||
`${entity.type}:${entity.id}`,
|
||||
{ startOffset: entity.startOffset, endOffset: entity.endOffset },
|
||||
]));
|
||||
for (const block of body?.customBlocks ?? []) {
|
||||
const range = { startOffset: block.startIndex, endOffset: block.startIndex + 1 };
|
||||
entityRanges.set(`custom-block:${block.blockId}`, range);
|
||||
entityRanges.set(`drawing:${block.blockId}`, range);
|
||||
}
|
||||
const segmentIndex: IDocumentPermissionSegmentIndex = {
|
||||
body,
|
||||
entities,
|
||||
entityRanges,
|
||||
paragraphContentStartOffsets: body == null ? new Map() : getParagraphContentStartOffsets(body),
|
||||
paragraphsById: new Map((body?.paragraphs ?? [])
|
||||
.filter((paragraph) => paragraph.paragraphId != null)
|
||||
.map((paragraph) => [paragraph.paragraphId!, paragraph])),
|
||||
};
|
||||
resolverIndex.segments.set(segmentId, segmentIndex);
|
||||
return segmentIndex;
|
||||
}
|
||||
|
||||
export function getDocumentDrawingSegmentId(documentDataModel: DocumentDataModel, drawingId: string): string {
|
||||
return getDocumentPermissionResolverIndex(documentDataModel).drawingSegmentIds.get(drawingId) ?? '';
|
||||
}
|
||||
|
||||
export function getDocumentEditTargetObjectIds(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
range: IDocumentPermissionRange
|
||||
): string[] {
|
||||
const resolverIndex = getDocumentPermissionResolverIndex(documentDataModel);
|
||||
const segmentIndex = getDocumentPermissionSegmentIndex(documentDataModel, segmentId, resolverIndex);
|
||||
const { body } = segmentIndex;
|
||||
if (!body) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
...getSectionPermissionObjectIds(documentDataModel, segmentId, range, resolverIndex),
|
||||
...getParagraphPermissionObjectIds(body, segmentId, range, segmentIndex),
|
||||
...getEntityPermissionObjectIds(segmentId, range, segmentIndex),
|
||||
];
|
||||
}
|
||||
|
||||
export function getDocumentEditTargetObjectIdsFromActions(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
actions: JSONXActions
|
||||
): string[] {
|
||||
const result = new Set<string>();
|
||||
if (actions == null) {
|
||||
return [];
|
||||
}
|
||||
const cursor = JSON1.type.readCursor(actions);
|
||||
cursor.traverse(null, () => {
|
||||
const path = cursor.getPath();
|
||||
const edit = cursor.getComponent();
|
||||
const scopedSegment = ['headers', 'footers', 'notes'].includes(String(path[0])) && typeof path[1] === 'string'
|
||||
? path[1]
|
||||
: segmentId;
|
||||
if (path[0] === 'notes' && typeof path[1] === 'string') {
|
||||
getSectionPermissionObjectIds(documentDataModel, path[1], { startOffset: 0, endOffset: 0 })
|
||||
.forEach((objectId) => result.add(objectId));
|
||||
}
|
||||
if (path[0] === 'noteSettings') {
|
||||
getDocumentPermissionResolverIndex(documentDataModel).topLevelSections.forEach((section) => {
|
||||
result.add(getDocumentSectionPermissionObjectId('', section.sectionId));
|
||||
});
|
||||
}
|
||||
if (path[0] === 'body' && path[1] === 'sectionBreaks' && typeof path[2] === 'number' && (path[3] === 'noteProperties' || path[3] === 'suppressEndnotes')) {
|
||||
const sectionId = documentDataModel.getBody()?.sectionBreaks?.[path[2]]?.sectionId;
|
||||
if (sectionId) {
|
||||
result.add(getDocumentSectionPermissionObjectId('', sectionId));
|
||||
}
|
||||
}
|
||||
if (path[path.length - 1] !== 'body' || edit?.et !== TextX.id || !Array.isArray(edit.e) || !edit.e.every(isTextXAction)) {
|
||||
return;
|
||||
}
|
||||
const textActions = edit.e;
|
||||
let offset = 0;
|
||||
const addRange = (startOffset: number, endOffset: number): void => {
|
||||
getDocumentEditTargetObjectIds(documentDataModel, scopedSegment, { startOffset, endOffset })
|
||||
.forEach((objectId) => result.add(objectId));
|
||||
};
|
||||
|
||||
textActions.forEach((action) => {
|
||||
if (action.t === TextXActionType.INSERT) {
|
||||
addRange(offset, offset);
|
||||
return;
|
||||
}
|
||||
if (action.t === TextXActionType.DELETE) {
|
||||
addRange(offset, offset + action.len);
|
||||
offset += action.len;
|
||||
return;
|
||||
}
|
||||
if (action.body !== undefined || action.oldBody !== undefined || action.coverType !== undefined) {
|
||||
addRange(offset, offset + action.len);
|
||||
}
|
||||
offset += action.len;
|
||||
});
|
||||
});
|
||||
|
||||
getDrawingIdsFromActions(actions).forEach((drawingId) => {
|
||||
result.add(getDocumentEntityPermissionObjectId(segmentId, 'drawing', drawingId));
|
||||
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
const block = body?.customBlocks?.find((item) => item.blockId === drawingId);
|
||||
if (block) {
|
||||
getSectionPermissionObjectIds(documentDataModel, segmentId, {
|
||||
startOffset: block.startIndex,
|
||||
endOffset: block.startIndex + 1,
|
||||
}).forEach((objectId) => result.add(objectId));
|
||||
}
|
||||
});
|
||||
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function getDrawingIdsFromActions(actions: JSONXActions): string[] {
|
||||
const result = new Set<string>();
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
if (item === 'drawings' && typeof value[index + 1] === 'string') {
|
||||
result.add(value[index + 1]);
|
||||
}
|
||||
visit(item);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
Object.values(value).forEach(visit);
|
||||
};
|
||||
visit(actions);
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function isTextXAction(value: unknown): value is TextXAction {
|
||||
if (!isRecord(value) || typeof value.len !== 'number') {
|
||||
return false;
|
||||
}
|
||||
if (value.t === TextXActionType.INSERT) {
|
||||
return isRecord(value.body);
|
||||
}
|
||||
return value.t === TextXActionType.RETAIN || value.t === TextXActionType.DELETE;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getDocumentEntityParentPermissionObjectIds(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): string[] {
|
||||
const resolverIndex = getDocumentPermissionResolverIndex(documentDataModel);
|
||||
const segmentIndex = getDocumentPermissionSegmentIndex(documentDataModel, segmentId, resolverIndex);
|
||||
const range = segmentIndex.entityRanges.get(`${entityType}:${entityId}`) ?? null;
|
||||
return range ? getSectionPermissionObjectIds(documentDataModel, segmentId, range, resolverIndex) : [];
|
||||
}
|
||||
|
||||
export function getDocumentParagraphParentPermissionObjectIds(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
paragraphId: string
|
||||
): string[] {
|
||||
const resolverIndex = getDocumentPermissionResolverIndex(documentDataModel);
|
||||
const segmentIndex = getDocumentPermissionSegmentIndex(documentDataModel, segmentId, resolverIndex);
|
||||
const { body } = segmentIndex;
|
||||
const paragraph = segmentIndex.paragraphsById.get(paragraphId);
|
||||
if (!body || !paragraph) {
|
||||
return [];
|
||||
}
|
||||
return getSectionPermissionObjectIds(documentDataModel, segmentId, {
|
||||
startOffset: segmentIndex.paragraphContentStartOffsets.get(paragraph.startIndex) ?? getParagraphContentStartOffset(body, paragraph),
|
||||
endOffset: paragraph.startIndex + 1,
|
||||
}, resolverIndex);
|
||||
}
|
||||
|
||||
export function getDocumentSectionPermissionObjectIdsByIds(
|
||||
sectionIds: Iterable<string>
|
||||
): string[] {
|
||||
return Array.from(sectionIds, (sectionId) => getDocumentSectionPermissionObjectId('', sectionId));
|
||||
}
|
||||
|
||||
export function getDocumentSectionIdsAtOffset(body: IDocumentBody, offset: number): string[] {
|
||||
return getSectionsIntersectingRange(getTopLevelSectionBreaks(body), {
|
||||
startOffset: offset,
|
||||
endOffset: offset,
|
||||
}).map((section) => section.sectionId);
|
||||
}
|
||||
|
||||
function getSectionPermissionObjectIds(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
range: IDocumentPermissionRange,
|
||||
resolverIndex = getDocumentPermissionResolverIndex(documentDataModel)
|
||||
): string[] {
|
||||
if (segmentId) {
|
||||
const referenceRange = resolverIndex.footnoteReferenceRanges.get(segmentId);
|
||||
if (referenceRange) {
|
||||
// A note belongs to its reference, including the enclosing paragraph,
|
||||
// table and section. Moving the reference moves those restrictions too.
|
||||
return getDocumentEditTargetObjectIds(documentDataModel, '', referenceRange);
|
||||
}
|
||||
return getHeaderFooterOwnerSectionIds(documentDataModel, segmentId, resolverIndex.topLevelSections)
|
||||
.map((sectionId) => getDocumentSectionPermissionObjectId('', sectionId));
|
||||
}
|
||||
return getSectionsIntersectingRange(resolverIndex.topLevelSections, range)
|
||||
.map((section) => getDocumentSectionPermissionObjectId('', section.sectionId));
|
||||
}
|
||||
|
||||
function getParagraphPermissionObjectIds(
|
||||
body: IDocumentBody,
|
||||
segmentId: string,
|
||||
range: IDocumentPermissionRange,
|
||||
segmentIndex: IDocumentPermissionSegmentIndex
|
||||
): string[] {
|
||||
const startOffset = Math.min(range.startOffset, range.endOffset);
|
||||
const endOffset = Math.max(range.startOffset, range.endOffset);
|
||||
return (body.paragraphs ?? [])
|
||||
.filter((paragraph) => intersectsRange(
|
||||
segmentIndex.paragraphContentStartOffsets.get(paragraph.startIndex) ?? getParagraphContentStartOffset(body, paragraph),
|
||||
paragraph.startIndex + 1,
|
||||
startOffset,
|
||||
endOffset
|
||||
))
|
||||
.flatMap((paragraph) => paragraph.paragraphId
|
||||
? [getDocumentParagraphPermissionObjectId(segmentId, paragraph.paragraphId)]
|
||||
: []);
|
||||
}
|
||||
|
||||
function getEntityPermissionObjectIds(
|
||||
segmentId: string,
|
||||
range: IDocumentPermissionRange,
|
||||
segmentIndex: IDocumentPermissionSegmentIndex
|
||||
): string[] {
|
||||
const startOffset = Math.min(range.startOffset, range.endOffset);
|
||||
const endOffset = Math.max(range.startOffset, range.endOffset);
|
||||
return segmentIndex.entities
|
||||
.filter((item) => intersectsRange(item.startOffset, item.endOffset, startOffset, endOffset))
|
||||
.map((item) => getDocumentEntityPermissionObjectId(segmentId, item.type, item.id));
|
||||
}
|
||||
|
||||
function getSectionsIntersectingRange(
|
||||
sections: ISectionBreak[],
|
||||
range: IDocumentPermissionRange
|
||||
): ISectionBreak[] {
|
||||
const startOffset = Math.min(range.startOffset, range.endOffset);
|
||||
const endOffset = Math.max(range.startOffset, range.endOffset);
|
||||
return sections.filter((section, index) => intersectsRange(
|
||||
index === 0 ? 0 : sections[index - 1].startIndex + 1,
|
||||
section.startIndex + 1,
|
||||
startOffset,
|
||||
endOffset
|
||||
));
|
||||
}
|
||||
|
||||
function intersectsRange(
|
||||
targetStart: number,
|
||||
targetEnd: number,
|
||||
rangeStart: number,
|
||||
rangeEnd: number
|
||||
): boolean {
|
||||
if (rangeStart === rangeEnd) {
|
||||
return rangeStart >= targetStart && rangeStart < targetEnd;
|
||||
}
|
||||
return rangeStart < targetEnd && rangeEnd > targetStart;
|
||||
}
|
||||
|
||||
function getHeaderFooterOwnerSectionIds(
|
||||
documentDataModel: DocumentDataModel,
|
||||
segmentId: string,
|
||||
sections = getDocumentPermissionResolverIndex(documentDataModel).topLevelSections
|
||||
): string[] {
|
||||
const snapshot = documentDataModel.getSnapshot();
|
||||
const result = new Set<string>();
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
if (HEADER_FOOTER_REFERENCE_KEYS.some((key) =>
|
||||
resolveSectionHeaderFooterReference(snapshot.documentStyle, sections, sectionIndex, key).segmentId === segmentId)) {
|
||||
result.add(section.sectionId);
|
||||
}
|
||||
});
|
||||
return [...result];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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 { IResourceManagerService, ObjectPermissionRuleModel, UniverInstanceType } from '@univerjs/core';
|
||||
import { UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentPermissionRuleModel extends ObjectPermissionRuleModel {
|
||||
constructor(@IResourceManagerService resources: IResourceManagerService) {
|
||||
super(resources, 'DOC_OBJECT_PERMISSION_PLUGIN', UniverInstanceType.UNIVER_DOC, [
|
||||
UnitObject.DocumentSection,
|
||||
UnitObject.DocumentParagraph,
|
||||
UnitObject.DocumentEntity,
|
||||
]);
|
||||
}
|
||||
}
|
||||
152
packages/docs/src/services/permission/document-permission.ts
Normal file
152
packages/docs/src/services/permission/document-permission.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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 { IPermissionService } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
import { DocumentCommentPermission } from './permission-point/document/comment';
|
||||
import { DocumentCopyPermission } from './permission-point/document/copy';
|
||||
import { DocumentEditablePermission } from './permission-point/document/editable';
|
||||
import { DocumentExportPermission } from './permission-point/document/export';
|
||||
import { DocumentPrintPermission } from './permission-point/document/print';
|
||||
import { DocumentEntityEditPermission } from './permission-point/entity/edit';
|
||||
import { DocumentParagraphEditPermission } from './permission-point/paragraph/edit';
|
||||
import { DocumentSectionEditPermission } from './permission-point/section/edit';
|
||||
|
||||
export const DOCUMENT_UNIT_PERMISSION_ACTIONS = [
|
||||
UnitAction.Edit,
|
||||
UnitAction.Copy,
|
||||
UnitAction.Print,
|
||||
UnitAction.Export,
|
||||
UnitAction.Comment,
|
||||
] as const;
|
||||
|
||||
export type DocumentUnitPermissionAction = typeof DOCUMENT_UNIT_PERMISSION_ACTIONS[number];
|
||||
|
||||
const DOCUMENT_PERMISSION_OBJECT_TYPES = new Set([
|
||||
UnitObject.Document,
|
||||
UnitObject.DocumentSection,
|
||||
UnitObject.DocumentParagraph,
|
||||
UnitObject.DocumentEntity,
|
||||
]);
|
||||
const DOCUMENT_SECTION_PERMISSION_OBJECT_PREFIX = 'section/';
|
||||
const DOCUMENT_PARAGRAPH_PERMISSION_OBJECT_PREFIX = 'paragraph/';
|
||||
const DOCUMENT_ENTITY_PERMISSION_OBJECT_PREFIX = 'entity/';
|
||||
|
||||
export function getDocumentSectionPermissionObjectId(segmentId: string, sectionId: string): string {
|
||||
return `${DOCUMENT_SECTION_PERMISSION_OBJECT_PREFIX}${encodeURIComponent(segmentId)}/${encodeURIComponent(sectionId)}`;
|
||||
}
|
||||
|
||||
export function getDocumentParagraphPermissionObjectId(segmentId: string, paragraphId: string): string {
|
||||
return `${DOCUMENT_PARAGRAPH_PERMISSION_OBJECT_PREFIX}${encodeURIComponent(segmentId)}/${encodeURIComponent(paragraphId)}`;
|
||||
}
|
||||
|
||||
export function getDocumentEntityPermissionObjectId(
|
||||
segmentId: string,
|
||||
entityType: string,
|
||||
entityId: string
|
||||
): string {
|
||||
return `${DOCUMENT_ENTITY_PERMISSION_OBJECT_PREFIX}${encodeURIComponent(segmentId)}/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`;
|
||||
}
|
||||
|
||||
export function createDocumentPermissionPoint(
|
||||
unitId: string,
|
||||
objectId: string,
|
||||
action: UnitAction
|
||||
) {
|
||||
if (objectId === unitId) {
|
||||
switch (action) {
|
||||
case UnitAction.Edit:
|
||||
return new DocumentEditablePermission(unitId);
|
||||
case UnitAction.Copy:
|
||||
return new DocumentCopyPermission(unitId);
|
||||
case UnitAction.Print:
|
||||
return new DocumentPrintPermission(unitId);
|
||||
case UnitAction.Export:
|
||||
return new DocumentExportPermission(unitId);
|
||||
case UnitAction.Comment:
|
||||
return new DocumentCommentPermission(unitId);
|
||||
default:
|
||||
throw new Error(`Unsupported Document permission action: ${action}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (action !== UnitAction.Edit) {
|
||||
throw new Error(`Document object permissions only support Edit: ${objectId}`);
|
||||
}
|
||||
if (objectId.startsWith(DOCUMENT_SECTION_PERMISSION_OBJECT_PREFIX)) {
|
||||
return new DocumentSectionEditPermission(unitId, objectId);
|
||||
}
|
||||
if (objectId.startsWith(DOCUMENT_PARAGRAPH_PERMISSION_OBJECT_PREFIX)) {
|
||||
return new DocumentParagraphEditPermission(unitId, objectId);
|
||||
}
|
||||
if (objectId.startsWith(DOCUMENT_ENTITY_PERMISSION_OBJECT_PREFIX)) {
|
||||
return new DocumentEntityEditPermission(unitId, objectId);
|
||||
}
|
||||
throw new Error(`Unsupported Document permission object: ${objectId}`);
|
||||
}
|
||||
|
||||
export function getDocumentPermissionValue(
|
||||
permissionService: IPermissionService,
|
||||
unitId: string,
|
||||
objectId: string,
|
||||
action: UnitAction
|
||||
): boolean {
|
||||
return permissionService.getPermissionPoint(createDocumentPermissionPoint(unitId, objectId, action).id)?.value ?? true;
|
||||
}
|
||||
|
||||
export function setDocumentPermissionValue(
|
||||
permissionService: IPermissionService,
|
||||
unitId: string,
|
||||
objectId: string,
|
||||
action: UnitAction,
|
||||
value: boolean
|
||||
): void {
|
||||
const point = createDocumentPermissionPoint(unitId, objectId, action);
|
||||
if (!permissionService.getPermissionPoint(point.id)) {
|
||||
permissionService.addPermissionPoint(point);
|
||||
}
|
||||
permissionService.updatePermissionPoint(point.id, value);
|
||||
}
|
||||
|
||||
export function clearDocumentPermissionValuesForUnit(
|
||||
permissionService: IPermissionService,
|
||||
unitId: string
|
||||
): void {
|
||||
permissionService.getAllPermissionPoint().forEach((point$, id) => {
|
||||
const subscription = point$.subscribe((point) => {
|
||||
if (DOCUMENT_PERMISSION_OBJECT_TYPES.has(point.type) && 'unitId' in point && point.unitId === unitId) {
|
||||
permissionService.deletePermissionPoint(id);
|
||||
}
|
||||
});
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
export function canEditDocumentTargets(
|
||||
permissionService: IPermissionService,
|
||||
unitId: string,
|
||||
objectIds: Iterable<string>
|
||||
): boolean {
|
||||
if (!getDocumentPermissionValue(permissionService, unitId, unitId, UnitAction.Edit)) {
|
||||
return false;
|
||||
}
|
||||
for (const objectId of objectIds) {
|
||||
if (!getDocumentPermissionValue(permissionService, unitId, objectId, UnitAction.Edit)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentCommentPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.Document;
|
||||
readonly subType = UnitAction.Comment;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentCopyPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.Document;
|
||||
readonly subType = UnitAction.Copy;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentEditablePermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.Document;
|
||||
readonly subType = UnitAction.Edit;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentExportPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.Document;
|
||||
readonly subType = UnitAction.Export;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentPrintPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.Document;
|
||||
readonly subType = UnitAction.Print;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentEntityEditPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.DocumentEntity;
|
||||
readonly subType = UnitAction.Edit;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string, readonly objectId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}_${objectId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentParagraphEditPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.DocumentParagraph;
|
||||
readonly subType = UnitAction.Edit;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string, readonly objectId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}_${objectId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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 { IPermissionPoint } from '@univerjs/core';
|
||||
import { PermissionStatus } from '@univerjs/core';
|
||||
import { UnitAction, UnitObject } from '@univerjs/protocol';
|
||||
|
||||
export class DocumentSectionEditPermission implements IPermissionPoint {
|
||||
readonly type = UnitObject.DocumentSection;
|
||||
readonly subType = UnitAction.Edit;
|
||||
readonly status = PermissionStatus.INIT;
|
||||
readonly id: string;
|
||||
value = true;
|
||||
|
||||
constructor(readonly unitId: string, readonly objectId: string) {
|
||||
this.id = `${this.type}.${this.subType}_${unitId}_${objectId}`;
|
||||
}
|
||||
}
|
||||
153
packages/docs/src/utils/custom-range-factory.ts
Normal file
153
packages/docs/src/utils/custom-range-factory.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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 { CustomRangeType, DocumentDataModel, IAccessor, IAddCustomRangeTextXParam, IDocumentBody, IMutationInfo, ITextRange, ITextRangeParam, Nullable, TextX } from '@univerjs/core';
|
||||
import type { IRichTextEditingMutationParams } from '../commands/mutations/core-editing.mutation';
|
||||
import { BuildTextUtils, getRichTextEditPath, IUniverInstanceService, JSONX, UniverInstanceType } from '@univerjs/core';
|
||||
import { RichTextEditingMutation } from '../commands/mutations/core-editing.mutation';
|
||||
import { DocSelectionManagerService } from '../services/doc-selection-manager.service';
|
||||
|
||||
interface IAddCustomRangeParam extends IAddCustomRangeTextXParam {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
export function addCustomRangeFactory(accessor: IAccessor, param: IAddCustomRangeParam, body: IDocumentBody) {
|
||||
const { unitId, segmentId } = param;
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId);
|
||||
if (!documentDataModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId: param.unitId,
|
||||
actions: [],
|
||||
textRanges: undefined,
|
||||
},
|
||||
};
|
||||
const jsonX = JSONX.getInstance();
|
||||
const textX = BuildTextUtils.customRange.add({ ...param, body });
|
||||
if (!textX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = getRichTextEditPath(documentDataModel, segmentId);
|
||||
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
|
||||
return doMutation;
|
||||
}
|
||||
|
||||
interface IAddCustomRangeFactoryParam {
|
||||
rangeId: string;
|
||||
rangeType: CustomRangeType;
|
||||
wholeEntity?: boolean;
|
||||
properties?: Record<string, any>;
|
||||
unitId: string;
|
||||
selections?: ITextRangeParam[];
|
||||
}
|
||||
|
||||
export function addCustomRangeBySelectionFactory(accessor: IAccessor, param: IAddCustomRangeFactoryParam) {
|
||||
const { rangeId, rangeType, wholeEntity, properties, unitId, selections: propSelection } = param;
|
||||
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
const selections = propSelection ?? docSelectionManagerService.getTextRanges({ unitId, subUnitId: unitId });
|
||||
const segmentId = selections?.[0]?.segmentId;
|
||||
if (!selections?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
|
||||
if (!documentDataModel) {
|
||||
return false;
|
||||
}
|
||||
const body = documentDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
|
||||
if (!body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textX = BuildTextUtils.customRange.add({
|
||||
ranges: selections as ITextRange[],
|
||||
rangeId,
|
||||
rangeType,
|
||||
segmentId,
|
||||
wholeEntity,
|
||||
properties,
|
||||
body,
|
||||
});
|
||||
|
||||
if (!textX) {
|
||||
return false;
|
||||
}
|
||||
const jsonX = JSONX.getInstance();
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> & { textX: TextX } = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId,
|
||||
actions: [],
|
||||
textRanges: textX.selections,
|
||||
segmentId,
|
||||
},
|
||||
textX,
|
||||
};
|
||||
const path = getRichTextEditPath(documentDataModel, segmentId);
|
||||
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
|
||||
return doMutation;
|
||||
}
|
||||
|
||||
export interface IDeleteCustomRangeFactoryParams {
|
||||
rangeId: string;
|
||||
segmentId?: string;
|
||||
unitId: string;
|
||||
insert?: Nullable<IDocumentBody>;
|
||||
}
|
||||
|
||||
export function deleteCustomRangeFactory(accessor: IAccessor, params: IDeleteCustomRangeFactoryParams) {
|
||||
const { unitId, segmentId, insert } = params;
|
||||
const univerInstanceService = accessor.get(IUniverInstanceService);
|
||||
|
||||
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId);
|
||||
if (!documentDataModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
|
||||
id: RichTextEditingMutation.id,
|
||||
params: {
|
||||
unitId: params.unitId,
|
||||
actions: [],
|
||||
textRanges: undefined,
|
||||
segmentId,
|
||||
},
|
||||
};
|
||||
|
||||
const jsonX = JSONX.getInstance();
|
||||
const textX = BuildTextUtils.customRange.delete({
|
||||
documentDataModel,
|
||||
rangeId: params.rangeId,
|
||||
insert,
|
||||
segmentId,
|
||||
});
|
||||
|
||||
if (!textX) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const path = getRichTextEditPath(documentDataModel, segmentId);
|
||||
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
|
||||
doMutation.params.textRanges = textX.selections;
|
||||
return doMutation;
|
||||
}
|
||||
62
packages/docs/src/utils/paragraphs.ts
Normal file
62
packages/docs/src/utils/paragraphs.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 { IParagraph, IParagraphBorder } from '@univerjs/core';
|
||||
import { createParagraphId, DataStreamTreeTokenType, generateRandomId, Tools } from '@univerjs/core';
|
||||
|
||||
/** Builds paragraph metadata for inserted paragraph tokens, including horizontal-rule borders. */
|
||||
export function generateParagraphs(
|
||||
dataStream: string,
|
||||
prevParagraph?: IParagraph,
|
||||
borderBottom?: IParagraphBorder,
|
||||
existingParagraphIds: Iterable<string> = []
|
||||
): IParagraph[] {
|
||||
const paragraphs: IParagraph[] = [];
|
||||
const existingIds = new Set(existingParagraphIds);
|
||||
|
||||
for (let i = 0, len = dataStream.length; i < len; i++) {
|
||||
if (dataStream[i] !== DataStreamTreeTokenType.PARAGRAPH) {
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraphs.push({
|
||||
startIndex: i,
|
||||
paragraphId: createParagraphId(existingIds),
|
||||
});
|
||||
}
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
if (prevParagraph?.bullet) {
|
||||
paragraph.bullet = Tools.deepClone(prevParagraph.bullet);
|
||||
}
|
||||
|
||||
if (prevParagraph?.paragraphStyle) {
|
||||
paragraph.paragraphStyle = Tools.deepClone(prevParagraph.paragraphStyle);
|
||||
delete paragraph.paragraphStyle.borderBottom;
|
||||
if (prevParagraph.paragraphStyle.headingId) {
|
||||
paragraph.paragraphStyle.headingId = generateRandomId(6);
|
||||
}
|
||||
}
|
||||
|
||||
if (borderBottom) {
|
||||
paragraph.paragraphStyle ??= {};
|
||||
paragraph.paragraphStyle.borderBottom = Tools.deepClone(borderBottom);
|
||||
paragraph.paragraphStyle.spaceBelow = { v: 10 };
|
||||
}
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user