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

39
packages/core/README.md Normal file
View File

@@ -0,0 +1,39 @@
# @univerjs/core
[![npm version](https://img.shields.io/npm/v/@univerjs/core?style=flat-square)](https://npmjs.com/package/@univerjs/core)
[![license](https://img.shields.io/npm/l/@univerjs/core?style=flat-square)](https://npmjs.com/package/@univerjs/core)
[![downloads](https://img.shields.io/npm/dm/@univerjs/core?style=flat-square)](https://npmjs.com/package/@univerjs/core)
`@univerjs/core` is the foundation of Univer. It provides the Univer runtime, dependency injection, command and mutation services, data models, configuration, localization, and the shared Facade entry point used by higher-level packages.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/core` | `UniverCore` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/core
# or
npm install @univerjs/core
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { LocaleType, Univer } from '@univerjs/core';
const univer = new Univer({
locale: LocaleType.EN_US,
});
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/core)
- [GitHub repository](https://github.com/dream-num/univer)

100
packages/core/package.json Normal file
View File

@@ -0,0 +1,100 @@
{
"name": "@univerjs/core",
"version": "1.0.0-rc.0",
"private": false,
"description": "Core runtime, data models, command system, dependency injection, and Facade APIs for Univer.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"office-sdk",
"core",
"runtime",
"facade"
],
"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/protocol": "workspace:*",
"@univerjs/themes": "workspace:*",
"@wendellhu/redi": "1.1.3",
"async-lock": "^1.4.1",
"fast-diff": "1.3.0",
"kdbush": "^4.1.0",
"lodash-es": "^4.18.1",
"ot-json1": "^1.0.2",
"rbush": "^4.0.1"
},
"devDependencies": {
"@types/async-lock": "^1.4.2",
"@types/lodash-es": "^4.17.12",
"@types/rbush": "^4.0.0",
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,151 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { ILocales } from '../shared/locale';
import { defaultTheme } from '@univerjs/themes';
import { describe, expect, it, vi } from 'vitest';
import { Injector } from '../common/di';
import { UniverInstanceType } from '../common/unit';
import { COMMAND_LOG_EXECUTION_CONFIG_KEY } from '../services/command/command.service';
import { IConfigService } from '../services/config/config.service';
import { IUniverInstanceService } from '../services/instance/instance.service';
import { LocaleService } from '../services/locale/locale.service';
import { LogLevel } from '../services/log/log.service';
import { RegionService } from '../services/region/region.service';
import { IUndoRedoService, UNDO_REDO_HISTORY_LIMIT_CONFIG_KEY } from '../services/undoredo/undoredo.service';
import { Skeleton } from '../skeleton';
import { LocaleType } from '../types/enum/locale-type';
import { Univer } from '../univer';
describe('Skeleton', () => {
it('should update dirty state and release locale data on dispose', () => {
const skeleton = new Skeleton(new LocaleService());
expect(skeleton.dirty).toBe(true);
expect(skeleton.getFontLocale()).toBeUndefined();
skeleton.makeDirty(false);
expect(skeleton.dirty).toBe(false);
skeleton.dispose();
expect(skeleton.getFontLocale()).toBeNull();
});
});
describe('Univer', () => {
it('should apply constructor config and expose locale/config methods', () => {
const locales: ILocales = {
[LocaleType.EN_US]: {
test: {
greeting: 'Hello {0}',
},
},
} as unknown as ILocales;
const univer = new Univer({
theme: defaultTheme,
darkMode: true,
locales,
locale: LocaleType.EN_US,
region: LocaleType.FR_FR,
direction: 'rtl',
logLevel: LogLevel.VERBOSE,
logCommandExecution: true,
undoRedoHistoryLimit: 12,
});
const injector = univer.__getInjector();
const localeService = injector.get(LocaleService);
const regionService = injector.get(RegionService);
expect(localeService.getCurrentLocale()).toBe(LocaleType.EN_US);
expect(regionService.getCurrentRegion()).toBe(LocaleType.FR_FR);
expect(localeService.getDirection()).toBe('rtl');
expect(localeService.t('test.greeting', 'Univer')).toBe('Hello Univer');
expect(injector.get(IConfigService).getConfig(COMMAND_LOG_EXECUTION_CONFIG_KEY)).toBe(true);
expect(injector.get(IConfigService).getConfig(UNDO_REDO_HISTORY_LIMIT_CONFIG_KEY)).toBe(12);
univer.setLocale(LocaleType.ZH_CN);
expect(localeService.getCurrentLocale()).toBe(LocaleType.ZH_CN);
expect(regionService.getCurrentRegion()).toBe(LocaleType.FR_FR);
expect(localeService.getDirection()).toBe('rtl');
univer.setRegion(LocaleType.JA_JP);
expect(regionService.getCurrentRegion()).toBe(LocaleType.JA_JP);
univer.dispose();
});
it('should apply the configured undo redo history limit', () => {
const unitId = 'undo-redo-limit-doc';
const univer = new Univer({ undoRedoHistoryLimit: 2 });
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: unitId });
const injector = univer.__getInjector();
injector.get(IUniverInstanceService).focusUnit(unitId);
const undoRedoService = injector.get(IUndoRedoService);
for (let i = 0; i < 3; i++) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [],
redoMutations: [],
id: `item-${i}`,
});
}
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('item-2');
undoRedoService.popUndoToRedo();
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('item-1');
undoRedoService.popUndoToRedo();
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
univer.dispose();
});
it('should support add/remove dispose callbacks', () => {
const univer = new Univer();
const removedCallback = vi.fn();
const activeCallback = vi.fn();
const disposable = univer.onDispose(removedCallback);
univer.onDispose(activeCallback);
disposable.dispose();
univer.dispose();
expect(removedCallback).not.toHaveBeenCalled();
expect(activeCallback).toHaveBeenCalledTimes(1);
});
it('should delegate plugin registration for tuple-style APIs and support parent injector', () => {
const parentInjector = new Injector([]);
const univer = new Univer({}, parentInjector);
const registerPluginSpy = vi
.spyOn((univer as any)._pluginService, 'registerPlugin')
.mockImplementation(() => undefined);
univer.registerPlugins([
[class FakePluginA {} as never, { enabled: true }],
[class FakePluginB {} as never],
] as never);
expect(registerPluginSpy).toHaveBeenCalledTimes(2);
registerPluginSpy.mockRestore();
univer.dispose();
});
});

View File

@@ -0,0 +1,126 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IBaseSnapshot } from '../typedef';
import { describe, expect, it, vi } from 'vitest';
import { BaseDataModel } from '../base-data-model';
import { BASE_RECORD_ID_FIELD_ID, createBaseRecordIdField } from '../record-identity';
import { BaseFieldType } from '../typedef';
describe('BaseDataModel', () => {
it('excludes field types without a public authoring contract', () => {
expect(Object.values(BaseFieldType)).toContain('recordLink');
expect(Object.values(BaseFieldType)).not.toEqual(expect.arrayContaining([
'twoWayLink',
'lookup',
'summary',
'location',
'button',
'flow',
'barcode',
]));
});
it('creates an empty default table without persistent records', () => {
const model = new BaseDataModel({ id: 'base-1', name: 'Base' });
const snapshot = model.getSnapshot();
const table = snapshot.tables['table-1'];
expect(snapshot.tableOrder).toEqual(['table-1']);
expect(table.recordOrder).toEqual([]);
expect(table.records).toEqual({});
expect(table.rowId).toEqual({});
expect(table.rowIndex).toEqual({});
expect(table.cellData).toEqual({});
expect(table.fieldOrder[0]).toBe(BASE_RECORD_ID_FIELD_ID);
expect(table.fields[BASE_RECORD_ID_FIELD_ID]).toEqual(createBaseRecordIdField());
expect(table.views[table.viewOrder[0] ?? '']?.fieldSettings?.[BASE_RECORD_ID_FIELD_ID]?.hidden).toBe(true);
});
it('keeps complete recordOrder without sorting all records', () => {
const localeCompare = vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => {
throw new Error('complete recordOrder should avoid sorting all records');
});
const snapshot: Partial<IBaseSnapshot> = {
id: 'base-1',
name: 'Base',
tableOrder: ['table-1'],
tables: {
'table-1': {
id: 'table-1',
name: 'Table',
fields: {
[BASE_RECORD_ID_FIELD_ID]: createBaseRecordIdField(),
title: { id: 'title', name: 'Title', type: BaseFieldType.Text, config: {} },
},
fieldOrder: [BASE_RECORD_ID_FIELD_ID, 'title'],
primaryFieldId: 'title',
records: {
'record-1': {
id: 'record-1',
orderKey: '1',
createdAt: 0,
updatedAt: 0,
values: { [BASE_RECORD_ID_FIELD_ID]: 'record-1', title: 'One' },
},
'record-2': {
id: 'record-2',
orderKey: '2',
createdAt: 0,
updatedAt: 0,
values: { [BASE_RECORD_ID_FIELD_ID]: 'record-2', title: 'Two' },
},
},
recordOrder: ['record-2', 'record-1'],
viewOrder: [],
views: {},
},
},
};
const model = new BaseDataModel(snapshot);
expect(model.getSnapshot().tables['table-1'].recordOrder).toEqual(['record-2', 'record-1']);
expect(model.getSnapshot().tables['table-1'].cellData?.[0]?.[0]?.v).toBe('record-2');
expect(model.getSnapshot().tables['table-1'].cellData?.[1]?.[0]?.v).toBe('record-1');
expect(localeCompare).not.toHaveBeenCalled();
localeCompare.mockRestore();
});
it('rejects a table whose record identity is missing instead of silently migrating it', () => {
const snapshot: Partial<IBaseSnapshot> = {
id: 'base-1',
name: 'Base',
tables: {
'table-1': {
id: 'table-1',
name: 'Table',
fields: {
title: { id: 'title', name: 'Title', type: BaseFieldType.Text, config: {} },
},
fieldOrder: ['title'],
primaryFieldId: 'title',
records: {},
recordOrder: [],
viewOrder: [],
views: {},
},
},
};
expect(() => new BaseDataModel(snapshot)).toThrow('has an invalid record-id system field');
});
});

View File

@@ -0,0 +1,164 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { BaseDataModel } from '../base-data-model';
import { getEmptySnapshot } from '../empty-snapshot';
import {
allocateBaseFormulaTableName,
createBaseFormulaTableNameMap,
createBaseFormulaTableReferenceNormalizer,
getBaseFormulaTableName,
migrateBaseFormulaTableNames,
normalizeBaseFormulaTableName,
normalizeBaseFormulaTableReferences,
} from '../formula-table-name';
describe('Base formula table names', () => {
it('normalizes display names into OOXML-compatible identifiers', () => {
expect(normalizeBaseFormulaTableName('07 | Inventory and alerts')).toBe('_07_Inventory_and_alerts');
expect(normalizeBaseFormulaTableName('A1')).toBe('_A1');
expect(normalizeBaseFormulaTableName('R')).toBe('_R');
expect(normalizeBaseFormulaTableName('库存')).toBe('Table');
});
it('uses one deterministic namespace for normalized duplicates and suffix collisions', () => {
const snapshot = {
tables: {
c: { id: 'c', name: 'Sales-table' },
b: { id: 'b', name: 'Sales_table_2' },
a: { id: 'a', name: 'Sales table' },
},
};
expect(Object.fromEntries(createBaseFormulaTableNameMap(snapshot))).toEqual({
a: 'Sales_table',
b: 'Sales_table_2',
c: 'Sales_table_3',
});
expect(getBaseFormulaTableName(snapshot.tables.c, snapshot)).toBe('Sales_table_3');
});
it('preserves persisted formula names when a lower-sorting colliding table is added', () => {
const before = {
tables: {
b: { id: 'b', name: 'Sales table', formulaName: 'Sales_table' },
},
};
const insertedFormulaName = allocateBaseFormulaTableName(
'Sales-table',
createBaseFormulaTableNameMap(before).values()
);
const after = {
tables: {
b: before.tables.b,
a: { id: 'a', name: 'Sales-table', formulaName: insertedFormulaName },
},
};
expect(Object.fromEntries(createBaseFormulaTableNameMap(after))).toEqual({
a: 'Sales_table_2',
b: 'Sales_table',
});
expect(normalizeBaseFormulaTableReferences('=SUM(Sales_table[Amount])', after)).toBe(
'=SUM(Sales_table[Amount])'
);
});
it('keeps the persisted formula name when the display name changes', () => {
const snapshot = {
tables: {
a: { id: 'a', name: 'Revenue', formulaName: 'Sales' },
},
};
expect(getBaseFormulaTableName(snapshot.tables.a, snapshot)).toBe('Sales');
});
it('does not collapse a persisted suffix when another table is deleted', () => {
const snapshot = {
tables: {
b: { id: 'b', name: 'Sales-table', formulaName: 'Sales_table_2' },
},
};
expect(getBaseFormulaTableName(snapshot.tables.b, snapshot)).toBe('Sales_table_2');
});
it('migrates id-derived input aliases without touching strings or external tables', () => {
const snapshot = {
tables: {
'table-1': { id: 'table-1', name: 'Work Items' },
},
};
expect(normalizeBaseFormulaTableReferences(
'=SUM(_T_table_x2d_1[Amount],table-1[Amount],"_T_table_x2d_1[Amount]",Book!_T_table_x2d_1[Amount])',
snapshot
)).toBe('=SUM(Work_Items[Amount],Work_Items[Amount],"_T_table_x2d_1[Amount]",Book!_T_table_x2d_1[Amount])');
});
it('reuses one compiled alias normalizer across formulas', () => {
const snapshot = {
tables: {
'table.+(1)': { id: 'table.+(1)', name: 'Work Items' },
},
};
const normalizeReferences = createBaseFormulaTableReferenceNormalizer(snapshot);
expect(normalizeReferences('=SUM(table.+(1)[Amount])')).toBe('=SUM(Work_Items[Amount])');
expect(normalizeReferences('="table.+(1)[Amount]"&Book!table.+(1)[Amount]')).toBe(
'="table.+(1)[Amount]"&Book!table.+(1)[Amount]'
);
});
it('persists a canonical formula name while migrating a historical snapshot', () => {
const snapshot = {
tables: {
'table-1': {
id: 'table-1',
name: 'Work Items',
formulaName: undefined as string | undefined,
fields: {
total: { type: 'formula', config: { formula: '=SUM(table-1[Amount])' } },
},
cellData: {
0: { 0: { f: '=SUM(_T_table_x2d_1[Amount])' } },
},
},
},
};
migrateBaseFormulaTableNames(snapshot as never);
expect(snapshot.tables['table-1'].formulaName).toBe('Work_Items');
expect(snapshot.tables['table-1'].fields.total.config.formula).toBe('=SUM(Work_Items[Amount])');
expect(snapshot.tables['table-1'].cellData[0][0].f).toBe('=SUM(Work_Items[Amount])');
migrateBaseFormulaTableNames(snapshot as never);
expect(snapshot.tables['table-1'].formulaName).toBe('Work_Items');
expect(snapshot.tables['table-1'].fields.total.config.formula).toBe('=SUM(Work_Items[Amount])');
});
it('derives a historical formula name without silently changing the OT snapshot', () => {
const snapshot = getEmptySnapshot('base-1', 'Base');
snapshot.tables['table-1'].name = 'Work Items';
const loaded = new BaseDataModel(snapshot).getSnapshot();
expect(loaded.tables['table-1'].formulaName).toBeUndefined();
expect(getBaseFormulaTableName(loaded.tables['table-1'], loaded)).toBe('Work_Items');
});
});

View File

@@ -0,0 +1,478 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { Observable } from 'rxjs';
import type {
CellValue,
IBaseCellData,
IBaseSnapshot,
IFieldSnapshot,
IRecordSnapshot,
ITableSnapshot,
} from './typedef';
import { BehaviorSubject } from 'rxjs';
import { UnitModel, UniverInstanceType } from '../common/unit';
import { Tools } from '../shared/tools';
import { CellValueType } from '../types/enum';
import { getEmptySnapshot } from './empty-snapshot';
import { assertBaseTableRecordIdentity, BASE_RECORD_ID_FIELD_ID } from './record-identity';
import { BaseFieldType } from './typedef';
const BASE_LIST_VALUE_SEPARATOR = ', ';
const BASE_ATTACHMENT_RESOURCE_KEY_SEPARATOR = '\u001F';
type ICompressedBaseCellTuple = [number, number, IBaseCellData & { c?: 1 }];
export class BaseDataModel extends UnitModel<IBaseSnapshot, UniverInstanceType.UNIVER_BASE> {
override readonly type: UniverInstanceType.UNIVER_BASE = UniverInstanceType.UNIVER_BASE;
private readonly _name$: BehaviorSubject<string>;
override readonly name$: Observable<string>;
private _snapshot: IBaseSnapshot;
constructor(snapshot: Partial<IBaseSnapshot> = {}) {
super();
const defaultSnapshot = getEmptySnapshot();
this._snapshot = normalizeBaseSnapshot(Tools.commonExtend(defaultSnapshot, snapshot));
if (!this._snapshot.id) {
this._snapshot.id = `base-${Math.random().toString(36).slice(2, 10)}`;
}
this._name$ = new BehaviorSubject<string>(this._snapshot.name);
this.name$ = this._name$.asObservable();
}
override getUnitId(): string {
return this._snapshot.id;
}
override setName(name: string): void {
this._snapshot.name = name;
this._snapshot.updatedAt = Date.now();
this._name$.next(name);
}
override getSnapshot(): IBaseSnapshot {
return this._snapshot;
}
/** Check if a table name already exists, ignoring case. */
checkTableName(name: string): boolean {
return Object.values(this._snapshot.tables).some((table) => table.name.toLowerCase() === name.toLowerCase());
}
/** Generate a table name that does not conflict with existing tables. */
uniqueTableName(name: string): string {
let output = name;
let count = 1;
while (this.checkTableName(output)) {
output = `${name}${count}`;
count++;
}
return output;
}
setSnapshot(snapshot: IBaseSnapshot): void {
if (isNormalizedBaseSnapshot(snapshot)) {
this._snapshot = snapshot;
} else {
this._snapshot = normalizeBaseSnapshot(snapshot);
}
this._name$.next(snapshot.name);
}
override getRev(): number {
return this._snapshot.rev ?? 1;
}
override incrementRev(): void {
this._snapshot.rev = this.getRev() + 1;
}
override setRev(rev: number): void {
this._snapshot.rev = rev;
}
override dispose(): void {
super.dispose();
this._name$.complete();
}
}
function normalizeBaseSnapshot(snapshot: IBaseSnapshot): IBaseSnapshot {
delete (snapshot as IBaseSnapshot & { compress?: unknown }).compress;
delete (snapshot as IBaseSnapshot & { kind?: unknown }).kind;
Object.values(snapshot.tables ?? {}).forEach((table) => {
assertBaseTableRecordIdentity(table);
normalizeBaseTable(table);
assertMaterializedRecordIdentity(table);
});
return snapshot;
}
function isNormalizedBaseSnapshot(snapshot: IBaseSnapshot): boolean {
return Object.values(snapshot.tables ?? {}).every((table) => isNormalizedBaseTable(table));
}
function isNormalizedBaseTable(table: ITableSnapshot): boolean {
const records = table.records ?? {};
const fields = table.fields ?? {};
assertBaseTableRecordIdentity(table);
if (
!Array.isArray(table.recordOrder)
|| !table.rowIndex
|| !table.rowId
|| !table.colIndex
|| !table.colId
|| !table.cellData
|| !table.resources
|| !table.resources.attachmentSets
|| !table.resources.attachments
) {
return false;
}
if (Object.keys(records).length !== table.recordOrder.length) {
return false;
}
for (let index = 0; index < table.recordOrder.length; index++) {
const recordId = table.recordOrder[index];
if (
!records[recordId]
|| table.rowIndex[recordId] !== index
|| table.rowId[index] !== recordId
|| table.cellData[index]?.[0]?.v !== recordId
) {
return false;
}
}
for (let index = 0; index < table.fieldOrder.length; index++) {
const fieldId = table.fieldOrder[index];
if (!fields[fieldId]) {
continue;
}
if (table.colIndex[fieldId] !== index || table.colId[index] !== fieldId) {
return false;
}
}
return true;
}
function normalizeBaseTable(table: ITableSnapshot): void {
hydrateCompressedCellData(table);
const records = table.records ?? {};
const fields = table.fields ?? {};
const orderedRecordIds = getOrderedRecordIds(table, records);
const orderedFieldIds = getOrderedFieldIds(table, fields);
table.recordOrder = orderedRecordIds;
cloneBaseTableMutableMaps(table);
normalizeExistingCellData(table, fields);
rebuildRowIndexes(table, records, orderedRecordIds);
rebuildColumnIndexes(table, fields, orderedFieldIds);
hydrateRecordCellData(table, records, fields);
}
function hydrateCompressedCellData(table: ITableSnapshot): void {
const compressedCellData = (table as ITableSnapshot & { cd?: ICompressedBaseCellTuple[] }).cd;
if (compressedCellData && !table.cellData) {
table.cellData = Object.create(null) as ITableSnapshot['cellData'];
compressedCellData.forEach(([row, col, cell]) => {
table.cellData![row] = table.cellData![row] ?? {};
const { c: _compressed, ...cellData } = cell;
table.cellData![row][col] = cellData;
});
delete (table as ITableSnapshot & { cd?: unknown }).cd;
}
}
function getOrderedRecordIds(table: ITableSnapshot, records: Record<string, IRecordSnapshot>): string[] {
const orderedRecordIds = table.recordOrder?.filter((recordId) => records[recordId]) ?? [];
const orderedRecordIdSet = new Set(orderedRecordIds);
const missingRecords = Object.values(records).filter((record) => !orderedRecordIdSet.has(record.id));
if (!missingRecords.length) {
return orderedRecordIds;
}
return [
...orderedRecordIds,
...missingRecords.sort((a, b) => a.orderKey.localeCompare(b.orderKey)).map((record) => record.id),
];
}
function getOrderedFieldIds(table: ITableSnapshot, fields: Record<string, IFieldSnapshot>): string[] {
return table.fieldOrder.filter((fieldId) => fields[fieldId]);
}
function cloneBaseTableMutableMaps(table: ITableSnapshot): void {
table.rowIndex = { ...table.rowIndex };
table.rowId = { ...table.rowId };
table.colIndex = { ...table.colIndex };
table.colId = { ...table.colId };
table.cellData = { ...table.cellData };
table.resources = { ...table.resources };
table.resources.attachmentSets = { ...table.resources.attachmentSets };
table.resources.attachments = { ...table.resources.attachments };
}
function normalizeExistingCellData(table: ITableSnapshot, fields: Record<string, IFieldSnapshot>): void {
Object.entries(table.cellData ?? {}).forEach(([rowKey, rowData]) => {
const row = Number(rowKey);
table.cellData![row] = { ...rowData };
Object.entries(table.cellData![row] ?? {}).forEach(([colKey, cell]) => {
const col = Number(colKey);
const fieldId = table.colId?.[col];
table.cellData![row][col] = normalizeBaseCellData(cell, fieldId ? fields[fieldId] : undefined);
});
});
}
function rebuildRowIndexes(table: ITableSnapshot, records: Record<string, IRecordSnapshot>, orderedRecordIds: string[]): void {
orderedRecordIds.forEach((recordId, index) => {
if (table.rowIndex![recordId] == null) {
table.rowIndex![recordId] = index;
}
});
Object.entries(table.rowIndex ?? {}).forEach(([recordId, row]) => {
if (records[recordId]) {
table.rowId![row] = recordId;
}
});
}
function rebuildColumnIndexes(table: ITableSnapshot, fields: Record<string, IFieldSnapshot>, orderedFieldIds: string[]): void {
orderedFieldIds.forEach((fieldId, index) => {
if (table.colIndex![fieldId] == null) {
table.colIndex![fieldId] = index;
}
});
Object.entries(table.colIndex ?? {}).forEach(([fieldId, col]) => {
if (fields[fieldId]) {
table.colId![col] = fieldId;
}
});
}
function hydrateRecordCellData(table: ITableSnapshot, records: Record<string, IRecordSnapshot>, fields: Record<string, IFieldSnapshot>): void {
const rowIndex = table.rowIndex ?? {};
const colIndex = table.colIndex ?? {};
const cellData = table.cellData ?? {};
table.rowIndex = rowIndex;
table.colIndex = colIndex;
table.cellData = cellData;
Object.values(records).forEach((record) => {
const row = rowIndex[record.id];
if (row == null) {
return;
}
cellData[row] = { ...cellData[row] };
cellData[row][0] = { v: record.id, t: CellValueType.STRING };
Object.entries(record.values ?? {}).forEach(([fieldId, value]) => {
const field = fields[fieldId];
if (field?.type === BaseFieldType.Attachment) {
writeAttachmentResources(table, record.id, fieldId, value);
}
const col = colIndex[fieldId];
if (col == null) {
return;
}
const existingCell = cellData[row][col];
if (existingCell != null) {
cellData[row][col] = normalizeBaseCellData(existingCell, field);
if (shouldRefreshCellDataFromRecord(existingCell, field, value)) {
cellData[row][col] = toBaseCellData(value, field);
}
return;
}
cellData[row][col] = toBaseCellData(value, field);
});
});
}
function assertMaterializedRecordIdentity(table: ITableSnapshot): void {
for (const record of Object.values(table.records)) {
const row = table.rowIndex?.[record.id];
if (
row == null
|| table.colIndex?.[BASE_RECORD_ID_FIELD_ID] !== 0
|| table.colId?.[0] !== BASE_RECORD_ID_FIELD_ID
|| table.cellData?.[row]?.[0]?.v !== record.id
) {
throw new Error(`[BaseDataModel]: record "${record.id}" is missing its record-id cell projection.`);
}
}
}
function toBaseCellData(value: CellValue | IBaseCellData, field?: IFieldSnapshot): IBaseCellData {
if (isBaseCellData(value)) {
if (field?.type === BaseFieldType.Attachment) {
return { ...value, v: '', t: CellValueType.STRING };
}
return normalizeBaseCellData(value, field);
}
if (field?.type === BaseFieldType.Attachment) {
return { v: '', t: CellValueType.STRING };
}
if (isListField(field)) {
return { v: normalizeListValue(value).join(BASE_LIST_VALUE_SEPARATOR), t: CellValueType.STRING };
}
if (field?.type === BaseFieldType.Link && value && typeof value === 'object' && !Array.isArray(value)) {
const link = value as { text?: unknown; url?: unknown };
return { v: String(link.text ?? link.url ?? ''), t: CellValueType.STRING };
}
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return { v: value, t: inferPrimitiveType(value) };
}
return { v: null, t: null };
}
function normalizeBaseCellData(cell: IBaseCellData, _field?: IFieldSnapshot): IBaseCellData {
const type = normalizeBaseCellValueType(cell.t, cell.v);
if (type === undefined) {
const { t: _t, ...rest } = cell;
return rest;
}
return { ...cell, t: type };
}
function isBaseCellData(value: unknown): value is IBaseCellData {
return !!value && typeof value === 'object' && (
Object.prototype.hasOwnProperty.call(value, 'v')
|| Object.prototype.hasOwnProperty.call(value, 't')
|| Object.prototype.hasOwnProperty.call(value, 'p')
|| Object.prototype.hasOwnProperty.call(value, 'f')
|| Object.prototype.hasOwnProperty.call(value, 'si')
);
}
function writeAttachmentResources(table: ITableSnapshot, recordId: string, fieldId: string, value: unknown): void {
const attachments = normalizeAttachmentValue(value);
table.resources = { ...table.resources };
table.resources.attachmentSets = { ...table.resources.attachmentSets };
table.resources.attachments = { ...table.resources.attachments };
const key = `${fieldId}${BASE_ATTACHMENT_RESOURCE_KEY_SEPARATOR}${recordId}`;
table.resources.attachmentSets[key] = attachments.map((attachment, index) => {
const id = String(attachment.id ?? `${key}${BASE_ATTACHMENT_RESOURCE_KEY_SEPARATOR}${index}`);
const normalizedAttachment = { ...attachment, id };
table.resources!.attachments![id] = normalizedAttachment;
return id;
});
}
function normalizeAttachmentValue(value: unknown): Record<string, unknown>[] {
if (value == null || value === '') {
return [];
}
if (isBaseCellData(value) && !('id' in value) && !('name' in value)) {
return [];
}
const values = Array.isArray(value) ? value : [value];
const attachments: Record<string, unknown>[] = [];
for (let i = 0; i < values.length; i++) {
const item = values[i];
if (item && typeof item === 'object') {
const attachment = { ...(item as Record<string, unknown>) };
if (Object.keys(attachment).length > 0) {
attachments.push(attachment);
}
continue;
}
attachments.push({ id: `attachment-${i}`, name: String(item) });
}
return attachments;
}
function shouldRefreshCellDataFromRecord(cell: IBaseCellData, field: IFieldSnapshot | undefined, value: unknown): boolean {
if (field?.type === BaseFieldType.RecordId) {
return cell.v !== value;
}
if (field?.type === BaseFieldType.Attachment) {
return cell.v !== '';
}
if (isListField(field)) {
return Array.isArray(value) && (cell.v == null || cell.v === '');
}
return false;
}
function isListField(field?: IFieldSnapshot): boolean {
return field?.type === BaseFieldType.MultiSelect || field?.type === BaseFieldType.Person || field?.type === BaseFieldType.Group;
}
function normalizeListValue(value: unknown): string[] {
if (value == null || value === '') {
return [];
}
const result: string[] = [];
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const item = primitiveText(value[i]);
if (item) {
result.push(item);
}
}
return result;
}
const items = String(value).split(',');
for (let i = 0; i < items.length; i++) {
const item = items[i].trim();
if (item) {
result.push(item);
}
}
return result;
}
function primitiveText(value: unknown): string {
if (value == null) {
return '';
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
return String(record.id ?? record.name ?? record.text ?? '');
}
return String(value);
}
function inferPrimitiveType(value: CellValue): CellValueType | null {
if (value == null) {
return null;
}
if (typeof value === 'number') {
return CellValueType.NUMBER;
}
if (typeof value === 'boolean') {
return CellValueType.BOOLEAN;
}
return CellValueType.STRING;
}
function normalizeBaseCellValueType(type: IBaseCellData['t'], value: IBaseCellData['v']): CellValueType | null | undefined {
if (type === CellValueType.STRING || type === CellValueType.NUMBER || type === CellValueType.BOOLEAN || type === CellValueType.FORCE_STRING || type == null) {
return type;
}
return inferPrimitiveType(value ?? null);
}

View File

@@ -0,0 +1,138 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IBaseSnapshot, IFieldSnapshot, IRecordSnapshot, ITableSnapshot, IViewSnapshot } from './typedef';
import pkg from '../../package.json';
import { generateRandomId } from '../shared';
import { CellValueType, LocaleType } from '../types/enum';
import { BASE_RECORD_ID_FIELD_ID, createBaseRecordIdField } from './record-identity';
import { BaseFieldType, BaseViewType } from './typedef';
export interface ICreateDefaultBaseTableSnapshotOptions {
id: string;
name: string;
primaryFieldId?: string;
primaryFieldName?: string;
gridViewId?: string;
recordCount?: number;
now?: number;
}
export function createDefaultBaseTableSnapshot(options: ICreateDefaultBaseTableSnapshotOptions): ITableSnapshot {
const now = options.now ?? Date.now();
const primaryFieldId = options.primaryFieldId ?? generateRandomId(6);
const gridViewId = options.gridViewId ?? generateRandomId(6);
const recordCount = options.recordCount ?? 0;
const recordIdField = createBaseRecordIdField();
const primaryField: IFieldSnapshot = {
id: primaryFieldId,
name: options.primaryFieldName ?? 'Name',
type: BaseFieldType.Text,
config: { placeholder: 'bases.fieldConfig.textPlaceholder' },
};
const records: Record<string, IRecordSnapshot> = {};
const recordOrder: string[] = [];
const rowIndex: Record<string, number> = {};
const rowId: Record<number, string> = {};
const cellData: NonNullable<ITableSnapshot['cellData']> = {};
for (let index = 0; index < recordCount; index++) {
const recordId = `${options.id}-record-${index + 1}`;
records[recordId] = {
id: recordId,
values: { [BASE_RECORD_ID_FIELD_ID]: recordId },
orderKey: String(index + 1).padStart(4, '0'),
createdAt: now,
updatedAt: now,
};
recordOrder.push(recordId);
rowIndex[recordId] = index;
rowId[index] = recordId;
cellData[index] = {
0: { v: recordId, t: CellValueType.STRING },
};
}
const gridView: IViewSnapshot = {
id: gridViewId,
tableId: options.id,
name: 'Grid',
type: BaseViewType.Grid,
fieldOrder: [BASE_RECORD_ID_FIELD_ID, primaryFieldId],
fieldSettings: {
[BASE_RECORD_ID_FIELD_ID]: { hidden: true },
},
config: { frozenFieldCount: 1 },
};
return {
id: options.id,
name: options.name,
primaryFieldId,
fieldOrder: [BASE_RECORD_ID_FIELD_ID, primaryFieldId],
fields: {
[BASE_RECORD_ID_FIELD_ID]: recordIdField,
[primaryFieldId]: primaryField,
},
records,
recordOrder,
rowIndex,
rowId,
colIndex: {
[BASE_RECORD_ID_FIELD_ID]: 0,
[primaryFieldId]: 1,
},
colId: {
0: BASE_RECORD_ID_FIELD_ID,
1: primaryFieldId,
},
cellData,
resources: {
attachmentSets: {},
attachments: {},
},
views: { [gridViewId]: gridView },
viewOrder: [gridViewId],
};
}
export function getEmptySnapshot(
unitId: string = generateRandomId(),
name: string = '',
locale: LocaleType = LocaleType.ZH_CN
): IBaseSnapshot {
const now = Date.now();
const tableId = 'table-1';
return {
id: unitId,
name,
locale,
appVersion: pkg.version,
schemaVersion: 2,
tableOrder: [tableId],
tables: {
[tableId]: createDefaultBaseTableSnapshot({
id: tableId,
name: 'Table 1',
now,
recordCount: 0,
}),
},
createdAt: now,
updatedAt: now,
};
}

View File

@@ -0,0 +1,285 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IBaseSnapshot, ITableSnapshot } from './typedef';
type BaseFormulaTable = Pick<ITableSnapshot, 'id' | 'name' | 'formulaName'>;
interface IBaseFormulaSnapshot {
tables: Record<string, BaseFormulaTable>;
}
interface ICompiledBaseFormulaTableAliases {
formulaNameByAlias: ReadonlyMap<string, string>;
pattern?: RegExp;
}
export function normalizeBaseFormulaTableName(displayName: string): string {
const replaced = displayName.trim().replace(/[^A-Za-z0-9_.]+/g, '_');
const normalized = /[A-Za-z0-9]/.test(replaced) ? replaced : 'Table';
const prefixed = /^[A-Za-z_]/.test(normalized) ? normalized : `_${normalized}`;
const nonReference = /^[RC]$/i.test(prefixed)
|| /^[A-Za-z]{1,3}[1-9]\d*$/.test(prefixed)
|| /^R(?:\d+)?C(?:\d+)?$/i.test(prefixed)
? `_${prefixed}`
: prefixed;
return nonReference.slice(0, 255);
}
export function createBaseFormulaTableNameMap(snapshot: IBaseFormulaSnapshot): ReadonlyMap<string, string> {
const result = new Map<string, string>();
const usedNames = new Set<string>();
const nextSuffixByBaseName = new Map<string, number>();
const tables = Object.values(snapshot.tables).sort((left, right) => left.id.localeCompare(right.id));
// Persisted names own the namespace. Missing historical names are allocated only
// after every valid persisted name has been reserved, so loading an old table can
// never steal the stable formula identity of a newer table.
for (const table of tables) {
const formulaName = validBaseFormulaTableName(table.formulaName);
if (!formulaName || usedNames.has(formulaName.toLowerCase())) continue;
usedNames.add(formulaName.toLowerCase());
result.set(table.id, formulaName);
}
for (const table of tables) {
if (result.has(table.id)) continue;
const formulaName = allocateBaseFormulaTableNameFromSet(
table.name,
usedNames,
table.formulaName,
nextSuffixByBaseName
);
usedNames.add(formulaName.toLowerCase());
result.set(table.id, formulaName);
}
return result;
}
export function allocateBaseFormulaTableName(
displayName: string,
existingNames: Iterable<string>,
preferredName?: string
): string {
const usedNames = new Set(Array.from(existingNames, (name) => name.toLowerCase()));
return allocateBaseFormulaTableNameFromSet(displayName, usedNames, preferredName);
}
function allocateBaseFormulaTableNameFromSet(
displayName: string,
usedNames: ReadonlySet<string>,
preferredName?: string,
nextSuffixByBaseName = new Map<string, number>()
): string {
const validPreferredName = validBaseFormulaTableName(preferredName);
if (validPreferredName && !usedNames.has(validPreferredName.toLowerCase())) {
return validPreferredName;
}
const baseName = normalizeBaseFormulaTableName(displayName);
const baseNameKey = baseName.toLowerCase();
if (!usedNames.has(baseNameKey)) {
return baseName;
}
let suffixNumber = nextSuffixByBaseName.get(baseNameKey) ?? 2;
while (true) {
const suffix = `_${suffixNumber++}`;
const formulaName = `${baseName.slice(0, 255 - suffix.length)}${suffix}`;
if (!usedNames.has(formulaName.toLowerCase())) {
nextSuffixByBaseName.set(baseNameKey, suffixNumber);
return formulaName;
}
}
}
export function getBaseFormulaTableName(
table: BaseFormulaTable,
snapshot: IBaseFormulaSnapshot
): string {
return createBaseFormulaTableNameMap(snapshot).get(table.id) ?? normalizeBaseFormulaTableName(table.name);
}
export function normalizeBaseFormulaTableReferences(formula: string, snapshot: IBaseFormulaSnapshot): string {
return createBaseFormulaTableReferenceNormalizer(snapshot)(formula);
}
export function createBaseFormulaTableReferenceNormalizer(
snapshot: IBaseFormulaSnapshot,
formulaNames = createBaseFormulaTableNameMap(snapshot)
): (formula: string) => string {
const compiledAliases = compileBaseFormulaTableAliases(createBaseFormulaTableAliases(snapshot, formulaNames));
return (formula) => rewriteFormulaTableAliases(formula, compiledAliases);
}
export function migrateBaseFormulaTableNames(snapshot: IBaseSnapshot): void {
const formulaNames = createBaseFormulaTableNameMap(snapshot);
const normalizeReferences = createBaseFormulaTableReferenceNormalizer(snapshot, formulaNames);
for (const table of Object.values(snapshot.tables)) {
table.formulaName = formulaNames.get(table.id) ?? normalizeBaseFormulaTableName(table.name);
}
for (const table of Object.values(snapshot.tables)) {
for (const field of Object.values(table.fields)) {
if (field.type !== 'formula' || typeof field.config?.formula !== 'string') continue;
field.config.formula = normalizeReferences(field.config.formula);
}
for (const row of Object.values(table.cellData ?? {})) {
for (const cell of Object.values(row ?? {})) {
if (cell && typeof cell.f === 'string') {
cell.f = normalizeReferences(cell.f);
}
}
}
}
}
function createBaseFormulaTableAliases(
snapshot: IBaseFormulaSnapshot,
formulaNames = createBaseFormulaTableNameMap(snapshot)
): Array<{ alias: string; formulaName: string }> {
const legacyFormulaNames = createLegacyBaseFormulaTableNameMap(snapshot);
const formulaNameOwners = new Map(
Array.from(formulaNames, ([tableId, formulaName]) => [formulaName.toLowerCase(), tableId])
);
const displayNameCounts = new Map<string, number>();
for (const table of Object.values(snapshot.tables)) {
const key = table.name.toLowerCase();
displayNameCounts.set(key, (displayNameCounts.get(key) ?? 0) + 1);
}
return Object.values(snapshot.tables)
.flatMap((table) => {
const formulaName = formulaNames.get(table.id) ?? normalizeBaseFormulaTableName(table.name);
const inputAliases = [
table.id,
createLegacyBaseFormulaTableName(table.id),
legacyFormulaNames.get(table.id),
table.formulaName,
];
if (displayNameCounts.get(table.name.toLowerCase()) === 1) {
inputAliases.push(table.name);
}
return inputAliases
.filter((alias): alias is string => Boolean(alias))
.flatMap((alias) => [alias, quoteBaseFormulaTableAlias(alias)])
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => alias.toLowerCase() !== formulaName.toLowerCase())
.filter((alias) => {
const owner = formulaNameOwners.get(unquoteBaseFormulaTableAlias(alias).toLowerCase());
return owner == null || owner === table.id;
})
.map((alias) => ({ alias, formulaName }));
})
.sort((left, right) => right.alias.length - left.alias.length);
}
function createLegacyBaseFormulaTableNameMap(snapshot: IBaseFormulaSnapshot): ReadonlyMap<string, string> {
const result = new Map<string, string>();
const usedNames = new Set<string>();
const nextSuffixByBaseName = new Map<string, number>();
const tables = Object.values(snapshot.tables).sort((left, right) => left.id.localeCompare(right.id));
for (const table of tables) {
const formulaName = allocateBaseFormulaTableNameFromSet(
table.name,
usedNames,
undefined,
nextSuffixByBaseName
);
usedNames.add(formulaName.toLowerCase());
result.set(table.id, formulaName);
}
return result;
}
function validBaseFormulaTableName(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed && normalizeBaseFormulaTableName(trimmed) === trimmed ? trimmed : undefined;
}
function quoteBaseFormulaTableAlias(alias: string): string | undefined {
return /[^A-Za-z0-9_.]/.test(alias) ? `'${alias.replaceAll("'", "''")}'` : undefined;
}
function unquoteBaseFormulaTableAlias(alias: string): string {
return alias.startsWith("'") && alias.endsWith("'")
? alias.slice(1, -1).replaceAll("''", "'")
: alias;
}
function compileBaseFormulaTableAliases(
aliases: ReadonlyArray<{ alias: string; formulaName: string }>
): ICompiledBaseFormulaTableAliases {
const formulaNameByAlias = new Map<string, string>();
for (const { alias, formulaName } of aliases) {
const key = alias.toLowerCase();
if (!formulaNameByAlias.has(key)) {
formulaNameByAlias.set(key, formulaName);
}
}
const orderedAliases = Array.from(formulaNameByAlias.keys()).sort((left, right) => right.length - left.length);
return {
formulaNameByAlias,
pattern: orderedAliases.length ? new RegExp(`(${orderedAliases.map(escapeRegExp).join('|')})(\\s*)\\[`, 'gi') : undefined,
};
}
function createLegacyBaseFormulaTableName(tableId: string): string {
const encoded = Array.from(tableId, (character) => /[A-Za-z0-9]/.test(character) ? character : `_x${character.codePointAt(0)?.toString(16) ?? '0'}_`).join('');
return `_T_${encoded}`;
}
function rewriteFormulaTableAliases(
formula: string,
aliases: ICompiledBaseFormulaTableAliases
): string {
if (!aliases.pattern) return formula;
let scannedUntil = 0;
let inString = false;
aliases.pattern.lastIndex = 0;
return formula.replace(
aliases.pattern,
(match: string, alias: string, whitespace: string, offset: number) => {
inString = scanFormulaStringState(formula, scannedUntil, offset, inString);
scannedUntil = offset;
const previous = formula[offset - 1];
const hasIdentifierPrefix = previous != null && /[A-Za-z0-9_.]/.test(previous);
const isExternalReference = previous === '!';
if (hasIdentifierPrefix || isExternalReference || inString) {
return match;
}
const formulaName = aliases.formulaNameByAlias.get(alias.toLowerCase());
return formulaName ? `${formulaName}${whitespace}[` : match;
}
);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function scanFormulaStringState(formula: string, start: number, end: number, initialState: boolean): boolean {
let inString = initialState;
for (let index = start; index < end; index++) {
if (formula[index] !== '"') continue;
if (inString && formula[index + 1] === '"') {
index++;
continue;
}
inString = !inString;
}
return inString;
}

View File

@@ -0,0 +1,130 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { BaseDataModel } from './base-data-model';
export {
createDefaultBaseTableSnapshot,
getEmptySnapshot as getBasesEmptySnapshot,
type ICreateDefaultBaseTableSnapshotOptions,
} from './empty-snapshot';
export {
allocateBaseFormulaTableName,
createBaseFormulaTableNameMap,
createBaseFormulaTableReferenceNormalizer,
getBaseFormulaTableName,
migrateBaseFormulaTableNames,
normalizeBaseFormulaTableName,
normalizeBaseFormulaTableReferences,
} from './formula-table-name';
export {
assertBaseTableRecordIdentity,
BASE_RECORD_ID_FIELD_ID,
BASE_RECORD_ID_FIELD_NAME,
createBaseRecordIdField,
isBaseRecordIdFieldName,
isValidBaseRecordId,
} from './record-identity';
export {
BaseConditionalColorOperator,
BaseConditionalColorTarget,
BaseConditionalDateMode,
BaseFieldType,
BaseFilterConjunction,
BaseFilterOperator,
BaseHierarchyInvalidReason,
BaseRecordLinkRole,
BaseSortDirection,
BaseViewType,
} from './typedef';
export type {
BaseCellMatrix,
BaseCellPrimitiveValue,
CellValue as BaseCellValue,
BaseCellValueType,
BaseDateHourCycle,
BaseHitTestResult,
BaseId,
PrimitiveCellValue as BasePrimitiveCellValue,
BaseSelection,
BaseSnapshot,
BaseViewProjection,
FieldConfig,
FieldId,
FieldSnapshot,
IBaseAttachment,
IBaseCellData,
IBaseConditionalColoringConfig,
IBaseConditionalColorRule,
IBaseDateFieldConfig,
IBaseHierarchyNodeProjection,
IBaseHierarchyProjection,
IBaseInvalidation,
IBaseRect,
IBaseResources,
IBaseSnapshot,
IBaseViewColorCondition,
IBaseViewCommonConfig,
IBaseViewport,
ICalendarEventResizeSelection,
ICalendarEventSelection,
ICalendarProjection,
ICalendarViewConfig,
ICardLayoutConfig,
IFieldCapabilities,
IFieldSnapshot,
IFilterCondition,
IFilterConfig,
IGalleryCardSelection,
IGalleryProjection,
IGalleryViewConfig,
IGanttBarSelection,
IGanttCellSelection,
IGanttProjection,
IGanttTimeColumn,
IGanttViewConfig,
IGridCellSelection,
IGridFieldSelection,
IGridGroupSelection,
IGridProjection,
IGridRecordSelection,
IGridViewConfig,
IGroupConfig,
IInvalidViewProjection,
IKanbanCardSelection,
IKanbanColumnSetting,
IKanbanFieldCardSetting,
IKanbanProjection,
IKanbanViewConfig,
IProjectedField,
IProjectedGroup,
IProjectedRow,
IRecordLinkFieldConfig,
IRecordSnapshot,
ISortConfig,
ITableSnapshot,
IValidationResult,
IViewFieldSetting,
IViewProjection,
IViewSnapshot,
KanbanCardLayoutMode,
RecordId,
RecordSnapshot,
TableId,
TableSnapshot,
ViewId,
ViewSnapshot,
ViewSpecificConfig,
} from './typedef';

View File

@@ -0,0 +1,77 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IFieldSnapshot, ITableSnapshot } from './typedef';
import { BaseFieldType } from './typedef';
export const BASE_RECORD_ID_FIELD_ID = '__record_id';
export const BASE_RECORD_ID_FIELD_NAME = 'record-id';
const BASE_RECORD_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
export function createBaseRecordIdField(): IFieldSnapshot {
return {
id: BASE_RECORD_ID_FIELD_ID,
name: BASE_RECORD_ID_FIELD_NAME,
type: BaseFieldType.RecordId,
config: {},
system: true,
readonly: true,
};
}
export function isValidBaseRecordId(recordId: string): boolean {
return BASE_RECORD_ID_PATTERN.test(recordId);
}
export function isBaseRecordIdFieldName(name: string): boolean {
return name.trim().toLowerCase() === BASE_RECORD_ID_FIELD_NAME;
}
export function assertBaseTableRecordIdentity(table: ITableSnapshot): void {
const recordIdField = table.fields[BASE_RECORD_ID_FIELD_ID];
const recordIdFields = Object.values(table.fields).filter((field) => field.type === BaseFieldType.RecordId);
if (
!recordIdField
|| recordIdFields.length !== 1
|| recordIdField.type !== BaseFieldType.RecordId
|| recordIdField.name !== BASE_RECORD_ID_FIELD_NAME
|| recordIdField.system !== true
|| recordIdField.readonly !== true
|| table.fieldOrder[0] !== BASE_RECORD_ID_FIELD_ID
|| table.primaryFieldId === BASE_RECORD_ID_FIELD_ID
|| !table.fields[table.primaryFieldId]
) {
throw new Error(`[BaseDataModel]: table "${table.id}" has an invalid record-id system field.`);
}
const conflictingField = Object.values(table.fields).find((field) => {
return field.id !== BASE_RECORD_ID_FIELD_ID && isBaseRecordIdFieldName(field.name);
});
if (conflictingField) {
throw new Error(`[BaseDataModel]: field name "${conflictingField.name}" is reserved for the record-id system field.`);
}
for (const [recordKey, record] of Object.entries(table.records)) {
if (
record.id !== recordKey
|| !isValidBaseRecordId(record.id)
|| record.values[BASE_RECORD_ID_FIELD_ID] !== record.id
) {
throw new Error(`[BaseDataModel]: record "${recordKey}" has an invalid record-id projection.`);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { dedupe, dedupeBy, findLast, groupBy, makeArray, remove, rotate } from '../array';
describe('test array utils', () => {
it('should "rotate" function work correctly', () => {
const raw = [1, 2, 3, 4, 5];
// The function do not mutate the original array.
expect(rotate(raw, 0)).toEqual([1, 2, 3, 4, 5]);
expect(rotate(raw, 1)).toEqual([2, 3, 4, 5, 1]);
expect(rotate(raw, 2)).toEqual([3, 4, 5, 1, 2]);
expect(rotate(raw, 3)).toEqual([4, 5, 1, 2, 3]);
expect(rotate(raw, 4)).toEqual([5, 1, 2, 3, 4]);
expect(rotate(raw, 5)).toEqual([1, 2, 3, 4, 5]);
expect(rotate(raw, 6)).toEqual([2, 3, 4, 5, 1]);
});
it('should "dedupeBy" function work correctly', () => {
const raw = [
{ id: '1', name: 'a' },
{ id: '2', name: 'b' },
{ id: '3', name: 'c' },
{ id: '1', name: 'd' },
];
expect(raw.length).toBe(4);
expect(raw[0].name).toBe('a');
expect(raw[1].name).toBe('b');
expect(raw[2].name).toBe('c');
expect(raw[3].name).toBe('d');
const deduped = dedupeBy(raw, (item) => item.id);
expect(deduped.length).toBe(3);
expect(deduped[0].name).toBe('a');
expect(deduped[1].name).toBe('b');
expect(deduped[2].name).toBe('c');
expect(deduped[0].id).toBe('1');
});
it('should remove, dedupe, findLast, groupBy and make arrays consistently', () => {
const raw = [1, 2, 2, 3, 4, 3];
expect(remove(raw, 2)).toBe(true);
expect(raw).toEqual([1, 2, 3, 4, 3]);
expect(remove(raw, 9)).toBe(false);
expect(dedupe(raw)).toEqual([1, 2, 3, 4]);
expect(findLast(raw, (item, index) => item % 2 === 1 && index > 2)).toBe(3);
expect(findLast(raw, (item) => item === 9)).toBeNull();
const grouped = groupBy([
{ group: 'odd', value: 1 },
{ group: 'even', value: 2 },
{ group: 'odd', value: 3 },
], (item) => item.group);
expect(grouped.get('odd')).toEqual([
{ group: 'odd', value: 1 },
{ group: 'odd', value: 3 },
]);
expect(grouped.get('even')).toEqual([{ group: 'even', value: 2 }]);
expect(makeArray('single')).toEqual(['single']);
const arr = ['already'];
expect(makeArray(arr)).toBe(arr);
});
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { ArrangeTypeEnum } from '../../types/interfaces/i-drawing';
import { getDrawingOrderIndex, normalizeDrawingOrderIndex } from '../drawing-order';
describe('drawing order', () => {
it('resolves relative arrangement to a zero-based order index', () => {
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.front)).toBe(3);
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.forward)).toBe(2);
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.backward)).toBe(0);
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.back)).toBe(0);
});
it('keeps relative arrangement inside the available order', () => {
expect(getDrawingOrderIndex(3, 4, ArrangeTypeEnum.forward)).toBe(3);
expect(getDrawingOrderIndex(0, 4, ArrangeTypeEnum.backward)).toBe(0);
expect(getDrawingOrderIndex(0, 0, ArrangeTypeEnum.front)).toBe(0);
});
it('normalizes absolute indexes to the available order', () => {
expect(normalizeDrawingOrderIndex(-1, 3)).toBe(0);
expect(normalizeDrawingOrderIndex(1.9, 3)).toBe(1);
expect(normalizeDrawingOrderIndex(99, 3)).toBe(2);
expect(normalizeDrawingOrderIndex(Number.POSITIVE_INFINITY, 3)).toBe(2);
expect(normalizeDrawingOrderIndex(Number.NaN, 3)).toBe(0);
expect(normalizeDrawingOrderIndex(1, 0)).toBe(0);
});
});

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 { afterEach, describe, expect, it, vi } from 'vitest';
import { throttle } from '../function';
afterEach(() => vi.useRealTimers());
describe('throttle', () => {
it('does not replay a delayed old value after a newer leading call', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const render = vi.fn();
const update = throttle(render, 100);
update('initial');
vi.setSystemTime(1010);
update('old');
// A background tab can delay timers beyond the throttle interval.
vi.setSystemTime(1200);
update('latest');
vi.runAllTimers();
expect(render.mock.calls).toEqual([['initial'], ['latest']]);
});
it('still delivers the last value of a burst', () => {
vi.useFakeTimers();
vi.setSystemTime(1000);
const render = vi.fn();
const update = throttle(render, 100);
update('initial');
update('middle');
update('latest');
vi.runAllTimers();
expect(render.mock.calls).toEqual([['initial'], ['latest']]);
});
});

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 { describe, expect, it } from 'vitest';
import { AsyncInterceptorManager, createAsyncInterceptorKey } from '../interceptor';
describe('Test interceptor', () => {
it('should return "true" when each interceptor returns true ', async () => {
const INTERCEPTOR_KEY = createAsyncInterceptorKey<boolean, boolean>('test');
const interceptor = new AsyncInterceptorManager({ INTERCEPTOR_KEY });
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
priority: 0,
handler: async (value, context, next) => {
return await next(value);
},
});
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
priority: 10,
handler: async (value, context, next) => {
return await next(true);
},
});
const interceptorRes = await interceptor.fetchThroughAsyncInterceptors(INTERCEPTOR_KEY)(false, true);
expect(interceptorRes).toBe(true);
});
it('should return "false" when one of the interceptor returns false', async () => {
const INTERCEPTOR_KEY = createAsyncInterceptorKey<boolean, boolean>('test');
const interceptor = new AsyncInterceptorManager({ INTERCEPTOR_KEY });
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
priority: 0,
handler: async (value, context, next) => {
return await next(value);
},
});
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
priority: 10,
handler: async (value, context, next) => {
return await next(false);
},
});
const interceptorRes = await interceptor.fetchThroughAsyncInterceptors(INTERCEPTOR_KEY)(false, true);
expect(interceptorRes).toBe(false);
});
});

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 { describe, expect, it } from 'vitest';
import { invertColorByHSL, stringToRgb } from '../invert-color/invert-hsl';
import { invertColorByMatrix } from '../invert-color/invert-rgb';
import { denormalizeRGBColor, normalizeRGBColor } from '../invert-color/utils';
function expectRgbColorInRange(color: [number, number, number]) {
color.forEach((channel) => {
expect(Number.isInteger(channel)).toBe(true);
expect(channel).toBeGreaterThanOrEqual(0);
expect(channel).toBeLessThanOrEqual(255);
});
}
describe('invert color helpers', () => {
it('should normalize and denormalize rgb colors', () => {
expect(normalizeRGBColor([0, 128, 255])).toEqual([0, 128 / 255, 1]);
expect(denormalizeRGBColor([0, 0.5, 1])).toEqual([0, 128, 255]);
});
it('should parse rgb strings and ignore trailing channels', () => {
expect(stringToRgb('12,34,56')).toEqual([12, 34, 56]);
expect(stringToRgb('12,34,56,0.5')).toEqual([12, 34, 56]);
});
it('should invert colors with the rgb matrix implementation', () => {
expect(invertColorByMatrix([0, 0, 0])).toEqual([255, 255, 255]);
expect(invertColorByMatrix([255, 255, 255])).toEqual([0, 0, 0]);
expect(invertColorByMatrix([255, 0, 0])).toEqual([255, 85, 85]);
});
it('should invert grayscale colors with the hsl implementation', () => {
expect(invertColorByHSL([0, 0, 0])).toEqual([255, 255, 255]);
expect(invertColorByHSL([255, 255, 255])).toEqual([3, 3, 3]);
expect(invertColorByHSL([128, 128, 128])).toEqual([127, 127, 127]);
});
it('should invert chromatic colors through all hue branches', () => {
const redDominantLowBlue = invertColorByHSL([255, 128, 0]);
const redDominantHighBlue = invertColorByHSL([255, 0, 128]);
const greenDominant = invertColorByHSL([0, 255, 0]);
const blueDominant = invertColorByHSL([0, 0, 255]);
const brightChromatic = invertColorByHSL([255, 200, 100]);
[redDominantLowBlue, redDominantHighBlue, greenDominant, blueDominant, brightChromatic].forEach((color) => {
expectRgbColorInRange(color);
});
expect(redDominantLowBlue).toHaveLength(3);
expect(redDominantHighBlue).toHaveLength(3);
expect(greenDominant).toHaveLength(3);
expect(blueDominant).toHaveLength(3);
expect(brightChromatic).toHaveLength(3);
});
});

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { regexp } from '../regexp';
describe('regexp utilities', () => {
it('escapes regular expression syntax characters', () => {
expect(regexp.escapeRegExp('a+b*c?.[test]')).toBe(String.raw`a\+b\*c\?\.\[test\]`);
expect(new RegExp(regexp.escapeRegExp('a+b*c?.[test]')).test('a+b*c?.[test]')).toBe(true);
});
it('converts wildcard text to an anchored regular expression', () => {
const regex = regexp.createFromWildcard('file-??-*.ts');
expect(regex.test('file-ab-index.ts')).toBe(true);
expect(regex.test('FILE-12-.ts')).toBe(true);
expect(regex.test('prefix-file-ab-index.ts')).toBe(false);
expect(regex.test('file-a-index.ts')).toBe(false);
});
it('builds literal regexes from text containing regex syntax', () => {
const regex = regexp.createLiteralRegExp('a.b+(test)?', 'g');
expect('a.b+(test)? aXb+(test)?'.replace(regex, 'hit')).toBe('hit aXb+(test)?');
});
it('builds character sets with union, subtraction, and intersection', () => {
const letters = regexp.charset(['a', 'f']).subtract('b', ['d', 'e']);
const reg = letters.toRegExp();
expect(letters.toString()).toBe('[acf]');
expect(reg.test('a')).toBe(true);
expect(reg.test('b')).toBe(false);
expect(reg.test('c')).toBe(true);
expect(reg.test('d')).toBe(false);
expect(reg.test('e')).toBe(false);
expect(reg.test('f')).toBe(true);
const intersection = regexp.charset(['a', 'z']).intersect(['m', 'p']);
expect(intersection.toString()).toBe('[m-p]');
});
it('validates dynamic regexp flags before constructing regexes', () => {
expect(regexp.charset(['a', 'z']).toRegExp('iu').flags).toBe('iu');
expect(regexp.or('SUM', 'AVERAGE').toRegExp('i').test('sum')).toBe(true);
expect(() => regexp.charset(['a', 'z']).toRegExp('ii')).toThrow('Invalid regular expression flags');
expect(() => regexp.charset(['a', 'z']).toRegExp('x')).toThrow('Invalid regular expression flags');
expect(() => regexp.or('SUM').toRegExp('gg')).toThrow('Invalid regular expression flags');
});
it('builds alternatives from strings and character sets', () => {
const keyword = regexp.or('SUM', 'SUMIF', regexp.charset('A', 'B'));
const reg = new RegExp(`^${keyword}$`);
expect(keyword.toString()).toBe('(?:[A-B]|SUM|SUMIF)');
expect(reg.test('A')).toBe(true);
expect(reg.test('SUM')).toBe(true);
expect(reg.test('SUMIF')).toBe(true);
expect(reg.test('AVERAGE')).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { Registry, RegistryAsMap } from '../registry';
describe('registry helpers', () => {
it('should add unique items into Registry and delete existing ones', () => {
const registry = Registry.create<string>();
registry.add('a');
registry.add('a');
registry.add('b');
expect(registry.getData()).toEqual(['a', 'b']);
registry.delete('a');
expect(registry.getData()).toEqual(['b']);
});
it('should add unique keyed items into RegistryAsMap and delete by key', () => {
const registry = RegistryAsMap.create();
registry.add('a', { value: 1 });
registry.add('a', { value: 2 });
registry.add('b', { value: 3 });
expect(Array.from(registry.getData().entries())).toEqual([
['a', { value: 1 }],
['b', { value: 3 }],
]);
registry.delete('a');
expect(Array.from(registry.getData().entries())).toEqual([
['b', { value: 3 }],
]);
});
});

View File

@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installShims } from '../shims';
type RestorableDescriptor = PropertyDescriptor | undefined;
function restoreProperty(target: object, key: PropertyKey, descriptor: RestorableDescriptor) {
if (descriptor) {
Object.defineProperty(target, key, descriptor);
} else {
Reflect.deleteProperty(target, key);
}
}
function setPropertyUndefined(target: object, key: PropertyKey) {
Object.defineProperty(target, key, {
configurable: true,
writable: true,
value: undefined,
});
}
describe('installShims', () => {
let requestIdleDescriptor: RestorableDescriptor;
let cancelIdleDescriptor: RestorableDescriptor;
let queueMicrotaskDescriptor: RestorableDescriptor;
let findLastDescriptor: RestorableDescriptor;
let findLastIndexDescriptor: RestorableDescriptor;
let stringAtDescriptor: RestorableDescriptor;
beforeEach(() => {
vi.useFakeTimers();
requestIdleDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestIdleCallback');
cancelIdleDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelIdleCallback');
queueMicrotaskDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'queueMicrotask');
findLastDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'findLast');
findLastIndexDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'findLastIndex');
stringAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'at');
Object.defineProperty(globalThis, 'requestIdleCallback', {
configurable: true,
writable: true,
value: undefined,
});
Object.defineProperty(globalThis, 'cancelIdleCallback', {
configurable: true,
writable: true,
value: undefined,
});
Object.defineProperty(globalThis, 'queueMicrotask', {
configurable: true,
writable: true,
value: undefined,
});
setPropertyUndefined(Array.prototype, 'findLast');
setPropertyUndefined(Array.prototype, 'findLastIndex');
setPropertyUndefined(String.prototype, 'at');
});
afterEach(() => {
restoreProperty(globalThis, 'requestIdleCallback', requestIdleDescriptor);
restoreProperty(globalThis, 'cancelIdleCallback', cancelIdleDescriptor);
restoreProperty(globalThis, 'queueMicrotask', queueMicrotaskDescriptor);
restoreProperty(Array.prototype, 'findLast', findLastDescriptor);
restoreProperty(Array.prototype, 'findLastIndex', findLastIndexDescriptor);
restoreProperty(String.prototype, 'at', stringAtDescriptor);
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should install requestIdleCallback and allow cancellation', () => {
const callback = vi.fn();
installShims();
const canceledId = globalThis.requestIdleCallback?.(callback as never);
globalThis.cancelIdleCallback?.(canceledId as number);
vi.advanceTimersByTime(5);
expect(callback).not.toHaveBeenCalled();
globalThis.requestIdleCallback?.(callback as never);
vi.advanceTimersByTime(5);
expect(callback).toHaveBeenCalledTimes(1);
const idleDeadline = callback.mock.calls[0][0] as { didTimeout: boolean; timeRemaining: () => number };
expect(idleDeadline.didTimeout).toBe(false);
expect(idleDeadline.timeRemaining()).toBeGreaterThanOrEqual(0);
});
it('should install queueMicrotask polyfill', async () => {
const callback = vi.fn();
installShims();
globalThis.queueMicrotask?.(callback);
expect(callback).not.toHaveBeenCalled();
await Promise.resolve();
expect(callback).toHaveBeenCalledTimes(1);
});
it('should install array findLastIndex and findLast polyfills', () => {
installShims();
expect([1, 2, 3, 2].findLastIndex((value) => value === 2)).toBe(3);
expect([1, 2, 3, 2].findLast((value) => value === 2)).toBe(2);
expect(() => Array.prototype.findLastIndex.call([1], null as never)).toThrow(/callback must be a function/);
});
it('should install string at polyfill', () => {
installShims();
expect('abcd'.at(1)).toBe('b');
expect('abcd'.at(-1)).toBe('d');
expect('abcd'.at(10)).toBeUndefined();
});
});

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 { describe, expect, it } from 'vitest';
import { isLegalUrl, isSafeUrl, normalizeUrl, resolveWithBasePath } from '../url';
describe('Test url utils', () => {
it('should return true on legal url', () => {
expect(isLegalUrl('https://univer.ai/')).toBeTruthy();
expect(isLegalUrl('univer.ai')).toBeTruthy();
expect(isLegalUrl('http://localhost:5173')).toBeTruthy();
});
it('should return false on illegal url', () => {
expect(isLegalUrl('https://univer')).toBeFalsy();
expect(isLegalUrl('univer')).toBeFalsy();
expect(isLegalUrl('192.168')).toBeFalsy();
expect(isLegalUrl('咋看手机看到.是多少')).toBeFalsy();
expect(isLegalUrl('univer.univer')).toBeFalsy();
});
it('should add protocol to no protocol url', () => {
expect(normalizeUrl('univer.ai')).toEqual('https://univer.ai');
expect(normalizeUrl('https://univer.ai')).toEqual('https://univer.ai');
expect(normalizeUrl('zhang@univer.ai')).toEqual('mailto://zhang@univer.ai');
});
it('should allow safe url schemes and reject dangerous ones', () => {
expect(isSafeUrl('https://univer.ai')).toBe(true);
expect(isSafeUrl('http://localhost:3000')).toBe(true);
expect(isSafeUrl('mailto:test@example.com')).toBe(true);
expect(isSafeUrl('javascript:alert(1)')).toBe(false);
expect(isSafeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isSafeUrl('vbscript:msgbox(1)')).toBe(false);
expect(isSafeUrl('')).toBe(false);
});
it('should resolve url with base path', () => {
expect(resolveWithBasePath('/universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/univer/')).toEqual(
'http://univer.examples.com.cn:9080/univer/universer-api/strean/file/dowload?file_id-2008162633680711718'
);
expect(resolveWithBasePath('universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/univer/')).toEqual(
'http://univer.examples.com.cn:9080/univer/universer-api/strean/file/dowload?file_id-2008162633680711718'
);
expect(resolveWithBasePath('/universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/univer')).toEqual(
'http://univer.examples.com.cn:9080/univer/universer-api/strean/file/dowload?file_id-2008162633680711718'
);
expect(resolveWithBasePath('https://univer.examples.com.cn/file/2007806045355209235/univer-New%20Sheet.xlsx', 'http://univer.examples.com.cn:9080/univer/')).toEqual(
'https://univer.examples.com.cn/file/2007806045355209235/univer-New%20Sheet.xlsx'
);
expect(resolveWithBasePath('/universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/')).toEqual(
'http://univer.examples.com.cn:9080/universer-api/strean/file/dowload?file_id-2008162633680711718'
);
});
});

