snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
39
packages/core/README.md
Normal file
39
packages/core/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @univerjs/core
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/core)
|
||||
[](https://npmjs.com/package/@univerjs/core)
|
||||
[](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
100
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
151
packages/core/src/__tests__/univer-skeleton.spec.ts
Normal file
151
packages/core/src/__tests__/univer-skeleton.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
126
packages/core/src/bases/__tests__/base-data-model.spec.ts
Normal file
126
packages/core/src/bases/__tests__/base-data-model.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
164
packages/core/src/bases/__tests__/formula-table-name.spec.ts
Normal file
164
packages/core/src/bases/__tests__/formula-table-name.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
478
packages/core/src/bases/base-data-model.ts
Normal file
478
packages/core/src/bases/base-data-model.ts
Normal 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);
|
||||
}
|
||||
138
packages/core/src/bases/empty-snapshot.ts
Normal file
138
packages/core/src/bases/empty-snapshot.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
285
packages/core/src/bases/formula-table-name.ts
Normal file
285
packages/core/src/bases/formula-table-name.ts
Normal 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;
|
||||
}
|
||||
130
packages/core/src/bases/index.ts
Normal file
130
packages/core/src/bases/index.ts
Normal 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';
|
||||
77
packages/core/src/bases/record-identity.ts
Normal file
77
packages/core/src/bases/record-identity.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1051
packages/core/src/bases/typedef.ts
Normal file
1051
packages/core/src/bases/typedef.ts
Normal file
File diff suppressed because it is too large
Load Diff
83
packages/core/src/common/__tests__/array.spec.ts
Normal file
83
packages/core/src/common/__tests__/array.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
43
packages/core/src/common/__tests__/drawing-order.spec.ts
Normal file
43
packages/core/src/common/__tests__/drawing-order.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
packages/core/src/common/__tests__/function.spec.ts
Normal file
49
packages/core/src/common/__tests__/function.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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']]);
|
||||
});
|
||||
});
|
||||
64
packages/core/src/common/__tests__/interceptor.spec.ts
Normal file
64
packages/core/src/common/__tests__/interceptor.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
packages/core/src/common/__tests__/invert-color.spec.ts
Normal file
70
packages/core/src/common/__tests__/invert-color.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
});
|
||||
});
|
||||
76
packages/core/src/common/__tests__/regexp.spec.ts
Normal file
76
packages/core/src/common/__tests__/regexp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
packages/core/src/common/__tests__/registry.spec.ts
Normal file
51
packages/core/src/common/__tests__/registry.spec.ts
Normal 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 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
133
packages/core/src/common/__tests__/shims.spec.ts
Normal file
133
packages/core/src/common/__tests__/shims.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
72
packages/core/src/common/__tests__/url.spec.ts
Normal file
72
packages/core/src/common/__tests__/url.spec.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
141
packages/core/src/common/__tests__/utility-primitives.spec.ts
Normal file
141
packages/core/src/common/__tests__/utility-primitives.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
packages/core/src/common/array.ts
Normal file
104
packages/core/src/common/array.ts
Normal 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];
|
||||
}
|
||||
19
packages/core/src/common/async.ts
Normal file
19
packages/core/src/common/async.ts
Normal 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 };
|
||||
20
packages/core/src/common/boolean.ts
Normal file
20
packages/core/src/common/boolean.ts
Normal 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());
|
||||
}
|
||||
41
packages/core/src/common/const.ts
Normal file
41
packages/core/src/common/const.ts
Normal 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);
|
||||
}
|
||||
79
packages/core/src/common/di.ts
Normal file
79
packages/core/src/common/di.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
32
packages/core/src/common/drawing-order.ts
Normal file
32
packages/core/src/common/drawing-order.ts
Normal 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;
|
||||
}
|
||||
51
packages/core/src/common/equal.ts
Normal file
51
packages/core/src/common/equal.ts
Normal 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;
|
||||
}
|
||||
29
packages/core/src/common/error.ts
Normal file
29
packages/core/src/common/error.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
47
packages/core/src/common/function.ts
Normal file
47
packages/core/src/common/function.ts
Normal 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;
|
||||
}
|
||||
250
packages/core/src/common/interceptor.ts
Normal file
250
packages/core/src/common/interceptor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
127
packages/core/src/common/invert-color/invert-hsl.ts
Normal file
127
packages/core/src/common/invert-color/invert-hsl.ts
Normal 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)));
|
||||
}
|
||||
51
packages/core/src/common/invert-color/invert-rgb.ts
Normal file
51
packages/core/src/common/invert-color/invert-rgb.ts
Normal 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)));
|
||||
}
|
||||
26
packages/core/src/common/invert-color/utils.ts
Normal file
26
packages/core/src/common/invert-color/utils.ts
Normal 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;
|
||||
}
|
||||
23
packages/core/src/common/json.ts
Normal file
23
packages/core/src/common/json.ts
Normal 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;
|
||||
17
packages/core/src/common/lodash.ts
Normal file
17
packages/core/src/common/lodash.ts
Normal 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';
|
||||
32
packages/core/src/common/memory-cursor.ts
Normal file
32
packages/core/src/common/memory-cursor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
35
packages/core/src/common/mixin.ts
Normal file
35
packages/core/src/common/mixin.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
37
packages/core/src/common/number.ts
Normal file
37
packages/core/src/common/number.ts
Normal 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;
|
||||
}
|
||||
210
packages/core/src/common/regexp/charset.ts
Normal file
210
packages/core/src/common/regexp/charset.ts
Normal 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;
|
||||
}
|
||||
43
packages/core/src/common/regexp/escape.ts
Normal file
43
packages/core/src/common/regexp/escape.ts
Normal 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');
|
||||
}
|
||||
41
packages/core/src/common/regexp/factory.ts
Normal file
41
packages/core/src/common/regexp/factory.ts
Normal 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;
|
||||
}
|
||||
39
packages/core/src/common/regexp/index.ts
Normal file
39
packages/core/src/common/regexp/index.ts
Normal 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,
|
||||
};
|
||||
108
packages/core/src/common/regexp/or.ts
Normal file
108
packages/core/src/common/regexp/or.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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);
|
||||
68
packages/core/src/common/registry.ts
Normal file
68
packages/core/src/common/registry.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
packages/core/src/common/request-immediate-macro-task.ts
Normal file
47
packages/core/src/common/request-immediate-macro-task.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
81
packages/core/src/common/sequence.ts
Normal file
81
packages/core/src/common/sequence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
26
packages/core/src/common/set.ts
Normal file
26
packages/core/src/common/set.ts
Normal 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;
|
||||
}
|
||||
133
packages/core/src/common/shims.ts
Normal file
133
packages/core/src/common/shims.ts
Normal 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();
|
||||
}
|
||||
42
packages/core/src/common/unit.ts
Normal file
42
packages/core/src/common/unit.ts
Normal 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;
|
||||
}
|
||||
400
packages/core/src/common/url.ts
Normal file
400
packages/core/src/common/url.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
88
packages/core/src/docs/__tests__/paragraph-id.spec.ts
Normal file
88
packages/core/src/docs/__tests__/paragraph-id.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
226
packages/core/src/docs/data-model/__tests__/replacement.spec.ts
Normal file
226
packages/core/src/docs/data-model/__tests__/replacement.spec.ts
Normal 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
76
packages/core/src/docs/data-model/__tests__/utils.spec.ts
Normal file
76
packages/core/src/docs/data-model/__tests__/utils.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
530
packages/core/src/docs/data-model/document-data-model.ts
Normal file
530
packages/core/src/docs/data-model/document-data-model.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
170
packages/core/src/docs/data-model/document-statistics.ts
Normal file
170
packages/core/src/docs/data-model/document-statistics.ts
Normal 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;
|
||||
}
|
||||
128
packages/core/src/docs/data-model/empty-snapshot.ts
Normal file
128
packages/core/src/docs/data-model/empty-snapshot.ts
Normal 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;
|
||||
}
|
||||
70
packages/core/src/docs/data-model/index.ts
Normal file
70
packages/core/src/docs/data-model/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -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' }] });
|
||||
});
|
||||
});
|
||||
});
|
||||
131
packages/core/src/docs/data-model/json-x/json-x.ts
Normal file
131
packages/core/src/docs/data-model/json-x/json-x.ts
Normal 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);
|
||||
124
packages/core/src/docs/data-model/paragraph-style.ts
Normal file
124
packages/core/src/docs/data-model/paragraph-style.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
296
packages/core/src/docs/data-model/preset-list-type.ts
Normal file
296
packages/core/src/docs/data-model/preset-list-type.ts
Normal 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,
|
||||
};
|
||||
83
packages/core/src/docs/data-model/replacement.ts
Normal file
83
packages/core/src/docs/data-model/replacement.ts
Normal 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;
|
||||
}
|
||||
3249
packages/core/src/docs/data-model/rich-text-builder.ts
Normal file
3249
packages/core/src/docs/data-model/rich-text-builder.ts
Normal file
File diff suppressed because it is too large
Load Diff
17
packages/core/src/docs/data-model/subdocument.ts
Normal file
17
packages/core/src/docs/data-model/subdocument.ts
Normal 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__';
|
||||
5
packages/core/src/docs/data-model/text-x/README.md
Normal file
5
packages/core/src/docs/data-model/text-x/README.md
Normal 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
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
815
packages/core/src/docs/data-model/text-x/__tests__/apply.spec.ts
Normal file
815
packages/core/src/docs/data-model/text-x/__tests__/apply.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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' }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
1021
packages/core/src/docs/data-model/text-x/__tests__/transform.spec.ts
Normal file
1021
packages/core/src/docs/data-model/text-x/__tests__/transform.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
483
packages/core/src/docs/data-model/text-x/__tests__/utils.spec.ts
Normal file
483
packages/core/src/docs/data-model/text-x/__tests__/utils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
127
packages/core/src/docs/data-model/text-x/action-iterator.ts
Normal file
127
packages/core/src/docs/data-model/text-x/action-iterator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
59
packages/core/src/docs/data-model/text-x/action-types.ts
Normal file
59
packages/core/src/docs/data-model/text-x/action-types.ts
Normal 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
Reference in New Issue
Block a user