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

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

37
packages/docs/README.md Normal file
View File

@@ -0,0 +1,37 @@
# @univerjs/docs
[![npm version](https://img.shields.io/npm/v/@univerjs/docs?style=flat-square)](https://npmjs.com/package/@univerjs/docs)
[![license](https://img.shields.io/npm/l/@univerjs/docs?style=flat-square)](https://npmjs.com/package/@univerjs/docs)
[![downloads](https://img.shields.io/npm/dm/@univerjs/docs?style=flat-square)](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)

View 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"
}
}

View 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();
});
});

View 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);
});
});

View File

@@ -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);
});
});

View File

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

View File

@@ -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');
});
});

View File

@@ -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();
});
});

View File

@@ -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']);
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View 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;
}

View File

@@ -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);
},
};

View File

@@ -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));
},
};

View File

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

View File

@@ -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;
},
};

View 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 { 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 };
}));
},
};

View File

@@ -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)!,
];
}

View File

@@ -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,
}));
},
};

View File

@@ -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,
}));
}

View File

@@ -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();
});
});

View 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 { 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();
});
});

View File

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

View File

@@ -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([]);
});

View File

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

View File

@@ -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';

View 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,
};
},
};

View File

@@ -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;
}

View 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;
},
};

View File

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

View File

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

View 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 { 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;
},
};

View 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 = {};

View File

@@ -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);
});
});

View 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);
}
}
}));
}
}

View 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);
}

View 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);
}

View 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),
};
}

View 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]);
});
});

View 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 }],
});
});
});

View 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,
};
}

View 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');
});
});

View File

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

View 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';
}

View 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]
);
}
}

View 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] };
}
}

View 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);
}

File diff suppressed because it is too large Load Diff

View 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 {}
}

View 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 {}
}

View 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';

View 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
View 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';

View File

@@ -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',
]);
});
});

View File

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

View 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();
});
});

View 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,
};

View 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();
}

View 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 };

View 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);
}
}

View 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>;
}

View 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
View 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);
}
}

View File

@@ -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,
});
});
});

View File

@@ -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();
});
});

View File

@@ -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,
});
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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],
});
});
});

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

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

View File

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

View File

@@ -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);
}
}

View 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;
}
}

View 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 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' });
});
});

View File

@@ -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;
}
}

View 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 {
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,
};

File diff suppressed because it is too large Load Diff

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View File

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

View 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();
}
}

View 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;
}
}

View 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)])
),
}),
};
}

View File

@@ -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);
});
});

View File

@@ -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];
}

View File

@@ -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,
]);
}
}

View 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;
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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.
*/
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}`;
}
}

View 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;
}

View 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