View File

@@ -0,0 +1,141 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { isBooleanString } from '../boolean';
import { noop, throttle } from '../function';
import { mixinClass } from '../mixin';
import { isNumeric, isSafeNumeric, willLoseNumericPrecision } from '../number';
import { requestImmediateMacroTask } from '../request-immediate-macro-task';
import { mergeSets } from '../set';
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('common utility primitives', () => {
it('should classify boolean strings case-insensitively', () => {
expect(isBooleanString('true')).toBe(true);
expect(isBooleanString('FALSE')).toBe(true);
expect(isBooleanString('yes')).toBe(false);
});
it('should keep noop side-effect free and throttle rapid invocations', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
const calls: Array<[number, number]> = [];
const context = {
base: 7,
throttled: throttle(function (this: { base: number }, step: number) {
calls.push([this.base, step]);
}, 16),
};
expect(noop()).toBeUndefined();
context.throttled(1);
expect(calls).toEqual([[7, 1]]);
vi.setSystemTime(new Date('2026-01-01T00:00:00.005Z'));
context.throttled(2);
vi.setSystemTime(new Date('2026-01-01T00:00:00.010Z'));
context.throttled(3);
expect(calls).toEqual([[7, 1]]);
vi.advanceTimersByTime(16);
expect(calls).toEqual([[7, 1], [7, 3]]);
});
it('should mix enumerable methods into a class prototype', () => {
class Greeter {
prefix = 'hi';
}
mixinClass(Greeter.prototype, {
greet(this: Greeter, name: string) {
return `${this.prefix}, ${name}`;
},
});
const greeter = new Greeter() as Greeter & { greet(name: string): string };
expect(greeter.greet('univer')).toBe('hi, univer');
});
it('should recognize numeric strings and precision boundaries', () => {
expect(isNumeric('-12.5')).toBe(true);
expect(isNumeric('12e3')).toBe(false);
expect(isSafeNumeric(`${Number.MAX_SAFE_INTEGER}`)).toBe(true);
expect(isSafeNumeric('9007199254740992')).toBe(false);
expect(willLoseNumericPrecision('123456789123456789')).toBe(true);
expect(willLoseNumericPrecision('12345')).toBe(false);
});
it('should merge sets into the first instance', () => {
const left = new Set(['alpha', 'beta']);
const right = new Set(['beta', 'gamma']);
const merged = mergeSets(left, right);
expect(merged).toBe(left);
expect(Array.from(merged)).toEqual(['alpha', 'beta', 'gamma']);
});
it('should schedule immediate macro tasks and allow cancellation', async () => {
const events: string[] = [];
const port1Close = vi.fn();
const port2Close = vi.fn();
vi.stubGlobal('MessageChannel', class {
port1 = {
onmessage: null as null | (() => void),
close: port1Close,
};
port2 = {
postMessage: () => queueMicrotask(() => this.port1.onmessage?.()),
close: port2Close,
};
});
await new Promise<void>((resolve) => {
requestImmediateMacroTask(() => {
events.push('ran');
resolve();
});
});
expect(events).toEqual(['ran']);
expect(port1Close).toHaveBeenCalledOnce();
expect(port2Close).toHaveBeenCalledOnce();
await new Promise<void>((resolve) => {
const cancel = requestImmediateMacroTask(() => {
events.push('cancelled');
});
cancel();
setTimeout(resolve, 0);
});
expect(events).toEqual(['ran']);
expect(port1Close).toHaveBeenCalledTimes(2);
expect(port2Close).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function remove<T>(arr: T[], item: T): boolean {
const index = arr.indexOf(item);
if (index > -1) {
arr.splice(index, 1);
return true;
}
return false;
}
/**
* Deduplicate an array.
* @param arr The array to be dedupe.
* @returns Return the deduplicated array.
*/
export function dedupe<T>(arr: T[]): T[] {
const deduplicated = new Set<T>();
const result: T[] = [];
for (const element of arr) {
if (!deduplicated.has(element)) {
deduplicated.add(element);
result.push(element);
}
}
return result;
}
export function dedupeBy<T>(arr: T[], keyFn: (v: T) => string): T[] {
const deduplicated = new Set<string>();
const result: T[] = [];
for (const element of arr) {
const key = keyFn(element);
if (!deduplicated.has(key)) {
deduplicated.add(key);
result.push(element);
}
}
return result;
}
export function findLast<T>(arr: T[], callback: (item: T, index: number) => boolean): T | null {
for (let i = arr.length - 1; i > -1; i--) {
const item = arr[i];
if (callback(item, i)) {
return item;
}
}
return null;
}
/**
* Rotate an array without mutating the original array.
* @param arr the array to be rotated
* @param steps how many steps to rotate
* @returns the rotated array, it is another array, the original array is not mutated.
*/
export function rotate<T>(arr: Readonly<T[]>, steps: number): readonly T[] {
if (arr.length === 0) {
return arr;
}
const offset = steps % arr.length;
return arr.slice(offset).concat(arr.slice(0, offset));
}
export function groupBy<T>(arr: Readonly<T[]>, keyFn: (v: T) => string): Map<string, T[]> {
const groups = new Map<string, T[]>();
arr.forEach((element) => {
const key = keyFn(element);
let group = groups.get(key);
if (!groups.has(key)) {
group = [];
groups.set(key, group);
}
group!.push(element);
});
return groups;
}
export function makeArray<T>(thing: T | T[]): T[] {
if (Array.isArray(thing)) return thing;
return [thing];
}

View File

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

View File

@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const BooleanStringSet = new Set(['true', 'false']);
export function isBooleanString(str: string): boolean {
return BooleanStringSet.has(str.toLowerCase());
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const PREFIX = '__INTERNAL_EDITOR__';
export const DOCS_NORMAL_EDITOR_UNIT_ID_KEY = `${PREFIX}DOCS_NORMAL`;
export const DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY = `${PREFIX}DOCS_FORMULA_BAR`;
export const DOCS_COMMENT_EDITOR_UNIT_ID_KEY = `${PREFIX}COMMENT_EDITOR`;
export const DEFAULT_EMPTY_DOCUMENT_VALUE = '\r\n';
export const IS_ROW_STYLE_PRECEDE_COLUMN_STYLE = 'isRowStylePrecedeColumnStyle';
export const AUTO_HEIGHT_FOR_MERGED_CELLS = Symbol('AUTO_HEIGHT_FOR_MERGED_CELLS');
export function createInternalEditorID(id: string) {
return `${PREFIX}${id}`;
}
export function isInternalEditorID(id: string) {
return id.startsWith(PREFIX);
}
export function isCommentEditorID(id: string) {
return id.startsWith(DOCS_COMMENT_EDITOR_UNIT_ID_KEY);
}

View File

@@ -0,0 +1,79 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency, DependencyIdentifier, Injector } from '@wendellhu/redi';
export {
createIdentifier,
forwardRef,
Inject,
Injector,
InjectSelf,
isAsyncDependencyItem,
isAsyncHook,
isClassDependencyItem,
isCtor,
isDisposable,
isFactoryDependencyItem,
isValueDependencyItem,
LookUp,
Many,
Optional,
Quantity,
RediError,
Self,
setDependencies,
SkipSelf,
WithNew,
} from '@wendellhu/redi';
export type {
AsyncDependencyItem,
AsyncHook,
ClassDependencyItem,
Ctor,
Dependency,
DependencyIdentifier,
DependencyItem,
DependencyPair,
FactoryDependencyItem,
IAccessor,
IdentifierDecorator,
IDisposable,
SyncDependencyItem,
ValueDependencyItem,
} from '@wendellhu/redi';
/**
* Register the dependencies to the injector.
* @param injector The injector to register the dependencies.
* @param dependencies The dependencies to register.
*/
export function registerDependencies(injector: Injector, dependencies: Dependency[]): void {
dependencies.forEach((d) => injector.add(d));
}
/**
* Touch a group of dependencies to ensure they are instantiated.
* @param injector The injector to touch the dependencies.
* @param dependencies The dependencies to touch.
*/
export function touchDependencies(injector: Injector, dependencies: [DependencyIdentifier<unknown>][]): void {
dependencies.forEach(([d]) => {
if (injector.has(d)) {
injector.get(d);
}
});
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ArrangeTypeEnum } from '../types/interfaces/i-drawing';
/** Clamps a requested zero-based index to an available drawing order. */
export function normalizeDrawingOrderIndex(index: number, length: number): number {
const lastIndex = Math.max(0, length - 1);
return Math.max(0, Math.min(Math.floor(index), lastIndex)) || 0;
}
/** Resolves a relative drawing arrangement to its zero-based target index. */
export function getDrawingOrderIndex(currentIndex: number, length: number, arrangeType: ArrangeTypeEnum): number {
const index = normalizeDrawingOrderIndex(currentIndex, length);
if (arrangeType === ArrangeTypeEnum.front) return normalizeDrawingOrderIndex(length - 1, length);
if (arrangeType === ArrangeTypeEnum.forward) return normalizeDrawingOrderIndex(index + 1, length);
if (arrangeType === ArrangeTypeEnum.backward) return normalizeDrawingOrderIndex(index - 1, length);
return 0;
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {
return true;
}
if (typeof objA !== 'object' || !objA || typeof objB !== 'object' || !objB) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
const bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB);
// Test for A's keys different from B.
for (let idx = 0; idx < keysA.length; idx++) {
const key = keysA[idx];
if (!bHasOwnProperty(key)) {
return false;
}
const valueA = objA[key];
const valueB = objB[key];
if (valueA !== valueB) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export class CustomCommandExecutionError extends Error {
constructor(message: string) {
super(message);
this.name = 'CustomCommandExecutionError';
}
}
export class CanceledError extends CustomCommandExecutionError {
constructor() {
super('Canceled by facade');
this.name = 'CanceledError';
}
}

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.
*/
/**
* A no-op (no operation) function that does nothing.
* Use this as a default placeholder for callbacks or optional handlers.
*/
export function noop(): void {}
export function throttle<T extends (...args: any[]) => any>(fn: T, wait: number = 16): T {
let lastTime = 0;
let timer: number | null = null;
return function throttled(this: any, ...args: any[]) {
const now = Date.now();
if (now - lastTime < wait) {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
lastTime = now;
fn.apply(this, args);
}, wait) as unknown as number;
} else {
if (timer) {
clearTimeout(timer);
timer = null;
}
lastTime = now;
fn.apply(this, args);
}
} as unknown as T;
}

View File

@@ -0,0 +1,250 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable ts/no-explicit-any */
import type { Nullable } from '../shared/types';
import { remove } from './array';
export type InterceptorHandler<M = unknown, C = unknown> = (
value: Nullable<M>,
context: C,
next: (value: Nullable<M>) => Nullable<M>
) => Nullable<M>;
export enum InterceptorEffectEnum {
Style = 1, // 1<< 0
Value = 2, // 1<< 1
}
export interface IInterceptor<M, C> {
id?: string;
priority?: number;
handler: InterceptorHandler<M, C>;
}
export interface ICellInterceptor<M, C> extends IInterceptor<M, C> {
/**
* The effect of the interceptor handler.
* If the effect is 'Style', the worksheet@getCellValueOnly will bypass this interceptor.
* If the effect is 'Value', the worksheet@getStyleOnly will bypass this interceptor.
*/
effect?: InterceptorEffectEnum;
}
export function createInterceptorKey<T, C>(key: string): IInterceptor<T, C> {
const symbol = `sheet_interceptor_${key}`;
return symbol as unknown as IInterceptor<T, C>; // FIXME: priority and handler is completely missing?
};
export type IComposeInterceptors<T = any, C = any> = (
interceptors: Array<IInterceptor<T, C>>
) => (initValue: Nullable<T>, initContext: C) => Nullable<T>;
/**
* A helper to compose a certain type of interceptors.
*/
export const composeInterceptors = <T, C>(interceptors: Array<IInterceptor<T, C>>) =>
function (initialValue: Nullable<T>, context: C) {
let index = -1;
let value: Nullable<T> = initialValue;
let nextCalled = false;
const next = (nextValue: Nullable<T>) => {
nextCalled = true;
return nextValue;
};
for (let i = 0; i < interceptors.length; i++) {
if (i <= index) {
throw new Error('[SheetInterceptorService]: next() called multiple times!');
}
index = i;
const interceptor = interceptors[i];
nextCalled = false;
value = interceptor.handler!(value, context, next);
if (!nextCalled) {
break;
}
}
return value;
} as ReturnType<IComposeInterceptors<T, C>>;
export class InterceptorManager<P extends Record<string, IInterceptor<any, any>>> {
private _interceptorsByName: Map<string, Array<IInterceptor<unknown, unknown>>> = new Map();
private _interceptorPoints: P;
constructor(interceptorPoints: P) {
this._interceptorPoints = interceptorPoints;
}
/**
* Get the interceptors.
* @param name Name of the intercepted point.
* @param filter A callback function to filter the interceptors.
* @returns It will return a composed interceptor function. If you will perform the interceptor repeatedly,
* you should cache the result instead of calling this function multiple times.
*/
public fetchThroughInterceptors<T, C>(name: IInterceptor<T, C>, filter?: (interceptor: IInterceptor<any, any>) => boolean) {
const key = name as unknown as string;
let interceptors = this._interceptorsByName.get(key) as unknown as Array<typeof name>;
if (filter) {
interceptors = interceptors.filter(filter);
}
return composeInterceptors(interceptors || []);
}
public 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)) // from large to small
);
return () => remove(this._interceptorsByName.get(key)!, interceptor);
}
public getInterceptPoints() {
return this._interceptorPoints;
}
public dispose() {
this._interceptorsByName.clear();
}
}
export function createAsyncInterceptorKey<T, C>(key: string): IAsyncInterceptor<T, C> {
const symbol = `sheet_async_interceptor_${key}`;
return symbol as unknown as IAsyncInterceptor<T, C>;
};
export type AsyncInterceptorHandler<M = unknown, C = unknown> = (
value: Nullable<M>,
context: C,
next: (value: Nullable<M>) => Promise<Nullable<M>>
) => Promise<Nullable<M>>;
export interface IAsyncInterceptor<M, C> {
/**
* The priority of the interceptor, the larger the number, the higher the priority.
* @default 0
*/
priority?: number;
handler: AsyncInterceptorHandler<M, C>;
}
export type IComposeAsyncInterceptors<T = any, C = any> = (
interceptors: Array<IAsyncInterceptor<T, C>>
) => (initValue: Nullable<T>, initContext: C) => Promise<Nullable<T>>;
export const composeAsyncInterceptors = <T, C>(
interceptors: Array<IAsyncInterceptor<T, C>>
): ((initialValue: Nullable<T>, context: C) => Promise<Nullable<T>>) => {
return async function (initialValue: Nullable<T>, context: C) {
let index = -1;
let value: Nullable<T> = initialValue;
for (let i = 0; i <= interceptors.length; i++) {
if (i <= index) {
throw new Error('[SheetInterceptorService]: next() called multiple times!');
}
index = i;
if (i === interceptors.length) {
return value;
}
const interceptor = interceptors[i];
let nextCalled = false;
value = await interceptor.handler!(value, context, async (nextValue) => {
nextCalled = true;
return nextValue;
});
if (!nextCalled) {
break;
}
}
return value;
};
};
export class AsyncInterceptorManager<P extends Record<string, IAsyncInterceptor<any, any>>> {
private _asyncInterceptorsByName: Map<string, Array<IAsyncInterceptor<unknown, unknown>>> = new Map();
private _asyncInterceptorPoints: P;
constructor(asyncInterceptorPoints: P) {
this._asyncInterceptorPoints = asyncInterceptorPoints;
}
/**
* Get the interceptors.
* @param name Name of the intercepted point.
* @param filter A callback function to filter the interceptors.
* @returns It will return a composed interceptor function. If you will perform the interceptor repeatedly,
* you should cache the result instead of calling this function multiple times.
*/
public fetchThroughAsyncInterceptors<T, C>(name: IAsyncInterceptor<T, C>, filter?: (interceptor: IAsyncInterceptor<any, any>) => boolean) {
const key = name as unknown as string;
let interceptors = this._asyncInterceptorsByName.get(key) as unknown as Array<typeof name>;
if (filter) {
interceptors = interceptors.filter(filter);
}
return composeAsyncInterceptors(interceptors || []);
}
public async interceptAsync<T extends IAsyncInterceptor<any, any>>(name: T, interceptor: T) {
const key = name as unknown as string;
if (!this._asyncInterceptorsByName.has(key)) {
this._asyncInterceptorsByName.set(key, []);
}
const interceptors = this._asyncInterceptorsByName.get(key)!;
interceptors.push(interceptor);
this._asyncInterceptorsByName.set(
key,
interceptors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // from large to small
);
return () => remove(this._asyncInterceptorsByName.get(key)!, interceptor);
}
public getInterceptPoints() {
return this._asyncInterceptorPoints;
}
public dispose() {
this._asyncInterceptorsByName.clear();
}
}

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { RGBColorType } from './utils';
import { denormalizeRGBColor, normalizeRGBColor } from './utils';
function rgbToHsl([r, g, b]: RGBColorType): RGBColorType {
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return [h, s, l];
}
function getLuminance(r: number, g: number, b: number) {
const a = [r, g, b].map((v) => {
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
}
function getContrastRatio(luminance1: number, luminance2: number) {
return (
(Math.max(luminance1, luminance2) + 0.05) /
(Math.min(luminance1, luminance2) + 0.05)
);
}
function hslToRgb(h: number, s: number, l: number): RGBColorType {
let r, g, b;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, _t: number) => {
let t = _t;
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [r, g, b];
}
export function stringToRgb(color: string): RGBColorType {
return color.split(',').slice(0, 3).map(Number) as RGBColorType;
}
const white = { r: 1, g: 1, b: 1 };
const black = { r: 0, g: 0, b: 0 };
const whiteLuminance = getLuminance(white.r, white.g, white.b);
const blackLuminance = getLuminance(black.r, black.g, black.b);
/**
* Invert a color by HSL tunning method.
* @param color The color to invert. Note that this color is already normalized.
* @returns The inverted color.
*/
function invertNormalizedColorByHSL(color: RGBColorType): RGBColorType {
const originalLuminance = getLuminance(color[0], color[1], color[2]);
const originalContrast = getContrastRatio(
whiteLuminance,
originalLuminance
);
const hsl = rgbToHsl(color);
let l = 1 - hsl[2]; // Invert the lightness
let newColor, newLuminance, newContrast;
do {
newColor = hslToRgb(hsl[0], hsl[1], l);
newLuminance = getLuminance(newColor[0], newColor[1], newColor[2]);
newContrast = getContrastRatio(newLuminance, blackLuminance);
l += 0.01;
} while (l <= 1 && l >= 0 && Math.abs(newContrast - originalContrast) < 0.01);
return newColor;
}
export function invertColorByHSL(color: RGBColorType): RGBColorType {
return denormalizeRGBColor(invertNormalizedColorByHSL(normalizeRGBColor(color)));
}

View File

@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { RGBColorType } from './utils';
import { denormalizeRGBColor, normalizeRGBColor } from './utils';
const matrix = [
[0.333, -0.667, -0.667, 0, 1],
[-0.667, 0.333, -0.667, 0, 1],
[-0.667, -0.667, 0.333, 0, 1],
[0, 0, 0, 1, 0],
];
function invertNormalizedColorByMatrix(color: RGBColorType): RGBColorType {
const r = color[0];
const g = color[1];
const b = color[2];
let newColor = [
matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b + matrix[0][4],
matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b + matrix[1][4],
matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b + matrix[2][4],
];
// Clamp values between 0 and 1, or we can normalize it.
// It is better to normalize before rendering.
newColor = newColor.map((c) => (c > 1 ? 1 : c < 0 ? 0 : c));
return newColor as RGBColorType;
}
/**
* Invert a color by RGB matrix method.
* @param color The color to invert. Note that this color is already normalized.
* @returns The inverted color.
*/
export function invertColorByMatrix(color: RGBColorType): RGBColorType {
return denormalizeRGBColor(invertNormalizedColorByMatrix(normalizeRGBColor(color)));
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 type RGBColorType = [number, number, number];
export type HSLColorType = RGBColorType;
export function normalizeRGBColor(color: RGBColorType): RGBColorType {
return color.map((c) => c / 255) as RGBColorType;
}
export function denormalizeRGBColor(color: RGBColorType): RGBColorType {
return color.map((c) => Math.round(c * 255)) as RGBColorType;
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 type Serializable =
| { [property: string]: Serializable }
| readonly Serializable[]
| string
| number
| boolean
| null;

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 { debounce, get, merge, mergeWith, set, setWith } from 'lodash-es';

View File

@@ -0,0 +1,32 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 class MemoryCursor {
cursor: number = 0;
reset() {
this.cursor = 0;
return this;
}
moveCursor(pos: number) {
this.cursor += pos;
}
moveCursorTo(pos: number) {
this.cursor = pos;
}
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface IMixinProperty<T> {
// eslint-disable-next-line ts/no-explicit-any
[fnName: string]: (this: T, ...args: any[]) => any;
}
/**
* Mixin some methods to targetObject as prototype, the static methods will not be mixed in
* @param {T} targetClassPrototype The target class to mixin
* @param {IMixinProperty<T>} mixin The mixin object which contains the methods to mixin.
*/
export function mixinClass<T>(targetClassPrototype: T, mixin: IMixinProperty<T>): void {
for (const key in mixin) {
// eslint-disable-next-line no-prototype-builtins
if (mixin.hasOwnProperty(key)) {
// @ts-ignore
targetClassPrototype[key] = mixin[key];
}
}
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 function isNumeric(str: string): boolean {
return /^-?\d+(\.\d+)?$/.test(str);
}
export function isSafeNumeric(str: string): boolean {
const numeric = isNumeric(str);
if (!numeric) {
return false;
}
return Number(str) <= Number.MAX_SAFE_INTEGER;
}
/**
* Whether the numeric string will lose precision when converted to a number.
* e.g. '123456789123456789' -> 123456789123456780
* e.g. '1212121212121212.2345' -> 1212121212121212.2
*/
export function willLoseNumericPrecision(str: string): boolean {
return Number(str) > Number.MAX_SAFE_INTEGER || str.length >= 18;
}

View File

@@ -0,0 +1,210 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRegExpFromSafeFragment } from './factory';
export type CharsetDataUnit = [number, number];
/**
* A single character, code point, or inclusive range used to build a character class.
*
* String inputs must contain exactly one Unicode code point. Use `or()` for multi-character
* alternatives such as keywords or table names.
*/
export type CharsetRawInput = string | number | [string, string] | [number, number];
export type CharsetInput = Charset | CharsetRawInput;
const MAX_CODE_POINT = 0x10FFFF;
/**
* Builds a normalized regular-expression character class.
*
* `Charset` is intended for dynamic character sets, especially when callers need set
* operations that are hard to read or easy to get wrong with handwritten `[...]` strings.
* Ranges are stored as inclusive Unicode code-point pairs and merged during construction.
*/
export class Charset {
readonly data: CharsetDataUnit[];
constructor(...inputs: CharsetInput[]) {
this.data = normalizeData(inputs.flatMap((input) => inputToData(input)));
}
/**
* Returns a new charset containing this charset and all provided characters or ranges.
*/
union(...inputs: CharsetInput[]): Charset {
return new Charset(this, ...inputs);
}
/**
* Returns a new charset with the provided characters or ranges removed.
*/
subtract(...inputs: CharsetInput[]): Charset {
const subtractData = normalizeData(inputs.flatMap((input) => inputToData(input)));
let next = this.data.slice();
for (const [removeStart, removeEnd] of subtractData) {
const fragments: CharsetDataUnit[] = [];
for (const [start, end] of next) {
if (removeEnd < start || removeStart > end) {
fragments.push([start, end]);
continue;
}
if (removeStart > start) {
fragments.push([start, removeStart - 1]);
}
if (removeEnd < end) {
fragments.push([removeEnd + 1, end]);
}
}
next = fragments;
}
return new Charset(...next);
}
/**
* Returns a new charset containing only characters shared with the provided inputs.
*/
intersect(...inputs: CharsetInput[]): Charset {
const intersectData = normalizeData(inputs.flatMap((input) => inputToData(input)));
const next: CharsetDataUnit[] = [];
for (const [start, end] of this.data) {
for (const [intersectStart, intersectEnd] of intersectData) {
const nextStart = Math.max(start, intersectStart);
const nextEnd = Math.min(end, intersectEnd);
if (nextStart <= nextEnd) {
next.push([nextStart, nextEnd]);
}
}
}
return new Charset(...next);
}
/**
* Returns whether this charset would match no characters.
*/
isEmpty(): boolean {
return this.data.length === 0;
}
/**
* Serializes to a regex fragment, not a full anchored pattern.
*/
toString(): string {
if (this.isEmpty()) {
return '(?!)';
}
return `[${this.data.map(rangeToString).join('')}]`;
}
/**
* Creates a `RegExp` from this charset fragment with optional flags.
*/
toRegExp(flags?: string): RegExp {
return createRegExpFromSafeFragment(this.toString(), flags);
}
}
/**
* Convenience factory for `new Charset(...)`.
*/
export const charset = (...inputs: CharsetInput[]): Charset => new Charset(...inputs);
function inputToData(input: CharsetInput): CharsetDataUnit[] {
if (input instanceof Charset) {
return input.data;
}
if (Array.isArray(input)) {
const start = codePoint(input[0]);
const end = codePoint(input[1]);
return [[Math.min(start, end), Math.max(start, end)]];
}
const point = codePoint(input);
return [[point, point]];
}
function codePoint(input: string | number): number {
if (typeof input === 'number') {
if (!Number.isInteger(input) || input < 0 || input > MAX_CODE_POINT) {
throw new RangeError(`Invalid code point: ${input}`);
}
return input;
}
const point = input.codePointAt(0);
if (point == null || String.fromCodePoint(point) !== input) {
throw new RangeError(`Charset string inputs must contain exactly one code point: ${input}`);
}
return point;
}
function normalizeData(data: CharsetDataUnit[]): CharsetDataUnit[] {
if (data.length === 0) {
return [];
}
const sorted = data.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const result: CharsetDataUnit[] = [];
for (const [start, end] of sorted) {
const last = result[result.length - 1];
if (!last || start > last[1] + 1) {
result.push([start, end]);
} else {
last[1] = Math.max(last[1], end);
}
}
return result;
}
function rangeToString([start, end]: CharsetDataUnit): string {
if (start === end) {
return escapeCharClassCodePoint(start);
}
return `${escapeCharClassCodePoint(start)}-${escapeCharClassCodePoint(end)}`;
}
function escapeCharClassCodePoint(point: number): string {
const char = String.fromCodePoint(point);
if (char === '\\' || char === ']' || char === '-' || char === '^') {
return `\\${char}`;
}
if (point <= 0x1F || point === 0x7F) {
return `\\u${point.toString(16).padStart(4, '0')}`;
}
return char;
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRegExpFromSafeFragment } from './factory';
/**
* Escapes text so it can be embedded in a regular-expression pattern as a literal.
*/
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Creates a `RegExp` that matches literal text rather than regular-expression syntax.
*/
export function createLiteralRegExp(value: string, flags?: string): RegExp {
return createRegExpFromSafeFragment(escapeRegExp(value), flags);
}
/**
* Converts a spreadsheet-style wildcard pattern to an anchored regular expression.
*
* `*` matches any number of characters and `?` matches one character. All other
* regular-expression syntax is treated as literal text. Matching is case-insensitive.
*/
export function createFromWildcard(pattern: string): RegExp {
const escaped = escapeRegExp(pattern);
const source = escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
return createRegExpFromSafeFragment(`^${source}$`, 'i');
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const REGEXP_FLAGS = new Set(['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']);
export function createRegExpFromSafeFragment(fragment: string, flags?: string): RegExp {
const safeFlags = normalizeRegExpFlags(flags);
// Fragments passed here must come from regexp helpers that escape literals or normalize charsets.
return new RegExp(fragment, safeFlags); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
}
function normalizeRegExpFlags(flags: string | undefined): string | undefined {
if (flags === undefined) {
return undefined;
}
const seen = new Set<string>();
for (const flag of flags) {
if (!REGEXP_FLAGS.has(flag) || seen.has(flag)) {
throw new SyntaxError(`Invalid regular expression flags: ${flags}`);
}
seen.add(flag);
}
return flags;
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Charset, charset } from './charset';
import { createFromWildcard, createLiteralRegExp, escapeRegExp } from './escape';
import { createRegExpFromSafeFragment } from './factory';
import { Or, or } from './or';
/**
* Namespace-style public API for regex helpers.
*
* Import this as `regexp` and call methods through the namespace, for example
* `regexp.escapeRegExp(value)` or `regexp.or(...names)`. Do not export these
* helpers individually from the package root; keeping one namespace avoids
* scattered regex APIs and accidental name collisions.
*/
export const regexp = {
Charset,
Or,
charset,
or,
createRegExpFromSafeFragment,
escapeRegExp,
createLiteralRegExp,
createFromWildcard,
};

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.
*/
import type { CharsetInput } from './charset';
import { Charset, charset } from './charset';
import { escapeRegExp } from './escape';
import { createRegExpFromSafeFragment } from './factory';
/**
* A literal string, existing `Or`, or character-set input used to build alternatives.
*/
export type OrInput = string | Or | CharsetInput;
/**
* Builds a regex alternation from literal strings and character sets.
*
* Literal strings are escaped automatically. Character-set inputs are folded into one
* `Charset`, so `or('SUM', charset('A', 'B'))` serializes to an alternation containing
* both the string branch and the character-class branch.
*/
export class Or {
readonly charset: Charset;
readonly strings: string[];
constructor(...inputs: OrInput[]) {
const charsetInputs: CharsetInput[] = [];
const strings: string[] = [];
for (const input of inputs) {
if (input instanceof Or) {
charsetInputs.push(input.charset);
strings.push(...input.strings);
} else if (input instanceof Charset || typeof input === 'number' || Array.isArray(input)) {
charsetInputs.push(input);
} else {
strings.push(input);
}
}
this.charset = charset(...charsetInputs);
this.strings = Array.from(new Set(strings));
}
/**
* Returns a new alternation containing this instance and all provided inputs.
*/
union(...inputs: OrInput[]): Or {
return new Or(this, ...inputs);
}
/**
* Returns a new alternation with matching string and charset inputs removed.
*/
subtract(...inputs: OrInput[]): Or {
const other = new Or(...inputs);
const nextStrings = this.strings.filter((item) => !other.strings.includes(item));
return new Or(this.charset.subtract(other.charset), ...nextStrings);
}
/**
* Returns whether this alternation would match no branches.
*/
isEmpty(): boolean {
return this.charset.isEmpty() && this.strings.length === 0;
}
/**
* Serializes to a regex fragment, not a full anchored pattern.
*/
toString(): string {
if (this.isEmpty()) {
return '(?!)';
}
const alternatives = [
...(!this.charset.isEmpty() ? [this.charset.toString()] : []),
...this.strings.map(escapeRegExp),
];
return alternatives.length === 1 ? alternatives[0] : `(?:${alternatives.join('|')})`;
}
/**
* Creates a `RegExp` from this alternation fragment with optional flags.
*/
toRegExp(flags?: string): RegExp {
return createRegExpFromSafeFragment(this.toString(), flags);
}
}
/**
* Convenience factory for `new Or(...)`.
*/
export const or = (...inputs: OrInput[]): Or => new Or(...inputs);

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 class Registry<T = any> {
private _data: T[] = [];
static create<T = any>() {
return new Registry<T>();
}
add(dataInstance: T) {
if (this._data.indexOf(dataInstance) > -1) {
return;
}
this._data.push(dataInstance);
}
delete(dataInstance: T) {
const index = this._data.indexOf(dataInstance);
this._data.splice(index, 1);
}
getData() {
return this._data;
}
}
/**
* Add extension modules statically when the plugin is initialized, so that the plugin can register these extension modules uniformly
*
* @privateRemarks
* zh: 在插件初始化的时候静态添加扩展模块,方便插件统一注册这些扩展模块
*/
export class RegistryAsMap {
private _data: Map<string, any> = new Map();
static create() {
return new RegistryAsMap();
}
add(id: string, dataInstance: any) {
if (this._data.has(id)) {
return;
}
this._data.set(id, dataInstance);
}
delete(id: string) {
this._data.delete(id);
}
getData() {
return this._data;
}
}

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.
*/
export function requestImmediateMacroTask(callback: (value?: unknown) => void): () => void {
const channel = new MessageChannel();
let cancelled = false;
const close = () => {
channel.port1.onmessage = null;
channel.port1.close();
channel.port2.close();
};
const handler = () => {
if (!cancelled) {
cancelled = true;
close();
callback();
}
};
// Use onmessage for web worker compatibility and clear it when the task settles.
channel.port1.onmessage = handler;
channel.port2.postMessage(null);
return () => {
if (cancelled) {
return;
}
cancelled = true;
close();
};
}

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.
*/
export interface ISequenceExecuteResult {
index: number;
result: boolean;
error?: unknown;
}
/**
* Execute promise tasks in sequence, if one of the tasks return false, the sequence will be stopped.
* @param tasks All promise tasks that need to be triggered.
* @returns Returns `true` if all tasks are executed successfully, otherwise `false` and index of the task that returns false.
*/
export async function sequenceAsync(tasks: Array<() => Promise<boolean> | boolean>): Promise<ISequenceExecuteResult> {
for (const [index, task] of tasks.entries()) {
try {
const result = await task();
if (!result) {
return {
index,
result: false,
};
}
} catch (e: unknown) {
return {
index,
result: false,
error: e,
};
}
}
return {
result: true,
index: -1,
};
}
/**
* Execute tasks in sequence, if one of the tasks return false, the sequence will be stopped.
* @param tasks All tasks that need to be triggered.
* @returns Returns `true` if all tasks are executed successfully, otherwise `false` and index of the task that returns false.
*/
export function sequence(tasks: Array<() => boolean>): ISequenceExecuteResult {
for (const [index, task] of tasks.entries()) {
try {
const result = task();
if (!result) {
return {
index,
result: false,
};
}
} catch (e: unknown) {
return {
index,
result: false,
error: e,
};
}
}
return {
result: true,
index: -1,
};
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Merge the second set to the first set.
* @param s1 the first set
* @param s2 the second set
* @returns the merged set
*/
export function mergeSets<T>(s1: Set<T>, s2: Set<T>): Set<T> {
s2.forEach((s) => s1.add(s));
return s1;
}

View File

@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const glob = typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : window;
/**
* Polyfill for requestIdleCallback and cancelIdleCallback
*/
function installRequestIdleCallback() {
const TIME_WINDOW = 50;
const idleCallbacks = new Map<number, NodeJS.Timeout>();
let currentId = 0;
if (typeof glob.requestIdleCallback !== 'function') {
glob.requestIdleCallback = function shimRIC(callback: Function) {
const start = Date.now();
const id = ++currentId;
const timeoutId = setTimeout(function rICCallback() {
idleCallbacks.delete(id);
const remaining = Math.max(0, TIME_WINDOW - (Date.now() - start));
callback({
didTimeout: remaining === 0,
timeRemaining() {
return remaining;
},
});
}, 1);
idleCallbacks.set(id, timeoutId);
return id;
};
}
if (typeof glob.cancelIdleCallback !== 'function') {
glob.cancelIdleCallback = function shimCancelRIC(id: number) {
const timeoutId = idleCallbacks.get(id);
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
idleCallbacks.delete(id);
}
};
}
}
/**
* Polyfill for queueMicrotask
*/
function installQueueMicrotask() {
if (typeof glob.queueMicrotask !== 'function') {
glob.queueMicrotask = function shimQueueMicrotask(callback: VoidFunction) {
Promise.resolve()
.then(callback)
.catch((error) => {
setTimeout(() => {
throw error;
}, 0);
});
};
}
}
/**
* Polyfill for Array.prototype.findLastIndex and Array.prototype.findLast
*/
function installArrayFindLastIndex() {
if (typeof glob.Array.prototype.findLastIndex !== 'function') {
glob.Array.prototype.findLastIndex = function findLastIndex(callback: (value: any, index: number, array: any[]) => boolean, thisArg?: any): number {
if (this == null) {
throw new TypeError('Array.prototype.findLastIndex called on null or undefined');
}
if (typeof callback !== 'function') {
throw new TypeError('callback must be a function');
}
const len = this.length >>> 0;
for (let i = len - 1; i >= 0; i--) {
if (i in this && callback.call(thisArg, this[i], i, this)) {
return i;
}
}
return -1;
};
}
if (typeof glob.Array.prototype.findLast !== 'function') {
glob.Array.prototype.findLast = function findLast(callback: (value: any, index: number, array: any[]) => boolean, thisArg?: any): any {
const index = this.findLastIndex(callback, thisArg);
return index !== -1 ? this[index] : undefined;
};
}
}
/**
* Polyfill for String.prototype.at
*/
function installStringAt() {
if (typeof glob.String.prototype.at !== 'function') {
glob.String.prototype.at = function at(index: number): string | undefined {
if (this == null) {
throw new TypeError('String.prototype.at called on null or undefined');
}
const len = this.length;
if (index < 0) {
index = len + index;
}
if (index < 0 || index >= len) {
return undefined;
}
return this.charAt(index);
};
}
}
export function installShims() {
installRequestIdleCallback();
installQueueMicrotask();
installArrayFindLastIndex();
installStringAt();
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Observable } from 'rxjs';
import { UniverType as UniverInstanceType } from '@univerjs/protocol';
import { Disposable } from '../shared/lifecycle';
export { UniverInstanceType };
/**
* The base class for all units.
*/
export abstract class UnitModel<D = object, T extends UniverInstanceType = UniverInstanceType> extends Disposable {
abstract readonly type: T;
abstract getUnitId(): string;
abstract name$: Observable<string>;
abstract setName(name: string): void;
abstract getSnapshot(): D;
/** Get revision of the unit's snapshot. Note that revision should start from 1. */
abstract getRev(): number;
/** Increment the current revision. */
abstract incrementRev(): void;
/** Set revision of the current snapshot. */
abstract setRev(rev: number): void;
}

View File

@@ -0,0 +1,400 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 topLevelDomainSet = new Set([
'ac',
'ad',
'ae',
'aero',
'af',
'ag',
'ai',
'al',
'am',
'ao',
'aq',
'ar',
'arpa',
'as',
'asia',
'at',
'au',
'aw',
'ax',
'az',
'ba',
'bb',
'bd',
'be',
'bf',
'bg',
'bh',
'bi',
'biz',
'bj',
'bm',
'bn',
'bo',
'br',
'bs',
'bt',
'bv',
'bw',
'by',
'bz',
'ca',
'cat',
'cc',
'cd',
'cf',
'cg',
'ch',
'ci',
'ck',
'cl',
'cm',
'cn',
'co',
'com',
'coop',
'cr',
'cu',
'cv',
'cw',
'cx',
'cy',
'cz',
'de',
'dj',
'dk',
'dm',
'do',
'dz',
'ec',
'edu',
'ee',
'eg',
'er',
'es',
'et',
'eu',
'fi',
'fj',
'fk',
'fm',
'fo',
'fr',
'ga',
'gb',
'gd',
'ge',
'gf',
'gg',
'gh',
'gi',
'gl',
'gm',
'gn',
'gov',
'gp',
'gq',
'gr',
'gs',
'gt',
'gu',
'gw',
'gy',
'hk',
'hm',
'hn',
'hr',
'ht',
'hu',
'id',
'ie',
'il',
'im',
'in',
'info',
'int',
'io',
'iq',
'ir',
'is',
'it',
'je',
'jm',
'jo',
'jobs',
'jp',
'ke',
'kg',
'kh',
'ki',
'km',
'kn',
'kp',
'kr',
'kw',
'ky',
'kz',
'la',
'lb',
'lc',
'li',
'lk',
'lr',
'ls',
'lt',
'lu',
'lv',
'ly',
'ma',
'mc',
'md',
'me',
'mg',
'mh',
'mil',
'mk',
'ml',
'mm',
'mn',
'mo',
'mobi',
'mp',
'mq',
'mr',
'ms',
'mt',
'mu',
'museum',
'mv',
'mw',
'mx',
'my',
'mz',
'na',
'name',
'nc',
'ne',
'net',
'nf',
'ng',
'ni',
'nl',
'no',
'np',
'nr',
'nu',
'nz',
'om',
'onion',
'org',
'pa',
'pe',
'pf',
'pg',
'ph',
'pk',
'pl',
'pm',
'pn',
'post',
'pr',
'pro',
'ps',
'pt',
'pw',
'py',
'qa',
're',
'ro',
'rs',
'ru',
'rw',
'sa',
'sb',
'sc',
'sd',
'se',
'sg',
'sh',
'si',
'sj',
'sk',
'sl',
'sm',
'sn',
'so',
'sr',
'ss',
'st',
'su',
'sv',
'sx',
'sy',
'sz',
'tc',
'td',
'tel',
'tf',
'tg',
'th',
'tj',
'tk',
'tl',
'tm',
'tn',
'to',
'tr',
'tt',
'tv',
'tw',
'tz',
'ua',
'ug',
'uk',
'us',
'uy',
'uz',
'va',
'vc',
've',
'vg',
'vi',
'vn',
'vu',
'wf',
'ws',
'yt',
'za',
'zm',
'zw',
]);
const re_weburl = new RegExp(
'^' +
// protocol identifier (optional)
// short syntax // still required
'(?:(?:(?:https?|ftp):)?\\/\\/)?' +
// user:pass BasicAuth (optional)
'(?:\\S+(?::\\S*)?@)?' +
'(?:' +
// IP address dotted notation octets
// excludes loopback network 0.0.0.0
// excludes reserved space >= 224.0.0.0
// excludes network & broadcast addresses
// (first & last IP address of each class)
'(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
'(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
'(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
'|' +
// host & domain names, may end with dot
// can be replaced by a shortest alternative
// (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
'(?:' +
'(?:' +
'[a-z0-9\\u00a1-\\uffff]' +
'[a-z0-9\\u00a1-\\uffff_-]{0,62}' +
')?' +
'[a-z0-9\\u00a1-\\uffff]\\.' +
')+' +
// TLD identifier name, may end with dot
'(?:[a-z\\u00a1-\\uffff]{2,}\\.?)' +
')' +
// port number (optional)
'(?::\\d{2,5})?' +
// resource path (optional)
'(?:[/?#]\\S*)?' +
'$',
'i'
);
export function isLegalUrl(url: string) {
// skip number 0.1
if (!Number.isNaN(+url)) {
return false;
}
if (url.startsWith('http://localhost:5173') || url.startsWith('localhost:5173')) {
return true;
}
const legal = re_weburl.test(url);
if (legal) {
if (hasProtocol(url)) {
return true;
} else {
try {
const urlObj = new URL(normalizeUrl(url));
const topLevelDomain = urlObj.hostname.split('.').pop();
if (topLevelDomain && topLevelDomainSet.has(topLevelDomain)) {
return true;
};
} catch {
return false;
}
}
}
return false;
}
function hasProtocol(urlString: string) {
const pattern = /^[a-zA-Z]+:\/\//;
return pattern.test(urlString);
}
function isEmail(url: string) {
const pattern = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
return pattern.test(url);
}
export function normalizeUrl(urlStr: string) {
return hasProtocol(urlStr) ? urlStr : isEmail(urlStr) ? `mailto://${urlStr}` : `https://${urlStr}`;
}
export function isSafeUrl(url: string): boolean {
if (!url || typeof url !== 'string') {
return false;
}
try {
const base = typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost';
const parsed = new URL(url, base);
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
} catch {
return false;
}
}
/**
* Resolve a URL with a base URL, ensuring the path from the base URL is preserved.
* @param {string} url - The URL to resolve.
* @param {string} baseURL - The base URL to use for resolution.
* @returns {string} - The resolved URL.
*/
export function resolveWithBasePath(url: string, baseURL: string): string {
try {
const base = new URL(baseURL);
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
const cleanedUrl = url.startsWith('/') ? url.substring(1) : url;
return new URL(cleanedUrl, base.origin + basePath).toString();
} catch (error) {
console.error('Error resolving URL with base URL:', error);
return url;
}
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import {
cloneBodyWithFreshParagraphIds,
cloneParagraphWithId,
PARAGRAPH_ID_PREFIX,
} from '../paragraph-id';
function createBody(): IDocumentBody {
return {
dataStream: 'Alpha\rBeta\r\n',
paragraphs: [
{ startIndex: 5, paragraphId: 'para_alpha' },
{ startIndex: 10, paragraphId: 'para_existing' },
],
sectionBreaks: [{ sectionId: 'section_fixture_1', startIndex: 11 }],
};
}
describe('paragraph id helpers', () => {
it('clones paste/import fragments with fresh unique ids while preserving paragraph count and start indexes', () => {
const source = createBody();
const cloned = cloneBodyWithFreshParagraphIds(source, { unitId: 'doc-2', segmentId: '' });
expect(cloned.paragraphs).toHaveLength(source.paragraphs!.length);
expect(cloned.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual(source.paragraphs?.map((paragraph) => paragraph.startIndex));
expect(cloned.paragraphs?.map((paragraph) => paragraph.paragraphId)).not.toEqual(source.paragraphs?.map((paragraph) => paragraph.paragraphId));
expect(new Set(cloned.paragraphs?.map((paragraph) => paragraph.paragraphId)).size).toBe(cloned.paragraphs?.length);
expect(cloned.paragraphs?.every((paragraph) => paragraph.paragraphId?.startsWith(PARAGRAPH_ID_PREFIX))).toBe(true);
});
it('preserves a valid unique id when cloning a paragraph by default or when preserveId is true', () => {
const existingIds = new Set<string>();
const paragraph = { startIndex: 5, paragraphId: 'para_keep' };
const defaultClone = cloneParagraphWithId(paragraph, existingIds);
const explicitClone = cloneParagraphWithId({ ...paragraph, paragraphId: 'para_keep_explicit' }, existingIds, true);
expect(defaultClone).not.toBe(paragraph);
expect(defaultClone.paragraphId).toBe('para_keep');
expect(explicitClone.paragraphId).toBe('para_keep_explicit');
expect(existingIds.has('para_keep')).toBe(true);
expect(existingIds.has('para_keep_explicit')).toBe(true);
});
it('regenerates a paragraph id when preserving would duplicate an existing id', () => {
const existingIds = new Set<string>(['para_duplicate']);
const cloned = cloneParagraphWithId({ startIndex: 5, paragraphId: 'para_duplicate' }, existingIds);
expect(cloned.paragraphId).not.toBe('para_duplicate');
expect(cloned.paragraphId).toMatch(new RegExp(`^${PARAGRAPH_ID_PREFIX}`));
expect(existingIds.has(cloned.paragraphId!)).toBe(true);
});
it('regenerates invalid paragraph ids when cloning a paragraph', () => {
const existingIds = new Set<string>();
const cloned = cloneParagraphWithId({ startIndex: 5, paragraphId: 'invalid' }, existingIds);
expect(cloned.paragraphId).not.toBe('invalid');
expect(cloned.paragraphId).toMatch(new RegExp(`^${PARAGRAPH_ID_PREFIX}`));
expect(existingIds.has(cloned.paragraphId!)).toBe(true);
});
it('regenerates a paragraph id when preserveId is false', () => {
const existingIds = new Set<string>();
const cloned = cloneParagraphWithId({ startIndex: 5, paragraphId: 'para_replace' }, existingIds, false);
expect(cloned.paragraphId).not.toBe('para_replace');
expect(cloned.paragraphId).toMatch(new RegExp(`^${PARAGRAPH_ID_PREFIX}`));
expect(existingIds.has(cloned.paragraphId!)).toBe(true);
});
});

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { resolveSectionHeaderFooterReference, resolveSectionHeaderFooterReferences } from '../section-header-footer';
describe('section header/footer references', () => {
const documentStyle = { defaultHeaderId: 'header-global', defaultFooterId: 'footer-global' };
const sections = [
{ sectionId: 'section_1', startIndex: 3 },
{ sectionId: 'section_2', startIndex: 7 },
{ sectionId: 'section_3', startIndex: 11, defaultHeaderId: 'header-3' },
{ sectionId: 'section_4', startIndex: 15 },
];
it('uses document defaults only for the first section and inherits through later sections', () => {
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 0, 'defaultHeaderId')).toEqual({
segmentId: 'header-global',
linkedToPrevious: false,
});
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 1, 'defaultHeaderId')).toEqual({
segmentId: 'header-global',
linkedToPrevious: true,
sourceSectionId: undefined,
});
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 2, 'defaultHeaderId')).toEqual({
segmentId: 'header-3',
linkedToPrevious: false,
sourceSectionId: 'section_3',
});
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 3, 'defaultHeaderId')).toEqual({
segmentId: 'header-3',
linkedToPrevious: true,
sourceSectionId: 'section_3',
});
});
it('resolves all six reference variants independently', () => {
expect(resolveSectionHeaderFooterReferences(documentStyle, sections, 1)).toEqual({
defaultHeaderId: 'header-global',
defaultFooterId: 'footer-global',
});
});
});

View File

@@ -0,0 +1,119 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType, DocStyleType } from '../../../types/interfaces/i-document-data';
import { DocumentDataModel } from '../document-data-model';
import { JSONX } from '../json-x/json-x';
import { validateDocumentStructure } from '../text-x/structure-validator';
import { TextX } from '../text-x/text-x';
import { getRichTextEditPath } from '../text-x/utils';
function body(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
customBlocks: [],
customRanges: [],
customDecorations: [],
paragraphs: [{ startIndex: text.length, paragraphId: 'paragraph' }],
sectionBreaks: [{ startIndex: text.length + 1, sectionId: 'section' }],
};
}
function createDocument(): DocumentDataModel {
return new DocumentDataModel({
id: 'doc',
body: {
...body('A\uFFFCB'),
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, wholeEntity: true, properties: { noteId: 'note' } }],
},
notes: { note: { type: 'footnote' as const, noteId: 'note', body: body('Explanation') } },
noteSettings: { footnote: { position: 'pageBottom', restart: 'eachSect', numberFormat: 'lowerRoman', startNumber: 3 } },
});
}
describe('document footnote segments', () => {
it('inherits document styles and refreshes them without persisting duplicate styles in each note', () => {
const document = createDocument();
const jsonX = JSONX.getInstance();
try {
document.apply(jsonX.insertOp(['styles'], { FootnoteText: { name: 'Footnote Text', type: DocStyleType.paragraph, textStyle: { fs: 10 } } }));
const note = document.noteModelMap.get('note');
expect(note?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(10);
document.apply(jsonX.replaceOp(['styles', 'FootnoteText', 'textStyle', 'fs'], 10, 12));
expect(document.noteModelMap.get('note')).not.toBe(note);
expect(document.noteModelMap.get('note')?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(12);
document.updateDocumentStyle({ textStyle: { fs: 14 }, marginLeft: 40 });
expect(document.noteModelMap.get('note')?.getDocumentStyle()).toMatchObject({
textStyle: { fs: 14 },
marginLeft: 40,
});
expect(Object.keys(document.getSnapshot().notes!.note)).toEqual(['type', 'noteId', 'body']);
} finally {
document.dispose();
}
});
it('edits note text through the rich-text path and restores it through undo', () => {
const document = createDocument();
const before = structuredClone(document.getSnapshot());
const path = getRichTextEditPath(document, 'note');
const edit = JSONX.getInstance().editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), path);
const undo = JSONX.invertWithDoc(edit, before);
document.apply(edit);
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
expect(document.getBody()).toEqual(before.body);
document.apply(undo);
expect(document.getSnapshot()).toEqual(before);
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('Explanation\r\n');
document.dispose();
});
it('preserves the note identity when text before its reference is edited', () => {
const document = createDocument();
const edit = JSONX.getInstance().editOp(new TextX().insert(2, { dataStream: '前文' }).serialize(), ['body']);
document.apply(edit);
expect(document.getBody()?.customRanges?.[0]).toMatchObject({ startIndex: 3, endIndex: 3, properties: { noteId: 'note' } });
const restored = new DocumentDataModel(document.getSnapshot());
expect(restored.getSelfOrHeaderFooterModel('note')?.getBody()).toEqual(document.getSnapshot().notes?.note.body);
expect(restored.getSnapshot().noteSettings?.footnote).toEqual(document.getSnapshot().noteSettings?.footnote);
expect(validateDocumentStructure(restored.getSnapshot())).toEqual([]);
restored.dispose();
document.dispose();
});
it('keeps unrelated note models when one note is edited', () => {
const document = createDocument();
const jsonX = JSONX.getInstance();
document.apply(jsonX.insertOp(['notes', 'other'], { type: 'footnote' as const, noteId: 'other', body: body('Other explanation') }));
const other = document.noteModelMap.get('other');
const edit = jsonX.editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), ['notes', 'note', 'body']);
document.apply(edit);
expect(document.noteModelMap.get('other')).toBe(other);
expect(document.noteModelMap.get('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
document.dispose();
});
it('removes a disposed note segment from the model after a persisted deletion', () => {
const document = createDocument();
const edit = JSONX.getInstance().removeOp(['notes', 'note'], document.getSnapshot().notes?.note);
document.apply(edit);
expect(document.getSelfOrHeaderFooterModel('note')).toBeNull();
expect(validateDocumentStructure(document.getSnapshot()).map((issue) => issue.code)).toContain('missing-note');
document.dispose();
});
});

View File

@@ -0,0 +1,359 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../../common/const';
import { BooleanNumber, HorizontalAlign, TextDecoration, TextDirection } from '../../../types/enum/text-style';
import { DocumentFlavor } from '../../../types/interfaces/i-document-data';
import { DocumentDataModel } from '../document-data-model';
import { JSONX } from '../json-x/json-x';
import { ParagraphStyleBuilder, RichTextBuilder, TextDecorationBuilder, TextStyleBuilder } from '../rich-text-builder';
import { DataStreamTreeTokenType } from '../types';
function createDocSnapshot(id = 'doc-main'): IDocumentData {
return {
id,
title: 'Main Doc',
documentStyle: {
pageSize: { width: 300, height: 400 },
marginTop: 1,
marginBottom: 2,
marginLeft: 3,
marginRight: 4,
},
body: {
dataStream: 'Hello World\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_1' }],
textRuns: [{ st: 0, ed: 5, ts: { ff: 'Arial', fs: 12 } }],
customRanges: [],
customDecorations: [],
},
headers: {
header1: {
headerId: 'header1',
body: {
dataStream: 'Header\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_2' }],
textRuns: [],
},
},
},
footers: {
footer1: {
footerId: 'footer1',
body: {
dataStream: 'Footer\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_3' }],
textRuns: [],
},
},
},
drawings: {
drawing1: {
drawingId: 'drawing1',
docTransform: {
size: { width: 10, height: 10 },
positionH: { posOffset: 0 },
positionV: { posOffset: 0 },
},
},
} as any,
drawingsOrder: ['drawing1'],
};
}
describe('DocumentDataModel + RichTextBuilder integration', () => {
it('should hydrate missing default fields for partial normal document snapshots', () => {
const model = new DocumentDataModel({ id: 'partial-doc', title: 'Partial Doc' });
expect(model.getUnitId()).toBe('partial-doc');
expect(model.getTitle()).toBe('Partial Doc');
expect(model.getBody()?.dataStream).toBe('\r\n');
expect(model.getSnapshot().headers).toEqual({});
expect(model.getSnapshot().footers).toEqual({});
expect(model.getDrawings()).toEqual({});
expect(model.getDrawingsOrder()).toEqual([]);
expect(model.getSettings()).toEqual({});
expect(model.getSnapshot().tableSource).toEqual({});
expect(model.getDocumentStyle().documentFlavor).toBe(DocumentFlavor.MODERN);
expect(model.getDocumentStyle().pageSize).toBeDefined();
expect(() => model.updateDocumentDataMargin({ t: 10 })).not.toThrow();
expect(model.getDocumentStyle().marginTop).toBe(10);
model.reset({ id: 'partial-doc', title: 'Reset Partial Doc' });
expect(model.getTitle()).toBe('Reset Partial Doc');
expect(model.getBody()?.dataStream).toBe('\r\n');
expect(model.getSnapshot().headers).toEqual({});
expect(model.getDrawings()).toEqual({});
expect(model.getSettings()).toEqual({});
model.dispose();
});
it('should normalize missing and duplicate legacy section ids at the model boundary', () => {
const model = new DocumentDataModel({
id: 'legacy-section-ids',
body: {
dataStream: 'A\nB\n',
sectionBreaks: [
{ startIndex: 1, sectionId: '' },
{ startIndex: 3, sectionId: '' },
],
},
});
const sectionIds = model.getBody()?.sectionBreaks?.map((sectionBreak) => sectionBreak.sectionId) ?? [];
expect(sectionIds).toHaveLength(2);
expect(sectionIds.every(Boolean)).toBe(true);
expect(new Set(sectionIds).size).toBe(2);
model.dispose();
});
it('should keep internal editor document snapshots shallow', () => {
const model = new DocumentDataModel({
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
title: 'Editor Doc',
documentStyle: {},
body: {
dataStream: 'Draft\r\n',
},
});
expect(model.getUnitId()).toBe(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
expect(model.getTitle()).toBe('Editor Doc');
expect(model.getBody()?.dataStream).toBe('Draft\r\n');
expect(model.getSnapshot().headers).toBeUndefined();
expect(model.getSnapshot().footers).toBeUndefined();
expect(model.getDrawings()).toBeUndefined();
expect(model.getDrawingsOrder()).toBeUndefined();
expect(model.getSettings()).toBeUndefined();
expect(model.getSnapshot().tableSource).toBeUndefined();
expect(model.getDocumentStyle()).toEqual({});
model.reset({
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
title: 'Reset Editor Doc',
documentStyle: {},
body: {
dataStream: 'Reset\r\n',
},
});
expect(model.getTitle()).toBe('Reset Editor Doc');
expect(model.getBody()?.dataStream).toBe('Reset\r\n');
expect(model.getSnapshot().headers).toBeUndefined();
expect(model.getSettings()).toBeUndefined();
expect(model.getDocumentStyle()).toEqual({});
model.dispose();
});
it('should perform typical editing operations and reflect them in the document snapshot', () => {
const model = new DocumentDataModel(createDocSnapshot());
expect(model.getUnitId()).toBe('doc-main');
expect(model.getTitle()).toBe('Main Doc');
model.setName('Renamed');
expect(model.getTitle()).toBe('Renamed');
model.updateDocumentDataMargin({ t: 10, l: 20 });
model.updateDocumentDataPageSize(500, 600);
expect(model.getDocumentStyle().marginTop).toBe(10);
expect(model.getDocumentStyle().marginLeft).toBe(20);
expect(model.getDocumentStyle().pageSize).toEqual({ width: 500, height: 600 });
model.updateDocumentRenderConfig({
locale: 'enUS',
} as any);
model.updateDocumentStyle({
renderConfig: { direction: TextDirection.LEFT_TO_RIGHT },
} as any);
model.updateDrawing('drawing1', { width: 100, height: 50, left: 12, top: 34 });
expect(model.getDrawings()?.drawing1.docTransform.size).toEqual({ width: 100, height: 50 });
model.setZoomRatio(1.5);
model.setDisabled(true);
expect(model.zoomRatio).toBe(1.5);
expect(model.getDisabled()).toBe(true);
const richText = RichTextBuilder.create(RichTextBuilder.newEmptyData());
richText
.insertText('Hello')
.insertParagraph(
ParagraphStyleBuilder.create({
horizontalAlign: HorizontalAlign.CENTER,
direction: TextDirection.RIGHT_TO_LEFT,
})
)
.insertLink(' Univer', 'https://univer.ai')
.setStyle(0, 5, TextStyleBuilder.create({ ff: 'Inter', fs: 14, bl: BooleanNumber.TRUE }))
.setLink(0, 5, 'https://example.com');
const firstLinkId = richText.getLinks()[0].rangeId;
richText.updateLink(firstLinkId, 'https://updated.example.com');
expect(richText.toPlainText()).toContain('Hello');
expect(richText.getLinks()).toHaveLength(2);
const sliced = richText.slice(0, 5);
expect(sliced.toPlainText()).toContain('Hello');
const decoration = TextDecorationBuilder.create({ s: BooleanNumber.TRUE })
.setLineType(TextDecoration.SINGLE)
.setFollowFontColor(false)
.build();
const styled = new TextStyleBuilder({}).setUnderline(TextDecorationBuilder.create(decoration)).build();
expect(styled.ul?.s).toBe(BooleanNumber.TRUE);
const docData = richText.getData();
expect(docData.body?.dataStream).toContain('Hello');
const jsonx = JSONX.getInstance();
model.apply(jsonx.replaceOp(['title'], 'Renamed', 'Applied Title') as any);
model.apply(jsonx.replaceOp(['settings', 'zoomRatio'], 1.5, 2) as any);
expect(model.getTitle()).toBe('Applied Title');
expect(model.getPlainText()).toContain('Hello World');
expect(model.sliceBody(0, 5)?.dataStream).toContain('Hello');
const partialParagraphModel = new DocumentDataModel({
id: 'partial-paragraph',
title: 'Partial Paragraph',
documentStyle: {},
body: {
dataStream: 'First\rSecond\r',
paragraphs: [
{ startIndex: 5, paragraphId: 'para_fixture_4', paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER } },
{ startIndex: 12, paragraphId: 'para_fixture_5', paragraphStyle: { textStyle: { fs: 18 } } },
],
},
});
expect(partialParagraphModel.sliceBody(0, 5)?.paragraphs?.[0]).toMatchObject({
startIndex: 5,
paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER },
});
expect(partialParagraphModel.sliceBody(6, 12)?.paragraphs?.[0]).toMatchObject({
startIndex: 6,
paragraphStyle: { textStyle: { fs: 18 } },
});
partialParagraphModel.dispose();
const tableStream = [
DataStreamTreeTokenType.TABLE_START,
DataStreamTreeTokenType.TABLE_ROW_START,
DataStreamTreeTokenType.TABLE_CELL_START,
'Cell\r\n',
DataStreamTreeTokenType.TABLE_CELL_END,
DataStreamTreeTokenType.TABLE_ROW_END,
DataStreamTreeTokenType.TABLE_END,
].join('');
const partialTableModel = new DocumentDataModel({
id: 'partial-table',
title: 'Partial Table',
documentStyle: {},
body: {
dataStream: tableStream,
tables: [{ startIndex: 0, endIndex: tableStream.length, tableId: 'table-1' }],
paragraphs: [{ startIndex: 7, paragraphId: 'para_fixture_6' }],
},
});
expect(partialTableModel.sliceBody(1, tableStream.length - 1)?.tables?.[0]).toMatchObject({
startIndex: 0,
endIndex: tableStream.length - 2,
tableId: 'table-1',
});
partialTableModel.dispose();
model.resetDrawing({} as any, []);
expect(model.getDrawingsOrder()).toEqual([]);
model.dispose();
});
it('should manage reset, noop apply and header-footer reinitialization for realistic document state changes', () => {
const snapshot = createDocSnapshot('doc-reset');
const model = new DocumentDataModel(snapshot);
const jsonx = JSONX.getInstance();
const initialChangeCount = model.change$.getValue();
expect(model.getCustomRanges()).toEqual([]);
expect(model.getCustomDecorations()).toEqual([]);
expect(model.getSettings()).toEqual({});
expect(model.apply(null as never)).toBeUndefined();
expect(model.change$.getValue()).toBe(initialChangeCount);
model.setZoomRatio(2);
expect(model.getSettings()).toEqual({ zoomRatio: 2 });
model.apply(jsonx.replaceOp(['headers'], model.getSnapshot().headers, {
...model.getSnapshot().headers,
header2: {
headerId: 'header2',
body: {
dataStream: 'Another Header\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_7' }],
},
},
}) as never);
expect(model.getSnapshot().headers?.header2?.body.dataStream).toBe('Another Header\r\n');
expect(model.change$.getValue()).toBeGreaterThan(initialChangeCount);
expect(() => model.reset({ id: 'other-doc' })).toThrow('Cannot reset a document model with a different unit id!');
model.reset({
id: 'doc-reset',
title: 'Reset Title',
body: {
dataStream: 'Reset\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'para_fixture_8' }],
},
documentStyle: {},
});
expect(model.getTitle()).toBe('Reset Title');
expect(model.getBody()?.dataStream).toBe('Reset\r\n');
expect(model.getUnitId()).toBe('doc-reset');
expect(model.sliceBody(0, 5)?.dataStream).toContain('Reset');
model.dispose();
});
it('normalizes legacy section metadata that follows page breaks when loading a document', () => {
const model = new DocumentDataModel({
id: 'legacy-page-breaks',
body: {
dataStream: 'Before\r\n\fAfter\r\n\fTail\r\n\n',
paragraphs: [
{ startIndex: 6, paragraphId: 'before' },
{ startIndex: 14, paragraphId: 'after' },
{ startIndex: 21, paragraphId: 'tail' },
],
sectionBreaks: [
{ startIndex: 9, sectionId: 'legacy-1' },
{ startIndex: 17, sectionId: 'legacy-2' },
{ startIndex: 23, sectionId: 'final' },
],
},
});
expect(model.getBody()?.sectionBreaks).toEqual([{ startIndex: 23, sectionId: 'final' }]);
model.dispose();
});
});

View File

@@ -0,0 +1,58 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { LocaleType } from '../../../types/enum/locale-type';
import { DocumentDataModel } from '../document-data-model';
describe('DocumentDataModel.getStatistics', () => {
const model = new DocumentDataModel({ body: { dataStream: '你好,世界!\rHello world 123.\rOne\tTwo three\r\n' } });
it('calculates mixed-language document statistics asynchronously', async () => {
await expect(model.getStatistics({ locale: LocaleType.ZH_CN })).resolves.toEqual({
words: 12,
charactersWithoutSpaces: 31,
charactersWithSpaces: 36,
paragraphs: 3,
nonAsianWords: 6,
asianCharactersAndKoreanWords: 6,
});
});
it('merges overlapping selections before calculating statistics', async () => {
await expect(model.getStatistics({
locale: LocaleType.EN_US,
ranges: [
{ startOffset: 9, endOffset: 20, collapsed: false },
{ startOffset: 15, endOffset: 20, collapsed: false },
],
})).resolves.toEqual({
words: 3,
charactersWithoutSpaces: 9,
charactersWithSpaces: 11,
paragraphs: 0,
nonAsianWords: 3,
asianCharactersAndKoreanWords: 0,
});
});
it('stops obsolete calculations', async () => {
const controller = new AbortController();
controller.abort();
await expect(model.getStatistics({ signal: controller.signal })).rejects.toThrow();
});
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { getEmptySnapshot } from '../empty-snapshot';
describe('getEmptySnapshot', () => {
it('uses the configured paragraph spacing defaults for new docs', () => {
const snapshot = getEmptySnapshot();
expect(snapshot.body?.paragraphs?.[0].paragraphStyle).toEqual({});
expect(snapshot.documentStyle.defaultParagraphStyle).toEqual({
spaceAbove: { v: 0 },
lineSpacing: 1.5,
spaceBelow: { v: 12 },
});
});
it('initializes optional body arrays for structured doc features', () => {
expect(getEmptySnapshot().body).toMatchObject({
blockRanges: [],
columnGroups: [],
customBlocks: [],
customDecorations: [],
customRanges: [],
tables: [],
textRuns: [],
});
});
});

View File

@@ -0,0 +1,120 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IDocStyles, IDocumentStyle } from '../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { BooleanNumber } from '../../../types/enum';
import { DocStyleType, DocumentFlavor } from '../../../types/interfaces';
import { resolveDocumentParagraphStyle } from '../paragraph-style';
describe('resolveDocumentParagraphStyle pagination', () => {
const traditionalDocumentStyle: IDocumentStyle = {
documentFlavor: DocumentFlavor.TRADITIONAL,
};
it('preserves direct false over a named style and document default', () => {
const styles: IDocStyles = {
base: {
name: 'Base',
type: DocStyleType.paragraph,
paragraphStyle: {
keepNext: BooleanNumber.TRUE,
keepLines: BooleanNumber.TRUE,
},
},
heading: {
name: 'Heading',
basedOn: 'base',
type: DocStyleType.paragraph,
paragraphStyle: {
pageBreakBefore: BooleanNumber.TRUE,
},
},
};
expect(resolveDocumentParagraphStyle(
{
...traditionalDocumentStyle,
defaultParagraphStyle: {
keepNext: BooleanNumber.TRUE,
},
},
{
keepNext: BooleanNumber.FALSE,
widowControl: BooleanNumber.FALSE,
},
{
styles,
paragraphStyleId: 'heading',
}
)).toMatchObject({
keepNext: BooleanNumber.FALSE,
keepLines: BooleanNumber.TRUE,
widowControl: BooleanNumber.FALSE,
pageBreakBefore: BooleanNumber.TRUE,
});
});
it('uses the traditional widow default only when no higher-precedence value exists', () => {
expect(resolveDocumentParagraphStyle(traditionalDocumentStyle, {}))
.toMatchObject({ widowControl: BooleanNumber.TRUE });
expect(resolveDocumentParagraphStyle(
{
...traditionalDocumentStyle,
defaultParagraphStyle: { widowControl: BooleanNumber.FALSE },
},
{}
)).toMatchObject({ widowControl: BooleanNumber.FALSE });
expect(resolveDocumentParagraphStyle(
{ documentFlavor: DocumentFlavor.MODERN },
{}
).widowControl).toBeUndefined();
});
it('terminates style cycles and reads current named-style values', () => {
const styles: IDocStyles = {
first: {
name: 'First',
basedOn: 'second',
type: DocStyleType.paragraph,
paragraphStyle: { keepNext: BooleanNumber.TRUE },
},
second: {
name: 'Second',
basedOn: 'first',
type: DocStyleType.paragraph,
paragraphStyle: { keepLines: BooleanNumber.TRUE },
},
};
expect(resolveDocumentParagraphStyle(traditionalDocumentStyle, {}, {
styles,
paragraphStyleId: 'first',
})).toMatchObject({
keepNext: BooleanNumber.TRUE,
keepLines: BooleanNumber.TRUE,
});
styles.first.paragraphStyle = { keepNext: BooleanNumber.FALSE };
expect(resolveDocumentParagraphStyle(traditionalDocumentStyle, {}, {
styles,
paragraphStyleId: 'first',
})).toMatchObject({
keepNext: BooleanNumber.FALSE,
keepLines: BooleanNumber.TRUE,
});
});
});

View File

@@ -0,0 +1,37 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { PRESET_LIST_TYPE } from '../preset-list-type';
describe('preset list types', () => {
it('does not assign absolute font sizes to built-in list markers', () => {
for (const [listType, listData] of Object.entries(PRESET_LIST_TYPE)) {
for (const [level, nesting] of listData.nestingLevel.entries()) {
expect(nesting.textStyle?.fs, `${listType} level ${level}`).toBeUndefined();
}
}
});
it('keeps wrapped text aligned after the hanging marker at every level', () => {
for (const [listType, listData] of Object.entries(PRESET_LIST_TYPE)) {
for (const [level, nesting] of listData.nestingLevel.entries()) {
expect(nesting.paragraphProperties?.hanging, `${listType} level ${level}`).toEqual({ v: 21 });
expect(nesting.paragraphProperties?.indentStart, `${listType} level ${level}`).toEqual({ v: 21 * (level + 1) });
}
}
});
});

View File

@@ -0,0 +1,226 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { BooleanNumber } from '../../../types/enum/text-style';
import { replaceInDocumentBody } from '../replacement';
function getTestDocumentBody() {
const documentBody = {
dataStream: '荷塘月色\r作者:朱月色\r\n',
textRuns: [
{
st: 0,
ed: 3,
ts: {
fs: 24,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(0, 0, 0)',
},
bl: BooleanNumber.TRUE,
},
},
{
st: 3,
ed: 4,
ts: {
fs: 24,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(23, 23, 23)',
},
bl: BooleanNumber.TRUE,
},
},
{
st: 5,
ed: 11,
ts: {
fs: 18,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(30, 30, 30)',
},
bl: BooleanNumber.FALSE,
},
},
],
paragraphs: [
{
startIndex: 4,
paragraphId: 'para_fixture_1001',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
},
{
startIndex: 11,
paragraphId: 'para_fixture_1002',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
},
],
sectionBreaks: [
{
sectionId: 'section_fixture_replacement',
startIndex: 12,
},
],
} as IDocumentBody;
return documentBody;
}
describe('test case in replaceInDocumentBody utils', () => {
it('Should replace all `query` to `target`', () => {
const documentBody = getTestDocumentBody();
const expectedBody = {
customRanges: [],
customDecorations: [],
customBlocks: [],
dataStream: '荷塘Jocs\r作者:朱Jocs\r\n',
textRuns: [
{
st: 0,
ed: 6,
ts: {
fs: 24,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(0, 0, 0)',
},
bl: BooleanNumber.TRUE,
},
},
{
st: 7,
ed: 15,
ts: {
fs: 18,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(30, 30, 30)',
},
bl: BooleanNumber.FALSE,
},
},
],
paragraphs: [
{
startIndex: 6,
paragraphId: 'para_fixture_1001',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
},
{
startIndex: 15,
paragraphId: 'para_fixture_1002',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
},
],
sectionBreaks: [
{
sectionId: 'section_fixture_replacement',
startIndex: 16,
},
],
} as IDocumentBody;
expect(replaceInDocumentBody(documentBody, '月色', 'Jocs', true)).toEqual(expectedBody);
});
it('Should replace all `query` to `target` when `target` is empty', () => {
const documentBody = getTestDocumentBody();
const expectedBody = {
dataStream: '荷塘\r作者:朱\r\n',
customBlocks: [],
textRuns: [
{
st: 0,
ed: 2,
ts: {
fs: 24,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(0, 0, 0)',
},
bl: BooleanNumber.TRUE,
},
},
{
st: 3,
ed: 7,
ts: {
fs: 18,
ff: 'Microsoft YaHei',
cl: {
rgb: 'rgb(30, 30, 30)',
},
bl: BooleanNumber.FALSE,
},
},
],
paragraphs: [
{
startIndex: 2,
paragraphId: 'para_fixture_1001',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
},
{
startIndex: 7,
paragraphId: 'para_fixture_1002',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
},
],
sectionBreaks: [
{
sectionId: 'section_fixture_replacement',
startIndex: 8,
},
],
} as IDocumentBody;
expect(replaceInDocumentBody(documentBody, '月色', '', true)).toEqual(expectedBody);
});
it('Should return the origin body when the query is empty', () => {
const documentBody = getTestDocumentBody();
expect(replaceInDocumentBody(documentBody, '', 'Jocs', true)).toEqual(documentBody);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { DEFAULT_STYLES } from '../../../types/const';
import { BaselineOffset, BooleanNumber } from '../../../types/enum';
import { convertTextRotation, DEFAULT_FONTFACE_PLANE, getBaselineOffsetInfo, getFontStyleString } from '../utils';
describe('document data-model utils', () => {
it('should build font strings from default and styled text', () => {
const defaultStyle = getFontStyleString();
const superscriptStyle = getFontStyleString({
ff: 'Noto Sans',
fs: 12.2,
it: BooleanNumber.TRUE,
bl: BooleanNumber.TRUE,
va: BaselineOffset.SUPERSCRIPT,
});
const normalStyle = getFontStyleString({
ff: '"Ping Fang"',
fs: 14,
it: BooleanNumber.FALSE,
bl: BooleanNumber.FALSE,
});
const fontStackStyle = getFontStyleString({
ff: '"SF Mono", "Cascadia Code", Consolas, monospace',
fs: 12,
});
const fractionalStyle = getFontStyleString({
ff: 'Arial',
fs: 10.5,
});
expect(defaultStyle).toEqual({
fontCache: `${DEFAULT_STYLES.fs}pt ${DEFAULT_STYLES.ff}`,
fontString: `${DEFAULT_STYLES.fs}pt ${DEFAULT_STYLES.ff}`,
fontSize: DEFAULT_STYLES.fs,
originFontSize: DEFAULT_STYLES.fs,
fontFamily: DEFAULT_STYLES.ff,
});
expect(superscriptStyle.fontFamily).toBe('"Noto Sans"');
expect(superscriptStyle.originFontSize).toBe(12.2);
expect(superscriptStyle.fontSize).toBeCloseTo(7.32);
expect(superscriptStyle.fontString).toContain(DEFAULT_FONTFACE_PLANE);
expect(normalStyle.fontCache).toBe('normal normal 14pt "Ping Fang"');
expect(fontStackStyle.fontFamily).toBe('"SF Mono", "Cascadia Code", Consolas, monospace');
expect(fontStackStyle.fontString).toContain('"SF Mono", "Cascadia Code", Consolas, monospace');
expect(fractionalStyle.originFontSize).toBe(10.5);
expect(fractionalStyle.fontCache).toContain('10.5pt');
});
it('should expose baseline offsets and convert text rotation', () => {
expect(getBaselineOffsetInfo('Inter', 11)).toEqual({
sbr: 0.6,
sbo: 11,
spr: 0.6,
spo: 11,
});
expect(convertTextRotation({ a: 30 })).toEqual({ centerAngle: 0, vertexAngle: 30 });
expect(convertTextRotation({ a: 30, v: BooleanNumber.TRUE })).toEqual({ centerAngle: 90, vertexAngle: 90 });
expect(convertTextRotation()).toEqual({ centerAngle: 0, vertexAngle: 0 });
});
});

View File

@@ -0,0 +1,530 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { Nullable } from '../../shared';
import type { ITextRangeParam } from '../../sheets/typedef';
import type { LocaleType } from '../../types/enum';
import type {
IDocumentBody,
IDocumentData,
IDocumentRenderConfig,
IDocumentStyle,
IDrawings,
IListData,
} from '../../types/interfaces/i-document-data';
import type { IPaddingData } from '../../types/interfaces/i-style-data';
import type { JSONXActions } from './json-x/json-x';
import { BehaviorSubject } from 'rxjs';
import { isInternalEditorID } from '../../common/const';
import { mergeWith } from '../../common/lodash';
import { UnitModel, UniverInstanceType } from '../../common/unit';
import { generateRandomId } from '../../shared/random-id';
import { Tools } from '../../shared/tools';
import { createSectionId } from '../section-break-id';
import { calculateDocumentStatistics } from './document-statistics';
import { getEmptySnapshot } from './empty-snapshot';
import { JSON1, JSONX } from './json-x/json-x';
import { PRESET_LIST_TYPE } from './preset-list-type';
import { getPlainText } from './text-x/build-utils/parse';
import { getBodySlice, SliceBodyType } from './text-x/utils';
export const DEFAULT_DOC = {
id: 'default_doc',
documentStyle: {},
};
export interface IDocumentStatistics {
words: number;
charactersWithoutSpaces: number;
charactersWithSpaces: number;
paragraphs: number;
nonAsianWords: number;
asianCharactersAndKoreanWords: number;
}
export interface IDocumentStatisticsOptions {
locale?: LocaleType;
ranges?: Readonly<ITextRangeParam[]>;
signal?: AbortSignal;
}
function normalizeLegacyPageBreakSectionMetadata(body: IDocumentBody | undefined): IDocumentBody | undefined {
if (body?.dataStream == null || body.sectionBreaks == null) {
return body;
}
const compatibleSectionBreaks = body.sectionBreaks.filter(({ startIndex }) =>
body.dataStream[startIndex] === '\n' || startIndex <= 0 || body.dataStream[startIndex - 1] !== '\f'
);
const existingSectionIds = new Set<string>();
let didChange = compatibleSectionBreaks.length !== body.sectionBreaks.length;
const sectionBreaks = compatibleSectionBreaks.map((sectionBreak) => {
if (sectionBreak.sectionId && !existingSectionIds.has(sectionBreak.sectionId)) {
existingSectionIds.add(sectionBreak.sectionId);
return sectionBreak;
}
didChange = true;
return { ...sectionBreak, sectionId: createSectionId(existingSectionIds) };
});
return didChange ? { ...body, sectionBreaks } : body;
}
function createDocumentSnapshot(snapshot: Partial<IDocumentData>): IDocumentData {
if (snapshot.id != null && isInternalEditorID(snapshot.id)) {
return { ...DEFAULT_DOC, ...snapshot } as IDocumentData;
}
const defaultSnapshot = getEmptySnapshot(snapshot.id, snapshot.locale, snapshot.title, snapshot.documentStyle?.documentFlavor);
if (Tools.isEmptyObject(snapshot)) {
return defaultSnapshot;
}
const mergedSnapshot = mergeWith({}, defaultSnapshot, snapshot, (_objValue, srcValue, key) => {
if (Array.isArray(srcValue)) {
return srcValue;
}
if (key === 'body' && srcValue != null && typeof srcValue === 'object') {
return srcValue;
}
});
// Existing snapshots with a body must preserve the absence of document
// defaults. The renderer provides the legacy flavor fallback for them.
if (snapshot.body != null && snapshot.documentStyle?.defaultParagraphStyle === undefined) {
delete mergedSnapshot.documentStyle.defaultParagraphStyle;
}
mergedSnapshot.body = normalizeLegacyPageBreakSectionMetadata(mergedSnapshot.body);
return mergedSnapshot;
}
interface IDrawingUpdateConfig {
left: number;
top: number;
height: number;
width: number;
}
class DocumentDataModelSimple extends UnitModel<IDocumentData, UniverInstanceType.UNIVER_DOC> {
override type: UniverInstanceType.UNIVER_DOC = UniverInstanceType.UNIVER_DOC;
override getUnitId(): string {
throw new Error('Method not implemented.');
}
protected readonly _name$ = new BehaviorSubject<string>('');
override name$ = this._name$.asObservable();
protected snapshot: IDocumentData;
private _mutationRevision = 0;
constructor(snapshot: Partial<IDocumentData>) {
super();
this.snapshot = createDocumentSnapshot(snapshot);
this._name$.next(this.snapshot.title ?? 'No Title');
}
override getRev(): number {
return this.snapshot.rev ?? 1;
}
override incrementRev(): void {
this.snapshot.rev = this.getRev() + 1;
}
override setRev(rev: number): void {
this.snapshot.rev = rev;
}
setName(name: string) {
this.snapshot.title = name;
this._markMutation();
this._name$.next(name);
}
get drawings() {
return this.snapshot.drawings;
}
get documentStyle() {
return this.snapshot.documentStyle;
}
get lists() {
return this.snapshot.lists;
}
get zoomRatio() {
return this.snapshot.settings?.zoomRatio || 1;
}
resetDrawing(drawings: IDrawings, drawingsOrder: string[]) {
this.snapshot.drawings = drawings;
this.snapshot.drawingsOrder = drawingsOrder;
this._markMutation();
}
getBody() {
return this.snapshot.body;
}
getSnapshot() {
return this.snapshot;
}
getBulletPresetList() {
const customLists = this.snapshot.lists ?? {};
const lists: Record<string, IListData> = {
...PRESET_LIST_TYPE,
...customLists,
};
return lists;
}
updateDocumentId(unitId: string) {
this.snapshot.id = unitId;
}
updateDocumentRenderConfig(config: IDocumentRenderConfig) {
const { documentStyle } = this.snapshot;
if (documentStyle.renderConfig == null) {
documentStyle.renderConfig = config;
} else {
documentStyle.renderConfig = {
...documentStyle.renderConfig,
...config,
};
}
this._markMutation();
}
getDocumentStyle() {
return this.snapshot.documentStyle;
}
updateDocumentStyle(config: IDocumentStyle) {
if (this.snapshot.documentStyle == null) {
this.snapshot.documentStyle = config;
} else {
this.snapshot.documentStyle = {
...this.snapshot.documentStyle,
...config,
};
}
this._markMutation();
}
updateDocumentDataMargin(data: IPaddingData) {
const { t, l, b, r } = data;
const { documentStyle } = this.snapshot;
if (t != null) {
documentStyle.marginTop = t;
}
if (l != null) {
documentStyle.marginLeft = l;
}
if (b != null) {
documentStyle.marginBottom = b;
}
if (r != null) {
documentStyle.marginRight = r;
}
this._markMutation();
}
updateDocumentDataPageSize(width?: number, height?: number) {
const { documentStyle } = this.snapshot;
if (!documentStyle.pageSize) {
documentStyle.pageSize = {
width: width ?? Number.POSITIVE_INFINITY,
height: height ?? Number.POSITIVE_INFINITY,
};
this._markMutation();
return;
}
if (width !== undefined) {
documentStyle.pageSize.width = width;
}
if (height !== undefined) {
documentStyle.pageSize.height = height;
}
this._markMutation();
}
updateDrawing(id: string, config: IDrawingUpdateConfig) {
const { drawings } = this;
const { width, height, left, top } = config;
const drawing = drawings?.[id];
if (!drawing) {
return;
}
const objectTransform = drawing.docTransform;
objectTransform.size.width = width;
objectTransform.size.height = height;
objectTransform.positionH.posOffset = left;
objectTransform.positionV.posOffset = top;
this._markMutation();
}
setZoomRatio(zoomRatio: number = 1) {
if (!this.snapshot.settings) {
this.snapshot.settings = {
zoomRatio,
};
} else {
this.snapshot.settings.zoomRatio = zoomRatio;
}
this._markMutation();
}
setDisabled(disabled: boolean) {
this.snapshot.disabled = disabled;
this._markMutation();
}
getDisabled() {
return this.snapshot.disabled;
}
getTitle() {
return this.snapshot.title;
}
getMutationRevision(): number {
return this._mutationRevision;
}
protected _markMutation(): void {
this._mutationRevision++;
}
}
export class DocumentDataModel extends DocumentDataModelSimple {
private _unitId: string;
headerModelMap: Map<string, DocumentDataModel> = new Map();
footerModelMap: Map<string, DocumentDataModel> = new Map();
noteModelMap: Map<string, DocumentDataModel> = new Map();
change$ = new BehaviorSubject<number>(0);
constructor(snapshot: Partial<IDocumentData>) {
super(Tools.isEmptyObject(snapshot) ? getEmptySnapshot() : snapshot);
const UNIT_ID_LENGTH = 6;
this._unitId = this.snapshot.id ?? generateRandomId(UNIT_ID_LENGTH);
this._initializeHeaderFooterModel();
this._name$.next(this.snapshot.title ?? '');
}
override dispose() {
super.dispose();
this.headerModelMap.forEach((header) => {
header.dispose();
});
this.footerModelMap.forEach((footer) => {
footer.dispose();
});
this.noteModelMap.forEach((note) => note.dispose());
this.noteModelMap.clear();
this._name$.complete();
}
getDrawings() {
return this.snapshot.drawings;
}
getDrawingsOrder() {
return this.snapshot.drawingsOrder;
}
getCustomRanges() {
return this.snapshot.body?.customRanges;
}
getCustomDecorations() {
return this.snapshot.body?.customDecorations;
}
getSettings() {
return this.snapshot.settings;
}
// TODO: @JOCS do not use reset, please use apply to modify the snapshot.
reset(snapshot: Partial<IDocumentData>) {
if (snapshot.id && snapshot.id !== this._unitId) {
throw new Error('Cannot reset a document model with a different unit id!');
}
this.snapshot = createDocumentSnapshot(snapshot);
this._initializeHeaderFooterModel();
this._markMutation();
this.change$.next(this.change$.value + 1);
}
getSelfOrHeaderFooterModel(segmentId?: string): DocumentDataModel | null {
if (segmentId === null || segmentId === undefined || segmentId === '') {
return this;
}
if (this.headerModelMap.has(segmentId)) {
return this.headerModelMap.get(segmentId)!;
}
if (this.footerModelMap.has(segmentId)) {
return this.footerModelMap.get(segmentId)!;
}
if (this.noteModelMap.has(segmentId)) {
return this.noteModelMap.get(segmentId)!;
}
return null;
}
override getUnitId() {
return this._unitId;
}
apply(actions: JSONXActions) {
if (JSONX.isNoop(actions)) {
return;
}
const previousNotes = this.snapshot.notes;
const changedNotes = new Set<string>();
let changedInheritedNoteStyles = false;
const cursor = JSON1.type.readCursor(actions);
cursor.traverse(null, () => {
const path = cursor.getPath();
if (path[0] === 'notes' && typeof path[1] === 'string') {
changedNotes.add(path[1]);
}
if (path[0] === 'styles' || path[0] === 'documentStyle') {
changedInheritedNoteStyles = true;
}
});
this.snapshot = JSONX.apply(this.snapshot, actions) as unknown as IDocumentData;
this._markMutation();
if (changedInheritedNoteStyles || previousNotes !== this.snapshot.notes || changedNotes.size > 0) {
this._initializeNoteModels(changedInheritedNoteStyles ? undefined : previousNotes, changedNotes);
}
// FIXME: @JOCS, ANY better solution to find action that create or delete header/footer?
if (actions?.some((a) => Array.isArray(a) && (a?.[0] === 'headers' || a?.[0] === 'footers'))) {
this.headerModelMap.clear();
this.footerModelMap.clear();
this._initializeHeaderFooterModel();
}
this.change$.next(this.change$.value + 1);
return this.snapshot;
}
sliceBody(startOffset: number, endOffset: number, type = SliceBodyType.copy): Nullable<IDocumentBody> {
const body = this.getBody();
if (body == null) {
return;
}
return getBodySlice(body, startOffset, endOffset, false, type);
}
private _initializeHeaderFooterModel() {
this._initializeNoteModels();
const { headers, footers } = this.getSnapshot();
if (headers) {
for (const headerId in headers) {
const header = headers[headerId];
this.headerModelMap.set(headerId, new DocumentDataModel(header));
this.headerModelMap.get(headerId)!.updateDocumentId(this.getUnitId());
}
}
if (footers) {
for (const footerId in footers) {
const footer = footers[footerId];
this.footerModelMap.set(footerId, new DocumentDataModel(footer));
this.footerModelMap.get(footerId)!.updateDocumentId(this.getUnitId());
}
}
}
private _initializeNoteModels(previousNotes?: IDocumentData['notes'], changedNotes?: ReadonlySet<string>): void {
for (const [id, model] of this.noteModelMap) {
if (changedNotes?.has(id) || previousNotes?.[id] !== this.snapshot.notes?.[id] || !previousNotes?.[id]) {
model.dispose();
this.noteModelMap.delete(id);
}
}
for (const [id, note] of Object.entries(this.snapshot.notes ?? {})) {
if (this.noteModelMap.has(id)) {
continue;
}
const model = new DocumentDataModel({
...note,
id: this.getUnitId(),
documentStyle: this.snapshot.documentStyle,
styles: this.snapshot.styles,
});
this.noteModelMap.set(id, model);
}
}
override updateDocumentStyle(config: IDocumentStyle) {
super.updateDocumentStyle(config);
this._initializeNoteModels();
}
override updateDocumentId(unitId: string) {
super.updateDocumentId(unitId);
this._unitId = unitId;
}
getPlainText() {
return getPlainText(this.getBody()?.dataStream ?? '');
}
getStatistics(options: IDocumentStatisticsOptions = {}): Promise<IDocumentStatistics> {
return calculateDocumentStatistics(this.getBody()?.dataStream ?? '', options);
}
}

View File

@@ -0,0 +1,170 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IDocumentStatistics, IDocumentStatisticsOptions } from './document-data-model';
import { LOCALE_META } from '../../types/enum/locale-type';
import { DataStreamTreeTokenType } from './types';
const CONTROL_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.COLUMN_BREAK,
DataStreamTreeTokenType.PAGE_BREAK,
DataStreamTreeTokenType.DOCS_END,
DataStreamTreeTokenType.CUSTOM_BLOCK,
]);
const EAST_ASIAN_CHARACTER = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}\u3000-\u303F\uFF00-\uFFEF]/u;
const KOREAN_CHARACTER = /\p{Script=Hangul}/u;
const YIELD_INTERVAL = 20_000;
function emptyStatistics(): IDocumentStatistics {
return {
words: 0,
charactersWithoutSpaces: 0,
charactersWithSpaces: 0,
paragraphs: 0,
nonAsianWords: 0,
asianCharactersAndKoreanWords: 0,
};
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('Document statistics calculation aborted');
}
}
function yieldExecution(signal?: AbortSignal): Promise<void> {
throwIfAborted(signal);
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
function getRangeFragments(dataStream: string, ranges: IDocumentStatisticsOptions['ranges']): string[] {
if (!ranges?.length) {
return [dataStream];
}
const normalizedRanges = ranges
.filter((range) => !range.collapsed)
.map((range) => ({
startOffset: Math.max(0, Math.min(range.startOffset, range.endOffset)),
endOffset: Math.min(dataStream.length, Math.max(range.startOffset, range.endOffset)),
}))
.filter((range) => range.startOffset < range.endOffset)
.sort((a, b) => a.startOffset - b.startOffset || a.endOffset - b.endOffset);
const mergedRanges: Array<{ startOffset: number; endOffset: number }> = [];
for (const range of normalizedRanges) {
const previous = mergedRanges.at(-1);
if (previous && range.startOffset <= previous.endOffset) {
previous.endOffset = Math.max(previous.endOffset, range.endOffset);
} else {
mergedRanges.push({ ...range });
}
}
return mergedRanges.map((range) => dataStream.slice(range.startOffset, range.endOffset));
}
function isControlToken(segment: string): boolean {
return Array.from(segment).every((character) => CONTROL_TOKENS.has(character));
}
export async function calculateDocumentStatistics(
dataStream: string,
options: IDocumentStatisticsOptions
): Promise<IDocumentStatistics> {
const { locale, ranges, signal } = options;
const localeTag = locale == null ? undefined : LOCALE_META[locale].tag;
const graphemeSegmenter = new Intl.Segmenter(localeTag, { granularity: 'grapheme' });
const wordSegmenter = new Intl.Segmenter(localeTag, { granularity: 'word' });
const statistics = emptyStatistics();
await yieldExecution(signal);
throwIfAborted(signal);
for (const fragment of getRangeFragments(dataStream, ranges)) {
let processedCharacters = 0;
let paragraphHasContent = false;
for (const { segment } of graphemeSegmenter.segment(fragment)) {
throwIfAborted(signal);
if (segment.includes(DataStreamTreeTokenType.PARAGRAPH)) {
if (paragraphHasContent) {
statistics.paragraphs += 1;
}
paragraphHasContent = false;
}
if (!isControlToken(segment)) {
statistics.charactersWithSpaces += 1;
if (!/^\s+$/u.test(segment)) {
statistics.charactersWithoutSpaces += 1;
paragraphHasContent = true;
}
if (EAST_ASIAN_CHARACTER.test(segment)) {
statistics.asianCharactersAndKoreanWords += 1;
}
}
processedCharacters += segment.length;
if (processedCharacters >= YIELD_INTERVAL) {
processedCharacters = 0;
await yieldExecution(signal);
}
}
let processedWordCharacters = 0;
for (const word of wordSegmenter.segment(fragment)) {
throwIfAborted(signal);
if (word.isWordLike && !EAST_ASIAN_CHARACTER.test(word.segment)) {
if (KOREAN_CHARACTER.test(word.segment)) {
statistics.asianCharactersAndKoreanWords += 1;
} else {
statistics.nonAsianWords += 1;
}
}
processedWordCharacters += word.segment.length;
if (processedWordCharacters >= YIELD_INTERVAL) {
processedWordCharacters = 0;
await yieldExecution(signal);
}
}
}
throwIfAborted(signal);
statistics.words = statistics.nonAsianWords + statistics.asianCharactersAndKoreanWords;
return statistics;
}

View File

@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData } from '../../types/interfaces';
import { generateRandomId } from '../../shared/random-id';
import {
DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING,
DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE,
DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW,
MODERN_DOCUMENT_WIDTH,
ModernDocumentWidthMode,
PAGE_SIZE,
TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
} from '../../types/const';
import { BooleanNumber } from '../../types/enum';
import { LocaleType } from '../../types/enum/locale-type';
import { DocumentFlavor, PaperType } from '../../types/interfaces';
import { createParagraphId } from '../paragraph-id';
import { createSectionId } from '../section-break-id';
export function getEmptySnapshot(
unitID = generateRandomId(6),
locale = LocaleType.EN_US,
title = '',
documentFlavor: DocumentFlavor = DocumentFlavor.MODERN
): IDocumentData {
const EMPTY_DOCUMENT_DATA: IDocumentData = {
id: unitID,
locale,
title, // title should get from request.
tableSource: {},
drawings: {},
drawingsOrder: [],
headers: {},
footers: {},
// Stable containers let concurrent first-note mutations address distinct child IDs.
notes: {},
noteSettings: {},
body: {
dataStream: '\r\n',
textRuns: [],
customBlocks: [],
tables: [],
columnGroups: [],
blockRanges: [],
customRanges: [],
customDecorations: [],
paragraphs: [
{
startIndex: 0,
paragraphId: createParagraphId(new Set()),
paragraphStyle: {
lineSpacing: 1,
},
},
],
sectionBreaks: [
{
sectionId: createSectionId(new Set()),
startIndex: 1,
},
],
},
documentStyle: {
pageSize: PAGE_SIZE[PaperType.A4],
documentFlavor,
marginTop: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
marginBottom: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
marginRight: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
marginLeft: TRADITIONAL_DOCUMENT_DEFAULT_MARGIN,
autoHyphenation: BooleanNumber.TRUE,
doNotHyphenateCaps: BooleanNumber.FALSE,
consecutiveHyphenLimit: 2,
defaultHeaderId: '',
defaultFooterId: '',
evenPageHeaderId: '',
evenPageFooterId: '',
firstPageHeaderId: '',
firstPageFooterId: '',
evenAndOddHeaders: BooleanNumber.FALSE,
useFirstPageHeaderFooter: BooleanNumber.FALSE,
marginHeader: 30,
marginFooter: 30,
},
settings: {},
};
// Set default values for modern document flavor
if (documentFlavor === DocumentFlavor.MODERN) {
EMPTY_DOCUMENT_DATA.body!.paragraphs![0].paragraphStyle = {};
EMPTY_DOCUMENT_DATA.documentStyle.defaultParagraphStyle = {
spaceAbove: { v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE },
lineSpacing: DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING,
spaceBelow: { v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW },
};
EMPTY_DOCUMENT_DATA.documentStyle.pageSize = {
width: MODERN_DOCUMENT_WIDTH[ModernDocumentWidthMode.MEDIUM],
height: 842 / 0.75,
};
EMPTY_DOCUMENT_DATA.documentStyle.marginTop = 50;
EMPTY_DOCUMENT_DATA.documentStyle.marginBottom = 50;
EMPTY_DOCUMENT_DATA.documentStyle.marginRight = 50;
EMPTY_DOCUMENT_DATA.documentStyle.marginLeft = 50;
EMPTY_DOCUMENT_DATA.documentStyle.renderConfig = {
zeroWidthParagraphBreak: BooleanNumber.FALSE,
vertexAngle: 0,
centerAngle: 0,
background: {
rgb: '#ccc',
},
};
}
return EMPTY_DOCUMENT_DATA;
}

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.
*/
export * from './document-data-model';
export { getEmptySnapshot as getDocsEmptySnapshot } from './empty-snapshot';
export { JSON1, JSONX } from './json-x/json-x';
export type { JSONXActions, JSONXPath } from './json-x/json-x';
export { resolveDocumentParagraphStyle } from './paragraph-style';
export type { IResolveDocumentParagraphStyleOptions } from './paragraph-style';
export * from './preset-list-type';
export { replaceInDocumentBody } from './replacement';
export {
ParagraphStyleBuilder,
ParagraphStyleValue,
RichTextBuilder,
RichTextParagraphBuilder,
RichTextRunBuilder,
RichTextValue,
TextDecorationBuilder,
TextStyleBuilder,
TextStyleValue,
} from './rich-text-builder';
export type { IRichTextColumnsOptions, IRichTextRange } from './rich-text-builder';
export { DEFAULT_DOCUMENT_SUB_COMPONENT_ID } from './subdocument';
export { ActionIterator } from './text-x/action-iterator';
export { PRESERVE_INSERTED_PARAGRAPH_IDS, TextXActionType } from './text-x/action-types';
export type { IDeleteAction, IInsertAction, IRetainAction, TextXAction } from './text-x/action-types';
export {
normalizeInsertedSectionIdsForDocument,
normalizeTextRuns,
RESTORE_INSERTED_PARAGRAPH_IDS,
} from './text-x/apply-utils/common';
export { updateAttributeByDelete } from './text-x/apply-utils/delete-apply';
export { updateAttributeByInsert } from './text-x/apply-utils/insert-apply';
export * from './text-x/build-utils';
export { getPlainText } from './text-x/build-utils/parse';
export { validateDocBodyStructure, validateDocumentStructure } from './text-x/structure-validator';
export type { DocStructureIssueCode, IDocStructureIssue } from './text-x/structure-validator';
export { TextX } from './text-x/text-x';
export type { TPriority } from './text-x/text-x';
export {
composeBody,
getBodySlice,
getBodySliceForSplitTextXAction,
getBodySliceForTextXAction,
getCustomBlockSlice,
getCustomDecorationSlice,
getCustomRangeSlice,
getParagraphsSlice,
getRichTextEditPath,
getSectionBreakSlice,
getTableSlice,
getTextRunSlice,
normalizeBody,
SliceBodyType,
} from './text-x/utils';
export * from './types';

View File

@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { TextX } from '../../text-x/text-x';
import { JSONX } from '../json-x';
function createDoc(id = 'doc-json-x'): IDocumentData {
return {
id,
body: {
dataStream: 'Hello\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'para_fixture_9' }],
},
documentStyle: {
pageSize: { width: 100, height: 100 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
};
}
describe('Basic use of json-x', () => {
describe('Static methods', () => {
it('Should return true when the action is null', () => {
expect(JSONX.isNoop(null)).toBe(true);
});
it('should apply insert, replace and remove operations to document snapshots', () => {
const jsonX = JSONX.getInstance();
const doc = createDoc();
const inserted = JSONX.apply(doc, jsonX.insertOp(['settings'], { zoomRatio: 2 })) as unknown as IDocumentData;
const replaced = JSONX.apply(inserted, jsonX.replaceOp(['id'], 'doc-json-x', 'doc-json-x-2')) as unknown as IDocumentData;
const removed = JSONX.apply(replaced, jsonX.removeOp(['settings'], { zoomRatio: 2 })) as unknown as IDocumentData;
expect(inserted.settings).toEqual({ zoomRatio: 2 });
expect(replaced.id).toBe('doc-json-x-2');
expect(removed.settings).toBeUndefined();
});
it('should compose rich-text edits and transform positions through text-x subtype operations', () => {
const jsonX = JSONX.getInstance();
const first = new TextX().retain(5).insert(1, { dataStream: '!' }).serialize();
const second = new TextX().retain(6).insert(1, { dataStream: '?' }).serialize();
const firstEdit = jsonX.editOp(first);
const secondEdit = jsonX.editOp(second);
const composed = JSONX.compose(firstEdit, secondEdit);
const transformed = JSONX.transform(firstEdit, secondEdit, 'left');
const doc = createDoc();
const edited = JSONX.apply(doc, composed) as unknown as IDocumentData;
const inverted = JSONX.invertWithDoc(firstEdit, doc);
const restored = JSONX.apply(JSONX.apply(createDoc(), firstEdit) as unknown as IDocumentData, inverted) as unknown as IDocumentData;
expect(JSONX.transformPosition(firstEdit, 5, 'right')).toBe(6);
expect(JSONX.transformPosition(secondEdit, 6, 'left')).toBe(6);
expect(transformed).toBeTruthy();
expect(edited.body?.dataStream).toBe('Hello!?\r\n');
expect(restored).toMatchObject(createDoc());
expect(restored.body).toMatchObject({ dataStream: 'Hello\r\n', paragraphs: [{ startIndex: 5, paragraphId: 'para_fixture_9' }] });
});
});
});

View File

@@ -0,0 +1,131 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { Doc, JSONOp, Path } from 'ot-json1';
import type { Nullable } from '../../../shared/types';
import type { IDocumentBody, IDocumentData } from '../../../types/interfaces';
import type { TextXAction } from '../text-x/action-types';
import type { TPriority } from '../text-x/text-x';
import * as json1 from 'ot-json1';
import { TextX } from '../text-x/text-x';
export interface ISubType {
name: string;
id?: string;
uri?: string;
apply(doc: IDocumentBody, actions: TextXAction[]): IDocumentBody;
compose(thisActions: TextXAction[], otherActions: TextXAction[]): TextXAction[];
transform(thisActions: TextXAction[], otherActions: TextXAction[], priority: 'left' | 'right'): TextXAction[];
invert?: (actions: TextXAction[]) => TextXAction[];
isNoop?: (actions: TextXAction[]) => boolean;
makeInvertible?: (actions: TextXAction[], doc: IDocumentBody) => TextXAction[];
// eslint-disable-next-line ts/no-explicit-any
[k: string]: any;
};
export { json1 as JSON1, JSONOp as JSONXActions, Path as JSONXPath };
export class JSONX {
// static name = 'json-x';
static uri = 'https://github.com/dream-num/univer#json-x';
private static _subTypes: Map<string, ISubType> = new Map();
static registerSubtype(subType: ISubType) {
// Add `subType == null` just use to pass tests.
if (subType == null || (this._subTypes.has(subType.name) && this._subTypes.get(subType.name)?.id !== TextX.id)) {
return;
}
this._subTypes.set(subType.name, subType);
json1.type.registerSubtype(subType);
}
static apply(doc: IDocumentData, actions: JSONOp) {
if (json1.type.isNoop(actions)) {
return;
}
return json1.type.apply(doc as unknown as Doc, actions);
}
static compose(thisActions: JSONOp, otherActions: JSONOp) {
return json1.type.compose(thisActions, otherActions);
}
static transform(thisActions: JSONOp, otherActions: JSONOp, priority: TPriority) {
return json1.type.transform(thisActions, otherActions, priority);
}
// Use to transform cursor position, just call TextXPro.transformPosition.
static transformPosition(thisActions: JSONOp, index: number, priority: TPriority = 'right'): number {
if (thisActions && thisActions.length === 2 && thisActions[0] === 'body' && (thisActions[1] as any).et === TextX.name) {
return TextX.transformPosition((thisActions[1] as any).e, index, priority === 'left');
}
return index;
}
static invertWithDoc(actions: JSONOp, doc: IDocumentData) {
// Why not use invert?
// Because invertWithDoc = invert(makeInvertible(op, doc));
// First we need to make all op invertible, then we can invert it.
// invertWithDoc is a helper function to make all op invertible and then invert it.
return json1.type.invertWithDoc(actions, doc as unknown as Doc);
}
static isNoop(actions: JSONOp) {
return json1.type.isNoop(actions);
}
private static _instance: Nullable<JSONX> = null;
static getInstance() {
if (this._instance == null) {
this._instance = new JSONX();
}
return this._instance;
}
// eslint-disable-next-line ts/no-explicit-any
removeOp(path: Path, value?: any) {
return json1.removeOp(path, value);
}
moveOp(from: Path, to: Path) {
return json1.moveOp(from, to);
}
// eslint-disable-next-line ts/no-explicit-any
insertOp(path: Path, value: any) {
return json1.insertOp(path, value);
}
// eslint-disable-next-line ts/no-explicit-any
replaceOp(path: Path, oldVal: any, newVal: any) {
return json1.replaceOp(path, oldVal, newVal);
}
editOp(subOp: TextXAction[], path = ['body']) {
// Hardcode the path to ['body'] for now. Because rich text is in the body property.
return json1.editOp(path, TextX.name, subOp);
}
}
JSONX.registerSubtype(TextX);

View File

@@ -0,0 +1,124 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '../../shared';
import type { IDocStyles, IDocumentStyle, IParagraphStyle } from '../../types/interfaces';
import { Tools } from '../../shared';
import {
DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING,
DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE,
DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW,
NAMED_STYLE_SPACE_MAP,
} from '../../types/const';
import { BooleanNumber } from '../../types/enum';
import { DocStyleType, DocumentFlavor } from '../../types/interfaces';
export interface IResolveDocumentParagraphStyleOptions {
/** Exclude document-level outer spacing while retaining all other defaults. */
excludeDocumentOuterSpacing?: boolean;
/** Preserve the legacy Modern Doc spacing defaults when no explicit flavor is available. */
useLegacyModernDefaults?: boolean;
/** Named document styles keyed by stable style id. */
styles?: IDocStyles;
/** Named paragraph style referenced by the paragraph. */
paragraphStyleId?: string;
}
function _definedStyle(style: Nullable<IParagraphStyle>): IParagraphStyle {
if (style == null) {
return {};
}
const definedStyle = Tools.deepClone(style);
Tools.removeNull(definedStyle);
return definedStyle;
}
function _resolveNamedParagraphStyle(
styles: IDocStyles | undefined,
styleId: string | undefined
): IParagraphStyle {
if (!styles || !styleId) {
return {};
}
const visiting = new Set<string>();
const resolve = (currentStyleId: string): IParagraphStyle => {
if (visiting.has(currentStyleId)) {
return {};
}
const style = styles[currentStyleId];
if (!style || style.type !== DocStyleType.paragraph) {
return {};
}
visiting.add(currentStyleId);
const base = style.basedOn ? resolve(style.basedOn) : {};
visiting.delete(currentStyleId);
return {
...base,
..._definedStyle(style.paragraphStyle),
};
};
return resolve(styleId);
}
export function resolveDocumentParagraphStyle(
documentStyle: Nullable<IDocumentStyle>,
paragraphStyle: Nullable<IParagraphStyle>,
options: IResolveDocumentParagraphStyleOptions = {}
): IParagraphStyle {
const useLegacyModernDefaults = options.useLegacyModernDefaults ??
documentStyle?.documentFlavor === DocumentFlavor.MODERN;
const legacyStyle: IParagraphStyle = useLegacyModernDefaults
? {
lineSpacing: DEFAULT_DOCUMENT_PARAGRAPH_LINE_SPACING,
spaceAbove: { v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_ABOVE },
spaceBelow: { v: DEFAULT_DOCUMENT_PARAGRAPH_SPACE_BELOW },
}
: {};
const traditionalStyle: IParagraphStyle =
documentStyle?.documentFlavor === DocumentFlavor.TRADITIONAL
? { widowControl: BooleanNumber.TRUE }
: {};
const documentDefaults = {
...traditionalStyle,
...legacyStyle,
..._definedStyle(documentStyle?.defaultParagraphStyle),
};
if (options.excludeDocumentOuterSpacing) {
delete documentDefaults.spaceAbove;
delete documentDefaults.spaceBelow;
}
const definedParagraphStyle = _definedStyle(paragraphStyle);
const referencedParagraphStyle = _resolveNamedParagraphStyle(
options.styles,
options.paragraphStyleId
);
const namedStyleType = definedParagraphStyle.namedStyleType ?? referencedParagraphStyle.namedStyleType;
const namedStyle = namedStyleType == null
? null
: NAMED_STYLE_SPACE_MAP[namedStyleType];
return Tools.deepClone({
...documentDefaults,
...namedStyle,
...referencedParagraphStyle,
...definedParagraphStyle,
});
}

View File

@@ -0,0 +1,296 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IListData, INestingLevel, ITextStyle } from '../../types/interfaces/i-document-data';
import { Tools } from '../../shared';
import { BooleanNumber } from '../../types/enum';
import { BulletAlignment, ListGlyphType } from '../../types/interfaces/i-document-data';
export enum QuickListType {
ORDER_LIST_QUICK_1 = '1.',
ORDER_LIST_QUICK_2 = 'a)',
ORDER_LIST_QUICK_3 = 'a.',
ORDER_LIST_QUICK_4 = 'i.',
ORDER_LIST_QUICK_5 = 'A.',
ORDER_LIST_QUICK_6 = 'I.',
ORDER_LIST_QUICK_7 = '01.',
BULLET_LIST = '*',
}
export enum PresetListType {
/** Standard filled-circle bullet list. */
BULLET_LIST = 'BULLET_LIST',
/** Decorative diamond and arrow bullet list. */
BULLET_LIST_1 = 'BULLET_LIST_1',
/** Checkmark-led bullet list for completed-item style content. */
BULLET_LIST_2 = 'BULLET_LIST_2',
/** Square and diamond bullet list. */
BULLET_LIST_3 = 'BULLET_LIST_3',
/** Star-like decorative bullet list. */
BULLET_LIST_4 = 'BULLET_LIST_4',
/** Arrow-led bullet list. */
BULLET_LIST_5 = 'BULLET_LIST_5',
/**
* Hierarchical ordered list using `1`, `a`, and `i` markers.
*/
ORDER_LIST = 'ORDER_LIST',
/**
* Hierarchical ordered list using `1)`, `a)`, and `i)` markers.
*/
ORDER_LIST_1 = 'ORDER_LIST_1',
/**
* Outline list using compound markers such as `1.`, `1.1.`, and `1.1.1.`.
*/
ORDER_LIST_2 = 'ORDER_LIST_2',
/**
* Hierarchical ordered list using `A`, `a`, and `i` markers.
*/
ORDER_LIST_3 = 'ORDER_LIST_3',
/**
* Hierarchical ordered list using `A`, `1`, and `i` markers.
*/
ORDER_LIST_4 = 'ORDER_LIST_4',
/**
* Zero-padded ordered list using `01`, `a`, and `i` markers.
*/
ORDER_LIST_5 = 'ORDER_LIST_5',
/** Quick ordered list using closing-parenthesis markers. */
ORDER_LIST_QUICK_2 = 'ORDER_LIST_QUICK_2',
/** Quick ordered list using lowercase letter and period markers. */
ORDER_LIST_QUICK_3 = 'ORDER_LIST_QUICK_3',
/** Quick ordered list using lowercase Roman numeral markers. */
ORDER_LIST_QUICK_4 = 'ORDER_LIST_QUICK_4',
/** Quick ordered list using uppercase letter markers. */
ORDER_LIST_QUICK_5 = 'ORDER_LIST_QUICK_5',
/** Quick ordered list using uppercase Roman numeral markers. */
ORDER_LIST_QUICK_6 = 'ORDER_LIST_QUICK_6',
/** Unchecked task-list item. */
CHECK_LIST = 'CHECK_LIST',
/** Checked task-list item. */
CHECK_LIST_CHECKED = 'CHECK_LIST_CHECKED',
}
const orderListSymbolMap = {
'a)': { glyphFormat: '%1)', glyphType: ListGlyphType.DECIMAL },
'1.': { glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL },
'a.': { glyphFormat: '%1.', glyphType: ListGlyphType.LOWER_LETTER },
'A.': { glyphFormat: '%1.', glyphType: ListGlyphType.UPPER_LETTER },
'i.': { glyphFormat: '%1.', glyphType: ListGlyphType.LOWER_ROMAN },
'I.': { glyphFormat: '%1.', glyphType: ListGlyphType.UPPER_LETTER },
};
type BulletSymbols = [string, string, string];
const bulletListFactory = (symbols: BulletSymbols): INestingLevel[] => {
return [
...symbols,
...symbols,
...symbols,
].map((templateSymbol, i) => ({
glyphFormat: ` %${i + 1}`,
glyphSymbol: templateSymbol,
bulletAlignment: BulletAlignment.START,
startNumber: 0,
paragraphProperties: {
hanging: { v: 21 },
indentStart: { v: 21 * (i + 1) },
},
}));
};
const orderListFactory = (options: { glyphFormat: string; glyphType: ListGlyphType }[]): INestingLevel[] => {
return options.map((format, i) => ({
...format,
bulletAlignment: BulletAlignment.START,
startNumber: 0,
paragraphProperties: {
hanging: { v: 21 },
indentStart: { v: 21 * (i + 1) },
},
}));
};
const checkListFactory = (symbol: string, textStyle?: ITextStyle): INestingLevel[] => {
return new Array(9).fill(0).map((_, i) => ({
glyphFormat: ` %${i + 1}`,
glyphSymbol: symbol,
bulletAlignment: BulletAlignment.START,
startNumber: 0,
paragraphProperties: {
hanging: { v: 21 },
indentStart: { v: 21 * (i + 1) },
textStyle,
},
} as INestingLevel));
};
export const PRESET_LIST_TYPE: Record<string, IListData> = {
[PresetListType.BULLET_LIST]: {
listType: PresetListType.BULLET_LIST,
nestingLevel: bulletListFactory(['\u25CF', '\u25CB', '\u25A0']),
} as IListData,
[PresetListType.BULLET_LIST_1]: {
listType: PresetListType.BULLET_LIST,
nestingLevel: bulletListFactory(['\u2756', '\u27A2', '\u25A0']),
} as IListData,
[PresetListType.BULLET_LIST_2]: {
listType: PresetListType.BULLET_LIST,
nestingLevel: bulletListFactory(['\u2714', '\u25CF', '\u25C6']),
} as IListData,
[PresetListType.BULLET_LIST_3]: {
listType: PresetListType.BULLET_LIST,
nestingLevel: bulletListFactory(['\u25A0', '\u25C6', '\u25CB']),
} as IListData,
[PresetListType.BULLET_LIST_4]: {
listType: PresetListType.BULLET_LIST,
nestingLevel: bulletListFactory(['\u2726', '\u25CB', '\u25A0']),
} as IListData,
[PresetListType.BULLET_LIST_5]: {
listType: PresetListType.BULLET_LIST,
nestingLevel: bulletListFactory(['\u27A2', '\u25CB', '\u25C6']),
} as IListData,
[PresetListType.ORDER_LIST]: {
listType: PresetListType.ORDER_LIST,
nestingLevel: orderListFactory([
{ glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%2.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%3.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%4.', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%5.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%6.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%7.', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%8.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%9.', glyphType: ListGlyphType.LOWER_ROMAN },
]),
} as IListData,
[PresetListType.ORDER_LIST_1]: {
listType: PresetListType.ORDER_LIST,
nestingLevel: orderListFactory([
{ glyphFormat: '%1)', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%2)', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%3)', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%4)', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%5)', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%6)', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%7)', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%8)', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%9)', glyphType: ListGlyphType.LOWER_ROMAN },
]),
} as IListData,
[PresetListType.ORDER_LIST_2]: {
listType: PresetListType.ORDER_LIST,
nestingLevel: orderListFactory([
'%1.',
'%1.%2.',
'%1.%2.%3.',
'%1.%2.%3.%4.',
'%1.%2.%3.%4.%5.',
'%1.%2.%3.%4.%5.%6.',
'%1.%2.%3.%4.%5.%6.%7.',
].map((format) => ({ glyphFormat: format, glyphType: ListGlyphType.DECIMAL }))),
} as IListData,
[PresetListType.ORDER_LIST_3]: {
listType: PresetListType.ORDER_LIST,
nestingLevel: orderListFactory([
{ glyphFormat: '%1.', glyphType: ListGlyphType.UPPER_LETTER },
{ glyphFormat: '%2.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%3.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%4.', glyphType: ListGlyphType.UPPER_LETTER },
{ glyphFormat: '%5.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%6.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%7.', glyphType: ListGlyphType.UPPER_LETTER },
{ glyphFormat: '%8.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%9.', glyphType: ListGlyphType.LOWER_ROMAN },
]),
} as IListData,
[PresetListType.ORDER_LIST_4]: {
listType: PresetListType.ORDER_LIST,
nestingLevel: orderListFactory([
{ glyphFormat: '%1.', glyphType: ListGlyphType.UPPER_LETTER },
{ glyphFormat: '%2.', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%3.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%4.', glyphType: ListGlyphType.UPPER_LETTER },
{ glyphFormat: '%5.', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%6.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%7.', glyphType: ListGlyphType.UPPER_LETTER },
{ glyphFormat: '%8.', glyphType: ListGlyphType.DECIMAL },
{ glyphFormat: '%9.', glyphType: ListGlyphType.LOWER_ROMAN },
]),
} as IListData,
[PresetListType.ORDER_LIST_5]: {
listType: PresetListType.ORDER_LIST,
nestingLevel: orderListFactory([
{ glyphFormat: '%1.', glyphType: ListGlyphType.DECIMAL_ZERO },
{ glyphFormat: '%2.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%3.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%4.', glyphType: ListGlyphType.DECIMAL_ZERO },
{ glyphFormat: '%5.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%6.', glyphType: ListGlyphType.LOWER_ROMAN },
{ glyphFormat: '%7.', glyphType: ListGlyphType.DECIMAL_ZERO },
{ glyphFormat: '%8.', glyphType: ListGlyphType.LOWER_LETTER },
{ glyphFormat: '%9.', glyphType: ListGlyphType.LOWER_ROMAN },
]),
} as IListData,
[PresetListType.CHECK_LIST]: {
listType: PresetListType.CHECK_LIST,
nestingLevel: checkListFactory('\u2610'),
} as IListData,
[PresetListType.CHECK_LIST_CHECKED]: {
listType: PresetListType.CHECK_LIST_CHECKED,
nestingLevel: checkListFactory(
'\u2611',
{
st: {
s: BooleanNumber.TRUE,
},
}
),
} as IListData,
};
const generateOrderList = (opt: { glyphFormat: string; glyphType: ListGlyphType }) => {
const { glyphFormat, glyphType } = opt;
const data = Tools.deepClone(PRESET_LIST_TYPE[PresetListType.ORDER_LIST]);
data.nestingLevel[0].glyphFormat = glyphFormat;
data.nestingLevel[0].glyphType = glyphType;
return data;
};
const QUICK_LIST_TYPE = {
[PresetListType.ORDER_LIST_QUICK_2]: generateOrderList(orderListSymbolMap[QuickListType.ORDER_LIST_QUICK_2]),
[PresetListType.ORDER_LIST_QUICK_3]: generateOrderList(orderListSymbolMap[QuickListType.ORDER_LIST_QUICK_3]),
[PresetListType.ORDER_LIST_QUICK_4]: generateOrderList(orderListSymbolMap[QuickListType.ORDER_LIST_QUICK_4]),
[PresetListType.ORDER_LIST_QUICK_6]: generateOrderList(orderListSymbolMap[QuickListType.ORDER_LIST_QUICK_6]),
};
Object.assign(PRESET_LIST_TYPE, QUICK_LIST_TYPE);
export const QuickListTypeMap = {
[QuickListType.ORDER_LIST_QUICK_1]: PresetListType.ORDER_LIST,
[QuickListType.ORDER_LIST_QUICK_2]: PresetListType.ORDER_LIST_QUICK_2,
[QuickListType.ORDER_LIST_QUICK_3]: PresetListType.ORDER_LIST_QUICK_3,
[QuickListType.ORDER_LIST_QUICK_4]: PresetListType.ORDER_LIST_QUICK_4,
[QuickListType.ORDER_LIST_QUICK_5]: PresetListType.ORDER_LIST_3,
[QuickListType.ORDER_LIST_QUICK_6]: PresetListType.ORDER_LIST_QUICK_6,
[QuickListType.ORDER_LIST_QUICK_7]: PresetListType.ORDER_LIST_5,
[QuickListType.BULLET_LIST]: PresetListType.BULLET_LIST,
};

View File

@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody, IDocumentData } from '../../types/interfaces/i-document-data';
import { DocumentDataModel } from './document-data-model';
import { JSONX } from './json-x/json-x';
import { TextX } from './text-x/text-x';
// TODO: this function should be replaced by replaceSelection
export function replaceInDocumentBody(body: IDocumentBody, query: string, target: string, caseSensitive: boolean): IDocumentBody {
if (query === '') {
return body;
}
const mockDocumentData: IDocumentData = {
id: 'mock-id',
body,
documentStyle: {},
};
const documentDataModel = new DocumentDataModel(mockDocumentData);
const queryLen = query.length;
let index;
// eslint-disable-next-line no-cond-assign
while ((index = (caseSensitive ? documentDataModel.getBody()!.dataStream : documentDataModel.getBody()!.dataStream.toLowerCase()).indexOf(query)) >= 0) {
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (index > 0) {
textX.retain(index);
}
if (target.length > 0) {
const sliceBody = documentDataModel.sliceBody(index, index + queryLen);
const replaceBody: IDocumentBody = {
dataStream: target,
};
if (Array.isArray(sliceBody?.textRuns) && sliceBody.textRuns.length) {
replaceBody.textRuns = [{
...sliceBody.textRuns[0],
st: 0,
ed: target.length,
}];
}
if (sliceBody?.customRanges?.length) {
const customRange = sliceBody.customRanges[0];
replaceBody.customRanges = [{
...customRange,
startIndex: 0,
endIndex: target.length - 1,
}];
}
textX.insert(target.length, replaceBody);
}
textX.delete(queryLen);
documentDataModel.apply(jsonX.editOp(textX.serialize()));
}
const newBody = documentDataModel.getBody()!;
documentDataModel.dispose();
return newBody;
}

File diff suppressed because it is too large Load Diff

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 DEFAULT_DOCUMENT_SUB_COMPONENT_ID = '__default_document_sub_component_id20231101__';

View File

@@ -0,0 +1,5 @@
# TextX
TextX is used to build and serialize `RichTextEditingMutation` actions in doc. There are three core actions in doc, insert, retain and delete.
The TextX class also includes action operation methods, such as `insert`, `retain`, `delete` and `push` method, and associated methods, such as `apply`, `invert`, `compose` etc

View File

@@ -0,0 +1,195 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { BooleanNumber } from '../../../../types/enum/text-style';
import { DataStreamTreeTokenType } from '../../types';
import { ActionIterator } from '../action-iterator';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
describe('Test action iterator', () => {
it('test action iterator basic use', () => {
const iterator = new ActionIterator([{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'hello',
textRuns: [{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
len: 5,
}, {
t: TextXActionType.DELETE,
len: 5,
}]);
expect(iterator.hasNext()).toBe(true);
expect(iterator.peekType()).toBe(TextXActionType.RETAIN);
expect(iterator.peekLength()).toBe(5);
expect(iterator.peek()).toEqual({
t: TextXActionType.RETAIN,
len: 5,
});
let action = iterator.next();
expect(action).toEqual({
t: TextXActionType.RETAIN,
len: 5,
});
expect(iterator.peekType()).toBe(TextXActionType.INSERT);
action = iterator.next(2);
expect(action).toEqual({
t: TextXActionType.INSERT,
body: {
dataStream: 'he',
textRuns: [{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
len: 2,
});
expect(iterator.peekType()).toBe(TextXActionType.INSERT);
action = iterator.next(4);
expect(action).toEqual({
t: TextXActionType.INSERT,
body: {
dataStream: 'llo',
textRuns: [{
st: 0,
ed: 3,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
len: 3,
});
expect(iterator.peekType()).toBe(TextXActionType.DELETE);
action = iterator.next();
expect(action).toEqual({
t: TextXActionType.DELETE,
len: 5,
});
expect(iterator.hasNext()).toBe(false);
expect(iterator.peekType()).toBe(TextXActionType.RETAIN);
expect(iterator.peekLength()).toBe(Number.POSITIVE_INFINITY);
expect(iterator.peek()).toBe(undefined);
expect(iterator.rest()).toEqual([]);
action = iterator.next();
expect(action).toEqual({
t: TextXActionType.RETAIN,
len: Number.POSITIVE_INFINITY,
});
});
it('test action iterator rest', () => {
const iterator = new ActionIterator([{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.DELETE,
len: 5,
}]);
expect(iterator.rest()).toEqual([{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.DELETE,
len: 5,
}]);
iterator.next(3);
expect(iterator.rest()).toEqual([{
t: TextXActionType.RETAIN,
len: 2,
}, {
t: TextXActionType.DELETE,
len: 5,
}]);
});
it('preserves table metadata on the chunk containing the table end when an insert action is split', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}`;
const tableEnd = tableStream.length - 1;
const iterator = new ActionIterator([{
t: TextXActionType.INSERT,
body: {
dataStream: tableStream,
tables: [{ startIndex: 0, endIndex: tableEnd, tableId: 'table-1' }],
},
len: tableStream.length,
}]);
const partialAction = iterator.next(4);
const restAction = iterator.next();
expect(partialAction.body?.tables).toBeUndefined();
expect(restAction.body?.tables).toEqual([{ startIndex: -4, endIndex: tableEnd - 4, tableId: 'table-1' }]);
const body = { dataStream: '' };
TextX.apply(body, [partialAction, restAction]);
expect(body).toMatchObject({
dataStream: tableStream,
tables: [{ startIndex: 0, endIndex: tableEnd, tableId: 'table-1' }],
});
const fullIterator = new ActionIterator([{
t: TextXActionType.INSERT,
body: {
dataStream: tableStream,
tables: [{ startIndex: 0, endIndex: tableEnd, tableId: 'table-1' }],
},
len: tableStream.length,
}]);
expect(fullIterator.next().body?.tables).toEqual([{ startIndex: 0, endIndex: tableEnd, tableId: 'table-1' }]);
});
});

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.
*/
import type { IDocumentBody } from '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../../../types/interfaces';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
function getDefaultDocWithCustomRange() {
const doc: IDocumentBody = {
dataStream: '人之初,\r性本善。\r\n',
textRuns: [],
customBlocks: [],
customRanges: [{
startIndex: 1,
endIndex: 3,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}],
paragraphs: [
{
startIndex: 4,
paragraphId: 'para_first',
},
{
startIndex: 9,
paragraphId: 'para_second',
},
],
customDecorations: [],
};
return doc;
}
describe('apply consistency', () => {
it('should get the same result when compose delete action with body', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.DELETE,
len: 2,
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 4,
},
{
t: TextXActionType.DELETE,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_first',
}],
customRanges: [],
},
},
{
t: TextXActionType.DELETE,
len: 1,
},
{
t: TextXActionType.DELETE,
len: 1,
},
];
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'right'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'left'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'right'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'left'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
const undoActions = TextX.invert(TextX.makeInvertible(composedAction1, getDefaultDocWithCustomRange()));
const undoDoc = TextX.apply(resultA, undoActions);
// console.log(JSON.stringify(composedAction1, null, 2));
// console.log(JSON.stringify(undoActions, null, 2));
// console.log(JSON.stringify(undoDoc, null, 2));
expect(undoDoc).toEqual(getDefaultDocWithCustomRange());
});
it('should get the same result when apply delete and cancel link', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.DELETE,
len: 2,
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 3,
body: {
dataStream: '',
customRanges: [],
},
},
];
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'right'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'left'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'right'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'left'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
});

View File

@@ -0,0 +1,543 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { Nullable } from '../../../../shared';
import type { IDocumentBody, ITextRun } from '../../../../types/interfaces';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { BooleanNumber } from '../../../../types/enum';
import { deleteParagraphs, deleteTextRuns, insertTextRuns } from '../apply-utils/common';
import { coverTextRuns } from '../apply-utils/update-apply';
describe('test case in apply utils', () => {
let body: Nullable<IDocumentBody> = null;
let updateTextRuns: ITextRun[] = [];
beforeEach(() => {
body = {
dataStream: 'hello\rworld hello\rworld hello\rworld he\r\n',
textRuns: [
{
st: 0,
ed: 15,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 15,
ed: 20,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
{
st: 30,
ed: 40,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
paragraphs: [
{
startIndex: 5,
paragraphId: 'para_fixture_1010',
},
{
startIndex: 17,
paragraphId: 'para_fixture_1011',
},
{
startIndex: 29,
paragraphId: 'para_fixture_1012',
},
{
startIndex: 38,
paragraphId: 'para_fixture_1013',
},
],
};
updateTextRuns = [
{
st: 0,
ed: 10,
ts: {
bl: BooleanNumber.TRUE,
fs: 28,
},
},
];
});
afterEach(() => {
body = null;
updateTextRuns = [];
});
describe('test cases in function deleteTextRuns', () => {
it('the inserted textRun is between two testRuns', async () => {
const removedTextRuns = deleteTextRuns(body as IDocumentBody, 10, 20);
expect(removedTextRuns.length).toBe(1);
expect(body?.textRuns![2].st).toBe(20);
expect(body?.textRuns![2].ed).toBe(30);
});
it('the inserted textRun is bigger than one testRuns', async () => {
const removeTextRuns = deleteTextRuns(body as IDocumentBody, 20, 5);
expect(removeTextRuns.length).toBe(2);
expect(body?.textRuns![0].ed).toBe(5);
expect(body?.textRuns![1].st).toBe(10);
expect(body?.textRuns![1].ed).toBe(20);
});
it('the inserted textRun is smaller than one testRuns', async () => {
const removeTextRuns = deleteTextRuns(body as IDocumentBody, 2, 16);
expect(removeTextRuns.length).toBe(1);
expect(body?.textRuns![1].ed).toBe(18);
expect(body?.textRuns![2].st).toBe(28);
expect(body?.textRuns![2].ed).toBe(38);
});
it('the inserted textRun is across two testRuns', async () => {
const removeTextRuns = deleteTextRuns(body as IDocumentBody, 13, 5);
expect(removeTextRuns.length).toBe(2);
expect(body?.textRuns![0].ed).toBe(5);
expect(body?.textRuns![1].st).toBe(5);
expect(body?.textRuns![1].ed).toBe(7);
expect(body?.textRuns![2].st).toBe(17);
expect(body?.textRuns![2].ed).toBe(27);
});
it('the inserted textRun is across two testRuns that scattered', async () => {
const removeTextRuns = deleteTextRuns(body as IDocumentBody, 20, 18);
expect(removeTextRuns.length).toBe(2);
expect(body?.textRuns![1].ed).toBe(18);
expect(body?.textRuns![2].st).toBe(18);
expect(body?.textRuns![2].ed).toBe(20);
});
it('Should return collapsed textRun when textLength is 0', () => {
const removeTextRuns = deleteTextRuns(body as IDocumentBody, 0, 5);
const expectedTextRuns = [{
st: 0,
ed: 0,
ts: {
bl: BooleanNumber.FALSE,
},
}];
expect(removeTextRuns).toEqual(expectedTextRuns);
});
it('Should return textRun when textLength is 5 and remove the textRun in body', () => {
const body = {
dataStream: 'hello\rworld hello\rworld hello\rworld he\r\n',
textRuns: [
{
st: 0,
ed: 15,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 15,
ed: 20,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
{
st: 30,
ed: 40,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
paragraphs: [
{
startIndex: 5,
paragraphId: 'para_fixture_1014',
},
{
startIndex: 17,
paragraphId: 'para_fixture_1015',
},
{
startIndex: 29,
paragraphId: 'para_fixture_1016',
},
{
startIndex: 38,
paragraphId: 'para_fixture_1017',
},
],
};
const removeTextRuns = deleteTextRuns(body as IDocumentBody, 5, 15);
const expectedTextRuns = [{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
}];
expect(removeTextRuns).toEqual(expectedTextRuns);
expect(body?.textRuns?.length).toBe(2);
});
});
describe('test cases in function coverTextRuns', () => {
it('it should return the updateTextRuns when the removeTextRuns is empty', async () => {
const needUpdateTextRuns = coverTextRuns(updateTextRuns, [], UpdateDocsAttributeType.COVER);
expect(needUpdateTextRuns.length).toBe(1);
expect(needUpdateTextRuns[0].st).toBe(0);
expect(needUpdateTextRuns[0].ed).toBe(10);
expect(needUpdateTextRuns[0].ts?.bl).toBe(BooleanNumber.TRUE);
});
it('it should be passed when the removeTextRuns are continuous', async () => {
const removeTextRuns = [
{
st: 0,
ed: 5,
ts: {
it: BooleanNumber.TRUE,
bl: BooleanNumber.FALSE,
},
},
{
st: 5,
ed: 10,
ts: {
it: BooleanNumber.FALSE,
bl: BooleanNumber.FALSE,
},
},
];
const expectedTextRuns = [
{
st: 0,
ed: 5,
ts: {
it: BooleanNumber.TRUE,
bl: BooleanNumber.TRUE,
fs: 28,
},
},
{
st: 5,
ed: 10,
ts: {
it: BooleanNumber.FALSE,
bl: BooleanNumber.TRUE,
fs: 28,
},
},
];
const needUpdateTextRuns = coverTextRuns(updateTextRuns, removeTextRuns, UpdateDocsAttributeType.COVER);
expect(needUpdateTextRuns).toEqual(expectedTextRuns);
expect(needUpdateTextRuns.length).toBe(2);
expect(needUpdateTextRuns[0]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[1]?.ts?.bl).toBe(BooleanNumber.TRUE);
});
it('it should be passed when the removeTextRuns are scattered', async () => {
const removeTextRuns = [
{
st: 3,
ed: 5,
ts: {
it: BooleanNumber.TRUE,
bl: BooleanNumber.FALSE,
},
},
{
st: 7,
ed: 9,
ts: {
it: BooleanNumber.FALSE,
bl: BooleanNumber.FALSE,
},
},
];
const needUpdateTextRuns = coverTextRuns(updateTextRuns, removeTextRuns, UpdateDocsAttributeType.COVER);
expect(needUpdateTextRuns.length).toBe(5);
expect(needUpdateTextRuns[0]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[1]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[2]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[3]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[4]?.ts?.bl).toBe(BooleanNumber.TRUE);
});
it('it should be pass the test when the removeTextRuns and updateTextRuns are both scattered', async () => {
const updateTextRuns = [
{
st: 1,
ed: 3,
ts: {
fs: 28,
bl: BooleanNumber.TRUE,
},
},
{
st: 5,
ed: 10,
ts: {
fs: 23,
bl: BooleanNumber.FALSE,
},
},
];
const removeTextRuns = [
{
st: 2,
ed: 4,
ts: {
it: BooleanNumber.TRUE,
bl: BooleanNumber.FALSE,
},
},
{
st: 6,
ed: 8,
ts: {
it: BooleanNumber.FALSE,
bl: BooleanNumber.TRUE,
},
},
];
const needUpdateTextRuns = coverTextRuns(updateTextRuns, removeTextRuns, UpdateDocsAttributeType.COVER);
expect(needUpdateTextRuns.length).toBe(6);
expect(needUpdateTextRuns[0]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[1]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[2]?.ts?.bl).toBe(BooleanNumber.FALSE);
expect(needUpdateTextRuns[3]?.ts?.bl).toBe(BooleanNumber.FALSE);
expect(needUpdateTextRuns[4]?.ts?.bl).toBe(BooleanNumber.FALSE);
expect(needUpdateTextRuns[5]?.ts?.bl).toBe(BooleanNumber.FALSE);
});
it('it should be pass the test when the removeTextRuns and updateTextRuns are both scattered, and has no intersection', async () => {
const updateTextRuns = [
{
st: 1,
ed: 2,
ts: {
fs: 28,
bl: BooleanNumber.TRUE,
},
},
{
st: 5,
ed: 6,
ts: {
fs: 23,
bl: BooleanNumber.FALSE,
},
},
];
const removeTextRuns = [
{
st: 3,
ed: 4,
ts: {
it: BooleanNumber.TRUE,
bl: BooleanNumber.FALSE,
},
},
{
st: 7,
ed: 8,
ts: {
it: BooleanNumber.FALSE,
bl: BooleanNumber.TRUE,
},
},
];
const needUpdateTextRuns = coverTextRuns(updateTextRuns, removeTextRuns, UpdateDocsAttributeType.COVER);
expect(needUpdateTextRuns.length).toBe(4);
expect(needUpdateTextRuns[0]?.ts?.bl).toBe(BooleanNumber.TRUE);
expect(needUpdateTextRuns[1]?.ts?.bl).toBe(BooleanNumber.FALSE);
expect(needUpdateTextRuns[2]?.ts?.bl).toBe(BooleanNumber.FALSE);
expect(needUpdateTextRuns[3]?.ts?.bl).toBe(BooleanNumber.TRUE);
});
describe('test cases in function insertTextRuns', () => {
it('it should pass the case when the insertTextRuns is at the beginning of one testRun', async () => {
insertTextRuns(
body as IDocumentBody,
{
textRuns: updateTextRuns,
} as IDocumentBody,
10,
0
);
expect(body?.textRuns!.length).toBe(4);
});
it('it should pass the case when the insertTextRuns is between original testRuns', async () => {
insertTextRuns(
body as IDocumentBody,
{
textRuns: updateTextRuns,
} as IDocumentBody,
10,
25
);
expect(body?.textRuns!.length).toBe(4);
expect(body?.textRuns![0].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![1].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![2].ts?.bl).toBe(BooleanNumber.TRUE);
expect(body?.textRuns![3].ts?.bl).toBe(BooleanNumber.FALSE);
});
it('it should pass the case when the insertTextRuns is at the end of one testRun', async () => {
insertTextRuns(
body as IDocumentBody,
{
textRuns: updateTextRuns,
} as IDocumentBody,
10,
15
);
expect(body?.textRuns!.length).toBe(4);
});
it('it should pass the case when the insertTextRuns is at the in the middle of one testRun', async () => {
insertTextRuns(
body as IDocumentBody,
{
textRuns: updateTextRuns,
} as IDocumentBody,
10,
10
);
expect(body?.textRuns!.length).toBe(5);
});
it('it should pass the case when the insertTextRuns has the same style with the origin textRun, and should be merged', async () => {
const updateTextRuns = [
{
st: 0,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
},
},
];
insertTextRuns(
body as IDocumentBody,
{
textRuns: updateTextRuns,
} as IDocumentBody,
10,
10
);
expect(body?.textRuns!.length).toBe(3);
expect(body?.textRuns![0].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![1].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![2].ts?.bl).toBe(BooleanNumber.FALSE);
});
it('it should pass the case when the insertTextRuns is empty', async () => {
insertTextRuns(
body as IDocumentBody,
{
textRuns: [],
} as unknown as IDocumentBody,
10,
10
);
expect(body?.textRuns!.length).toBe(4);
expect(body?.textRuns![0].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![1].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![2].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![2].ts?.it).toBe(BooleanNumber.TRUE);
expect(body?.textRuns![3].ts?.bl).toBe(BooleanNumber.FALSE);
});
it('If textRuns doesn\'t intersect, they shouldn\'t be merged', async () => {
const updateTextRuns = [
{
st: 0,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
},
},
];
insertTextRuns(
body as IDocumentBody,
{
textRuns: updateTextRuns,
} as unknown as IDocumentBody,
10,
25
);
expect(body?.textRuns!.length).toBe(4);
expect(body?.textRuns![0].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![1].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![2].ts?.bl).toBe(BooleanNumber.FALSE);
expect(body?.textRuns![3].ts?.bl).toBe(BooleanNumber.FALSE);
});
});
describe('test cases in function deleteParagraphs', () => {
it('it should pass the case when the delete range has no paragraphs', async () => {
const removedParagraphs = deleteParagraphs(body!, 2, 0);
expect(removedParagraphs.length).toBe(0);
});
it('it should pass the case when the delete range has one paragraphs', async () => {
const removedParagraphs = deleteParagraphs(body!, 2, 5);
expect(removedParagraphs.length).toBe(1);
expect(removedParagraphs[0].startIndex).toBe(0);
});
});
});
});

View File

@@ -0,0 +1,815 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it, vi } from 'vitest';
import { Tools, UpdateDocsAttributeType } from '../../../../shared';
import { BooleanNumber, HorizontalAlign } from '../../../../types/enum';
import { CustomRangeType } from '../../../../types/interfaces';
import { PresetListType } from '../../preset-list-type';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
function getDefaultDoc() {
const doc: IDocumentBody = {
dataStream: 'w\r\n',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
return doc;
}
function getDefaultDocWithLength2() {
const doc: IDocumentBody = {
dataStream: 'ww\r\n',
textRuns: [
{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
return doc;
}
function getDefaultDocWithParagraph() {
const doc: IDocumentBody = {
dataStream: 'w\r\n',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
paragraphs: [
{
startIndex: 1,
paragraphId: 'para_fixture_1018',
},
],
};
return doc;
}
function getDefaultDocWithCustomRange() {
const doc: IDocumentBody = {
dataStream: 'helloworld\r\n',
textRuns: [],
customRanges: [{
startIndex: 0,
endIndex: 4,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}, {
startIndex: 5,
endIndex: 9,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.yahoo.com',
},
}],
paragraphs: [
{
startIndex: 10,
paragraphId: 'para_fixture_1019',
},
],
};
return doc;
}
describe('apply method', () => {
it('does not deep-clone plain typing and delete actions', () => {
const deepClone = vi.spyOn(Tools, 'deepClone');
const doc: IDocumentBody = { dataStream: 'A\r\n' };
try {
TextX.apply(doc, [
{ t: TextXActionType.RETAIN, len: 1 },
{ t: TextXActionType.INSERT, len: 1, body: { dataStream: 'B' } },
{ t: TextXActionType.DELETE, len: 1 },
]);
expect(doc.dataStream).toBe('AB\n');
expect(deepClone).not.toHaveBeenCalled();
} finally {
deepClone.mockRestore();
}
});
it('keeps structural insert action payloads immutable', () => {
const doc: IDocumentBody = {
dataStream: 'A\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'source' }],
};
const actions: TextXAction[] = [{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{
startIndex: 0,
paragraphId: 'inserted',
paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER },
}],
},
}];
const serializedActions = JSON.stringify(actions);
TextX.apply(doc, actions);
expect(JSON.stringify(actions)).toBe(serializedActions);
expect(doc.paragraphs?.[0]).not.toBe(actions[0].body?.paragraphs?.[0]);
});
it('preserves whole-entity metadata when inserting inside a custom range', () => {
const doc: IDocumentBody = {
dataStream: 'formula\r\n',
customRanges: [{
startIndex: 0,
endIndex: 6,
rangeId: 'formula-range',
rangeType: CustomRangeType.CUSTOM,
wholeEntity: true,
properties: { kind: 'formula' },
}],
};
TextX.apply(doc, [
{ t: TextXActionType.RETAIN, len: 3 },
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'X',
customRanges: [{
startIndex: 0,
endIndex: 0,
rangeId: 'formula-range',
rangeType: CustomRangeType.CUSTOM,
wholeEntity: true,
properties: { kind: 'formula' },
}],
},
},
]);
expect(doc.customRanges).toEqual([{
startIndex: 0,
endIndex: 7,
rangeId: 'formula-range',
rangeType: CustomRangeType.CUSTOM,
wholeEntity: true,
properties: { kind: 'formula' },
}]);
});
it('should get the same result when apply two actions by order OR composed first case 1', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
},
},
];
const doc1 = getDefaultDoc();
const doc2 = getDefaultDoc();
const doc3 = getDefaultDoc();
const doc4 = getDefaultDoc();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
// https://github.com/dream-num/univer-pro/issues/2943
it('should get the same result when apply two actions by order OR composed first case 2', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
},
},
];
const doc1 = getDefaultDoc();
const doc2 = getDefaultDoc();
const doc3 = getDefaultDoc();
const doc4 = getDefaultDoc();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when apply two actions by order OR composed first case 3', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 2,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
},
},
];
const doc1 = getDefaultDocWithLength2();
const doc2 = getDefaultDocWithLength2();
const doc3 = getDefaultDocWithLength2();
const doc4 = getDefaultDocWithLength2();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when set paragraph align type at 2 clients', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1020',
paragraphStyle: {
horizontalAlign: HorizontalAlign.LEFT,
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1020',
paragraphStyle: {
horizontalAlign: HorizontalAlign.RIGHT,
},
}],
},
},
];
const doc1 = getDefaultDocWithParagraph();
const doc2 = getDefaultDocWithParagraph();
const doc3 = getDefaultDocWithParagraph();
const doc4 = getDefaultDocWithParagraph();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when set paragraph align with REPLACE cover type at 2 clients', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1022',
paragraphStyle: {
horizontalAlign: HorizontalAlign.LEFT,
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1022',
paragraphStyle: {
horizontalAlign: HorizontalAlign.RIGHT,
},
}],
},
},
];
const doc1 = getDefaultDocWithParagraph();
const doc2 = getDefaultDocWithParagraph();
const doc3 = getDefaultDocWithParagraph();
const doc4 = getDefaultDocWithParagraph();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when set different list style with REPLACE cover type at 2 clients', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1024',
bullet: {
listId: 'J7FZTm',
listType: PresetListType.CHECK_LIST,
nestingLevel: 0,
textStyle: {
fs: 20,
},
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1024',
bullet: {
listId: 'uODEbf',
listType: PresetListType.BULLET_LIST,
nestingLevel: 0,
textStyle: {
fs: 20,
},
},
}],
},
},
];
const doc1 = getDefaultDocWithParagraph();
const doc2 = getDefaultDocWithParagraph();
const doc3 = getDefaultDocWithParagraph();
const doc4 = getDefaultDocWithParagraph();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when set different style at 2 clients', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: { fs: 9 },
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 2,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 2,
ts: {
cl: {
rgb: '#ee0000',
},
},
}],
},
},
];
const doc1 = getDefaultDocWithLength2();
const doc2 = getDefaultDocWithLength2();
const doc3 = getDefaultDocWithLength2();
const doc4 = getDefaultDocWithLength2();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when one is break line and the other is edit after the break point', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1026',
paragraphStyle: {
horizontalAlign: HorizontalAlign.LEFT,
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 2,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'X',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
},
];
const doc1 = getDefaultDocWithLength2();
const doc2 = getDefaultDocWithLength2();
const doc3 = getDefaultDocWithLength2();
const doc4 = getDefaultDocWithLength2();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
// console.log(JSON.stringify(resultA, null, 2));
// console.log(JSON.stringify(resultB, null, 2));
// console.log('composedAction1', JSON.stringify(composedAction1, null, 2));
// console.log('composedAction2', JSON.stringify(composedAction2, null, 2));
// console.log(JSON.stringify(resultC, null, 2));
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when one is delete actions and the other is insert actions', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.DELETE,
len: 2,
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 2,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '1',
},
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '1',
},
},
];
const doc1 = getDefaultDocWithLength2();
const doc2 = getDefaultDocWithLength2();
const doc3 = getDefaultDocWithLength2();
const doc4 = getDefaultDocWithLength2();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should get the same result when one is undo actions and the other is cancel link', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 5,
body: {
dataStream: '',
customRanges: [],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 5,
},
{
t: TextXActionType.RETAIN,
len: 5,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customDecorations: [],
customRanges: [],
},
},
];
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
// console.log(JSON.stringify(resultA, null, 2));
// console.log(JSON.stringify(resultB, null, 2));
// console.log('composedAction1', JSON.stringify(composedAction1, null, 2));
// console.log('composedAction2', JSON.stringify(composedAction2, null, 2));
// console.log(JSON.stringify(resultC, null, 2));
// console.log(JSON.stringify(resultD, null, 2));
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
});

View File

@@ -0,0 +1,189 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import { DocumentBlockRangeType } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { DataStreamTreeTokenType } from '../../types';
import { deleteBlockRanges, insertBlockRanges } from '../apply-utils/common';
import { BuildTextUtils } from '../build-utils';
import { getPlainText } from '../build-utils/parse';
import { validateDocBodyStructure } from '../structure-validator';
import { TextX } from '../text-x';
import { getBodySlice, getBodySliceForTextXAction, getParagraphsSlice, SliceBodyType } from '../utils';
describe('document block ranges', () => {
it('keeps a collapsed deletion as a no-op during structural normalization', () => {
const body: IDocumentBody = {
dataStream: 'A\r\n',
paragraphs: [{ paragraphId: 'paragraph-1', startIndex: 1 }],
sectionBreaks: [{ sectionId: 'section-1', startIndex: 2 }],
};
const actions = BuildTextUtils.selection.delete([
{ startOffset: 1, endOffset: 1, collapsed: true },
], body);
TextX.apply(body, actions);
expect(body.dataStream).toBe('A\r\n');
});
it('moves following block ranges when text is inserted before them', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
blockRanges: [{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 2, endIndex: 3 }],
};
insertBlockRanges(body, { dataStream: 'XX' }, 2, 1);
expect(body.blockRanges).toEqual([
{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 4, endIndex: 5 },
]);
});
it('expands containing block ranges when text is inserted inside them', () => {
const body: IDocumentBody = {
dataStream: `${DataStreamTreeTokenType.BLOCK_START}A\r${DataStreamTreeTokenType.BLOCK_END}\n`,
blockRanges: [{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 0, endIndex: 3 }],
};
insertBlockRanges(body, { dataStream: 'XX' }, 2, 2);
expect(body.blockRanges).toEqual([
{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 0, endIndex: 5 },
]);
});
it('cuts block ranges to the copied body with relative indexes', () => {
const body: IDocumentBody = {
dataStream: `A\r${DataStreamTreeTokenType.BLOCK_START}B\r${DataStreamTreeTokenType.BLOCK_END}C\r\n`,
blockRanges: [{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 2, endIndex: 5 }],
};
const slice = getBodySlice(body, 2, 6);
expect(slice.dataStream).toBe(`${DataStreamTreeTokenType.BLOCK_START}B\r${DataStreamTreeTokenType.BLOCK_END}`);
expect(slice.blockRanges).toEqual([
{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 0, endIndex: 3 },
]);
});
it('does not include partial block ranges in action bodies', () => {
const body: IDocumentBody = {
dataStream: `A\r${DataStreamTreeTokenType.BLOCK_START}B\r${DataStreamTreeTokenType.BLOCK_END}C\r\n`,
blockRanges: [{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 2, endIndex: 5 }],
};
const slice = getBodySliceForTextXAction(body, 3, 5, false);
expect(slice.dataStream).toBe('B\r');
expect(slice.blockRanges).toBeUndefined();
});
it('does not treat a block end sentinel as text in the following paragraph', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_END}B${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'inside-block' },
{ startIndex: 5, paragraphId: 'after-block' },
],
};
expect(getParagraphsSlice(body, 3, 4, SliceBodyType.copy)).toBeUndefined();
expect(getParagraphsSlice(body, 4, 5, SliceBodyType.copy)).toEqual([{
startIndex: 1,
paragraphId: 'after-block',
}]);
});
it('removes fully deleted block ranges', () => {
const body: IDocumentBody = {
dataStream: `${DataStreamTreeTokenType.BLOCK_START}A\r${DataStreamTreeTokenType.BLOCK_END}\n`,
blockRanges: [{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 0, endIndex: 3 }],
};
const removed = deleteBlockRanges(body, 4, 0);
expect(removed).toEqual([{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 0, endIndex: 3 }]);
expect(body.blockRanges).toEqual([]);
});
it('keeps block sentinels and a paragraph when a deletion crosses one boundary', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_END}B${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'inside-block' },
{ startIndex: 5, paragraphId: 'after-block' },
],
sectionBreaks: [{ sectionId: 'section_fixture_5', startIndex: 6 }],
blockRanges: [{
blockId: 'code-1',
blockType: DocumentBlockRangeType.CODE,
startIndex: 0,
endIndex: 3,
}],
};
const actions = BuildTextUtils.selection.delete([
{ startOffset: 1, endOffset: 5, collapsed: false },
], body);
TextX.apply(body, actions);
expect(body.dataStream).toBe(`${T.BLOCK_START}${T.PARAGRAPH}${T.BLOCK_END}${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.blockRanges).toEqual([{
blockId: 'code-1',
blockType: DocumentBlockRangeType.CODE,
startIndex: 0,
endIndex: 2,
}]);
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('allows deleting a fully selected block atomically', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_END}B${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'inside-block' },
{ startIndex: 5, paragraphId: 'after-block' },
],
sectionBreaks: [{ sectionId: 'section_fixture_6', startIndex: 6 }],
blockRanges: [{
blockId: 'code-1',
blockType: DocumentBlockRangeType.CODE,
startIndex: 0,
endIndex: 3,
}],
};
const actions = BuildTextUtils.selection.delete([
{ startOffset: 0, endOffset: 4, collapsed: false },
], body);
TextX.apply(body, actions);
expect(body.dataStream).toBe(`B${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.blockRanges).toEqual([]);
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('removes block tokens from plain text', () => {
const stream = `${DataStreamTreeTokenType.BLOCK_START}Callout\r${DataStreamTreeTokenType.BLOCK_END}\n`;
expect(getPlainText(stream)).toBe('Callout');
});
});

View File

@@ -0,0 +1,336 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { DocumentBlockRangeType } from '../../../../types/interfaces';
import { DataStreamTreeTokenType } from '../../types';
import { validateDocBodyStructure, validateDocumentStructure } from '../structure-validator';
describe('validateDocBodyStructure', () => {
it('accepts a valid plain document body', () => {
const body: IDocumentBody = {
dataStream: `A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'para-1' }],
sectionBreaks: [{ sectionId: 'section_fixture_7', startIndex: 2 }],
};
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('reports root bodies without a minimum paragraph and section pair', () => {
const body: IDocumentBody = {
dataStream: 'A',
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toEqual([
'missing-root-paragraph',
'missing-root-section-break',
]);
});
it('reports paragraph and section metadata that do not point to matching tokens', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 4, paragraphId: 'bad-para' }],
sectionBreaks: [{ sectionId: 'section_fixture_8', startIndex: 5 }],
columnGroups: [{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toEqual([
'paragraph-token-mismatch',
'section-break-token-mismatch',
]);
});
it('reports duplicate paragraph and section metadata', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.PARAGRAPH}B${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 1, paragraphId: 'first' },
{ startIndex: 1, paragraphId: 'duplicate-first' },
],
sectionBreaks: [
{ sectionId: 'section_duplicate_a', startIndex: 4 },
{ sectionId: 'section_duplicate_b', startIndex: 4 },
],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toEqual([
'duplicate-paragraph-metadata',
'duplicate-section-break-metadata',
]);
});
it('reports columns that have no paragraph or section child', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 5, paragraphId: 'root' }],
sectionBreaks: [{ sectionId: 'section_fixture_11', startIndex: 6 }],
columnGroups: [{ startIndex: 0, endIndex: 4, columnGroupId: 'cg-1' }],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('empty-column');
});
it('reports table cells that have no section child', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}A${T.PARAGRAPH}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 4, paragraphId: 'cell' },
{ startIndex: 8, paragraphId: 'after-table' },
],
sectionBreaks: [{ sectionId: 'section_fixture_12', startIndex: 9 }],
tables: [{ startIndex: 0, endIndex: 8, tableId: 'table-1' }],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('empty-table-cell');
});
it('keeps table metadata anchored to the table end token when the following paragraph has text', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}A${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const body: IDocumentBody = {
dataStream: `${tableStream}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 4, paragraphId: 'cell' },
{ startIndex: tableStream.length + 5, paragraphId: 'after-table' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_13', startIndex: 5 },
{ sectionId: 'section_fixture_14', startIndex: tableStream.length + 6 },
],
tables: [{ startIndex: 0, endIndex: tableStream.length, tableId: 'table-1' }],
};
expect(validateDocBodyStructure(body)).toEqual([]);
body.tables![0].endIndex = tableStream.length + 5;
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('table-end-token-mismatch');
});
it('reports table token ranges without metadata', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
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}`,
paragraphs: [
{ startIndex: 3, paragraphId: 'cell' },
{ startIndex: 8, paragraphId: 'after-table' },
],
sectionBreaks: [
{ sectionId: 'section_cell', startIndex: 4 },
{ sectionId: 'section_root', startIndex: 9 },
],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('missing-table-metadata');
});
it('reports block metadata that points to another block end sentinel', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_START}${T.BLOCK_END}${T.PARAGRAPH}${T.BLOCK_END}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'outer' },
{ startIndex: 5, paragraphId: 'inner' },
],
sectionBreaks: [{ sectionId: 'section_fixture_17', startIndex: 7 }],
blockRanges: [
{ startIndex: 0, endIndex: 6, blockId: 'outer', blockType: DocumentBlockRangeType.CODE },
{ startIndex: 3, endIndex: 6, blockId: 'inner', blockType: DocumentBlockRangeType.CALLOUT },
],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toEqual([
'block-range-token-mismatch',
'overlapping-block-range',
]);
});
it('accepts adjacent block ranges', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_END}${T.BLOCK_START}${T.PARAGRAPH}${T.BLOCK_END}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'first' },
{ startIndex: 5, paragraphId: 'second' },
],
sectionBreaks: [{ sectionId: 'section_fixture_18', startIndex: 7 }],
blockRanges: [
{ startIndex: 0, endIndex: 3, blockId: 'first', blockType: DocumentBlockRangeType.CODE },
{ startIndex: 4, endIndex: 6, blockId: 'second', blockType: DocumentBlockRangeType.CALLOUT },
],
};
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('reports block token ranges without metadata and unbalanced block tokens', () => {
const T = DataStreamTreeTokenType;
const missingMetadata: IDocumentBody = {
dataStream: `${T.BLOCK_START}${T.PARAGRAPH}${T.BLOCK_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 1, paragraphId: 'inside' },
{ startIndex: 3, paragraphId: 'after' },
],
sectionBreaks: [{ sectionId: 'section_fixture_19', startIndex: 4 }],
};
const unbalanced: IDocumentBody = {
dataStream: `${T.BLOCK_START}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'inside' }],
sectionBreaks: [{ sectionId: 'section_fixture_20', startIndex: 2 }],
};
expect(validateDocBodyStructure(missingMetadata).map((issue) => issue.code)).toContain('missing-block-range-metadata');
expect(validateDocBodyStructure(unbalanced).map((issue) => issue.code)).toContain('unbalanced-block');
});
it('validates column group pairing, metadata presence, overlap, and column count', () => {
const T = DataStreamTreeTokenType;
const dataStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`;
const baseBody: IDocumentBody = {
dataStream,
paragraphs: [
{ startIndex: 2, paragraphId: 'left' },
{ startIndex: 5, paragraphId: 'right' },
],
sectionBreaks: [{ sectionId: 'section_fixture_21', startIndex: 8 }],
};
expect(validateDocBodyStructure(baseBody).map((issue) => issue.code)).toContain('missing-column-group-metadata');
const badCount: IDocumentBody = {
...baseBody,
columnGroups: [{
startIndex: 0,
endIndex: 7,
columnGroupId: 'columns-1',
columns: [{ columnId: 'left', widthRatio: 1 }],
}],
};
expect(validateDocBodyStructure(badCount).map((issue) => issue.code)).toContain('column-group-column-count-mismatch');
const overlapping: IDocumentBody = {
...baseBody,
columnGroups: [
{ startIndex: 0, endIndex: 7, columnGroupId: 'columns-1' },
{ startIndex: 0, endIndex: 7, columnGroupId: 'columns-2' },
],
};
expect(validateDocBodyStructure(overlapping).map((issue) => issue.code)).toContain('overlapping-column-group');
const exclusiveEnd: IDocumentBody = {
...baseBody,
columnGroups: [{
startIndex: 0,
endIndex: 8,
columnGroupId: 'columns-exclusive',
columns: [
{ columnId: 'left', widthRatio: 1 },
{ columnId: 'right', widthRatio: 1 },
],
}],
};
expect(validateDocBodyStructure(exclusiveEnd).map((issue) => issue.code)).toContain('column-group-range-token-mismatch');
});
it('requires custom block metadata to point at exactly one custom block sentinel', () => {
const T = DataStreamTreeTokenType;
const valid: IDocumentBody = {
dataStream: `${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'after-custom-block' }],
sectionBreaks: [{ sectionId: 'section_fixture_22', startIndex: 2 }],
customBlocks: [{ startIndex: 0, blockId: 'drawing-1' }],
};
expect(validateDocBodyStructure(valid)).toEqual([]);
expect(validateDocBodyStructure({ ...valid, customBlocks: [] }).map((issue) => issue.code)).toContain('missing-custom-block-metadata');
expect(validateDocBodyStructure({
...valid,
customBlocks: [
{ startIndex: 0, blockId: 'drawing-1' },
{ startIndex: 0, blockId: 'drawing-2' },
],
}).map((issue) => issue.code)).toContain('duplicate-custom-block-metadata');
expect(validateDocBodyStructure({
...valid,
dataStream: `${T.BLOCK_START}${T.PARAGRAPH}${T.SECTION_BREAK}`,
}).map((issue) => issue.code)).toContain('custom-block-token-mismatch');
});
it('accepts DOCX raw custom-block metadata as the owner of its sentinel', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'after-raw-block' }],
sectionBreaks: [{ sectionId: 'section_raw_block', startIndex: 2 }],
docxRawCustomBlocks: [{
startIndex: 0,
blockId: 'raw-0',
docxRawXml: '<w:r><w:footnoteReference w:id="1"/></w:r>',
}],
};
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('reports unbalanced structural tokens', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 3, paragraphId: 'para-1' }],
sectionBreaks: [{ sectionId: 'section_fixture_23', startIndex: 5 }],
};
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toContain('unbalanced-column-group');
});
it('validates header and footer bodies with segment diagnostics', () => {
const T = DataStreamTreeTokenType;
const issues = validateDocumentStructure({
body: {
dataStream: `${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 0, paragraphId: 'body' }],
sectionBreaks: [{ sectionId: 'section_fixture_24', startIndex: 1 }],
},
headers: {
'header-1': {
headerId: 'header-1',
body: { dataStream: 'bad-header' },
},
},
footers: {
'footer-1': {
footerId: 'footer-1',
body: {
dataStream: `${T.PARAGRAPH}bad-footer`,
paragraphs: [{ startIndex: 0, paragraphId: 'footer' }],
},
},
},
});
expect(issues.map((issue) => `${issue.segmentType}:${issue.segmentId}:${issue.code}`)).toEqual([
'header:header-1:missing-root-paragraph',
'header:header-1:missing-root-section-break',
'footer:footer-1:missing-root-section-break',
]);
});
});

View File

@@ -0,0 +1,267 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { DataStreamTreeTokenType } from '../../types';
import { TextXActionType } from '../action-types';
import { BuildTextUtils } from '../build-utils';
import { validateDocBodyStructure } from '../structure-validator';
import { TextX } from '../text-x';
import { getBodySliceForTextXAction } from '../utils';
describe('TextX column groups', () => {
it('creates missing paragraph and section-break collections from inserted column metadata', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}${T.COLUMN_END}${T.COLUMN_GROUP_END}`,
columnGroups: [{ startIndex: 0, endIndex: 3, columnGroupId: 'cg-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 2 },
{
t: TextXActionType.INSERT,
len: 2,
body: {
dataStream: `${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 0, paragraphId: 'inserted' }],
sectionBreaks: [{ sectionId: 'section_fixture_25', startIndex: 1 }],
},
},
]);
expect(body.paragraphs).toEqual([{
startIndex: 2,
paragraphId: expect.any(String),
}]);
expect(body.sectionBreaks).toEqual([{ sectionId: expect.any(String), startIndex: 3 }]);
});
it('adds inserted column group metadata to documents without existing column groups', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'before' }],
sectionBreaks: [{ sectionId: 'section_fixture_26', startIndex: 2 }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 2 },
{
t: TextXActionType.INSERT,
len: 5,
body: {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}`,
paragraphs: [{ startIndex: 2, paragraphId: 'column' }],
columnGroups: [{ startIndex: 0, endIndex: 4, columnGroupId: 'cg-1' }],
},
},
]);
expect(body.columnGroups).toEqual([{ startIndex: 2, endIndex: 6, columnGroupId: 'cg-1' }]);
});
it('expands a containing column group when text is inserted into a column', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'left' },
{ startIndex: 5, paragraphId: 'right' },
],
sectionBreaks: [{ sectionId: 'section_fixture_27', startIndex: 8 }],
columnGroups: [{ startIndex: 0, endIndex: 7, columnGroupId: 'cg-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 2 },
{ t: TextXActionType.INSERT, body: { dataStream: 'Alpha' }, len: 5 },
]);
expect(body.dataStream).toBe(`${T.COLUMN_GROUP_START}${T.COLUMN_START}Alpha${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([7, 10]);
expect(body.sectionBreaks?.[0].startIndex).toBe(13);
expect(body.columnGroups).toEqual([{ startIndex: 0, endIndex: 12, columnGroupId: 'cg-1' }]);
});
it('keeps a column group wrapped when text is inserted before the closing boundary token', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}B${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 3, paragraphId: 'left' },
{ startIndex: 7, paragraphId: 'right' },
{ startIndex: 9, paragraphId: 'after' },
],
sectionBreaks: [{ sectionId: 'section_fixture_28', startIndex: 10 }],
columnGroups: [{ startIndex: 0, endIndex: 9, columnGroupId: 'cg-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 9 },
{ t: TextXActionType.INSERT, body: { dataStream: T.PARAGRAPH, paragraphs: [{ startIndex: 0, paragraphId: 'inserted' }] }, len: 1 },
]);
expect(body.dataStream).toBe(`${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}B${T.PARAGRAPH}${T.COLUMN_END}${T.PARAGRAPH}${T.COLUMN_GROUP_END}${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.columnGroups).toEqual([{ startIndex: 0, endIndex: 10, columnGroupId: 'cg-1' }]);
});
it('does not expand a column group when text is inserted after the closing boundary token', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}Z${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 3, paragraphId: 'left' },
{ startIndex: 6, paragraphId: 'after' },
],
sectionBreaks: [{ sectionId: 'section_fixture_29', startIndex: 7 }],
columnGroups: [{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 6 },
{ t: TextXActionType.INSERT, body: { dataStream: 'X' }, len: 1 },
]);
expect(body.dataStream).toBe(`${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}XZ${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.columnGroups).toEqual([{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }]);
});
it('returns deleted column groups in delete undo bodies', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 3, paragraphId: 'left' }],
sectionBreaks: [{ sectionId: 'section_fixture_30', startIndex: 5 }],
columnGroups: [{ startIndex: 0, endIndex: 4, columnGroupId: 'cg-1' }],
};
const actions: TextXAction[] = [
{ t: TextXActionType.DELETE, len: 5 },
];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.columnGroups).toEqual([]);
expect(actions[0].body?.columnGroups).toEqual([{ startIndex: 0, endIndex: 4, columnGroupId: 'cg-1' }]);
});
it('does not include partial column groups in action bodies', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 3, paragraphId: 'left' }],
sectionBreaks: [{ sectionId: 'section_fixture_31', startIndex: 5 }],
columnGroups: [{ startIndex: 0, endIndex: 4, columnGroupId: 'cg-1' }],
};
const slice = getBodySliceForTextXAction(body, 2, 4, false);
expect(slice.dataStream).toBe(`A${T.PARAGRAPH}`);
expect(slice.columnGroups).toBeUndefined();
});
it('does not carry column-group metadata without a start token into undo bodies', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 3, paragraphId: 'left' }],
sectionBreaks: [{ sectionId: 'section_fixture_31b', startIndex: 6 }],
columnGroups: [
{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' },
{ startIndex: 5, endIndex: 5, columnGroupId: 'cg-1' },
],
};
const slice = getBodySliceForTextXAction(body, 0, 6, false);
expect(slice.columnGroups).toEqual([{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }]);
});
it('keeps a minimum paragraph when replacing all text in a column', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 3, paragraphId: 'left' }],
sectionBreaks: [{ sectionId: 'section_fixture_32', startIndex: 6 }],
columnGroups: [{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }],
};
const actions = BuildTextUtils.selection.delete(
[{ startOffset: 2, endOffset: 4, collapsed: false }],
body,
0,
{ dataStream: 'X' }
);
TextX.apply(body, actions);
expect(body.dataStream).toBe(`${T.COLUMN_GROUP_START}${T.COLUMN_START}X${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([3]);
expect(body.columnGroups).toEqual([{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }]);
});
it('allows deleting a paragraph sentinel when another column paragraph remains', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}B${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 3, paragraphId: 'first' },
{ startIndex: 5, paragraphId: 'second' },
],
sectionBreaks: [{ sectionId: 'section_fixture_33', startIndex: 8 }],
columnGroups: [{ startIndex: 0, endIndex: 7, columnGroupId: 'cg-1' }],
};
const actions = BuildTextUtils.selection.delete(
[{ startOffset: 4, endOffset: 6, collapsed: false }],
body
);
TextX.apply(body, actions);
expect(body.dataStream).toBe(`${T.COLUMN_GROUP_START}${T.COLUMN_START}A${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`);
expect(body.columnGroups).toEqual([{ startIndex: 0, endIndex: 5, columnGroupId: 'cg-1' }]);
});
it('keeps column boundary tokens balanced when deleting across a column edge', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.COLUMN_GROUP_START}${T.COLUMN_START}Alpha${T.PARAGRAPH}${T.SECTION_BREAK}${T.COLUMN_END}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'left' },
{ startIndex: 11, paragraphId: 'right' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_34', startIndex: 8 },
{ sectionId: 'section_fixture_35', startIndex: 14 },
],
columnGroups: [{ startIndex: 0, endIndex: 13, columnGroupId: 'cg-1' }],
};
const actions = BuildTextUtils.selection.delete(
[{ startOffset: 4, endOffset: 10, collapsed: false }],
body
);
TextX.apply(body, actions);
expect(validateDocBodyStructure(body)).toEqual([]);
expect(body.dataStream).toBe(`${T.COLUMN_GROUP_START}${T.COLUMN_START}Al${T.SECTION_BREAK}${T.COLUMN_END}${T.COLUMN_START}${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`);
});
});

View File

@@ -0,0 +1,71 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ITextRun } from '../../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { normalizeTextRuns } from '../apply-utils/common';
describe('common utils test cases', () => {
describe('normalizeTextRuns', () => {
it('should normalize text runs', () => {
const textRuns: ITextRun[] = [
{
st: 0,
ed: 0,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 0,
ed: 15,
sId: undefined,
ts: {},
},
{
st: 15,
ed: 30,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
{
st: 30,
ed: 35,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
];
const expectedTextRuns: ITextRun[] = [
{
st: 15,
ed: 35,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
];
expect(normalizeTextRuns(textRuns)).toEqual(expectedTextRuns);
});
});
});

View File

@@ -0,0 +1,458 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
describe('compose test cases', () => {
it('should return empty array when action A and action B are both empty', () => {
expect(TextX.compose([], [])).toEqual([]);
});
it('should return second actions when action A is empty', () => {
const actions_a: TextXAction[] = [];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'w',
customRanges: [],
customDecorations: [],
},
len: 5,
}];
const expect_actions = actions_b;
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('should return first actions when action B is empty', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'w',
customRanges: [],
customDecorations: [],
},
len: 5,
}];
const actions_b: TextXAction[] = [];
const expect_actions = actions_a;
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose basic use', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.DELETE,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 8,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 10,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose delete + delete', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 8,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 10,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose delete + retain', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 8,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 2,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose delete + insert', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}];
// Always put insert before delete.
const expect_actions: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}, {
t: TextXActionType.DELETE,
len: 2,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose retain + retain', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 8,
}];
const expect_actions: TextXAction[] = [];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose retain + insert', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose retain + delete', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 2,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 1,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 1,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose insert + retain', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose insert + delete', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.DELETE,
len: 1,
}];
const expect_actions: TextXAction[] = [];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose retain + retain with style', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
it: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose insert + retain with style', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose retain with no body + retain with style', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
},
}],
},
len: 1,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(expect_actions);
});
it('test compose r-i with r-i-d', () => {
const actions_a: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'h',
},
len: 1,
}];
const actions_b: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'ab',
},
len: 2,
}, {
t: TextXActionType.DELETE,
len: 1,
}];
const inner_actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'ab',
},
len: 2,
}];
expect(TextX.compose(actions_a, actions_b)).toEqual(inner_actions);
const actions_c: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'c',
},
len: 1,
}, {
t: TextXActionType.DELETE,
len: 2,
}];
const expect_actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
body: {
dataStream: 'c',
},
len: 1,
}];
expect(TextX.compose(inner_actions, actions_c)).toEqual(expect_actions);
});
});

View File

@@ -0,0 +1,132 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../../../types/interfaces';
import { deleteCustomRanges } from '../apply-utils/common';
function getBodyWithCustomRanges() {
const body: IDocumentBody = {
dataStream: '人之初\r\n',
customRanges: [{
startIndex: 1,
endIndex: 2,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}],
};
return body;
}
describe('delete apply consistency', () => {
it('should pass the test case when delete start part custom range', () => {
const body = getBodyWithCustomRanges();
deleteCustomRanges(body, 2, 0);
expect(body.customRanges).toEqual([{
startIndex: 0,
endIndex: 0,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}]);
});
it('should pass the test case when delete end part custom range', () => {
const body = getBodyWithCustomRanges();
deleteCustomRanges(body, 2, 2);
expect(body.customRanges).toEqual([{
startIndex: 1,
endIndex: 1,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}]);
});
it('should pass the test case when delete all custom range', () => {
const body = getBodyWithCustomRanges();
deleteCustomRanges(body, 2, 1);
expect(body.customRanges).toEqual([]);
});
it('should pass the test case when delete middle part custom range', () => {
const body: IDocumentBody = {
dataStream: '人之初,性本善\r\n',
customRanges: [{
startIndex: 1,
endIndex: 6,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}],
};
deleteCustomRanges(body, 3, 2);
expect(body.customRanges).toEqual([{
startIndex: 1,
endIndex: 3,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}]);
});
it('should pass the test case when delete before custom range', () => {
const body = getBodyWithCustomRanges();
deleteCustomRanges(body, 1, 0);
expect(body.customRanges).toEqual([{
startIndex: 0,
endIndex: 1,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}]);
});
it('should pass the test case when delete after custom range', () => {
const body = getBodyWithCustomRanges();
deleteCustomRanges(body, 1, 3);
expect(body.customRanges).toEqual([{
startIndex: 1,
endIndex: 2,
rangeId: 'rangeId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'http://www.baidu.com',
},
}]);
});
});

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 { IDocumentBody } from '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { DataStreamTreeTokenType } from '../../types';
import { TextXActionType } from '../action-types';
import { validateDocBodyStructure } from '../structure-validator';
import { TextX } from '../text-x';
describe('DOCX raw metadata', () => {
it('moves raw custom-block offsets through insertion and undo', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.PARAGRAPH}${T.CUSTOM_BLOCK}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 1, paragraphId: 'before-raw-block' },
{ startIndex: 3, paragraphId: 'after-raw-block' },
],
sectionBreaks: [{ sectionId: 'section_docx_raw', startIndex: 4 }],
docxRawCustomBlocks: [{
startIndex: 2,
blockId: 'raw-2',
docxRawXml: '<w:r><w:footnoteReference w:id="1"/></w:r>',
}],
docxRawBlocks: [{ startIndex: 2, xml: '<w:sdt/>' }],
docxExportExcludedRanges: [{ start: 2, end: 4 }],
};
const original = structuredClone(body);
const actions: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 1 },
{ t: TextXActionType.INSERT, len: 1, body: { dataStream: 'B' } },
];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.docxRawCustomBlocks?.[0].startIndex).toBe(3);
expect(body.docxRawBlocks?.[0].startIndex).toBe(3);
expect(body.docxExportExcludedRanges).toEqual([{ start: 3, end: 5 }]);
expect(validateDocBodyStructure(body)).toEqual([]);
TextX.apply(body, TextX.invert(actions));
expect(body.dataStream).toBe(original.dataStream);
expect(body.paragraphs).toEqual(original.paragraphs);
expect(body.sectionBreaks).toEqual(original.sectionBreaks);
expect(body.docxRawCustomBlocks).toEqual(original.docxRawCustomBlocks);
expect(body.docxRawBlocks).toEqual(original.docxRawBlocks);
expect(body.docxExportExcludedRanges).toEqual(original.docxExportExcludedRanges);
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('restores raw metadata after deleting and undoing its range', () => {
const body: IDocumentBody = {
dataStream: 'ABCDE',
docxRawBlocks: [{ startIndex: 2, xml: '<w:sdt/>' }],
docxExportExcludedRanges: [{ start: 1, end: 4 }],
};
const actions: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 1 },
{ t: TextXActionType.DELETE, len: 3 },
];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.docxRawBlocks).toEqual([]);
expect(body.docxExportExcludedRanges).toEqual([]);
TextX.apply(body, TextX.invert(actions));
expect(body.docxRawBlocks).toEqual([{ startIndex: 2, xml: '<w:sdt/>' }]);
expect(body.docxExportExcludedRanges).toEqual([{ start: 1, end: 4 }]);
});
});

View File

@@ -0,0 +1,241 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared/command-enum';
import { BooleanNumber } from '../../../../types/enum';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
describe('test TextX static methods invert and makeInvertible', () => {
it('test TextX static method invert', () => {
const actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.INSERT,
len: 5,
body: {
dataStream: 'hello',
},
}, {
t: TextXActionType.DELETE,
len: 5,
body: {
dataStream: 'world',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
},
}, {
t: TextXActionType.RETAIN,
len: 2,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 2,
ts: {
it: BooleanNumber.TRUE,
},
},
],
},
oldBody: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
},
}];
const invertedActions = TextX.invert(actions);
const exceptActions = [{
t: TextXActionType.RETAIN,
len: 5,
}, {
t: TextXActionType.DELETE,
len: 5,
body: {
dataStream: 'hello',
},
}, {
t: TextXActionType.INSERT,
len: 5,
body: {
dataStream: 'world',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
},
}, {
t: TextXActionType.RETAIN,
len: 2,
coverType: UpdateDocsAttributeType.REPLACE,
oldBody: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 2,
ts: {
it: BooleanNumber.TRUE,
},
},
],
},
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
},
}];
expect(invertedActions).toEqual(exceptActions);
});
it('test TextX static method makeInvertible when `retain` action occur', () => {
const docBody: IDocumentBody = {
dataStream: 'hello\r\n',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
const textX = new TextX();
textX.retain(3, {
dataStream: '',
textRuns: [{
st: 0,
ed: 3,
ts: {
it: BooleanNumber.TRUE,
},
}],
}, UpdateDocsAttributeType.COVER);
TextX.makeInvertible(textX.serialize(), docBody);
const expectedActions = [{
t: TextXActionType.RETAIN,
len: 3,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 3,
ts: {
it: BooleanNumber.TRUE,
},
}],
},
oldBody: {
dataStream: '',
paragraphs: undefined,
sectionBreaks: [],
textRuns: [{
st: 0,
ed: 3,
ts: {
bl: BooleanNumber.TRUE,
},
}],
customRanges: [],
customDecorations: [],
customBlocks: undefined,
},
coverType: UpdateDocsAttributeType.COVER,
}];
expect(textX.serialize()).toEqual(expectedActions);
});
it('test TextX static method makeInvertible when `delete` action occur', () => {
const docBody: IDocumentBody = {
dataStream: 'hello\r\n',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
const textX = new TextX();
textX.retain(3);
textX.delete(2);
TextX.makeInvertible(textX.serialize(), docBody);
const expectedActions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 3,
}, {
t: TextXActionType.DELETE,
len: 2,
body: {
dataStream: 'lo',
textRuns: [{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
}];
expect(textX.serialize()).toEqual(expectedActions);
});
});

View File

@@ -0,0 +1,486 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import type { TextXAction } from '../../index';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { DataStreamTreeTokenType } from '../../types';
import { PRESERVE_INSERTED_PARAGRAPH_IDS, TextXActionType } from '../action-types';
import { TextX } from '../text-x';
const PARAGRAPH_ID_PATTERN = /^para_.+/;
describe('TextX paragraph ids', () => {
it('does not treat a structural gap as part of the following paragraph', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_END}${T.BLOCK_START}B${T.PARAGRAPH}${T.BLOCK_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 2, paragraphId: 'para_left' },
{ startIndex: 6, paragraphId: 'para_right', paragraphStyle: { indentStart: { v: 60 } } },
{ startIndex: 8, paragraphId: 'para_trailing' },
],
};
const actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 4,
}, {
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: T.PARAGRAPH,
paragraphs: [{ startIndex: 0, paragraphId: 'para_inserted' }],
},
}];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.paragraphs?.find((paragraph) => paragraph.paragraphId === 'para_right')).toEqual({
startIndex: 7,
paragraphId: 'para_right',
paragraphStyle: { indentStart: { v: 60 } },
});
expect(body.paragraphs?.find((paragraph) => paragraph.startIndex === 4)?.paragraphId).not.toBe('para_right');
});
it('preserves paragraph ids when inserting text before paragraphs', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
};
TextX.apply(body, [{
t: TextXActionType.INSERT,
len: 1,
body: { dataStream: 'X' },
}]);
expect(body.paragraphs).toEqual([
{ startIndex: 2, paragraphId: 'para_first' },
{ startIndex: 4, paragraphId: 'para_second' },
]);
});
it('preserves inserted paragraph ids through invert', () => {
const textX = new TextX();
textX.insert(2, {
dataStream: 'A\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_inserted' }],
});
const invertedActions = TextX.invert(textX.serialize());
expect(invertedActions).toEqual([{
t: TextXActionType.DELETE,
len: 2,
body: {
dataStream: 'A\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_inserted' }],
},
}]);
});
it('generates paragraph ids for inserted builder bodies when making them invertible', () => {
const textX = new TextX();
textX.insert(2, {
dataStream: 'A\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_fixture_11' }],
});
const actions = textX.serialize();
TextX.makeInvertible(actions, {
dataStream: '\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_existing' }],
});
const insertParagraphId = actions[0].body?.paragraphs?.[0].paragraphId;
const invertedParagraphId = TextX.invert(actions)[0].body?.paragraphs?.[0].paragraphId;
expect(insertParagraphId).toMatch(PARAGRAPH_ID_PATTERN);
expect(invertedParagraphId).toBe(insertParagraphId);
});
it('generates paragraph ids for inserted bodies that omit them', () => {
const body: IDocumentBody = {
dataStream: '\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_existing' }],
};
TextX.apply(body, [{
t: TextXActionType.INSERT,
len: 2,
body: {
dataStream: 'A\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_fixture_12' }],
},
}]);
expect(body.paragraphs?.[0]).toMatchObject({ startIndex: 1 });
expect(body.paragraphs?.[0].paragraphId).toMatch(PARAGRAPH_ID_PATTERN);
expect(body.paragraphs?.[0].paragraphId).not.toBe('para_existing');
expect(body.paragraphs?.[1]).toEqual({ startIndex: 2, paragraphId: 'para_existing' });
});
it('keeps original paragraph id and gives split paragraph a new id', () => {
const body: IDocumentBody = {
dataStream: 'AB\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'para_original' }],
};
TextX.apply(body, [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_13' }],
},
}]);
expect(body.paragraphs?.[0]).toEqual({ startIndex: 1, paragraphId: 'para_original' });
expect(body.paragraphs?.[1].startIndex).toBe(3);
expect(body.paragraphs?.[1].paragraphId).toMatch(PARAGRAPH_ID_PATTERN);
expect(body.paragraphs?.[1].paragraphId).not.toBe('para_original');
});
it('keeps original paragraph id and gives explicit inserted id to split paragraph', () => {
const body: IDocumentBody = {
dataStream: 'AB\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'para_original' }],
};
TextX.apply(body, [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{ startIndex: 0, paragraphId: 'para_explicit' }],
},
}]);
expect(body.paragraphs).toEqual([
{ startIndex: 1, paragraphId: 'para_original' },
{ startIndex: 3, paragraphId: 'para_explicit' },
]);
});
it('keeps original paragraph id when applying an invertible split action', () => {
const body: IDocumentBody = {
dataStream: 'AB\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'para_original' }],
};
const actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{ startIndex: 0 } as never],
},
}];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(actions[1].body?.paragraphs?.[0].paragraphId).toBe('para_original');
expect(body.paragraphs?.[0]).toEqual({ startIndex: 1, paragraphId: 'para_original' });
expect(body.paragraphs?.[1].startIndex).toBe(3);
expect(body.paragraphs?.[1].paragraphId).toMatch(PARAGRAPH_ID_PATTERN);
expect(body.paragraphs?.[1].paragraphId).not.toBe('para_original');
});
it('keeps explicit split paragraph id through makeInvertible and apply', () => {
const body: IDocumentBody = {
dataStream: 'AB\r\n',
paragraphs: [{ startIndex: 2, paragraphId: 'para_original' }],
};
const actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: '\r',
paragraphs: [{ startIndex: 0, paragraphId: 'para_explicit' }],
},
}];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(actions[1].body?.paragraphs?.[0].paragraphId).toBe('para_explicit');
expect(body.paragraphs).toEqual([
{ startIndex: 1, paragraphId: 'para_original' },
{ startIndex: 3, paragraphId: 'para_explicit' },
]);
});
it('repairs inserted paragraph ids that collide with the target document before invert', () => {
const body: IDocumentBody = {
dataStream: 'A\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'para_existing' }],
};
const actions: TextXAction[] = [{
t: TextXActionType.INSERT,
len: 2,
body: {
dataStream: 'B\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_existing' }],
},
}];
TextX.makeInvertible(actions, body);
const insertedParagraphId = actions[0].body?.paragraphs?.[0].paragraphId;
const invertedParagraphId = TextX.invert(actions)[0].body?.paragraphs?.[0].paragraphId;
TextX.apply(body, actions);
expect(insertedParagraphId).toMatch(PARAGRAPH_ID_PATTERN);
expect(insertedParagraphId).not.toBe('para_existing');
expect(invertedParagraphId).toBe(insertedParagraphId);
expect(body.paragraphs?.[0]).toEqual({ startIndex: 1, paragraphId: insertedParagraphId });
expect(body.paragraphs?.[1]).toEqual({ startIndex: 3, paragraphId: 'para_existing' });
});
it('repairs duplicate paragraph ids across multiple inserts before invert', () => {
const body: IDocumentBody = {
dataStream: 'A\r\n',
paragraphs: [{ startIndex: 1, paragraphId: 'para_existing' }],
};
const actions: TextXAction[] = [{
t: TextXActionType.INSERT,
len: 2,
body: {
dataStream: 'B\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_dup' }],
},
}, {
t: TextXActionType.INSERT,
len: 2,
body: {
dataStream: 'C\r',
paragraphs: [{ startIndex: 1, paragraphId: 'para_dup' }],
},
}];
TextX.makeInvertible(actions, body);
const firstInsertParagraphId = actions[0].body?.paragraphs?.[0].paragraphId;
const secondInsertParagraphId = actions[1].body?.paragraphs?.[0].paragraphId;
const invertedActions = TextX.invert(actions);
TextX.apply(body, actions);
expect(firstInsertParagraphId).toBe('para_dup');
expect(secondInsertParagraphId).toMatch(PARAGRAPH_ID_PATTERN);
expect(secondInsertParagraphId).not.toBe(firstInsertParagraphId);
expect(invertedActions[0].body?.paragraphs?.[0].paragraphId).toBe(firstInsertParagraphId);
expect(invertedActions[1].body?.paragraphs?.[0].paragraphId).toBe(secondInsertParagraphId);
expect(body.paragraphs?.[0]).toEqual({ startIndex: 1, paragraphId: firstInsertParagraphId });
expect(body.paragraphs?.[1]).toEqual({ startIndex: 3, paragraphId: secondInsertParagraphId });
});
it('keeps the following paragraph id when deleting a paragraph marker under the current merge model', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
};
TextX.apply(body, [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.DELETE,
len: 1,
}]);
expect(body.dataStream).toBe('AB\r\n');
expect(body.paragraphs).toEqual([{ startIndex: 2, paragraphId: 'para_second' }]);
});
it('preserves target paragraph id when retain metadata omits one', () => {
const body: IDocumentBody = {
dataStream: '\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_original' }],
};
TextX.apply(body, [{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphStyle: { lineSpacing: 2 },
} as never],
},
}]);
expect(body.paragraphs).toEqual([{
startIndex: 0,
paragraphId: 'para_original',
paragraphStyle: { lineSpacing: 2 },
}]);
});
it('uses explicit paragraph id when retain metadata provides one', () => {
const body: IDocumentBody = {
dataStream: '\r\n',
paragraphs: [{ startIndex: 0, paragraphId: 'para_original' }],
};
TextX.apply(body, [{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_replacement',
paragraphStyle: { lineSpacing: 2 },
}],
},
}]);
expect(body.paragraphs).toEqual([{
startIndex: 0,
paragraphId: 'para_replacement',
paragraphStyle: { lineSpacing: 2 },
}]);
});
it('makeInvertible delete body captures original paragraph ids', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
};
const actions: TextXAction[] = [{
t: TextXActionType.RETAIN,
len: 1,
}, {
t: TextXActionType.DELETE,
len: 1,
}];
TextX.makeInvertible(actions, body);
expect(actions[1]).toEqual({
t: TextXActionType.DELETE,
len: 1,
body: {
dataStream: '\r',
textRuns: undefined,
paragraphs: [{ startIndex: 0, paragraphId: 'para_first' }],
customBlocks: undefined,
},
});
});
it('preserves explicit paragraph ids during an atomic structural replacement', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
};
const replacementBody = {
dataStream: 'A\rB\r',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
[PRESERVE_INSERTED_PARAGRAPH_IDS]: true,
} as IDocumentBody;
const textX = new TextX();
textX.insert(4, replacementBody);
textX.delete(4);
const actions = textX.serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.dataStream).toBe('A\rB\r\n');
expect(body.paragraphs).toEqual([
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
]);
expect((actions[0].body as IDocumentBody & Record<string, unknown>)[PRESERVE_INSERTED_PARAGRAPH_IDS]).toBe(true);
expect((body as IDocumentBody & Record<string, unknown>)[PRESERVE_INSERTED_PARAGRAPH_IDS]).toBeUndefined();
});
it('preserves the structural replacement marker when insert actions are split and composed', () => {
const body: IDocumentBody = {
dataStream: 'A\rB\r\n',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
};
const replacement = new TextX();
replacement.insert(4, {
dataStream: 'A\rB\r',
paragraphs: [
{ startIndex: 1, paragraphId: 'para_first' },
{ startIndex: 3, paragraphId: 'para_second' },
],
[PRESERVE_INSERTED_PARAGRAPH_IDS]: true,
} as IDocumentBody);
replacement.delete(4);
const formatting = new TextX();
formatting.retain(2, {
dataStream: '',
paragraphs: [{ startIndex: 1, paragraphId: 'para_first', paragraphStyle: { spaceAbove: { v: 8 } } }],
});
formatting.retain(2);
const composed = TextX.compose(replacement.serialize(), formatting.serialize());
const insertedBodies = composed
.filter((action) => action.t === TextXActionType.INSERT)
.map((action) => action.body as IDocumentBody & Record<string, unknown>);
expect(insertedBodies.length).toBeGreaterThan(0);
expect(insertedBodies.every((insertedBody) => insertedBody[PRESERVE_INSERTED_PARAGRAPH_IDS] === true)).toBe(true);
TextX.makeInvertible(composed, body);
TextX.apply(body, composed);
expect(body.paragraphs?.map((paragraph) => paragraph.paragraphId)).toEqual(['para_first', 'para_second']);
});
});

View File

@@ -0,0 +1,125 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { createSectionId, SECTION_ID_PREFIX } from '../../../section-break-id';
import { DataStreamTreeTokenType } from '../../types';
import { TextXActionType } from '../action-types';
import { validateDocBodyStructure } from '../structure-validator';
import { TextX } from '../text-x';
describe('section break identity', () => {
it('creates scoped section ids', () => {
const ids = new Set<string>();
const first = createSectionId(ids);
const second = createSectionId(ids);
expect(first).toMatch(new RegExp(`^${SECTION_ID_PREFIX}`));
expect(second).not.toBe(first);
expect(ids).toEqual(new Set([first, second]));
});
it('freshens a colliding inserted section id before applying TextX', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'para_a' }],
sectionBreaks: [{ startIndex: 2, sectionId: 'section_existing' }],
};
const actions: TextXAction[] = [{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: T.SECTION_BREAK,
sectionBreaks: [{ startIndex: 0, sectionId: 'section_existing' }],
},
}];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.sectionBreaks?.map((section) => section.sectionId)).toHaveLength(2);
expect(new Set(body.sectionBreaks?.map((section) => section.sectionId)).size).toBe(2);
});
it('restores the original section id and config through undo', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.SECTION_BREAK}B${T.SECTION_BREAK}`,
sectionBreaks: [
{ startIndex: 1, sectionId: 'section_left', sectionType: 1 },
{ startIndex: 3, sectionId: 'section_right' },
],
};
const actions: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 1 },
{ t: TextXActionType.DELETE, len: 1 },
];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
TextX.apply(body, TextX.invert(actions));
expect(body.sectionBreaks?.[0]).toMatchObject({
startIndex: 1,
sectionId: 'section_left',
sectionType: 1,
});
});
it('preserves identity when section configuration is replaced', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: T.SECTION_BREAK,
sectionBreaks: [{ startIndex: 0, sectionId: 'section_original' }],
};
TextX.apply(body, [{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
sectionBreaks: [{ startIndex: 0, sectionId: 'section_attempted_replacement', sectionType: 1 }],
},
}]);
expect(body.sectionBreaks?.[0]).toMatchObject({
sectionId: 'section_original',
sectionType: 1,
});
});
it('reports missing and duplicate section ids', () => {
const T = DataStreamTreeTokenType;
const body = {
dataStream: `${T.SECTION_BREAK}${T.SECTION_BREAK}${T.SECTION_BREAK}`,
sectionBreaks: [
{ startIndex: 0, sectionId: '' },
{ startIndex: 1, sectionId: 'section_duplicate' },
{ startIndex: 2, sectionId: 'section_duplicate' },
],
} as IDocumentBody;
expect(validateDocBodyStructure(body).map((issue) => issue.code)).toEqual(expect.arrayContaining([
'missing-section-id',
'duplicate-section-id',
]));
});
});

View File

@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody, IDocumentNote } from '../../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { CustomRangeType } from '../../../../types/interfaces/i-document-data';
import { validateDocumentStructure } from '../structure-validator';
function noteBody(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
paragraphs: [{ paragraphId: 'paragraph', startIndex: text.length }],
sectionBreaks: [{ sectionId: 'section', startIndex: text.length + 1 }],
};
}
describe('validateDocumentStructure notes', () => {
it.each(['footnote', 'endnote'] as const)('rejects independent page setup in a %s while accepting a normal rich note body', (type) => {
const note: IDocumentNote = { type, noteId: 'note', body: noteBody('Explanation') };
note.body.textRuns = [{ st: 0, ed: 3, ts: { bl: 1 } }];
expect(validateDocumentStructure({ notes: { note } })).toEqual([]);
note.body.sectionBreaks![0].marginTop = 50;
expect(validateDocumentStructure({ notes: { note } }).map((issue) => issue.code)).toEqual(['invalid-note-body']);
note.body = {
dataStream: 'A\r\nB\r\n',
paragraphs: [{ paragraphId: 'a', startIndex: 1 }, { paragraphId: 'b', startIndex: 4 }],
sectionBreaks: [{ sectionId: 'a', startIndex: 2 }, { sectionId: 'b', startIndex: 5 }],
};
expect(validateDocumentStructure({ notes: { note } }).map((issue) => issue.code)).toContain('invalid-note-body');
});
it('rejects duplicate references and mismatched segment identities', () => {
const body = noteBody('\uFFFC\uFFFC');
body.customRanges = [0, 1].map((index) => ({
rangeType: CustomRangeType.FOOTNOTE,
rangeId: `reference-${index}`,
startIndex: index,
endIndex: index,
wholeEntity: true,
properties: { noteId: 'note' },
}));
const notes = { note: { type: 'footnote' as const, noteId: 'note', body: noteBody('Explanation') } };
expect(validateDocumentStructure({ body, notes }).map((issue) => issue.code)).toEqual(['duplicate-note-reference']);
notes.note.noteId = 'other';
expect(validateDocumentStructure({ notes }).map((issue) => issue.code)).toEqual(['invalid-note-id']);
});
it('rejects nested references even when the referenced note exists', () => {
const body = noteBody('\uFFFC');
body.customRanges = [{ rangeType: CustomRangeType.FOOTNOTE, rangeId: 'nested', startIndex: 0, endIndex: 0, wholeEntity: true, properties: { noteId: 'second' } }];
expect(validateDocumentStructure({ notes: {
first: { type: 'footnote' as const, noteId: 'first', body },
second: { type: 'footnote' as const, noteId: 'second', body: noteBody('Second') },
} }).map((issue) => issue.code)).toEqual(['nested-note']);
});
it.each(['headers', 'footers'] as const)('rejects references in %s and ambiguous note segment identities', (kind) => {
const body = noteBody('\uFFFC');
body.customRanges = [{ rangeType: CustomRangeType.FOOTNOTE, rangeId: 'reference', startIndex: 0, endIndex: 0, wholeEntity: true, properties: { noteId: 'note' } }];
const notes = { note: { type: 'footnote' as const, noteId: 'note', body: noteBody('Explanation') } };
const running = { headerId: 'running', footerId: 'running', body };
expect(validateDocumentStructure({ [kind]: { running }, notes })).toEqual([
expect.objectContaining({ code: 'invalid-note-reference', segmentId: 'running', index: 0 }),
]);
const colliding = { headerId: 'note', footerId: 'note', body: noteBody('Running text') };
expect(validateDocumentStructure({ [kind]: { note: colliding }, notes })).toEqual([
expect.objectContaining({ code: 'invalid-note-id', segmentType: 'note', segmentId: 'note' }),
]);
});
});

View File

@@ -0,0 +1,363 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { DataStreamTreeTokenType } from '../../types';
import { TextXActionType } from '../action-types';
import { BuildTextUtils } from '../build-utils';
import { TextX } from '../text-x';
import { getBodySliceForTextXAction } from '../utils';
describe('TextX tables', () => {
it('does not include table metadata when slicing text inside an existing table', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 12, paragraphId: 'after-table' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_36', startIndex: 8 },
{ sectionId: 'section_fixture_37', startIndex: 13 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
const slice = getBodySliceForTextXAction(body, 4, 8, false);
expect(slice.dataStream).toBe(`ell${T.PARAGRAPH}`);
expect(slice.tables).toBeUndefined();
});
it('includes table metadata in action bodies only when the full table range is sliced', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 12, paragraphId: 'after-table' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_38', startIndex: 8 },
{ sectionId: 'section_fixture_39', startIndex: 13 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
const slice = getBodySliceForTextXAction(body, 0, 12, false);
expect(slice.tables).toEqual([{ startIndex: 0, endIndex: 12, tableId: 'table-1' }]);
});
it('adds inserted table metadata to documents without existing tables', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 1, paragraphId: 'before' }],
sectionBreaks: [{ sectionId: 'section_fixture_40', startIndex: 2 }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 2 },
{
t: TextXActionType.INSERT,
body: {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}B${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}`,
paragraphs: [
{ startIndex: 4, paragraphId: 'cell' },
{ startIndex: 9, paragraphId: 'after-table' },
],
sectionBreaks: [{ sectionId: 'section_fixture_41', startIndex: 5 }],
tables: [{ startIndex: 0, endIndex: 9, tableId: 'table-1' }],
},
len: 10,
},
]);
expect(body.tables).toEqual([{ startIndex: 2, endIndex: 11, tableId: 'table-1' }]);
});
it('shifts table metadata when content is inserted at the table start boundary', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `A${T.PARAGRAPH}${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}B${T.PARAGRAPH}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 1, paragraphId: 'before' },
{ startIndex: 6, paragraphId: 'cell' },
],
sectionBreaks: [{ sectionId: 'section_fixture_42', startIndex: 10 }],
tables: [{ startIndex: 2, endIndex: 9, tableId: 'table-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 2 },
{
t: TextXActionType.INSERT,
body: {
dataStream: T.PARAGRAPH,
paragraphs: [{ startIndex: 0, paragraphId: 'inserted' }],
},
len: 1,
},
]);
expect(body.dataStream).toBe(`A${T.PARAGRAPH}${T.PARAGRAPH}${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}B${T.PARAGRAPH}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([{ startIndex: 3, endIndex: 10, tableId: 'table-1' }]);
});
it('shifts table metadata when text is inserted inside a table cell', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 18, paragraphId: 'after' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_43', startIndex: 8 },
{ sectionId: 'section_fixture_44', startIndex: 19 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 6 },
{
t: TextXActionType.INSERT,
body: { dataStream: '++' },
len: 2,
},
]);
expect(body.dataStream).toBe(`${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cel++l${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: 14, tableId: 'table-1' }]);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([9, 20]);
});
it('keeps the half-open table end fixed when text is inserted after a table', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 12, paragraphId: 'after' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_45', startIndex: 8 },
{ sectionId: 'section_fixture_46', startIndex: 13 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: 12 },
{ t: TextXActionType.INSERT, body: { dataStream: '啊手' }, len: 2 },
]);
expect(body.dataStream).toBe(`${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}啊手${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: 12, tableId: 'table-1' }]);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([7, 14]);
});
it('removes table metadata when exactly the half-open table token range is deleted', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const body: IDocumentBody = {
dataStream: `${tableStream}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: tableStream.length + 5, paragraphId: 'after' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_47', startIndex: 8 },
{ sectionId: 'section_fixture_48', startIndex: tableStream.length + 6 },
],
tables: [{ startIndex: 0, endIndex: tableStream.length, tableId: 'table-1' }],
};
const actions: TextXAction[] = [{ t: TextXActionType.DELETE, len: tableStream.length }];
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
expect(body.dataStream).toBe(`After${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([]);
expect(actions[0].body?.tables).toEqual([{ startIndex: 0, endIndex: tableStream.length, tableId: 'table-1' }]);
});
it('keeps a following column group outside the table when typing at the table end', () => {
const T = DataStreamTreeTokenType;
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
const columnGroupStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}Lane${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.SECTION_BREAK}`;
const tableEnd = tableStream.length;
const columnGroupStart = tableEnd + 1;
const body: IDocumentBody = {
dataStream: `${tableStream}${T.PARAGRAPH}${columnGroupStream}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: tableEnd, paragraphId: 'after-table' },
{ startIndex: columnGroupStart + 6, paragraphId: 'column' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_49', startIndex: 8 },
{ sectionId: 'section_fixture_50', startIndex: columnGroupStart + columnGroupStream.length - 1 },
],
tables: [{ startIndex: 0, endIndex: tableEnd, tableId: 'table-1' }],
columnGroups: [{
startIndex: columnGroupStart,
endIndex: columnGroupStart + columnGroupStream.length - 2,
columnGroupId: 'column-group-1',
}],
};
TextX.apply(body, [
{ t: TextXActionType.RETAIN, len: tableEnd },
{ t: TextXActionType.INSERT, body: { dataStream: 'After' }, len: 5 },
]);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: tableEnd, tableId: 'table-1' }]);
expect(body.columnGroups).toEqual([{
startIndex: columnGroupStart + 5,
endIndex: columnGroupStart + columnGroupStream.length + 3,
columnGroupId: 'column-group-1',
}]);
expect(body.dataStream.slice(tableEnd, tableEnd + 5)).toBe('After');
expect(body.dataStream[tableEnd + 5]).toBe(T.PARAGRAPH);
});
it('keeps the table end fixed across composed IME updates after a table', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 12, paragraphId: 'after' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_51', startIndex: 8 },
{ sectionId: 'section_fixture_52', startIndex: 13 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
const firstUpdate: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 12 },
{ t: TextXActionType.INSERT, body: { dataStream: 'A' }, len: 1 },
];
const secondUpdate: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 12 },
{ t: TextXActionType.DELETE, len: 1 },
{ t: TextXActionType.INSERT, body: { dataStream: 'AB' }, len: 2 },
];
TextX.apply(body, TextX.compose(firstUpdate, secondUpdate));
expect(body.dataStream).toBe(`${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}AB${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: 12, tableId: 'table-1' }]);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([7, 14]);
});
it('keeps table metadata aligned after composing IME updates inside a table cell', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 18, paragraphId: 'after' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_53', startIndex: 8 },
{ sectionId: 'section_fixture_54', startIndex: 19 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
const firstUpdate: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 6 },
{ t: TextXActionType.INSERT, body: { dataStream: '啊' }, len: 1 },
];
const secondUpdate: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 6 },
{ t: TextXActionType.DELETE, len: 1 },
{ t: TextXActionType.INSERT, body: { dataStream: '啊手' }, len: 2 },
];
TextX.apply(body, TextX.compose(firstUpdate, secondUpdate));
expect(body.dataStream).toBe(`${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cel啊手l${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: 14, tableId: 'table-1' }]);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([9, 20]);
});
it('keeps table metadata aligned after composing insert-then-delete IME updates inside a table cell', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cell${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ startIndex: 7, paragraphId: 'cell' },
{ startIndex: 18, paragraphId: 'after' },
],
sectionBreaks: [
{ sectionId: 'section_fixture_55', startIndex: 8 },
{ sectionId: 'section_fixture_56', startIndex: 19 },
],
tables: [{ startIndex: 0, endIndex: 12, tableId: 'table-1' }],
};
const firstUpdate: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 6 },
{ t: TextXActionType.INSERT, body: { dataStream: '啊' }, len: 1 },
];
const secondUpdate: TextXAction[] = [
{ t: TextXActionType.RETAIN, len: 6 },
{ t: TextXActionType.INSERT, body: { dataStream: '啊手' }, len: 2 },
{ t: TextXActionType.DELETE, len: 1 },
];
TextX.apply(body, TextX.compose(firstUpdate, secondUpdate));
expect(body.dataStream).toBe(`${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}Cel啊手l${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}After${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: 14, tableId: 'table-1' }]);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([9, 20]);
});
it('keeps a minimum paragraph and section break when replacing all text in a table cell', () => {
const T = DataStreamTreeTokenType;
const body: IDocumentBody = {
dataStream: `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}A${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.SECTION_BREAK}`,
paragraphs: [{ startIndex: 4, paragraphId: 'cell' }],
sectionBreaks: [
{ sectionId: 'section_fixture_57', startIndex: 5 },
{ sectionId: 'section_fixture_58', startIndex: 9 },
],
tables: [{ startIndex: 0, endIndex: 8, tableId: 'table-1' }],
};
const actions = BuildTextUtils.selection.delete(
[{ startOffset: 3, endOffset: 6, collapsed: false }],
body,
0,
{ dataStream: 'X' }
);
TextX.apply(body, actions);
expect(body.dataStream).toBe(`${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}X${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}${T.SECTION_BREAK}`);
expect(body.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([4]);
expect(body.sectionBreaks?.map((sectionBreak) => sectionBreak.startIndex)).toEqual([5, 9]);
expect(body.tables).toEqual([{ startIndex: 0, endIndex: 8, tableId: 'table-1' }]);
});
});

View File

@@ -0,0 +1,312 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentBody } from '../../../../types/interfaces/i-document-data';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared/command-enum';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { CustomDecorationType, DocumentBlockRangeType } from '../../../../types/interfaces/i-document-data';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
describe('test TextX methods and branches', () => {
describe('test TextX methods', () => {
it('test TextX insert method', () => {
const textX = new TextX();
const body: IDocumentBody = {
dataStream: 'hello',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
textX.insert(5, body);
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.INSERT,
body,
len: 5,
},
]);
});
it('test TextX delete method', () => {
const textX = new TextX();
textX.delete(5);
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.DELETE,
len: 5,
},
]);
});
it('test TextX retain method', () => {
const textX = new TextX();
const body: IDocumentBody = {
dataStream: '',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
textX.retain(5, body, UpdateDocsAttributeType.COVER);
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.RETAIN,
body,
len: 5,
coverType: UpdateDocsAttributeType.COVER,
},
]);
});
it('test TextX push method and merge two delete actions', () => {
const textX = new TextX();
textX.delete(5);
textX.delete(5);
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.DELETE,
len: 10, // 5 + 5
},
]);
});
it('test TextX push method and put insert action before delete action', () => {
const textX = new TextX();
const body: IDocumentBody = {
dataStream: 'hello',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
textX.delete(5);
textX.insert(5, body);
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.INSERT,
body,
len: 5,
},
{
t: TextXActionType.DELETE,
len: 5,
},
]);
});
it('test TextX push method and merge two simple retain action', () => {
const textX = new TextX();
textX.retain(4);
textX.retain(5);
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.RETAIN,
len: 9,
},
]);
});
it('test TextX push method and with more than one params', () => {
const textX = new TextX();
textX.push({
t: TextXActionType.RETAIN,
len: 4,
}, {
t: TextXActionType.RETAIN,
len: 5,
});
const actions = textX.serialize();
expect(actions).toEqual([
{
t: TextXActionType.RETAIN,
len: 9,
},
]);
});
it('test TextX push method and with more than one params and merge the last two actions', () => {
const textX = new TextX();
const body: IDocumentBody = {
dataStream: 'hello',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
textX.push({
t: TextXActionType.RETAIN,
len: 4,
}, {
t: TextXActionType.DELETE,
len: 5,
});
textX.push({
t: TextXActionType.INSERT,
len: 5,
body,
});
const actions = textX.serialize();
expect(actions.length).toBe(3);
expect(actions[0].t).toBe(TextXActionType.RETAIN);
expect(actions[1].t).toBe(TextXActionType.INSERT);
expect(actions[2].t).toBe(TextXActionType.DELETE);
});
});
describe('test TextX static methods', () => {
it.each([
{
name: 'column group',
offset: 2,
body: {
dataStream: '\x12\x13Field\r\n\x14\x15\r\n',
columnGroups: [{ columnGroupId: 'columns-1', startIndex: 0, endIndex: 10 }],
},
},
{
name: 'quote block',
offset: 1,
body: {
dataStream: '\x10Field\r\x11\r\n',
blockRanges: [{ blockId: 'quote-1', blockType: DocumentBlockRangeType.QUOTE, startIndex: 0, endIndex: 7 }],
},
},
])('does not clip an enclosing $name when undoing formatting', ({ body, offset }) => {
const original = JSON.parse(JSON.stringify(body)) as IDocumentBody;
const actions = new TextX().retain(offset).retain(3, {
dataStream: '',
textRuns: [{ st: 0, ed: 3, ts: { bl: BooleanNumber.TRUE } }],
}).serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
TextX.apply(body, TextX.invert(actions));
expect(body.columnGroups).toEqual(original.columnGroups);
expect(body.blockRanges).toEqual(original.blockRanges);
expect(body.dataStream).toBe(original.dataStream);
});
it('still restores table metadata when the retain explicitly changes it', () => {
const dataStream = '\x1A\x1B\x1CField\r\n\x1D\x0E\x0F\r\n';
const table = { tableId: 'table-1', startIndex: 0, endIndex: dataStream.length - 2 };
const body: IDocumentBody = { dataStream, tables: [table] };
const actions = new TextX().retain(table.endIndex, {
dataStream: '',
tables: [{ ...table, tableId: 'table-2' }],
}).serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
TextX.apply(body, TextX.invert(actions));
expect(body.tables).toEqual([table]);
});
it('keeps enclosing table metadata intact when undoing and redoing a comment inside a cell', () => {
const dataStream = '\x1A\x1B\x1CField\r\n\x1D\x0E\x0F\r\n';
const tables = [{ tableId: 'table-1', startIndex: 0, endIndex: dataStream.length - 2 }];
const body: IDocumentBody = { dataStream, tables: [...tables], customDecorations: [] };
const actions = new TextX().retain(3).retain(3, {
dataStream: '',
customDecorations: [{ id: 'comment-1', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 2 }],
}).serialize();
TextX.makeInvertible(actions, body);
TextX.apply(body, actions);
const undoActions = TextX.invert(actions);
TextX.makeInvertible(undoActions, body);
TextX.apply(body, undoActions);
expect(body.tables).toEqual(tables);
expect(body.customDecorations).toEqual([]);
const redoActions = TextX.invert(undoActions);
TextX.apply(body, redoActions);
expect(body.tables).toEqual(tables);
expect(body.dataStream).toBe(dataStream);
expect(body.customDecorations).toEqual([
expect.objectContaining({ id: 'comment-1', startIndex: 3, endIndex: 5 }),
]);
});
it('test TextX isNoop method', () => {
const textX = new TextX();
expect(TextX.isNoop(textX.serialize())).toBe(true);
textX.retain(4);
textX.delete(5);
expect(TextX.isNoop(textX.serialize())).toBe(false);
});
it('textX name should to be text-x', () => {
expect(TextX.name).toBe('text-x');
});
});
});

View File

@@ -0,0 +1,253 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces/i-document-data';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { BooleanNumber } from '../../../../types/enum';
import { CustomDecorationType } from '../../../../types/interfaces/i-document-data';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
function getDefaultDocWithCustomDecorations() {
const doc: IDocumentBody = {
dataStream: 'Google\r\n',
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.COMMENT,
},
],
};
return doc;
}
// Test Retain + Retain with different coverType in transform textRun.
describe('transform custom range in body', () => {
it('should pass test when normal transform', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.DELETED,
},
],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.COMMENT,
},
],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.DELETED,
},
],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.DELETED,
},
],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithCustomDecorations();
const doc2 = getDefaultDocWithCustomDecorations();
const doc3 = getDefaultDocWithCustomDecorations();
const doc4 = getDefaultDocWithCustomDecorations();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should pass test when any is empty', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
it: BooleanNumber.TRUE,
},
},
],
customDecorations: [],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.DELETED,
},
],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.DELETED,
},
],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
customDecorations: [
{
startIndex: 0,
endIndex: 5,
id: 'commentId',
type: CustomDecorationType.DELETED,
},
],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
});
});

View File

@@ -0,0 +1,509 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces/i-document-data';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { BooleanNumber } from '../../../../types/enum';
import { CustomRangeType } from '../../../../types/interfaces/i-document-data';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
function getDefaultDocWithCustomRange() {
const doc: IDocumentBody = {
dataStream: 'Google\r\n',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.google.com',
},
},
],
};
return doc;
}
// Test Retain + Retain with different coverType in transform textRun.
describe('transform custom range in body', () => {
it('should pass test when REPLACE + REPLACE', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'xxxId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.baidu.com',
},
},
],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'yyyId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.yahoo.com',
},
},
],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'xxxId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.baidu.com',
},
},
],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'yyyId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.yahoo.com',
},
},
],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should pass test when REPLACE + COVER', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'xxxId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.baidu.com',
},
},
],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.yahoo.com',
},
},
],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'xxxId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.baidu.com',
},
},
],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.yahoo.com',
},
},
],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
const doc5 = getDefaultDocWithCustomRange();
const doc6 = getDefaultDocWithCustomRange();
const doc7 = getDefaultDocWithCustomRange();
const doc8 = getDefaultDocWithCustomRange();
const resultE = TextX.apply(TextX.apply(doc5, actionsA), TextX.transform(actionsB, actionsA, 'right'));
const resultF = TextX.apply(TextX.apply(doc6, actionsB), TextX.transform(actionsA, actionsB, 'left'));
const composedAction3 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'right'));
const composedAction4 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'left'));
const resultG = TextX.apply(doc7, composedAction3);
const resultH = TextX.apply(doc8, composedAction4);
expect(resultE).toEqual(resultF);
expect(resultG).toEqual(resultH);
expect(resultE).toEqual(resultG);
expect(composedAction3).toEqual(composedAction4);
});
it('should pass test when COVER + COVER', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.baidu.com',
},
},
],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.yahoo.com',
},
},
],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.baidu.com',
},
},
],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
customRanges: [
{
startIndex: 0,
endIndex: 5,
rangeId: 'jocsId',
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: 'https://www.yahoo.com',
},
},
],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should pass test when any is empty', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
it: BooleanNumber.TRUE,
},
},
],
customRanges: [],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
customRanges: [],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
customRanges: [],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 6,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [
{
st: 0,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
},
},
],
customRanges: [],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithCustomRange();
const doc2 = getDefaultDocWithCustomRange();
const doc3 = getDefaultDocWithCustomRange();
const doc4 = getDefaultDocWithCustomRange();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
});

View File

@@ -0,0 +1,445 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces/i-document-data';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { PresetListType } from '../../preset-list-type';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
function getParagraphsWithoutBullets(body: IDocumentBody) {
return body.paragraphs?.map(({ bullet, ...paragraph }) => paragraph);
}
function getDefaultDocWithParagraph() {
const doc: IDocumentBody = {
dataStream: '\r\n',
paragraphs: [
{
startIndex: 0,
paragraphId: 'para_fixture_1028',
},
],
};
return doc;
}
// Test Retain + Retain with different coverType in transform paragraph.
describe('transform paragraph in body', () => {
it('should pass test when REPLACE + REPLACE', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1030',
paragraphStyle: {
lineSpacing: 1,
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1030',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
},
}],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1030',
paragraphStyle: {
lineSpacing: 1,
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1030',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
},
}],
},
},
];
const doc1 = getDefaultDocWithParagraph();
const doc2 = getDefaultDocWithParagraph();
const doc3 = getDefaultDocWithParagraph();
const doc4 = getDefaultDocWithParagraph();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
expect(resultA).toEqual(resultB);
expect(resultC.dataStream).toBe(resultD.dataStream);
expect(getParagraphsWithoutBullets(resultC)).toEqual(getParagraphsWithoutBullets(resultD));
expect(resultA.dataStream).toBe(resultC.dataStream);
expect(getParagraphsWithoutBullets(resultA)).toEqual(getParagraphsWithoutBullets(resultC));
expect(composedAction1[0].body?.dataStream).toBe(composedAction2[0].body?.dataStream);
expect(getParagraphsWithoutBullets(composedAction1[0].body!)).toEqual(getParagraphsWithoutBullets(composedAction2[0].body!));
});
it('should pass test when REPLACE + COVER', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1034',
paragraphStyle: {
lineSpacing: 2,
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1034',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1034',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1034',
paragraphStyle: {
lineSpacing: 2,
spaceBelow: {
v: 20,
},
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithParagraph();
const doc2 = getDefaultDocWithParagraph();
const doc3 = getDefaultDocWithParagraph();
const doc4 = getDefaultDocWithParagraph();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'right'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'left'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'right'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'left'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
// console.log(JSON.stringify(resultA, null, 2));
// console.log(JSON.stringify(resultB, null, 2));
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
const doc5 = getDefaultDocWithParagraph();
const doc6 = getDefaultDocWithParagraph();
const doc7 = getDefaultDocWithParagraph();
const doc8 = getDefaultDocWithParagraph();
const resultE = TextX.apply(TextX.apply(doc5, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultF = TextX.apply(TextX.apply(doc6, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction3 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction4 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultG = TextX.apply(doc7, composedAction3);
const resultH = TextX.apply(doc8, composedAction4);
expect(resultE).toEqual(resultF);
expect(resultG).toEqual(resultH);
expect(resultE).toEqual(resultG);
expect(composedAction3).toEqual(composedAction4);
});
it('should pass test when COVER + COVER', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1038',
paragraphStyle: {
lineSpacing: 2,
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1038',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
spaceAbove: {
v: 30,
},
},
}],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1038',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
spaceAbove: {
v: 30,
},
},
}],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1038',
paragraphStyle: {
spaceBelow: {
v: 20,
},
spaceAbove: {
v: 30,
},
},
}],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithParagraph();
const doc2 = getDefaultDocWithParagraph();
const doc3 = getDefaultDocWithParagraph();
const doc4 = getDefaultDocWithParagraph();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
});

View File

@@ -0,0 +1,188 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
describe('transformPosition()', () => {
it('insert before position', () => {
const actions = [
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
},
];
expect(TextX.transformPosition(actions, 2)).toEqual(3);
});
it('insert after position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
},
];
expect(TextX.transformPosition(actions, 0)).toEqual(0);
});
it('insert at position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
},
];
expect(TextX.transformPosition(actions, 1, true)).toEqual(1);
expect(TextX.transformPosition(actions, 1, false)).toEqual(2);
});
it('delete before position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.DELETE,
len: 1,
},
];
expect(TextX.transformPosition(actions, 3)).toEqual(2);
});
it('delete after position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 5,
},
{
t: TextXActionType.DELETE,
len: 1,
},
];
expect(TextX.transformPosition(actions, 3)).toEqual(3);
});
it('delete across position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.DELETE,
len: 5,
},
];
expect(TextX.transformPosition(actions, 3)).toEqual(1);
});
it('insert and delete before position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 2,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
},
{
t: TextXActionType.DELETE,
len: 2,
},
];
expect(TextX.transformPosition(actions, 4)).toEqual(3);
});
it('insert before and delete across position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 2,
},
{
t: TextXActionType.INSERT,
len: 1,
body: {
dataStream: 'h',
},
},
{
t: TextXActionType.DELETE,
len: 4,
},
];
expect(TextX.transformPosition(actions, 4)).toEqual(3);
});
it('delete before and delete across position', () => {
const actions: TextXAction[] = [
{
t: TextXActionType.DELETE,
len: 1,
},
{
t: TextXActionType.RETAIN,
len: 1,
},
{
t: TextXActionType.DELETE,
len: 4,
},
];
expect(TextX.transformPosition(actions, 4)).toEqual(1);
});
});

View File

@@ -0,0 +1,384 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces/i-document-data';
import type { TextXAction } from '../action-types';
import { describe, expect, it } from 'vitest';
import { UpdateDocsAttributeType } from '../../../../shared';
import { BooleanNumber } from '../../../../types/enum';
import { TextXActionType } from '../action-types';
import { TextX } from '../text-x';
function getDefaultDocWithTextRuns() {
const doc: IDocumentBody = {
dataStream: 'w\r\n',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
},
],
};
return doc;
}
// Test Retain + Retain with different coverType in transform textRun.
describe('transform textRun in body', () => {
it('should pass test when REPLACE + REPLACE', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
fs: 10,
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.FALSE,
fs: 14,
},
}],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
fs: 10,
},
}],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.FALSE,
fs: 14,
},
}],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithTextRuns();
const doc2 = getDefaultDocWithTextRuns();
const doc3 = getDefaultDocWithTextRuns();
const doc4 = getDefaultDocWithTextRuns();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
// console.log(JSON.stringify(TextX.transform(actionsB, actionsA, 'left'), null, 2));
// console.log(JSON.stringify(composedAction1, null, 2));
// console.log(JSON.stringify(composedAction2, null, 2));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
it('should pass test when REPLACE + COVER', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
fs: 10,
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
it: BooleanNumber.FALSE,
fs: 14,
},
}],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
it: BooleanNumber.FALSE,
fs: 14,
},
}],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithTextRuns();
const doc2 = getDefaultDocWithTextRuns();
const doc3 = getDefaultDocWithTextRuns();
const doc4 = getDefaultDocWithTextRuns();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
// console.log(JSON.stringify(TextX.transform(actionsA, actionsB, 'right'), null, 2));
// console.log('resultA');
// console.log(JSON.stringify(resultA, null, 2));
// console.log('resultB');
// console.log(JSON.stringify(resultB, null, 2));
// console.log(JSON.stringify(composedAction1, null, 2));
// console.log(JSON.stringify(composedAction2, null, 2));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
const doc5 = getDefaultDocWithTextRuns();
const doc6 = getDefaultDocWithTextRuns();
const doc7 = getDefaultDocWithTextRuns();
const doc8 = getDefaultDocWithTextRuns();
const resultE = TextX.apply(TextX.apply(doc5, actionsA), TextX.transform(actionsB, actionsA, 'right'));
const resultF = TextX.apply(TextX.apply(doc6, actionsB), TextX.transform(actionsA, actionsB, 'left'));
const composedAction3 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'right'));
const composedAction4 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'left'));
const resultG = TextX.apply(doc7, composedAction3);
const resultH = TextX.apply(doc8, composedAction4);
expect(resultE).toEqual(resultF);
expect(resultG).toEqual(resultH);
expect(resultE).toEqual(resultG);
expect(composedAction3).toEqual(composedAction4);
});
it('should pass test when COVER + COVER', () => {
const actionsA: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
it: BooleanNumber.TRUE,
fs: 10,
ul: {
s: BooleanNumber.TRUE,
},
},
}],
},
},
];
const actionsB: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
it: BooleanNumber.FALSE,
fs: 14,
},
}],
},
},
];
const expectedTransformedActionFalse: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
},
];
const expectedTransformedActionTrue: TextXAction[] = [
{
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.COVER,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
it: BooleanNumber.FALSE,
fs: 14,
},
}],
},
},
];
expect(TextX.transform(actionsB, actionsA, 'left')).toEqual(expectedTransformedActionTrue);
expect(TextX.transform(actionsB, actionsA, 'right')).toEqual(expectedTransformedActionFalse);
const doc1 = getDefaultDocWithTextRuns();
const doc2 = getDefaultDocWithTextRuns();
const doc3 = getDefaultDocWithTextRuns();
const doc4 = getDefaultDocWithTextRuns();
const resultA = TextX.apply(TextX.apply(doc1, actionsA), TextX.transform(actionsB, actionsA, 'left'));
const resultB = TextX.apply(TextX.apply(doc2, actionsB), TextX.transform(actionsA, actionsB, 'right'));
const composedAction1 = TextX.compose(actionsA, TextX.transform(actionsB, actionsA, 'left'));
const composedAction2 = TextX.compose(actionsB, TextX.transform(actionsA, actionsB, 'right'));
// console.log(JSON.stringify(TextX.transform(actionsB, actionsA, 'left'), null, 2));
// console.log(JSON.stringify(TextX.transform(actionsA, actionsB, 'right'), null, 2));
// console.log(JSON.stringify(composedAction1, null, 2));
// console.log(JSON.stringify(composedAction2, null, 2));
const resultC = TextX.apply(doc3, composedAction1);
const resultD = TextX.apply(doc4, composedAction2);
expect(resultA).toEqual(resultB);
expect(resultC).toEqual(resultD);
expect(resultA).toEqual(resultC);
expect(composedAction1).toEqual(composedAction2);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '../../../../types/interfaces/i-document-data';
import type { IRetainAction } from '../action-types';
import { describe, expect, it, vi } from 'vitest';
import { Tools } from '../../../../shared/tools';
import { BooleanNumber } from '../../../../types/enum/text-style';
import { PresetListType } from '../../preset-list-type';
import { TextXActionType } from '../action-types';
import { composeBody, getBodySlice, getTextRunSlice, isUselessRetainAction } from '../utils';
describe('test text-x utils', () => {
it('only clones text runs intersecting the requested slice', () => {
const body: IDocumentBody = {
dataStream: 'a'.repeat(10_000),
textRuns: Array.from({ length: 1_000 }, (_, index) => ({
st: index * 10,
ed: index * 10 + 10,
ts: {
bl: index % 2 === 0 ? BooleanNumber.TRUE : BooleanNumber.FALSE,
},
})),
};
const deepClone = vi.spyOn(Tools, 'deepClone');
const slice = getTextRunSlice(body, 5_000, 5_010);
expect(slice).toEqual([{
st: 0,
ed: 10,
ts: {
bl: BooleanNumber.TRUE,
},
}]);
expect(deepClone.mock.calls.length).toBeLessThan(50);
deepClone.mockRestore();
});
it('test getBodySlice fn', () => {
const body: IDocumentBody = {
dataStream: 'hello\nworld',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
{
st: 6,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
],
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1049',
}],
};
const sliceBody = getBodySlice(body, 3, 8, false);
expect(sliceBody).toMatchObject({
dataStream: 'lo\nwo',
textRuns: [
{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
},
},
{
st: 3,
ed: 5,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
],
paragraphs: [{
startIndex: 2,
paragraphId: 'para_fixture_1049',
}],
} as IDocumentBody);
});
it('test getBodySlice fn with a larger range', () => {
const body: IDocumentBody = {
dataStream: 'hello\nworld',
textRuns: [
{
st: 0,
ed: 3,
ts: {
bl: BooleanNumber.TRUE,
},
},
{
st: 3,
ed: 5,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 6,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
],
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1051',
}],
};
const sliceBody = getBodySlice(body, 2, 8, false);
expect(sliceBody).toMatchObject({
dataStream: 'llo\nwo',
textRuns: [
{
st: 0,
ed: 1,
ts: {
bl: BooleanNumber.TRUE,
},
},
{
st: 1,
ed: 3,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 4,
ed: 6,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
],
paragraphs: [{
startIndex: 3,
paragraphId: 'para_fixture_1051',
}],
});
});
it('test composeBody fn with textRuns', () => {
const thisBody: IDocumentBody = {
dataStream: 'hello\nworld',
textRuns: [
{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
},
{
st: 6,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
],
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1053',
}],
};
const otherBody: IDocumentBody = {
dataStream: '',
textRuns: [
{
st: 0,
ed: 3,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 3,
ed: 5,
ts: {
it: BooleanNumber.TRUE,
},
},
],
};
const composedBody = composeBody(thisBody, otherBody);
expect(composedBody).toMatchObject({
dataStream: 'hello\nworld',
textRuns: [
{
st: 0,
ed: 3,
ts: {
bl: BooleanNumber.FALSE,
},
},
{
st: 3,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
it: BooleanNumber.TRUE,
},
},
{
st: 6,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
it: BooleanNumber.TRUE,
},
},
],
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1053',
}],
});
});
it('test composeBody and throw error', () => {
const thisBody: IDocumentBody = {
dataStream: 'hello\nworld',
textRuns: [],
};
const otherBody: IDocumentBody = {
dataStream: 'error',
textRuns: [],
};
expect(() => {
composeBody(thisBody, otherBody);
}).toThrow();
});
it('test composeBody both with paragraphs', () => {
const thisBody: IDocumentBody = {
dataStream: 'hello\nworld',
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1055',
}],
};
const otherBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1056',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
}],
};
const composedBody = composeBody(thisBody, otherBody);
expect(composedBody).toMatchObject({
dataStream: 'hello\nworld',
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1056',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
}],
});
});
it('test composeBody both with paragraphs and one has bullet list', () => {
const thisBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1058',
paragraphStyle: {
lineSpacing: 2,
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
};
const otherBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1059',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
},
}],
};
const composedBody = composeBody(thisBody, otherBody);
expect(composedBody).toMatchObject({
dataStream: '',
paragraphs: [{
startIndex: 0,
paragraphId: 'para_fixture_1059',
paragraphStyle: {
lineSpacing: 1,
spaceBelow: {
v: 20,
},
},
bullet: {
listType: PresetListType.BULLET_LIST,
listId: 'testBullet',
nestingLevel: 0,
textStyle: {
fs: 15,
},
},
}],
});
});
it('test composeBody fn both width paragraphs and from retain', () => {
const thisBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 2,
paragraphId: 'para_fixture_1061',
}],
};
const otherBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1062',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
}],
};
const composedBody = composeBody(thisBody, otherBody);
expect(composedBody).toMatchObject({
dataStream: '',
paragraphs: [{
startIndex: 2,
paragraphId: 'para_fixture_1061',
}, {
startIndex: 5,
paragraphId: 'para_fixture_1062',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
}],
});
});
it('test composeBody fn both width paragraphs and from retain 2', () => {
const thisBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 8,
paragraphId: 'para_fixture_1065',
}],
};
const otherBody: IDocumentBody = {
dataStream: '',
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1066',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
}],
};
const composedBody = composeBody(thisBody, otherBody);
expect(composedBody).toMatchObject({
dataStream: '',
paragraphs: [{
startIndex: 5,
paragraphId: 'para_fixture_1066',
paragraphStyle: {
spaceAbove: { v: 10 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
}, {
startIndex: 8,
paragraphId: 'para_fixture_1065',
}],
});
});
it('test isUselessRetainAction()', () => {
const action1: IRetainAction = {
t: TextXActionType.RETAIN,
len: 10,
};
const action2: IRetainAction = {
t: TextXActionType.RETAIN,
len: 10,
body: {
dataStream: '',
textRuns: [],
},
};
const action3: IRetainAction = {
t: TextXActionType.RETAIN,
len: 10,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: 10,
ts: {
bl: BooleanNumber.FALSE,
},
}],
},
};
expect(isUselessRetainAction(action1)).toBe(true);
expect(isUselessRetainAction(action2)).toBe(false);
expect(isUselessRetainAction(action3)).toBe(false);
});
});

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable no-param-reassign */
import type { TextXAction } from './action-types';
import { Tools } from '../../../shared/tools';
import { TextXActionType } from './action-types';
import { getBodySliceForSplitTextXAction } from './utils';
export class ActionIterator {
private _index = 0;
private _offset = 0;
constructor(private _actions: TextXAction[]) {
// empty
}
hasNext() {
return this.peekLength() < Number.POSITIVE_INFINITY;
}
next(length?: number): TextXAction {
if (!length) {
length = Number.POSITIVE_INFINITY;
}
const nextAction = this._actions[this._index];
if (nextAction) {
const offset = this._offset;
const actionLength = nextAction.len;
if (length >= actionLength - offset) {
// Return the action if the length is great than action length, and reset offset to 0.
length = actionLength - offset;
this._index += 1;
this._offset = 0;
} else {
this._offset += length;
}
/**
* How to deal with it?
* 1. If it is of the delete type or retain(the body attribute is undefined)
* 1) Then change the len property and return the deepClone before action
* 2. If it is a retain, insert type
* 1) First of all, you need to change the len attribute to `length`
* 2) Slice the body and slice the range [offset, length]
* 3) Reassemble the returned action
*/
if (nextAction.t === TextXActionType.DELETE || (nextAction.t === TextXActionType.RETAIN && nextAction.body == null)) {
return Tools.deepClone({
...nextAction,
len: length,
});
} else {
// handle condition: (nextAction.t === TextXActionType.INSERT || nextAction.t === TextXActionType.RETAIN && nextAction.body)
return Tools.deepClone({
...nextAction,
len: length,
body: getBodySliceForSplitTextXAction(nextAction.body!, offset, offset + length, false),
});
}
} else {
// It indicate that the iterator is exhausted when return TextXActionType.RETAIN and len is positive infinity.
return {
t: TextXActionType.RETAIN,
len: Number.POSITIVE_INFINITY,
};
}
}
peek(): TextXAction {
return this._actions[this._index];
}
peekLength() {
if (this._actions[this._index]) {
// Should never return 0 if our index is being managed correctly
return this._actions[this._index].len - this._offset;
} else {
return Number.POSITIVE_INFINITY;
}
}
peekType(): TextXActionType {
const action = this._actions[this._index];
if (action) {
return action.t;
}
return TextXActionType.RETAIN;
}
rest(): TextXAction[] {
if (!this.hasNext()) {
return [];
} else if (this._offset === 0) {
return this._actions.slice(this._index);
} else {
const offset = this._offset;
const index = this._index;
const next = this.next();
const restActions = this._actions.slice(this._index);
this._offset = offset;
this._index = index;
return [next].concat(restActions);
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { UpdateDocsAttributeType } from '../../../shared/command-enum';
import type { IDocumentBody } from '../../../types/interfaces/i-document-data';
// Internal TextX structural-replacement marker. Inserted paragraphs replace the
// same logical paragraphs later in the operation, so their ids must stay stable.
export const PRESERVE_INSERTED_PARAGRAPH_IDS = '__textXPreserveParagraphIds';
export enum TextXActionType {
RETAIN = 'r',
INSERT = 'i',
DELETE = 'd',
}
/**
* Retain mutation is used to move the cursor or to update properties of the text in the given range.
*/
export interface IRetainAction {
t: TextXActionType.RETAIN;
len: number;
body?: IDocumentBody;
oldBody?: IDocumentBody;
coverType?: UpdateDocsAttributeType;
}
/**
* Insert mutation is used to insert text (maybe with rich text properties) at the given position.
*/
export interface IInsertAction {
t: TextXActionType.INSERT;
body: IDocumentBody;
len: number;
}
/**
* Delete mutation is used to delete text at the given position.
*/
export interface IDeleteAction {
t: TextXActionType.DELETE;
len: number;
body?: IDocumentBody; // Add a body property to make this action invertible.
}
export type TextXAction = IRetainAction | IInsertAction | IDeleteAction;

Some files were not shown because too many files have changed in this diff Show More