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

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

View File

@@ -0,0 +1,41 @@
# @univerjs/docs-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/docs-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-ui)
[![license](https://img.shields.io/npm/l/@univerjs/docs-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/docs-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-ui)
`@univerjs/docs-ui` provides the editor UI layer for Univer Docs, including selection rendering, clipboard support, menus, and document interaction services.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/docs-ui` | `UniverDocsUi` | Yes | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/docs-ui
# or
npm install @univerjs/docs-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/docs-ui/lib/index.css';
import EnUS from '@univerjs/docs-ui/locale/en-US';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
univer.registerPlugin(UniverDocsUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/docs-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

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

View File

@@ -0,0 +1,108 @@
{
"name": "@univerjs/docs-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Editor UI layer for Univer Docs.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"docs",
"document",
"editor",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts",
"./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"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.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": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/protocol": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"@univerjs/docs-drawing": "workspace:*",
"jest-canvas-mock": "^2.5.2",
"jsdom": "^29.1.1",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { UniverRenderingContext } from '@univerjs/engine-render';
import { ThemeService } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import {
DOC_CUSTOM_RANGE_CHROME_BACKGROUND_ALPHA,
DOC_CUSTOM_RANGE_CHROME_RADIUS,
drawDocCustomRangeChrome,
resolveDocCustomRangeChromeTheme,
} from '../basics/doc-custom-range-chrome';
describe('Doc custom range chrome', () => {
it('derives a subtle background and border from the current primary theme token', () => {
const themeService = new ThemeService();
const currentTheme = themeService.getCurrentTheme();
themeService.setTheme({
...currentTheme,
primary: {
...currentTheme.primary,
600: '#123456',
},
});
expect(resolveDocCustomRangeChromeTheme(themeService)).toEqual({
background: `rgba(18,52,86,${DOC_CUSTOM_RANGE_CHROME_BACKGROUND_ALPHA})`,
border: '#123456',
});
expect(DOC_CUSTOM_RANGE_CHROME_RADIUS).toBe(1);
themeService.dispose();
});
it('draws a filled rounded rectangle before its outline', () => {
const calls: string[] = [];
const ctx = {
beginPath: vi.fn(() => {
calls.push('beginPath');
}),
fill: vi.fn(() => {
calls.push('fill');
}),
fillStyle: '',
lineWidth: 0,
restore: vi.fn(() => {
calls.push('restore');
}),
roundRect: vi.fn(() => {
calls.push('roundRect');
}),
save: vi.fn(() => {
calls.push('save');
}),
stroke: vi.fn(() => {
calls.push('stroke');
}),
strokeStyle: '',
} satisfies Pick<
UniverRenderingContext,
| 'beginPath'
| 'fill'
| 'fillStyle'
| 'lineWidth'
| 'restore'
| 'roundRect'
| 'save'
| 'stroke'
| 'strokeStyle'
>;
drawDocCustomRangeChrome(ctx, {
bottom: 28,
left: 10,
right: 50,
top: 8,
}, {
background: 'rgba(18,52,86,0.07)',
border: '#123456',
});
expect(ctx.roundRect).toHaveBeenCalledWith(10, 8, 40, 20, 1);
expect(calls).toEqual([
'save',
'beginPath',
'roundRect',
'fill',
'stroke',
'restore',
]);
});
});

View File

@@ -0,0 +1,180 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest';
import {
resolveDocsTableLikeCustomBlockBleedViewport,
resolveDocsTableLikeCustomBlockContentHeight,
resolveDocsTableLikeCustomBlockContentWidth,
} from '../embed-docs-custom-block-bleed';
describe('resolveDocsTableLikeCustomBlockBleedViewport', () => {
it('keeps the viewport inside the block when content fits the block width', () => {
const boundary = createElementWithRect({ left: 100, right: 1200, width: 1100 });
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
boundary.style.overflow = 'hidden';
boundary.appendChild(root);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1800);
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 960)).toEqual({
bleedLeft: 0,
bleedRight: 0,
bleedWidth: 960,
contentWidth: 960,
virtualWidth: 960,
});
});
it('uses the clipping ancestor as the bleed boundary', () => {
const boundary = createElementWithRect({ left: 100, right: 1200, width: 1100 });
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
boundary.style.overflow = 'hidden';
boundary.appendChild(root);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1800);
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1500)).toEqual({
bleedLeft: 110,
bleedRight: 10,
bleedWidth: 1080,
contentWidth: 1500,
virtualWidth: 1610,
});
});
it('falls back to the visual window when wide content has no clipping ancestor', () => {
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
document.body.appendChild(root);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1440);
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1200)).toEqual({
bleedLeft: 210,
bleedRight: 250,
bleedWidth: 1420,
contentWidth: 1200,
virtualWidth: 1420,
});
});
it('converts measured screen geometry back to logical geometry at document scale', () => {
const root = createElementWithRect({ left: 440, right: 2360, width: 1920 });
document.body.appendChild(root);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(2880);
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1800, {
viewScale: 2,
})).toEqual({
bleedLeft: 215,
bleedRight: 255,
bleedWidth: 1430,
contentWidth: 1800,
virtualWidth: 2015,
});
});
it('uses authoritative render viewport bleed hints without expanding the layout root', () => {
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
document.body.appendChild(root);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1440);
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1800, {
bleedLeft: 210,
bleedWidth: 1420,
})).toEqual({
bleedLeft: 210,
bleedRight: 250,
bleedWidth: 1420,
contentWidth: 1800,
virtualWidth: 2010,
});
});
it('keeps authoritative bleed hints while runtime content width has not caught up', () => {
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 960, {
bleedLeft: 210,
bleedWidth: 1420,
})).toEqual({
bleedLeft: 210,
bleedRight: 250,
bleedWidth: 1420,
contentWidth: 960,
virtualWidth: 1420,
});
});
it('does not bleed when authoritative content width fits the block', () => {
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 960, {
authoritativeContentWidth: true,
bleedLeft: 210,
bleedWidth: 1420,
})).toEqual({
bleedLeft: 0,
bleedRight: 0,
bleedWidth: 960,
contentWidth: 960,
virtualWidth: 960,
});
});
it('uses the full visible canvas bleed once content exceeds the page', () => {
const root = createElementWithRect({ left: 220, right: 1180, width: 960 });
expect(resolveDocsTableLikeCustomBlockBleedViewport(root, 1100, {
bleedLeft: 210,
bleedWidth: 1420,
})).toEqual({
bleedLeft: 210,
bleedRight: 250,
bleedWidth: 1420,
contentWidth: 1100,
virtualWidth: 1420,
});
});
it('prefers authoritative product content width over runtime DOM fallback', () => {
expect(resolveDocsTableLikeCustomBlockContentWidth(1600, 960)).toBe(1600);
expect(resolveDocsTableLikeCustomBlockContentWidth(undefined, 960)).toBe(960);
expect(resolveDocsTableLikeCustomBlockContentWidth(0, 960)).toBe(960);
});
it('prefers authoritative product content height over runtime DOM fallback', () => {
expect(resolveDocsTableLikeCustomBlockContentHeight(1200, 480)).toBe(1200);
expect(resolveDocsTableLikeCustomBlockContentHeight(undefined, 480)).toBe(480);
expect(resolveDocsTableLikeCustomBlockContentHeight(0, 480)).toBe(480);
});
});
function createElementWithRect(rect: { left: number; right: number; width: number }): HTMLElement {
const element = document.createElement('div');
element.getBoundingClientRect = () => ({
bottom: 0,
height: 0,
left: rect.left,
right: rect.right,
top: 0,
width: rect.width,
x: rect.left,
y: 0,
toJSON: () => rect,
});
return element;
}

View File

@@ -0,0 +1,129 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @vitest-environment jsdom
*/
import { UniverInstanceType } from '@univerjs/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectDocsTableLikeEmbedChildUnitIds,
createDocsCustomBlockSizeRefreshScheduler,
getCommandUnitId,
shouldRefreshDocsCustomBlockSizeForCommand,
} from '../embed-docs-custom-block-refresh';
import { scrollDocsTableLikeCustomBlockLive } from '../embed-docs-custom-block-scroll';
describe('docs custom block refresh and scroll helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('collects table-like child units and matches command params', () => {
const childUnitIds = collectDocsTableLikeEmbedChildUnitIds({
sheet: { data: { childUnitId: 'sheet-1', childType: UniverInstanceType.UNIVER_SHEET } },
base: { data: { childUnitId: 'base-1', childType: UniverInstanceType.UNIVER_BASE } },
slide: { data: { childUnitId: 'slide-1', childType: UniverInstanceType.UNIVER_SLIDE } },
malformed: { data: { childUnitId: 123, childType: UniverInstanceType.UNIVER_SHEET } },
empty: null,
});
expect(Array.from(childUnitIds).sort()).toEqual(['base-1', 'sheet-1']);
expect(getCommandUnitId({ unitId: 'unit-a', unitID: 'unit-b' })).toBe('unit-a');
expect(getCommandUnitId({ unitID: 'unit-b' })).toBe('unit-b');
expect(getCommandUnitId(null)).toBeUndefined();
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds,
commandParams: { unitId: 'sheet-1' },
hostUnitId: 'doc-1',
})).toBe(true);
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds,
commandParams: { unitId: 'doc-1' },
hostUnitId: 'doc-1',
})).toBe(false);
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds,
commandParams: { unitId: 'other' },
hostUnitId: 'doc-1',
})).toBe(false);
});
it('coalesces refresh scheduling and cancels pending frames on dispose', () => {
const refresh = vi.fn();
let callback: (() => void) | undefined;
const requestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((next) => {
callback = () => next(0);
return 7;
});
const cancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame');
const scheduler = createDocsCustomBlockSizeRefreshScheduler(refresh);
scheduler.schedule();
scheduler.schedule();
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
callback?.();
expect(refresh).toHaveBeenCalledTimes(1);
scheduler.schedule();
scheduler.dispose();
expect(cancelAnimationFrame).toHaveBeenCalledWith(7);
scheduler.dispose();
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
});
it('scrolls live block content within horizontal and vertical limits', () => {
const live = createScrollableElement({
clientHeight: 200,
clientWidth: 300,
scrollHeight: 800,
scrollWidth: 1000,
});
expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { deltaX: 500 }), live, { maxScrollLeft: 420 })).toBe(true);
expect(live.scrollLeft).toBe(420);
expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { deltaY: 900 }), live)).toBe(true);
expect(live.scrollTop).toBe(600);
expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { deltaY: -50, shiftKey: true }), live)).toBe(true);
expect(live.scrollLeft).toBe(370);
expect(live.scrollTop).toBe(600);
expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { ctrlKey: true, deltaY: 10 }), live)).toBe(false);
expect(scrollDocsTableLikeCustomBlockLive(new WheelEvent('wheel', { metaKey: true, deltaY: 10 }), live)).toBe(false);
const prevented = new WheelEvent('wheel', { deltaY: 10 });
prevented.preventDefault();
expect(scrollDocsTableLikeCustomBlockLive(prevented, live)).toBe(false);
});
});
function createScrollableElement(params: {
clientHeight: number;
clientWidth: number;
scrollHeight: number;
scrollWidth: number;
}): HTMLElement {
const element = document.createElement('div');
Object.defineProperties(element, {
clientHeight: { configurable: true, value: params.clientHeight },
clientWidth: { configurable: true, value: params.clientWidth },
scrollHeight: { configurable: true, value: params.scrollHeight },
scrollWidth: { configurable: true, value: params.scrollWidth },
});
return element;
}

View File

@@ -0,0 +1,115 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UniverInstanceType } from '@univerjs/core';
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectDocsTableLikeEmbedChildUnitIds,
createDocsCustomBlockSizeRefreshScheduler,
shouldRefreshDocsCustomBlockSizeForCommand,
} from '../embed-docs-custom-block-refresh';
describe('docs custom block refresh helpers', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('collects only sheet-like custom block child unit ids', () => {
expect([...collectDocsTableLikeEmbedChildUnitIds({
base: { data: { childType: UniverInstanceType.UNIVER_BASE, childUnitId: 'base-1' } },
doc: { data: { childType: UniverInstanceType.UNIVER_DOC, childUnitId: 'doc-1' } },
sheet: { data: { childType: UniverInstanceType.UNIVER_SHEET, childUnitId: 'sheet-1' } },
missing: { data: { childType: UniverInstanceType.UNIVER_SHEET } },
})].sort()).toEqual(['base-1', 'sheet-1']);
});
it('can collect runtime child unit ids through a resolver', () => {
expect([...collectDocsTableLikeEmbedChildUnitIds({
base: { data: { childType: UniverInstanceType.UNIVER_BASE, embedId: 'base-embed' } },
sheet: { data: { childType: UniverInstanceType.UNIVER_SHEET, embedId: 'sheet-embed' } },
slide: { data: { childType: UniverInstanceType.UNIVER_SLIDE, embedId: 'slide-embed' } },
}, (data) => {
if (data.embedId === 'base-embed') {
return 'base-1';
}
if (data.embedId === 'sheet-embed') {
return 'sheet-1';
}
return 'slide-1';
})].sort()).toEqual(['base-1', 'sheet-1']);
});
it('refreshes only when a command targets an embedded child unit', () => {
const childUnitIds = new Set(['sheet-1']);
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds,
commandParams: { unitId: 'sheet-1' },
hostUnitId: 'doc-host',
})).toBe(true);
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds,
commandParams: { unitId: 'doc-host' },
hostUnitId: 'doc-host',
})).toBe(false);
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds,
commandParams: { unitId: 'other' },
hostUnitId: 'doc-host',
})).toBe(false);
});
it('accepts legacy unitID command params when detecting child unit changes', () => {
expect(shouldRefreshDocsCustomBlockSizeForCommand({
childUnitIds: new Set(['sheet-1']),
commandParams: { unitID: 'sheet-1' },
hostUnitId: 'doc-host',
})).toBe(true);
});
it('coalesces repeated refresh requests into one frame', () => {
const refresh = vi.fn();
let frameCallback: (() => void) | undefined;
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frameCallback = () => callback(0);
return 1;
});
const scheduler = createDocsCustomBlockSizeRefreshScheduler(refresh);
scheduler.schedule();
scheduler.schedule();
frameCallback?.();
expect(refresh).toHaveBeenCalledTimes(1);
});
it('cancels a pending refresh when disposed', () => {
const refresh = vi.fn();
vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(7);
const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame');
const scheduler = createDocsCustomBlockSizeRefreshScheduler(refresh);
scheduler.schedule();
scheduler.dispose();
expect(cancelFrame).toHaveBeenCalledWith(7);
expect(refresh).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,153 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest';
import { scrollDocsTableLikeCustomBlockLive } from '../embed-docs-custom-block-scroll';
describe('scrollDocsTableLikeCustomBlockLive', () => {
it('consumes horizontal wheel while the bleed viewport can scroll', () => {
const live = createScrollableElement({
clientWidth: 300,
scrollWidth: 900,
});
const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 120 }), live);
expect(handled).toBe(true);
expect(live.scrollLeft).toBe(120);
});
it('uses shift vertical wheel as horizontal scroll', () => {
const live = createScrollableElement({
clientWidth: 300,
scrollWidth: 900,
});
const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaY: 80, shiftKey: true }), live);
expect(handled).toBe(true);
expect(live.scrollLeft).toBe(80);
expect(live.scrollTop).toBe(0);
});
it('uses the native horizontal range after the viewport has bled to the left boundary', () => {
const live = createScrollableElement({
clientWidth: 300,
scrollWidth: 900,
scrollLeft: 250,
});
const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 300 }), live, {
maxScrollLeft: undefined,
});
expect(handled).toBe(true);
expect(live.scrollLeft).toBe(550);
const chained = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 10 }), live, {
maxScrollLeft: undefined,
});
expect(chained).toBe(true);
expect(live.scrollLeft).toBe(560);
});
it('chains horizontal wheel only after the native horizontal range is exhausted', () => {
const live = createScrollableElement({
clientWidth: 300,
scrollWidth: 900,
scrollLeft: 600,
});
const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaX: 10 }), live);
expect(handled).toBe(false);
expect(live.scrollLeft).toBe(600);
});
it('chains vertical wheel to docs when the block cannot scroll further', () => {
const live = createScrollableElement({
clientHeight: 300,
scrollHeight: 900,
scrollTop: 600,
});
const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaY: 120 }), live);
expect(handled).toBe(false);
expect(live.scrollTop).toBe(600);
});
it('consumes vertical wheel while the live container can scroll', () => {
const live = createScrollableElement({
clientHeight: 300,
scrollHeight: 900,
});
const handled = scrollDocsTableLikeCustomBlockLive(createWheelEvent({ deltaY: 120 }), live);
expect(handled).toBe(true);
expect(live.scrollTop).toBe(120);
});
it('does not scroll when the child runtime already consumed the wheel event', () => {
const live = createScrollableElement({
clientHeight: 300,
scrollHeight: 900,
});
const event = createWheelEvent({ deltaY: 120 });
event.preventDefault();
const handled = scrollDocsTableLikeCustomBlockLive(event, live);
expect(handled).toBe(false);
expect(live.scrollTop).toBe(0);
});
});
function createWheelEvent(params: { deltaX?: number; deltaY?: number; shiftKey?: boolean }): WheelEvent {
return new WheelEvent('wheel', {
cancelable: true,
deltaX: params.deltaX ?? 0,
deltaY: params.deltaY ?? 0,
shiftKey: params.shiftKey ?? false,
});
}
function createScrollableElement(params: {
clientHeight?: number;
clientWidth?: number;
scrollHeight?: number;
scrollLeft?: number;
scrollTop?: number;
scrollWidth?: number;
}): HTMLElement {
const element = document.createElement('div');
Object.defineProperties(element, {
clientHeight: { configurable: true, value: params.clientHeight ?? 300 },
clientWidth: { configurable: true, value: params.clientWidth ?? 300 },
scrollHeight: { configurable: true, value: params.scrollHeight ?? params.clientHeight ?? 300 },
scrollWidth: { configurable: true, value: params.scrollWidth ?? params.clientWidth ?? 300 },
});
element.scrollLeft = params.scrollLeft ?? 0;
element.scrollTop = params.scrollTop ?? 0;
return element;
}

View File

@@ -0,0 +1,279 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DocumentFlavor, PositionedObjectLayoutType, UniverInstanceType } from '@univerjs/core';
import {
createDocsCustomBlockDrawing,
resolveDocsCustomBlockSize,
shouldUseInlineTextSelectionForDocsCustomBlockDrawing,
} from '@univerjs/docs';
import { describe, expect, it } from 'vitest';
import { resolveDocsCustomBlockRenderViewport } from '../embed-host-anchor';
describe('resolveDocsCustomBlockSize', () => {
it('uses a taller default size for docs custom blocks', () => {
expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_DOC)).toEqual({ width: 720, height: 360 });
});
it('uses a wider and taller viewport for sheet-like content in docs', () => {
expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_SHEET)).toEqual({ width: 960, height: 480 });
expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_BASE)).toEqual({ width: 960, height: 480 });
});
it('keeps slide docs blocks at a 16:9 aspect size', () => {
expect(resolveDocsCustomBlockSize(UniverInstanceType.UNIVER_SLIDE)).toEqual({ width: 720, height: 405 });
});
});
describe('createDocsCustomBlockDrawing', () => {
it('writes the resolved size to both doc transform and drawing transform', () => {
const drawing = createDocsCustomBlockDrawing({
unitId: 'doc-1',
blockId: 'block-1',
startIndex: 0,
childType: UniverInstanceType.UNIVER_BASE,
});
expect(drawing.docTransform?.size).toEqual({ width: 960, height: 480 });
expect(drawing.transform).toMatchObject({ width: 960, height: 480 });
});
it('disables host transformer and text-selection semantics for block embeds by default', () => {
const drawing = createDocsCustomBlockDrawing({
unitId: 'doc-1',
blockId: 'block-1',
startIndex: 0,
childType: UniverInstanceType.UNIVER_SHEET,
});
expect(drawing.allowTransform).toBe(false);
expect(drawing.layoutType).toBe(PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM);
expect(shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing)).toBe(false);
});
it('allows explicit inline embeds to keep text-selection semantics', () => {
const drawing = createDocsCustomBlockDrawing({
unitId: 'doc-1',
blockId: 'block-1',
startIndex: 0,
interactionMode: 'inline',
});
expect(drawing.layoutType).toBe(PositionedObjectLayoutType.INLINE);
expect(shouldUseInlineTextSelectionForDocsCustomBlockDrawing(drawing)).toBe(true);
});
});
describe('resolveDocsCustomBlockRenderViewport', () => {
it('keeps non sheet-like custom blocks at their fallback size', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_DOC,
contentHeight: 1200,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 360,
fallbackWidth: 720,
visibleCanvasHeight: 900,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual({ width: 720, height: 360 });
});
it('uses the modern visible canvas viewport for sheet-like docs blocks', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_SHEET,
docsLeft: 120,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
scale: 1,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual({
bleedLeft: 206,
bleedWidth: 1420,
contentHeight: 480,
contentWidth: 960,
height: 480,
layoutWidth: 960,
offsetLeft: 0,
pageContentWidth: 1008,
viewScale: 1,
viewportHeight: 480,
width: 960,
});
});
it('keeps document scale separate from logical sheet-like viewport dimensions', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_SHEET,
docsLeft: 120,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
scale: 2,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual(expect.objectContaining({
bleedLeft: 211,
bleedWidth: 1430,
contentHeight: 480,
viewScale: 2,
width: 960,
}));
});
it('uses actual table height for sheet-like docs blocks so docs owns vertical scrolling', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_BASE,
contentHeight: 720,
docsLeft: 120,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
scale: 1,
visibleCanvasHeight: 900,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual(expect.objectContaining({
contentHeight: 720,
height: 720,
visibleCanvasHeight: 900,
viewportHeight: 720,
}));
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_BASE,
contentHeight: 1200,
docsLeft: 120,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
scale: 1,
visibleCanvasHeight: 900,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual(expect.objectContaining({
contentHeight: 1200,
height: 1200,
visibleCanvasHeight: 900,
viewportHeight: 900,
}));
});
it('keeps sheet-like layout within page content while the modern docs viewport bleeds', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_SHEET,
contentWidth: 1600,
docsLeft: 120,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
scale: 1,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual(expect.objectContaining({
bleedWidth: 1420,
contentWidth: 1600,
layoutWidth: 1008,
width: 1008,
}));
});
it('keeps narrow sheet-like docs blocks at their actual content width', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_SHEET,
contentWidth: 420,
docsLeft: 120,
documentFlavor: DocumentFlavor.MODERN,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 96,
pageMarginRight: 96,
pageWidth: 1200,
scale: 1,
visibleCanvasLeft: 0,
visibleCanvasWidth: 1440,
})).toEqual(expect.objectContaining({
contentWidth: 420,
layoutWidth: 420,
width: 420,
}));
});
it('falls back to page content width outside modern docs', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_BASE,
contentWidth: 1600,
documentFlavor: DocumentFlavor.TRADITIONAL,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 120,
pageMarginRight: 120,
pageWidth: 840,
})).toEqual({
contentHeight: 480,
contentWidth: 1600,
height: 480,
layoutWidth: 600,
offsetLeft: 0,
pageContentWidth: 600,
viewScale: 1,
viewportHeight: 480,
width: 600,
});
});
it('keeps narrow sheet-like docs blocks at their actual content width outside modern docs', () => {
expect(resolveDocsCustomBlockRenderViewport({
childType: UniverInstanceType.UNIVER_BASE,
contentWidth: 420,
documentFlavor: DocumentFlavor.TRADITIONAL,
fallbackHeight: 480,
fallbackWidth: 960,
pageMarginLeft: 120,
pageMarginRight: 120,
pageWidth: 840,
})).toEqual({
contentHeight: 480,
contentWidth: 420,
height: 480,
layoutWidth: 420,
offsetLeft: 0,
pageContentWidth: 600,
viewScale: 1,
viewportHeight: 480,
width: 420,
});
});
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IConfigService, Univer, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverMobileUIPlugin } from '@univerjs/ui';
import { expect, it, onTestFinished } from 'vitest';
import { DOCS_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import { UniverDocsMobileUIPlugin } from '../mobile-plugin';
it.each([undefined, {}, { fitToWidth: { paddingX: 32 } }])('keeps mobile fit-to-width defaults when registered with %j', (config) => {
const univer = new Univer();
onTestFinished(() => univer.dispose());
univer.registerPlugin(UniverRenderEnginePlugin);
univer.registerPlugin(UniverMobileUIPlugin);
univer.registerPlugin(UniverDocsPlugin);
univer.registerPlugin(UniverDocsMobileUIPlugin, config);
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'doc-1' });
const configService = univer.__getInjector().get(IConfigService);
expect(configService.getConfig(DOCS_UI_PLUGIN_CONFIG_KEY)).toMatchObject({
fitToWidth: {
mode: 'fit-width',
target: 'viewport',
paddingX: config?.fitToWidth?.paddingX ?? 12,
minScale: 0,
maxScale: 1,
align: 'center',
},
});
});

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { UniverRenderingContext } from '@univerjs/engine-render';
import { ThemeService } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import {
DOC_CUSTOM_RANGE_CHROME_BACKGROUND_ALPHA,
DOC_CUSTOM_RANGE_CHROME_RADIUS,
drawDocCustomRangeChrome,
resolveDocCustomRangeChromeTheme,
} from '../doc-custom-range-chrome';
describe('Doc custom range chrome', () => {
it('derives a subtle background and border from the current primary theme token', () => {
const themeService = new ThemeService();
const currentTheme = themeService.getCurrentTheme();
themeService.setTheme({
...currentTheme,
primary: {
...currentTheme.primary,
600: '#123456',
},
});
expect(resolveDocCustomRangeChromeTheme(themeService)).toEqual({
background: `rgba(18,52,86,${DOC_CUSTOM_RANGE_CHROME_BACKGROUND_ALPHA})`,
border: '#123456',
});
expect(DOC_CUSTOM_RANGE_CHROME_RADIUS).toBe(1);
themeService.dispose();
});
it('draws a filled rounded rectangle before its outline', () => {
const calls: string[] = [];
const ctx = {
beginPath: vi.fn(() => {
calls.push('beginPath');
}),
fill: vi.fn(() => {
calls.push('fill');
}),
fillStyle: '',
lineWidth: 0,
restore: vi.fn(() => {
calls.push('restore');
}),
roundRect: vi.fn(() => {
calls.push('roundRect');
}),
save: vi.fn(() => {
calls.push('save');
}),
stroke: vi.fn(() => {
calls.push('stroke');
}),
strokeStyle: '',
} satisfies Pick<
UniverRenderingContext,
| 'beginPath'
| 'fill'
| 'fillStyle'
| 'lineWidth'
| 'restore'
| 'roundRect'
| 'save'
| 'stroke'
| 'strokeStyle'
>;
drawDocCustomRangeChrome(ctx, {
bottom: 28,
left: 10,
right: 50,
top: 8,
}, {
background: 'rgba(18,52,86,0.07)',
border: '#123456',
});
expect(ctx.roundRect).toHaveBeenCalledWith(10, 8, 40, 20, 1);
expect(calls).toEqual([
'save',
'beginPath',
'roundRect',
'fill',
'stroke',
'restore',
]);
});
});

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { getTextRunAtPosition } from '../paragraph';
describe('getTextRunAtPosition', () => {
it('uses the concrete default text color without overriding inherited or cached colors', () => {
const defaultStyle = { cl: { rgb: '#F7F9FC' }, ff: 'Arial', fs: 11 };
expect(getTextRunAtPosition({ dataStream: '\r\n' }, 0, defaultStyle, null).ts).toEqual({
cl: { rgb: '#F7F9FC' },
});
expect(getTextRunAtPosition({
dataStream: 'A\r\n',
textRuns: [{ st: 0, ed: 1, ts: { cl: { rgb: '#000000' } } }],
}, 1, defaultStyle, null).ts).toEqual({
cl: { rgb: '#000000' },
});
expect(getTextRunAtPosition({ dataStream: '\r\n' }, 0, defaultStyle, { cl: { rgb: '#FF0000' } }).ts).toEqual({
cl: { rgb: '#FF0000' },
});
expect(getTextRunAtPosition({ dataStream: '\r\n' }, 0, defaultStyle, null, true).ts).toEqual({
cl: { rgb: '#F7F9FC' },
});
expect(getTextRunAtPosition({
dataStream: 'A\r\n',
textRuns: [{ st: 0, ed: 1, ts: { cl: { rgb: '#000000' } } }],
}, 1, defaultStyle, null, true).ts).toEqual({
cl: { rgb: '#000000' },
});
});
});

View File

@@ -0,0 +1,60 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, Nullable } from '@univerjs/core';
import type { DocBackground, Documents, Engine, IRender, IRenderContext, IRenderManagerService, Scene } from '@univerjs/engine-render';
import { DOCS_VIEW_KEY } from './docs-view-key';
export interface IDocObjectParam {
document: Documents;
docBackground: DocBackground;
scene: Scene;
engine: Engine;
}
export function neoGetDocObject(renderContext: IRender | IRenderContext<DocumentDataModel>) {
const { mainComponent, scene, engine, components } = renderContext;
const document = mainComponent as Documents;
const docBackground = components.get(DOCS_VIEW_KEY.BACKGROUND) as DocBackground;
return {
document,
docBackground,
scene,
engine,
};
}
export function getDocObjectById(
unitId: string,
renderManagerService: IRenderManagerService
): Nullable<IDocObjectParam> {
const currentRender = renderManagerService.getRenderUnitById(unitId);
if (currentRender == null) {
return;
}
const { mainComponent, scene, engine, components } = currentRender;
const document = mainComponent as Documents;
const docBackground = components.get(DOCS_VIEW_KEY.BACKGROUND) as DocBackground;
return {
document,
docBackground,
scene,
engine,
};
}

View File

@@ -0,0 +1,128 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CustomDecorationType, DocumentDataModel, IAccessor, IMutationInfo } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { BuildTextUtils, IUniverInstanceService, JSONX, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
interface IAddCustomDecorationParam {
unitId: string;
ranges: ITextRangeWithStyle[];
segmentId?: string;
id: string;
type: CustomDecorationType;
}
export function addCustomDecorationFactory(param: IAddCustomDecorationParam) {
const { unitId, ranges, id, type, segmentId } = param;
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: undefined,
// noHistory: true,
segmentId,
},
};
const jsonX = JSONX.getInstance();
const textX = BuildTextUtils.customDecoration.add({ ranges, id, type });
doMutation.params.actions = jsonX.editOp(textX.serialize());
return doMutation;
}
interface IAddCustomDecorationFactoryParam {
segmentId?: string;
id: string;
type: CustomDecorationType;
unitId?: string;
}
export function addCustomDecorationBySelectionFactory(accessor: IAccessor, param: IAddCustomDecorationFactoryParam) {
const { segmentId, id, type, unitId: propUnitId } = param;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const documentDataModel = propUnitId ?
univerInstanceService.getUnit<DocumentDataModel>(propUnitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!documentDataModel) {
return false;
}
const unitId = documentDataModel.getUnitId();
const selections = docSelectionManagerService.getTextRanges({ unitId, subUnitId: unitId });
if (!selections) {
return false;
}
const body = documentDataModel.getBody();
if (!body) {
return false;
}
const doMutation = addCustomDecorationFactory(
{
unitId,
ranges: selections as ITextRangeWithStyle[],
id,
type,
segmentId,
}
);
return doMutation;
}
export interface IDeleteCustomRangeParam {
unitId: string;
id: string;
segmentId?: string;
}
export function deleteCustomDecorationFactory(accessor: IAccessor, params: IDeleteCustomRangeParam) {
const { unitId, id, segmentId } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId);
if (!documentDataModel) {
return false;
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: undefined,
// noHistory: true,
segmentId,
},
};
const textX = BuildTextUtils.customDecoration.delete({ id, segmentId, documentDataModel });
if (!textX) {
return false;
}
const jsonX = JSONX.getInstance();
doMutation.params.actions = jsonX.editOp(textX.serialize());
return doMutation;
}

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { ThemeService } from '@univerjs/core';
import type { UniverRenderingContext } from '@univerjs/engine-render';
import { ColorKit } from '@univerjs/core';
export const DOC_CUSTOM_RANGE_CHROME_RADIUS = 1;
export const DOC_CUSTOM_RANGE_CHROME_BACKGROUND_ALPHA = 0.07;
export interface IDocCustomRangeChromeRect {
bottom: number;
left: number;
right: number;
top: number;
}
export interface IDocCustomRangeChromeTheme {
background: string;
border: string;
}
export function resolveDocCustomRangeChromeTheme(
themeService: ThemeService
): IDocCustomRangeChromeTheme {
const primary = themeService.getColorFromTheme('primary.600');
return {
background: new ColorKit(primary)
.setAlpha(DOC_CUSTOM_RANGE_CHROME_BACKGROUND_ALPHA)
.toRgbString(),
border: primary,
};
}
export function drawDocCustomRangeChrome(
ctx: Pick<
UniverRenderingContext,
| 'beginPath'
| 'fill'
| 'fillStyle'
| 'lineWidth'
| 'restore'
| 'roundRect'
| 'save'
| 'stroke'
| 'strokeStyle'
>,
rect: IDocCustomRangeChromeRect,
theme: IDocCustomRangeChromeTheme
): void {
ctx.save();
ctx.fillStyle = theme.background;
ctx.strokeStyle = theme.border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(
rect.left,
rect.top,
rect.right - rect.left,
rect.bottom - rect.top,
DOC_CUSTOM_RANGE_CHROME_RADIUS
);
ctx.fill();
ctx.stroke();
ctx.restore();
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export enum DOCS_VIEW_KEY {
MAIN = '__Document_Render_Main__',
BACKGROUND = '__Document_Render_Background__',
}
export enum VIEWPORT_KEY {
VIEW_MAIN = 'viewMain',
VIEW_TOP = 'viewTop',
VIEW_LEFT = 'viewLeft',
VIEW_LEFT_TOP = 'viewLeftTop',
}
export const DOCS_COMPONENT_BACKGROUND_LAYER_INDEX = 0;
export const DOCS_COMPONENT_MAIN_LAYER_INDEX = 2;
export const DOCS_COMPONENT_HEADER_LAYER_INDEX = 4;
export const DOCS_COMPONENT_DEFAULT_Z_INDEX = 10;
export const NORMAL_TEXT_SELECTION_PLUGIN_NAME = 'normalTextSelectionPluginName';

View File

@@ -0,0 +1,39 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export function isElementVisible(element: HTMLElement | null) {
if (!element) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0') {
return false;
}
const rect = element.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return false;
}
if (rect.bottom < 0 || rect.top > window.innerHeight ||
rect.right < 0 || rect.left > window.innerWidth) {
return false;
}
return true;
}

View File

@@ -0,0 +1,102 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICustomDecoration, ICustomRange, ICustomTable, IDocumentBody, IParagraph, ITextRun, ITextStyle, Nullable } from '@univerjs/core';
import { DataStreamTreeTokenType } from '@univerjs/core';
export function isTopLevelStructuralGap(dataStream: string, offset: number): boolean {
const previousToken = dataStream[offset - 1];
const nextToken = dataStream[offset];
return previousToken === DataStreamTreeTokenType.BLOCK_END ||
previousToken === DataStreamTreeTokenType.TABLE_END ||
previousToken === DataStreamTreeTokenType.COLUMN_GROUP_END ||
nextToken === DataStreamTreeTokenType.BLOCK_START ||
nextToken === DataStreamTreeTokenType.TABLE_START ||
nextToken === DataStreamTreeTokenType.COLUMN_GROUP_START;
}
export function hasParagraphInTable(paragraph: IParagraph, tables: ICustomTable[]) {
return tables.some((table) => paragraph.startIndex > table.startIndex && paragraph.startIndex < table.endIndex);
}
export function getTextRunAtPosition(
body: IDocumentBody,
position: number,
defaultStyle: ITextStyle,
cacheStyle: Nullable<ITextStyle>,
isCellEditor?: boolean
): ITextRun {
const { textRuns = [], dataStream } = body;
const isFormula = isCellEditor && dataStream.startsWith('=');
const retTextRun: ITextRun = {
st: 0,
ed: 0,
ts: !defaultStyle.cl ? {} : { cl: { ...defaultStyle.cl } },
};
if (isFormula) {
return retTextRun;
}
for (let i = textRuns.length - 1; i >= 0; i--) {
const textRun = textRuns[i];
const { st, ed } = textRun;
if (position > st && position <= ed) {
retTextRun.st = st;
retTextRun.ed = ed;
retTextRun.ts = {
...retTextRun.ts,
...textRun.ts,
};
}
}
if (position === 0) {
const textRun = textRuns?.[0];
if (textRun && textRun.st === 0) {
retTextRun.ts = {
...retTextRun.ts,
...textRun.ts,
};
}
}
if (cacheStyle) {
retTextRun.ts = {
...retTextRun.ts,
...cacheStyle,
};
}
return retTextRun;
}
export function getCustomRangeAtPosition(customRanges: ICustomRange[], position: number, extendRange?: boolean) {
if (extendRange) {
const range = customRanges.find((customRange) => position >= customRange.startIndex && position <= customRange.endIndex + 1);
return range?.wholeEntity ? null : range;
}
const range = customRanges.find((customRange) => position > customRange.startIndex && position <= customRange.endIndex);
return range?.wholeEntity ? null : range;
}
export function getCustomDecorationAtPosition(customDecorations: ICustomDecoration[], position: number) {
return customDecorations.filter((customDecoration) => position > customDecoration.startIndex && position <= customDecoration.endIndex);
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData } from '@univerjs/core';
import { DataStreamTreeTokenType } from '@univerjs/core';
export function findFirstCursorOffset(snapshot: IDocumentData) {
const { dataStream } = snapshot.body ?? {};
const EXCLUDE_TOKENS: string[] = [
DataStreamTreeTokenType.TABLE_START,
DataStreamTreeTokenType.TABLE_CELL_END,
DataStreamTreeTokenType.TABLE_CELL_START,
DataStreamTreeTokenType.TABLE_END,
DataStreamTreeTokenType.TABLE_ROW_END,
DataStreamTreeTokenType.TABLE_ROW_START,
DataStreamTreeTokenType.COLUMN_BREAK,
DataStreamTreeTokenType.PAGE_BREAK,
DataStreamTreeTokenType.TAB,
DataStreamTreeTokenType.DOCS_END,
DataStreamTreeTokenType.CUSTOM_BLOCK,
];
if (typeof dataStream === 'string') {
for (let i = 0; i < dataStream.length; i++) {
const char = dataStream[i];
if (!EXCLUDE_TOKENS.includes(char)) {
return i;
}
}
}
return 0;
}

View File

@@ -0,0 +1,213 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '@univerjs/core';
import type {
IDocumentSkeletonCached,
IDocumentSkeletonLine,
IDocumentSkeletonPage,
IDocumentSkeletonRow,
IDocumentSkeletonTable,
} from '@univerjs/engine-render';
import { DocumentSkeletonPageType, getLastColumn, getLastLine, lineIterator } from '@univerjs/engine-render';
export function firstLineInTable(table: IDocumentSkeletonTable) {
const firstRow = table.rows[0];
const firstCell = firstRow.cells[0];
const firstLine = firstCell.sections[0].columns[0].lines[0];
return firstLine;
}
export function lastLineInTable(table: IDocumentSkeletonTable) {
const lastRow = table.rows[table.rows.length - 1];
const lastCell = lastRow.cells[lastRow.cells.length - 1];
const lastLine = getLastLine(lastCell);
return lastLine;
}
export function findTableAfterLine(line: IDocumentSkeletonLine, page: IDocumentSkeletonPage) {
const { ed } = line;
const { skeTables } = page;
let table = null;
for (const t of skeTables.values()) {
if (t.st === ed + 1) {
table = t;
break;
}
}
return table;
}
export function findLineBeforeAndAfterTable(table: Nullable<IDocumentSkeletonTable>) {
const tablePage = table?.parent as IDocumentSkeletonPage;
let lineBeforeTable = null;
let lineAfterTable = null;
if (table == null || tablePage == null) {
return {
lineBeforeTable,
lineAfterTable,
};
}
const { st, ed } = table;
const pages = tablePage.type === DocumentSkeletonPageType.CELL ? [tablePage] : (tablePage.parent as IDocumentSkeletonCached).pages;
lineIterator(pages, (l) => {
if (l.st === ed + 1) {
lineAfterTable = l;
} else if (l.ed === st - 1) {
lineBeforeTable = l;
}
});
return {
lineBeforeTable,
lineAfterTable,
};
}
function isEmptyCellPage(cell: IDocumentSkeletonPage) {
return cell.sections[0].columns[0].lines.length === 0;
}
export function findBellowCell(cell: IDocumentSkeletonPage): Nullable<IDocumentSkeletonPage> {
const row = cell.parent as IDocumentSkeletonRow;
const table = row?.parent as IDocumentSkeletonTable;
const tableId = table?.tableId;
if (row == null || table == null) {
return;
}
const col = row.cells.indexOf(cell);
let bellowRow = table.rows[table.rows.indexOf(row) + 1];
if (bellowRow == null) {
if (tableId.indexOf('#-#')) {
const [id, index] = tableId.split('#-#');
const pages = (table.parent?.parent as IDocumentSkeletonCached)?.pages;
const nextTableId = `${id}#-#${Number.parseInt(index) + 1}`;
if (pages) {
for (const page of pages) {
const { skeTables } = page;
if (skeTables.has(nextTableId)) {
const nextTable = skeTables.get(nextTableId);
if (nextTable?.rows.length) {
bellowRow = nextTable.rows.find((r) => !r.isRepeatRow)!;
break;
}
}
}
}
}
}
if (bellowRow != null) {
const cell = bellowRow.cells[col];
return isEmptyCellPage(cell) ? findBellowCell(cell) : cell;
}
}
export function findAboveCell(cell: IDocumentSkeletonPage): Nullable<IDocumentSkeletonPage> {
const row = cell.parent as IDocumentSkeletonRow;
const table = row?.parent as IDocumentSkeletonTable;
if (row == null || table == null) {
return;
}
let aboveRow = table.rows[table.rows.indexOf(row) - 1];
const col = row.cells.indexOf(cell);
if (aboveRow == null || aboveRow.isRepeatRow) {
if (table.tableId.indexOf('#-#')) {
const [id, index] = table.tableId.split('#-#');
const pages = (table.parent?.parent as IDocumentSkeletonCached)?.pages;
const nextTableId = `${id}#-#${Number.parseInt(index) - 1}`;
if (pages) {
for (const page of pages) {
const { skeTables } = page;
if (skeTables.has(nextTableId)) {
const nextTable = skeTables.get(nextTableId);
if (nextTable?.rows.length) {
aboveRow = nextTable.rows[nextTable.rows.length - 1];
break;
}
}
}
}
}
}
if (aboveRow != null) {
const cell = aboveRow.cells[col];
return isEmptyCellPage(cell) ? findAboveCell(cell) : cell;
}
}
export function findTableBeforeLine(line: IDocumentSkeletonLine, page: IDocumentSkeletonPage) {
const { st } = line;
const { skeTables } = page;
let table = null;
for (const t of skeTables.values()) {
if (t.ed === st - 1) {
table = t;
break;
}
}
return table;
}
export function firstLineInCell(cell: IDocumentSkeletonPage) {
const firstLine = cell.sections[0].columns[0].lines[0];
return firstLine;
}
export function lastLineInCell(cell: IDocumentSkeletonPage) {
const lastColumn = getLastColumn(cell);
const lastLine = lastColumn.lines[lastColumn.lines.length - 1];
return lastLine;
}
export function isFirstLineInCell(line: IDocumentSkeletonLine, cell: IDocumentSkeletonPage) {
const firstLine = firstLineInCell(cell);
return line === firstLine;
}
export function isLastLineInCell(line: IDocumentSkeletonLine, cell: IDocumentSkeletonPage) {
const lastLine = lastLineInCell(cell);
return line === lastLine;
}

View File

@@ -0,0 +1,444 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector } from '@univerjs/core';
import { awaitTime, CustomRangeType, DataStreamTreeTokenType, DocumentBlockRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, NamedStyleType, PresetListType, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateChangeManagerService, DocStateEmitService, InsertTextCommand, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
import { BreakLineCommand, BreakLineInsertionMode } from '../break-line.command';
import { createCommandTestBed } from './create-command-test-bed';
it('keeps typing in the footnote after inserting a paragraph', async () => {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.add([DocMenuStyleService]);
injector.get(DocStateChangeManagerService);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'note-input',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: 'Body\uFFFC\r\n', paragraphs: [{ paragraphId: 'p', startIndex: 5 }], sectionBreaks: [{ sectionId: 's', startIndex: 6 }], customRanges: [{ rangeId: 'reference', rangeType: CustomRangeType.FOOTNOTE, startIndex: 4, endIndex: 4, properties: { noteId: 'note' } }] },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: '\r\n', paragraphs: [{ paragraphId: 'np', startIndex: 0 }] } } },
});
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
const commands = injector.get(ICommandService);
[RichTextEditingMutation, InsertTextCommand, BreakLineCommand]
.forEach((command) => commands.registerCommand(command));
const selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'note-input', subUnitId: 'note-input' });
selections.replaceSelectionInfoWithoutRefresh({
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true, isActive: true, segmentId: 'note' }],
rectRanges: [],
segmentId: '',
segmentPage: -1,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
try {
const noteId = Object.keys(doc.getSnapshot().notes!)[0];
for (const text of ['First', ' ', 'line', '\r', 'Next', ' ', 'line']) {
const range = selections.getActiveTextRange()!;
expect(range.segmentId).toBe(noteId);
if (text === '\r') {
expect(commands.syncExecuteCommand(BreakLineCommand.id)).toBe(true);
} else {
expect(commands.syncExecuteCommand(InsertTextCommand.id, {
unitId: doc.getUnitId(),
range,
body: { dataStream: text },
})).toBe(true);
}
await Promise.resolve();
}
expect(selections.getActiveTextRange()).toMatchObject({ segmentId: noteId, startOffset: 20 });
expect(doc.getBody()?.dataStream).toBe('Body\uFFFC\r\n');
expect(doc.getSnapshot().notes?.[noteId].body.dataStream).toBe('First line\rNext line\r\n');
} finally {
univer.dispose();
}
});
function getDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'Title\r\n',
textRuns: [{
st: 0,
ed: 5,
ts: {},
}],
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_1', startIndex: 5, paragraphStyle: {
headingId: 'heading-1',
namedStyleType: NamedStyleType.HEADING_1,
} }],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getDocumentDataWithColumnGroup(): IDocumentData {
const T = DataStreamTreeTokenType;
const columnGroupStream = `${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}`;
return {
id: 'test-doc',
body: {
dataStream: `Before${T.PARAGRAPH}${columnGroupStream}${T.PARAGRAPH}${T.SECTION_BREAK}`,
textRuns: [],
paragraphs: [
{ paragraphId: 'before', startIndex: 6 },
{ paragraphId: 'left', startIndex: 10 },
{ paragraphId: 'right', startIndex: 14 },
{ paragraphId: 'after', startIndex: 17 },
],
sectionBreaks: [{ sectionId: 'section_fixture_201', startIndex: 18 }],
columnGroups: [{ columnGroupId: 'cg-1', startIndex: 7, endIndex: 16 }],
},
documentStyle: {
documentFlavor: DocumentFlavor.MODERN,
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getStyledListDocumentData(documentFlavor: DocumentFlavor): IDocumentData {
const T = DataStreamTreeTokenType;
return {
id: 'test-doc',
body: {
dataStream: `A${T.PARAGRAPH}${T.SECTION_BREAK}`,
textRuns: [{
st: 0,
ed: 1,
ts: { fs: 24, cl: { rgb: '#00FF00' } },
}],
paragraphs: [{
paragraphId: 'styled-list',
startIndex: 1,
bullet: {
listId: 'list-1',
listType: PresetListType.BULLET_LIST,
nestingLevel: 0,
},
}],
sectionBreaks: [{ sectionId: 'section_fixture_204', startIndex: 2 }],
},
documentStyle: {
documentFlavor,
pageSize: { width: 594.3, height: 840.51 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getDocumentDataWithTable(documentFlavor: DocumentFlavor): IDocumentData {
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}`;
return {
id: 'test-doc',
body: {
dataStream: `${tableStream}${T.PARAGRAPH}${T.SECTION_BREAK}`,
paragraphs: [
{ paragraphId: 'cell', startIndex: 4 },
{ paragraphId: 'after', startIndex: tableStream.length },
],
sectionBreaks: [
{ sectionId: 'cell-section', startIndex: 5 },
{ sectionId: 'section_fixture_203', startIndex: tableStream.length + 1 },
],
tables: [{ tableId: 'table-1', startIndex: 0, endIndex: tableStream.length }],
},
documentStyle: {
documentFlavor,
pageSize: { width: 594.3, height: 840.51 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getDocumentDataWithAdjacentBlocks(): IDocumentData {
const T = DataStreamTreeTokenType;
return {
id: 'test-doc',
body: {
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: [
{ paragraphId: 'code-paragraph', startIndex: 2 },
{ paragraphId: 'callout-paragraph', startIndex: 6, paragraphStyle: { indentStart: { v: 60 } } },
{ paragraphId: 'trailing-paragraph', startIndex: 8 },
],
sectionBreaks: [{ sectionId: 'section_fixture_202', startIndex: 9 }],
blockRanges: [
{ blockId: 'code-1', blockType: DocumentBlockRangeType.CODE, startIndex: 0, endIndex: 3 },
{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 4, endIndex: 7 },
],
},
documentStyle: {
pageSize: { width: 594.3, height: 840.51 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('break line command', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
function getParagraphs() {
const univerInstanceService = get(IUniverInstanceService);
return univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)?.getBody()?.paragraphs ?? [];
}
function getBody() {
const univerInstanceService = get(IUniverInstanceService);
return univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)?.getBody();
}
function setupWithColumnGroup() {
univer.dispose();
const testBed = createCommandTestBed(getDocumentDataWithColumnGroup());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(BreakLineCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
return selectionManager;
}
function setupWithAdjacentBlocks() {
univer.dispose();
const testBed = createCommandTestBed(getDocumentDataWithAdjacentBlocks());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(BreakLineCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
return selectionManager;
}
function setupWithTable(documentFlavor: DocumentFlavor) {
univer.dispose();
const testBed = createCommandTestBed(getDocumentDataWithTable(documentFlavor));
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(BreakLineCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
return selectionManager;
}
async function executeBreakLineAndApplyRenderedSelection(selectionManager: DocSelectionManagerService) {
await commandService.executeCommand(BreakLineCommand.id);
await awaitTime(0);
if (selectionManager.getActiveTextRange() == null) {
throw new Error('BreakLineCommand did not preserve an active text range');
}
}
beforeEach(() => {
const testBed = createCommandTestBed(getDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(BreakLineCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{ startOffset: 5, endOffset: 5, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
});
afterEach(() => univer.dispose());
it('splits a heading paragraph and resets heading style for the new paragraph', async () => {
await commandService.executeCommand(BreakLineCommand.id);
await awaitTime(0);
expect(getParagraphs()).toHaveLength(2);
expect(getParagraphs()[0].paragraphStyle?.namedStyleType).toBe(NamedStyleType.HEADING_1);
expect(getParagraphs()[1].paragraphStyle?.namedStyleType).toBeUndefined();
expect(getParagraphs()[1].paragraphStyle?.headingId).toBeUndefined();
});
it.each([
DocumentFlavor.MODERN,
DocumentFlavor.TRADITIONAL,
])('keeps the current text style on an empty list paragraph in %s docs', async (documentFlavor) => {
univer.dispose();
const testBed = createCommandTestBed(getStyledListDocumentData(documentFlavor));
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(BreakLineCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{ startOffset: 1, endOffset: 1, collapsed: true, isActive: true, segmentId: '' }]);
await commandService.executeCommand(BreakLineCommand.id);
await awaitTime(0);
const body = getBody();
const emptyParagraph = body?.paragraphs?.[1];
const emptyParagraphTextRun = body?.textRuns?.find(({ st, ed }) =>
emptyParagraph != null && st <= emptyParagraph.startIndex && ed > emptyParagraph.startIndex
);
expect(emptyParagraph?.bullet?.listType).toBe(PresetListType.BULLET_LIST);
expect(emptyParagraphTextRun?.ts).toEqual({ fs: 24, cl: { rgb: '#00FF00' } });
});
it('keeps the cursor fixed for explicit gap insertion mode', async () => {
const selectionManager = get(DocSelectionManagerService);
await commandService.executeCommand(BreakLineCommand.id, { insertionMode: BreakLineInsertionMode.InsertGap });
await awaitTime(0);
expect(selectionManager.getActiveTextRange()).toMatchObject({ startOffset: 5, endOffset: 5 });
});
it('keeps column groups when breaking a blank paragraph below a column group', async () => {
const selectionManager = setupWithColumnGroup();
selectionManager.__TEST_ONLY_add([{ startOffset: 17, endOffset: 17, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
await executeBreakLineAndApplyRenderedSelection(selectionManager);
expect(getBody()?.columnGroups).toEqual([{ columnGroupId: 'cg-1', startIndex: 7, endIndex: 16 }]);
expect(getBody()?.dataStream).toContain(DataStreamTreeTokenType.COLUMN_GROUP_END);
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(18);
await executeBreakLineAndApplyRenderedSelection(selectionManager);
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(19);
});
it('keeps column groups wrapped when breaking at the closing column boundary', async () => {
const selectionManager = setupWithColumnGroup();
selectionManager.__TEST_ONLY_add([{ startOffset: 16, endOffset: 16, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
await commandService.executeCommand(BreakLineCommand.id);
await awaitTime(0);
expect(getBody()?.columnGroups).toEqual([{ columnGroupId: 'cg-1', startIndex: 7, endIndex: 17 }]);
expect(getBody()?.dataStream[17]).toBe(DataStreamTreeTokenType.COLUMN_GROUP_END);
});
it('inserts a paragraph between adjacent block ranges without corrupting either range', async () => {
const selectionManager = setupWithAdjacentBlocks();
selectionManager.__TEST_ONLY_add([{ startOffset: 4, endOffset: 4, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
await executeBreakLineAndApplyRenderedSelection(selectionManager);
const T = DataStreamTreeTokenType;
expect(getBody()?.dataStream).toBe(`${T.BLOCK_START}A${T.PARAGRAPH}${T.BLOCK_END}${T.PARAGRAPH}${T.BLOCK_START}B${T.PARAGRAPH}${T.BLOCK_END}${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(getBody()?.blockRanges).toEqual([
{ blockId: 'code-1', blockType: DocumentBlockRangeType.CODE, startIndex: 0, endIndex: 3 },
{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 5, endIndex: 8 },
]);
expect(getBody()?.paragraphs?.find((paragraph) => paragraph.startIndex === 4)?.paragraphStyle).toBeUndefined();
expect(getBody()?.paragraphs?.find((paragraph) => paragraph.paragraphId === 'callout-paragraph')?.paragraphStyle).toEqual({ indentStart: { v: 60 } });
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(4);
await executeBreakLineAndApplyRenderedSelection(selectionManager);
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(5);
});
it.each<{ name: string; documentFlavor: DocumentFlavor }>([
{ name: 'modern', documentFlavor: DocumentFlavor.MODERN },
{ name: 'traditional', documentFlavor: DocumentFlavor.TRADITIONAL },
])('moves the cursor after repeated Enter below a table in $name docs', async ({ documentFlavor }) => {
const selectionManager = setupWithTable(documentFlavor);
const tableEnd = getBody()?.tables?.[0].endIndex ?? 0;
selectionManager.__TEST_ONLY_add([{ startOffset: tableEnd, endOffset: tableEnd, collapsed: true, isActive: true, segmentId: '' }]);
await executeBreakLineAndApplyRenderedSelection(selectionManager);
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(tableEnd + 1);
await executeBreakLineAndApplyRenderedSelection(selectionManager);
expect(selectionManager.getActiveTextRange()?.startOffset).toBe(tableEnd + 2);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import { CustomRangeType, DocumentFlavor, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, Univer, UniverInstanceType, validateDocumentStructure } from '@univerjs/core';
import { DocSelectionManagerService, DocStateChangeManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createInternalClipboardDocData } from '../../../services/clipboard/internal-fragment';
import { InnerPasteCommand } from '../clipboard.inner.command';
const SOURCE: IDocumentData = {
id: 'source',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: {
dataStream: 'A\uFFFCB',
textRuns: [{ st: 2, ed: 3, ts: { bl: 1 } }],
customRanges: [{ startIndex: 1, endIndex: 1, wholeEntity: true, rangeId: 'reference', rangeType: CustomRangeType.FOOTNOTE, properties: { noteId: 'note' } }],
},
notes: {
note: { type: 'footnote' as const, noteId: 'note', customMark: '†', referenceTextStyle: { fs: 8, va: 3 }, body: {
dataStream: 'Explanation\r\n',
paragraphs: [{ startIndex: 11, paragraphId: 'source-paragraph' }],
sectionBreaks: [{ startIndex: 12, sectionId: 'source-section' }],
textRuns: [{ st: 0, ed: 11, ts: { fs: 9.5 } }],
} },
},
};
describe('InnerPasteCommand notes', () => {
let univer: Univer;
let document: DocumentDataModel;
let commands: ICommandService;
let selections: DocSelectionManagerService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.get(DocStateChangeManagerService);
commands = injector.get(ICommandService);
[InnerPasteCommand, RichTextEditingMutation].forEach((command) => commands.registerCommand(command));
document = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'target',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: '\r\n', customRanges: [], customDecorations: [], customBlocks: [], textRuns: [], paragraphs: [{ startIndex: 0, paragraphId: 'target-paragraph' }], sectionBreaks: [{ startIndex: 1, sectionId: 'target-section' }] },
});
injector.get(IUniverInstanceService).focusUnit(document.getUnitId());
selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'target', subUnitId: 'target' });
});
afterEach(() => univer.dispose());
async function paste(offset: number, segmentId = '') {
selections.replaceSelectionInfoWithoutRefresh({
textRanges: [{ startOffset: offset, endOffset: offset, segmentId, collapsed: true, isActive: true }],
rectRanges: [],
segmentId,
segmentPage: -1,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
return commands.executeCommand(InnerPasteCommand.id, {
doc: createInternalClipboardDocData(SOURCE),
segmentId,
textRanges: [{ startOffset: offset + 3, endOffset: offset + 3, collapsed: true, segmentId }],
});
}
it('copies independent rich notes and restores their identities through undo and redo', async () => {
const before = structuredClone(document.getSnapshot());
expect(await paste(0)).toBe(true);
const first = structuredClone(document.getSnapshot());
const firstId = Object.keys(first.notes!)[0];
expect(firstId).not.toBe('note');
expect(first.notes![firstId]).toMatchObject({ customMark: '†', referenceTextStyle: { fs: 8, va: 3 } });
expect(first.notes![firstId].body.textRuns).toEqual(SOURCE.notes!.note.body.textRuns);
commands.syncExecuteCommand(UndoCommand.id);
expect(document.getSnapshot()).toEqual(before);
commands.syncExecuteCommand(RedoCommand.id);
expect(document.getSnapshot()).toEqual(first);
expect(await paste(3)).toBe(true);
const second = document.getSnapshot();
const ids = second.body!.customRanges!.map((range) => range.properties!.noteId);
expect(new Set(ids).size).toBe(2);
expect(Object.keys(second.notes!)).toHaveLength(2);
expect(validateDocumentStructure(second)).toEqual([]);
expect(SOURCE.notes!.note.body.paragraphs![0].paragraphId).toBe('source-paragraph');
});
it('drops references in modern mode and adjusts following text styles', async () => {
document.updateDocumentStyle({ documentFlavor: DocumentFlavor.MODERN });
expect(await paste(0)).toBe(true);
expect(document.getBody()!.dataStream).toBe('AB\r\n');
expect(document.getBody()!.textRuns).toContainEqual(expect.objectContaining({ st: 1, ed: 2, ts: { bl: 1 } }));
expect(document.getSnapshot().notes).toEqual({});
expect(validateDocumentStructure(document.getSnapshot())).toEqual([]);
});
it('rejects nested references without leaving orphan note resources', async () => {
await paste(0);
const before = structuredClone(document.getSnapshot());
const id = Object.keys(before.notes!)[0];
expect(await paste(0, id)).toBe(false);
expect(document.getSnapshot()).toEqual(before);
});
});

View File

@@ -0,0 +1,500 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector, IStyleBase, Univer } from '@univerjs/core';
import type { IDeleteTextCommandParams, IInsertTextCommandParams, IUpdateTextCommandParams } from '@univerjs/docs';
import {
awaitTime,
BooleanNumber,
CustomRangeType,
DataStreamTreeTokenType,
DeleteDirection,
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DocumentBlockRangeType,
DocumentFlavor,
HorizontalAlign,
ICommandService,
IUniverInstanceService,
UniverInstanceType,
UpdateDocsAttributeType,
} from '@univerjs/core';
import {
DeleteTextCommand,
DocSelectionManagerService,
DocSkeletonManagerService,
InsertTextCommand,
RichTextEditingMutation,
SetTextSelectionsOperation,
UpdateTextCommand,
} from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DeleteLeftCommand, DeleteRightCommand, isDeleteOffsetInsideBlockRange } from '../doc-delete.command';
import { createCommandTestBed } from './create-command-test-bed';
function getDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'Hello world\r\n',
textRuns: [{
st: 0,
ed: 11,
ts: {
bl: BooleanNumber.FALSE,
},
}],
customRanges: [{
startIndex: 6,
endIndex: 10,
rangeId: 'range-world',
rangeType: CustomRangeType.HYPERLINK,
wholeEntity: true,
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getCenteredSingleCharacterDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'A\r\n',
textRuns: [{
st: 0,
ed: 1,
ts: {},
}],
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_4', startIndex: 1, paragraphStyle: {
horizontalAlign: HorizontalAlign.CENTER,
} }],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getCenteredEmptyParagraphDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: '\r\n',
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_5', startIndex: 0, paragraphStyle: {
horizontalAlign: HorizontalAlign.CENTER,
} }],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getIndentedBlockRangeDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}\n`,
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_6', startIndex: 2, paragraphStyle: {
indentStart: { v: 22 },
} }],
blockRanges: [{
blockId: 'quote-1',
blockType: DocumentBlockRangeType.QUOTE,
startIndex: 0,
endIndex: 3,
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createFirstGlyph(paragraphIndex: number, content: string) {
const line = { paragraphIndex, divides: [] as any[] };
const divide = { parent: line, glyphGroup: [] as any[] };
const glyph = {
parent: divide,
content,
streamType: content,
count: 1,
};
divide.glyphGroup.push(glyph);
line.divides.push(divide);
return glyph;
}
describe('core editing commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let unitId = 'test-doc';
function getBody() {
const univerInstanceService = get(IUniverInstanceService);
return univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC)?.getBody();
}
function getDataStream() {
return getBody()?.dataStream ?? '';
}
function getFormatValueAt(key: keyof IStyleBase, pos: number) {
for (const textRun of getBody()?.textRuns ?? []) {
const { st, ed, ts = {} } = textRun;
if (st <= pos && ed >= pos) {
return ts[key];
}
}
}
function setActiveSelection(offset: number) {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId,
subUnitId: unitId,
});
selectionManager.__TEST_ONLY_add([{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
}
function mockSkeleton() {
const skeletonManager = get(DocSkeletonManagerService) as unknown as { getSkeleton: () => unknown };
skeletonManager.getSkeleton = () => ({
findNodeByCharIndex: () => null,
});
}
function registerDeleteKeyCommands() {
commandService.registerCommand(DeleteLeftCommand);
commandService.registerCommand(DeleteRightCommand);
commandService.registerCommand(UpdateTextCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
}
beforeEach(() => {
unitId = 'test-doc';
const testBed = createCommandTestBed(getDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(InsertTextCommand);
commandService.registerCommand(DeleteTextCommand);
commandService.registerCommand(UpdateTextCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
});
afterEach(() => univer.dispose());
it('inserts text through the real mutation chain and moves the caret', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{ startOffset: 5, endOffset: 5, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
await commandService.executeCommand<IInsertTextCommandParams>(InsertTextCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 5, endOffset: 5, collapsed: true },
body: {
dataStream: ', brave',
},
});
await awaitTime(0);
expect(getDataStream()).toBe('Hello, brave world\r\n');
});
it('deletes an entire custom range when the selection hits a whole entity', async () => {
await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 7, endOffset: 7, collapsed: true },
direction: DeleteDirection.RIGHT,
});
await awaitTime(0);
expect(getDataStream()).toBe('Hello \r\n');
expect(getBody()?.customRanges).toEqual([]);
});
it('keeps center alignment when deleting the last character from a centered paragraph', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredSingleCharacterDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(DeleteTextCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 0, endOffset: 0, collapsed: true },
direction: DeleteDirection.RIGHT,
});
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.CENTER);
});
it('keeps center alignment when backspacing the last character from a centered paragraph', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredSingleCharacterDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(DeleteTextCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 1, endOffset: 1, collapsed: true },
direction: DeleteDirection.LEFT,
});
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.CENTER);
});
it('resets an empty centered paragraph to left alignment on a second delete', async () => {
univer.dispose();
const testBed = createCommandTestBed(getCenteredEmptyParagraphDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
mockSkeleton();
setActiveSelection(0);
await commandService.executeCommand(DeleteRightCommand.id);
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.LEFT);
});
it('resets an empty centered paragraph to left alignment in a traditional document', async () => {
univer.dispose();
const documentData = getCenteredEmptyParagraphDocumentData();
documentData.documentStyle.documentFlavor = DocumentFlavor.TRADITIONAL;
const testBed = createCommandTestBed(documentData);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
mockSkeleton();
setActiveSelection(0);
await commandService.executeCommand(DeleteLeftCommand.id);
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.LEFT);
});
it.each([
[DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DeleteLeftCommand.id],
[DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DeleteRightCommand.id],
])('keeps center alignment in Sheet editor %s', async (editorUnitId, commandId) => {
univer.dispose();
const documentData = getCenteredEmptyParagraphDocumentData();
documentData.id = editorUnitId;
documentData.documentStyle.documentFlavor = DocumentFlavor.UNSPECIFIED;
unitId = editorUnitId;
const testBed = createCommandTestBed(documentData);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
mockSkeleton();
setActiveSelection(0);
await commandService.executeCommand(commandId);
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.CENTER);
});
it('resets center alignment in a non-Sheet unspecified document', async () => {
univer.dispose();
const documentData = getCenteredEmptyParagraphDocumentData();
documentData.documentStyle.documentFlavor = DocumentFlavor.UNSPECIFIED;
const testBed = createCommandTestBed(documentData);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
mockSkeleton();
setActiveSelection(0);
await commandService.executeCommand(DeleteLeftCommand.id);
await awaitTime(0);
expect(getDataStream()).toBe('\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.horizontalAlign).toBe(HorizontalAlign.LEFT);
});
it('detects delete offsets inside block ranges so backspace does not clear block paragraph indent', () => {
const dataStream = `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}\n`;
const body = {
dataStream,
blockRanges: [{
blockId: 'block-1',
blockType: DocumentBlockRangeType.QUOTE,
startIndex: 0,
endIndex: 3,
}],
};
expect(isDeleteOffsetInsideBlockRange(body, 1)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 2)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 0)).toBe(false);
expect(isDeleteOffsetInsideBlockRange(body, 3)).toBe(false);
});
it('does not reinterpret a malformed block range endIndex', () => {
const dataStream = `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}\n`;
const body = {
dataStream,
blockRanges: [{
blockId: 'block-1',
blockType: DocumentBlockRangeType.CALLOUT,
startIndex: 0,
endIndex: 2,
}],
};
expect(isDeleteOffsetInsideBlockRange(body, 1)).toBe(true);
expect(isDeleteOffsetInsideBlockRange(body, 2)).toBe(false);
expect(isDeleteOffsetInsideBlockRange(body, 3)).toBe(false);
});
it('does not clear paragraph indent when backspacing at the start of a block range paragraph', async () => {
univer.dispose();
const testBed = createCommandTestBed(getIndentedBlockRangeDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
registerDeleteKeyCommands();
setActiveSelection(1);
const curGlyph = createFirstGlyph(2, 'A');
const preGlyph = createFirstGlyph(2, DataStreamTreeTokenType.BLOCK_START);
const skeletonManager = get(DocSkeletonManagerService) as unknown as { getSkeleton: () => unknown };
skeletonManager.getSkeleton = () => ({
findNodeByCharIndex: (offset: number) => offset === 1 ? curGlyph : preGlyph,
});
const originalExecuteCommand = commandService.executeCommand.bind(commandService);
const executeSpy = vi.spyOn(commandService, 'executeCommand').mockImplementation(async (id: string, params?: object) => {
if (id === DeleteLeftCommand.id) {
return originalExecuteCommand(id, params);
}
return true;
});
await commandService.executeCommand(DeleteLeftCommand.id);
expect(executeSpy).not.toHaveBeenCalledWith(UpdateTextCommand.id, expect.anything());
expect(executeSpy).toHaveBeenCalledWith(DeleteTextCommand.id, expect.objectContaining({
direction: DeleteDirection.LEFT,
}));
});
it('updates text styles through the shared rich text mutation flow', async () => {
await commandService.executeCommand<IUpdateTextCommandParams>(UpdateTextCommand.id, {
unitId: 'test-doc',
segmentId: '',
range: { startOffset: 0, endOffset: 5, collapsed: false },
coverType: UpdateDocsAttributeType.REPLACE,
updateBody: {
dataStream: 'Hello',
textRuns: [{
st: 0,
ed: 5,
ts: {
bl: BooleanNumber.TRUE,
},
}],
},
textRanges: [{
startOffset: 5,
endOffset: 5,
collapsed: true,
}],
});
await awaitTime(0);
expect(getDataStream()).toBe('Hello world\r\n');
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.TRUE);
});
});

View File

@@ -0,0 +1,255 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable ts/no-explicit-any */
import type { Dependency, DocumentDataModel, IDocumentData, Nullable } from '@univerjs/core';
import type { DocumentSkeleton, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import {
BooleanNumber,
DisposableCollection,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
ILogService,
Inject,
Injector,
IUndoRedoService,
IUniverInstanceService,
LogLevel,
Plugin,
RxDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocBlockMoveValidatorService,
DocSelectionManagerService,
DocSkeletonManagerService,
DocStateChangeManagerService,
DocStateEmitService,
} from '@univerjs/docs';
import { DocumentViewModel, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { BehaviorSubject, takeUntil } from 'rxjs';
import { DocHtmlExportService } from '../../../services/clipboard/udm-to-html/doc-html-export.service';
import { DocIMEInputManagerService } from '../../../services/doc-ime-input-manager.service';
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
import { DocSelectionRenderService } from '../../../services/selection/doc-selection-render.service';
const TEST_DOCUMENT_DATA_EN: IDocumentData = {
id: 'test-doc',
body: {
dataStream: 'What’s New in the 2022\r Gartner Hype Cycle for Emerging Technologies\r\n',
textRuns: [
{
st: 0,
ed: 22,
ts: {
bl: BooleanNumber.FALSE,
fs: 24,
cl: {
rgb: 'rgb(0, 40, 86)',
},
},
},
{
st: 23,
ed: 68,
ts: {
bl: BooleanNumber.TRUE,
fs: 24,
cl: {
rgb: 'rgb(0, 40, 86)',
},
},
},
],
paragraphs: [
{ paragraphId: 'para_docs_ui_fixture_7', startIndex: 22 },
{ paragraphId: 'para_docs_ui_fixture_8', startIndex: 68, paragraphStyle: {
spaceAbove: { v: 20 },
indentFirstLine: { v: 20 },
} },
],
sectionBreaks: [],
customBlocks: [],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
export function createCommandTestBed(docData?: IDocumentData, dependencies?: Dependency[]) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
injector.get(IUndoRedoService);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocBlockMoveValidatorService]);
injector.add([DocMenuStyleService]);
injector.add([DocStateEmitService]);
injector.add([DocStateChangeManagerService]);
injector.add([DocIMEInputManagerService]);
injector.add([DocSelectionRenderService]);
injector.add([DocHtmlExportService]);
dependencies?.forEach((d) => injector.add(d));
}
override onReady(): void {
this._injector.get(DocStateChangeManagerService);
}
}
univer.registerPlugin(TestPlugin);
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, docData || TEST_DOCUMENT_DATA_EN);
const univerInstanceService = get(IUniverInstanceService);
// NOTE: This is pretty hack for the test. But with these hacks we can avoid to create
// real canvas-environment in univerjs/docs. If some we have to do that, this hack could be removed.
// Refer to packages/sheets-ui/src/services/clipboard/__tests__/clipboard-test-bed.ts
const fakeDocSkeletonManager = new MockDocSkeletonManagerService({
unit: doc,
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: null as any,
scene: null as any,
mainComponent: null as any,
components: null as any,
isMainScene: true,
activated$: new BehaviorSubject(true),
activate: () => {},
deactivate: () => {},
}, univerInstanceService);
injector.add([DocSkeletonManagerService, { useValue: fakeDocSkeletonManager as unknown as DocSkeletonManagerService }]);
const renderManagerService = get(IRenderManagerService);
if (renderManagerService.getRenderUnitById(doc.getUnitId()) == null) {
renderManagerService.addRender(doc.getUnitId(), {
unitId: doc.getUnitId(),
type: UniverInstanceType.UNIVER_DOC,
engine: new DisposableCollection() as any,
scene: new DisposableCollection() as any,
mainComponent: null as any,
components: new Map(),
isMainScene: true,
activated$: new BehaviorSubject(true),
with: injector.get.bind(injector),
activate: () => {},
deactivate: () => {},
isDisposed: () => false,
});
}
univerInstanceService.focusUnit(doc.getUnitId());
const logService = get(ILogService);
logService.setLogLevel(LogLevel.SILENT);
return {
univer,
get,
doc,
injector,
};
}
export class MockDocSkeletonManagerService extends RxDisposable implements IRenderModule {
private _docViewModel: DocumentViewModel;
private readonly _currentSkeleton$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly currentSkeleton$ = this._currentSkeleton$.asObservable();
// CurrentSkeletonBefore for pre-triggered logic during registration
private readonly _currentSkeletonBefore$ = new BehaviorSubject<Nullable<DocumentSkeleton>>(null);
readonly currentSkeletonBefore$ = this._currentSkeletonBefore$.asObservable();
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._update();
this._univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)
.pipe(takeUntil(this.dispose$))
.subscribe((documentModel) => {
if (documentModel?.getUnitId() === this._context.unitId) {
this._update();
}
});
}
override dispose(): void {
super.dispose();
this._currentSkeletonBefore$.complete();
this._currentSkeleton$.complete();
}
private _update() {
const documentDataModel = this._context.unit;
const unitId = this._context.unitId;
// No need to build view model, if data model has no body.
if (documentDataModel.getBody() == null) {
return;
}
// Always need to reset document data model, because cell editor change doc instance every time.
if (this._docViewModel && unitId === DOCS_NORMAL_EDITOR_UNIT_ID_KEY) {
this._docViewModel.reset(documentDataModel);
} else if (!this._docViewModel) {
this._docViewModel = this._buildDocViewModel(documentDataModel);
}
}
getSkeleton(): DocumentSkeleton {
throw new Error('[MockDocSkeletonManagerService]: cannot access to doc skeleton in unit tests!');
}
getViewModel(): DocumentViewModel {
return this._docViewModel;
}
private _buildDocViewModel(documentDataModel: DocumentDataModel) {
return new DocumentViewModel(documentDataModel);
}
}

View File

@@ -0,0 +1,385 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, IDocumentData } from '@univerjs/core';
import {
awaitTime,
BlockType,
CustomRangeType,
DataStreamTreeTokenType,
DocumentBlockRangeType,
ICommandService,
} from '@univerjs/core';
import { DocBlockMoveValidatorService, RichTextEditingMutation } from '@univerjs/docs';
import { afterEach, describe, expect, it } from 'vitest';
import { buildMoveDocBlockActions, buildReplaceDocumentBodyActions, MoveDocBlockCommand } from '../doc-block-move.command';
import { createCommandTestBed } from './create-command-test-bed';
describe('buildMoveDocBlockActions', () => {
it('moves a paragraph and remaps paragraph indexes', () => {
const documentData = createDocument('A\rB\rC\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_9', startIndex: 1 }, { paragraphId: 'para_docs_ui_fixture_10', startIndex: 3 }, { paragraphId: 'para_docs_ui_fixture_11', startIndex: 5 }],
sectionBreaks: [{ sectionId: 'section_fixture_205', startIndex: 6 }],
});
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 6,
});
expect(nextDocumentData.body?.dataStream).toBe('B\rC\rA\r\n');
expect(nextDocumentData.body?.paragraphs?.map((item) => item.startIndex)).toEqual([1, 3, 5]);
expect(movedRange).toEqual({ startOffset: 4, endOffset: 6 });
});
it('moves a block range as one unit', () => {
const documentData = createDocument('aa\rBB\rcc\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_12', startIndex: 2 }, { paragraphId: 'para_docs_ui_fixture_13', startIndex: 5 }, { paragraphId: 'para_docs_ui_fixture_14', startIndex: 8 }],
sectionBreaks: [{ sectionId: 'section_fixture_206', startIndex: 9 }],
blockRanges: [{ blockId: 'quote-1', blockType: DocumentBlockRangeType.QUOTE, startIndex: 3, endIndex: 5 }],
});
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 3, endOffset: 6 },
targetOffset: 0,
});
expect(nextDocumentData.body?.dataStream).toBe('BB\raa\rcc\r\n');
expect(nextDocumentData.body?.blockRanges?.[0]).toMatchObject({ startIndex: 0, endIndex: 2 });
expect(nextDocumentData.body?.paragraphs?.map((item) => item.startIndex)).toEqual([2, 5, 8]);
expect(movedRange).toEqual({ startOffset: 0, endOffset: 3 });
});
it('moves a table range and remaps custom ranges and text runs', () => {
const documentData = createDocument('aa\rTT\rcc\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_15', startIndex: 2 }, { paragraphId: 'para_docs_ui_fixture_16', startIndex: 5 }, { paragraphId: 'para_docs_ui_fixture_17', startIndex: 8 }],
sectionBreaks: [{ sectionId: 'section_fixture_207', startIndex: 9 }],
tables: [{ tableId: 'table-1', startIndex: 3, endIndex: 6 }],
customRanges: [{ rangeId: 'comment-1', rangeType: CustomRangeType.COMMENT, startIndex: 6, endIndex: 7 }],
textRuns: [{ st: 6, ed: 8, ts: {} }],
});
const { nextDocumentData } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 3, endOffset: 6 },
targetOffset: 0,
});
expect(nextDocumentData.body?.dataStream).toBe('TT\raa\rcc\r\n');
expect(nextDocumentData.body?.tables?.[0]).toMatchObject({ startIndex: 0, endIndex: 3 });
expect(nextDocumentData.body?.customRanges?.[0]).toMatchObject({ startIndex: 6, endIndex: 7 });
expect(nextDocumentData.body?.textRuns?.[0]).toMatchObject({ st: 6, ed: 8 });
});
it('moves a custom block paragraph and keeps the custom block attached', () => {
const documentData = createDocument('\b\raa\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_18', startIndex: 1 }, { paragraphId: 'para_docs_ui_fixture_19', startIndex: 4 }],
sectionBreaks: [{ sectionId: 'section_fixture_208', startIndex: 5 }],
customBlocks: [{ blockId: 'custom-1', blockType: BlockType.CUSTOM, startIndex: 0 }],
});
const { nextDocumentData } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 5,
});
expect(nextDocumentData.body?.dataStream).toBe('aa\r\b\r\n');
expect(nextDocumentData.body?.customBlocks?.[0]).toMatchObject({ startIndex: 3 });
});
it('keeps the document unchanged when there is no movable body range', () => {
const emptyDocument = createDocument('', {
paragraphs: [],
sectionBreaks: [],
});
expect(buildMoveDocBlockActions({
documentData: emptyDocument,
sourceRange: { startOffset: 0, endOffset: 3 },
targetOffset: 10,
})).toEqual({
nextDocumentData: emptyDocument,
movedRange: { startOffset: 0, endOffset: 3 },
});
const documentData = createDocument('A\rB\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_20', startIndex: 1 }, { paragraphId: 'para_docs_ui_fixture_21', startIndex: 3 }],
sectionBreaks: [{ sectionId: 'section_fixture_209', startIndex: 4 }],
});
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: -5, endOffset: 2 },
targetOffset: 1,
});
expect(nextDocumentData.body?.dataStream).toBe('A\rB\r\n');
expect(movedRange).toEqual({ startOffset: 0, endOffset: 2 });
});
it('remaps custom decorations that move with their paragraph', () => {
const documentData = createDocument('aa\rDD\rcc\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_22', startIndex: 2 }, { paragraphId: 'para_docs_ui_fixture_23', startIndex: 5 }, { paragraphId: 'para_docs_ui_fixture_24', startIndex: 8 }],
sectionBreaks: [{ sectionId: 'section_fixture_210', startIndex: 9 }],
customDecorations: [{ id: 'decoration-1', startIndex: 3, endIndex: 5 } as never],
});
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 3, endOffset: 6 },
targetOffset: 0,
});
expect(nextDocumentData.body?.dataStream).toBe('DD\raa\rcc\r\n');
expect(nextDocumentData.body?.customDecorations?.[0]).toMatchObject({ startIndex: 0, endIndex: 2 });
expect(movedRange).toEqual({ startOffset: 0, endOffset: 3 });
});
it('keeps every document metadata collection ordered after moving content upward', () => {
const documentData = createDocument('aa\rMM\rzz\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_29', startIndex: 2 }, { paragraphId: 'para_docs_ui_fixture_30', startIndex: 5 }, { paragraphId: 'para_docs_ui_fixture_31', startIndex: 8 }],
sectionBreaks: [
{ sectionId: 'section_move_two', startIndex: 6 },
{ sectionId: 'section_move_three', startIndex: 9 },
],
customBlocks: [{ blockId: 'custom-2', blockType: BlockType.CUSTOM, startIndex: 3 }, { blockId: 'custom-3', blockType: BlockType.CUSTOM, startIndex: 6 }],
tables: [{ tableId: 'table-2', startIndex: 3, endIndex: 5 }, { tableId: 'table-3', startIndex: 6, endIndex: 8 }],
blockRanges: [{ blockId: 'quote-2', blockType: DocumentBlockRangeType.QUOTE, startIndex: 3, endIndex: 5 }, { blockId: 'quote-3', blockType: DocumentBlockRangeType.QUOTE, startIndex: 6, endIndex: 8 }],
customRanges: [{ rangeId: 'comment-2', rangeType: CustomRangeType.COMMENT, startIndex: 3, endIndex: 5 }, { rangeId: 'comment-3', rangeType: CustomRangeType.COMMENT, startIndex: 6, endIndex: 8 }],
customDecorations: [{ id: 'decoration-2', startIndex: 3, endIndex: 5 }, { id: 'decoration-3', startIndex: 6, endIndex: 8 }] as never,
textRuns: [{ st: 3, ed: 5, ts: {} }, { st: 6, ed: 8, ts: {} }],
});
const { nextDocumentData } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 3, endOffset: 6 },
targetOffset: 0,
});
expect(nextDocumentData.body?.dataStream).toBe('MM\raa\rzz\r\n');
expect(collectStarts(nextDocumentData.body?.sectionBreaks)).toEqual([6, 9]);
expect(collectStarts(nextDocumentData.body?.customBlocks)).toEqual([0, 6]);
expect(collectStarts(nextDocumentData.body?.tables)).toEqual([0, 6]);
expect(collectStarts(nextDocumentData.body?.blockRanges)).toEqual([0, 6]);
expect(collectStarts(nextDocumentData.body?.customRanges)).toEqual([0, 6]);
expect(collectStarts(nextDocumentData.body?.customDecorations)).toEqual([0, 6]);
expect(collectTextRunStarts(nextDocumentData.body?.textRuns)).toEqual([0, 6]);
});
it('remaps column group ranges when moving content before a column group', () => {
const T = DataStreamTreeTokenType;
const columnGroupStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}B${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}C${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}`;
const documentData = createDocument(`A${T.PARAGRAPH}${columnGroupStream}Z${T.PARAGRAPH}${T.SECTION_BREAK}`, {
paragraphs: [
{ paragraphId: 'para_docs_ui_fixture_32', startIndex: 1 },
{ paragraphId: 'para_docs_ui_fixture_33', startIndex: 5 },
{ paragraphId: 'para_docs_ui_fixture_34', startIndex: 9 },
{ paragraphId: 'para_docs_ui_fixture_35', startIndex: 13 },
],
sectionBreaks: [{ sectionId: 'section_fixture_213', startIndex: 14 }],
columnGroups: [{ columnGroupId: 'column-group-1', startIndex: 2, endIndex: 11 }],
});
const { nextDocumentData } = buildMoveDocBlockActions({
documentData,
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 12,
});
expect(nextDocumentData.body?.dataStream).toBe(`${columnGroupStream}A${T.PARAGRAPH}Z${T.PARAGRAPH}${T.SECTION_BREAK}`);
expect(nextDocumentData.body?.columnGroups).toEqual([{
columnGroupId: 'column-group-1',
startIndex: 0,
endIndex: 9,
}]);
});
});
describe('MoveDocBlockCommand', () => {
let testBed: ReturnType<typeof createCommandTestBed> | undefined;
afterEach(() => {
testBed?.univer.dispose();
testBed = undefined;
});
it('moves a block through the real rich text mutation flow', async () => {
testBed = createCommandTestBed(createDocument('A\rB\rC\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_25', startIndex: 1 }, { paragraphId: 'para_docs_ui_fixture_26', startIndex: 3 }, { paragraphId: 'para_docs_ui_fixture_27', startIndex: 5 }],
sectionBreaks: [{ sectionId: 'section_fixture_214', startIndex: 6 }],
}));
const commandService = testBed.get(ICommandService);
commandService.registerCommand(MoveDocBlockCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
await expect(commandService.executeCommand(MoveDocBlockCommand.id, {
unitId: 'test-doc',
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 6,
})).resolves.toBe(true);
await awaitTime(0);
expect(testBed.doc.getSnapshot().body?.dataStream).toBe('B\rC\rA\r\n');
expect(testBed.doc.getSnapshot().body?.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual([1, 3, 5]);
});
it('writes remapped column group ranges through the real rich text mutation flow', async () => {
const T = DataStreamTreeTokenType;
const columnGroupStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}B${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}C${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}`;
testBed = createCommandTestBed(createDocument(`A${T.PARAGRAPH}${columnGroupStream}Z${T.PARAGRAPH}${T.SECTION_BREAK}`, {
paragraphs: [
{ paragraphId: 'para_docs_ui_fixture_36', startIndex: 1 },
{ paragraphId: 'para_docs_ui_fixture_37', startIndex: 5 },
{ paragraphId: 'para_docs_ui_fixture_38', startIndex: 9 },
{ paragraphId: 'para_docs_ui_fixture_39', startIndex: 13 },
],
sectionBreaks: [{ sectionId: 'section_fixture_215', startIndex: 14 }],
columnGroups: [{ columnGroupId: 'column-group-1', startIndex: 2, endIndex: 11 }],
}));
const commandService = testBed.get(ICommandService);
commandService.registerCommand(MoveDocBlockCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
await expect(commandService.executeCommand(MoveDocBlockCommand.id, {
unitId: 'test-doc',
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 12,
})).resolves.toBe(true);
await awaitTime(0);
expect(testBed.doc.getSnapshot().body?.columnGroups).toEqual([{
columnGroupId: 'column-group-1',
startIndex: 0,
endIndex: 9,
}]);
});
it('applies registered block move transformers through the real rich text mutation flow', async () => {
testBed = createCommandTestBed(createDocument('A\rB\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_40', startIndex: 1 }, { paragraphId: 'para_docs_ui_fixture_41', startIndex: 3 }],
sectionBreaks: [{ sectionId: 'section_fixture_216', startIndex: 4 }],
}));
const commandService = testBed.get(ICommandService);
const moveValidatorService = testBed.get(DocBlockMoveValidatorService);
commandService.registerCommand(MoveDocBlockCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
moveValidatorService.registerTransformer((context) => ({
...context.result,
nextDocumentData: {
...context.result.nextDocumentData,
body: {
...context.result.nextDocumentData.body!,
dataStream: `${context.result.nextDocumentData.body?.dataStream}!`,
},
},
}));
await expect(commandService.executeCommand(MoveDocBlockCommand.id, {
unitId: 'test-doc',
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 4,
})).resolves.toBe(true);
await awaitTime(0);
expect(testBed.doc.getSnapshot().body?.dataStream).toBe('B\rA\r\n!');
});
it('does not mutate when the target document is missing', async () => {
testBed = createCommandTestBed(createDocument('A\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_28', startIndex: 1 }],
sectionBreaks: [{ sectionId: 'section_fixture_217', startIndex: 2 }],
}));
const commandService = testBed.get(ICommandService);
commandService.registerCommand(MoveDocBlockCommand);
await expect(commandService.executeCommand(MoveDocBlockCommand.id, {
unitId: 'missing-doc',
sourceRange: { startOffset: 0, endOffset: 2 },
targetOffset: 2,
})).resolves.toBe(false);
expect(testBed.doc.getSnapshot().body?.dataStream).toBe('A\r\n');
});
});
describe('buildReplaceDocumentBodyActions', () => {
it('uses TextX and local body patches instead of replacing whole body fields', () => {
const previousDocumentData = createDocument('A\rB\rC\r\n', {
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_42', startIndex: 1 }, { paragraphId: 'para_docs_ui_fixture_43', startIndex: 3 }, { paragraphId: 'para_docs_ui_fixture_44', startIndex: 5 }],
sectionBreaks: [{ sectionId: 'section_fixture_218', startIndex: 6 }],
});
const sourceRange = { startOffset: 0, endOffset: 2 };
const targetOffset = 6;
const { nextDocumentData, movedRange } = buildMoveDocBlockActions({
documentData: previousDocumentData,
sourceRange,
targetOffset,
});
const actions = buildReplaceDocumentBodyActions(previousDocumentData, nextDocumentData, {
movedRange,
sourceRange,
targetOffset,
});
const serializedActions = JSON.stringify(actions);
expect(actions).not.toBeNull();
expect(serializedActions).not.toContain('["body","dataStream"');
expect(serializedActions).not.toContain('["body","paragraphs",{"r"');
expect(serializedActions).toContain('"et":"text-x"');
});
});
function createDocument(dataStream: string, body: Partial<NonNullable<IDocumentData['body']>>): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream,
customBlocks: [],
...body,
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginLeft: 72,
marginRight: 72,
},
};
}
function collectStarts(items: Array<{ startIndex: number }> | undefined): number[] {
const starts: number[] = [];
for (const item of items ?? []) {
starts.push(item.startIndex);
}
return starts;
}
function collectTextRunStarts(items: Array<{ st: number }> | undefined): number[] {
const starts: number[] = [];
for (const item of items ?? []) {
starts.push(item.st);
}
return starts;
}

View File

@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import { DeleteDirection, DocumentFlavor, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, NORMAL_TEXT_SELECTION_PLUGIN_STYLE, RenderManagerService } from '@univerjs/engine-render';
import { describe, expect, it } from 'vitest';
import { MergeTwoParagraphCommand } from '../doc-delete.command';
describe('MergeTwoParagraphCommand segment selection', () => {
it.each([DeleteDirection.LEFT, DeleteDirection.RIGHT])('keeps the footnote caret after merging in direction %s', async (direction) => {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'note-merge',
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
body: { dataStream: 'Body\r\n', paragraphs: [{ startIndex: 4, paragraphId: 'body' }] },
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
dataStream: 'First\rSecond\r\n',
paragraphs: [{ startIndex: 5, paragraphId: 'first' }, { startIndex: 12, paragraphId: 'second' }],
} } },
});
try {
injector.get(IUniverInstanceService).focusUnit('note-merge');
const commands = injector.get(ICommandService);
commands.registerCommand(MergeTwoParagraphCommand);
commands.registerCommand(RichTextEditingMutation);
const selections = injector.get(DocSelectionManagerService);
selections.__TEST_ONLY_setCurrentSelection({ unitId: 'note-merge', subUnitId: 'note-merge' });
const offset = direction === DeleteDirection.LEFT ? 6 : 5;
const range = { startOffset: offset, endOffset: offset, collapsed: true, isActive: true, segmentId: 'note' };
selections.replaceSelectionInfoWithoutRefresh({
textRanges: [range],
rectRanges: [],
segmentId: 'note',
segmentPage: 2,
isEditing: true,
style: NORMAL_TEXT_SELECTION_PLUGIN_STYLE,
});
expect(await commands.executeCommand(MergeTwoParagraphCommand.id, { direction, range })).toBe(true);
expect(model.getSnapshot().notes!.note.body.dataStream).toBe('FirstSecond\r\n');
expect(model.getSnapshot().body!.dataStream).toBe('Body\r\n');
expect(selections.getActiveTextRange()).toMatchObject({ segmentId: 'note', startOffset: 5, endOffset: 5 });
} finally {
univer.dispose();
}
});
});

View File

@@ -0,0 +1,139 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DocumentEditArea } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { CloseHeaderFooterCommand, CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../doc-header-footer.command';
describe('CloseHeaderFooterCommand large document regression', () => {
it('reuses already-published body pages when leaving header/footer editing', async () => {
const calculate = vi.fn();
let editArea = DocumentEditArea.HEADER;
const skeleton = {
getSkeletonData: () => ({ pages: [{}] }),
calculate,
};
const skeletonManager = {
getViewModel: () => ({
getEditArea: () => editArea,
setEditArea: (next: DocumentEditArea) => {
editArea = next;
},
}),
getSkeleton: () => skeleton,
};
const selectionRenderService = {
setSegment: vi.fn(),
setSegmentPage: vi.fn(),
};
let renderDependency = 0;
const renderObject = {
with: vi.fn(() => renderDependency++ === 0 ? skeletonManager : selectionRenderService),
scene: {
getTransformerByCreate: () => ({ clearSelectedObjects: vi.fn() }),
},
mainComponent: { makeDirty: vi.fn() },
};
const commandService = { executeCommand: vi.fn(() => true) };
const renderManagerService = { getRenderUnitById: vi.fn(() => renderObject) };
const selectionManagerService = { replaceDocRanges: vi.fn() };
const instanceService = {
getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'doc-1' })),
getUnit: vi.fn(() => ({
getSnapshot: () => ({ body: { dataStream: 'body\r\n', paragraphs: [{ startIndex: 4 }] } }),
})),
};
const dependencies = [
commandService,
renderManagerService,
selectionManagerService,
instanceService,
];
const accessor = { get: vi.fn(() => dependencies.shift()) };
const result = await CloseHeaderFooterCommand.handler(accessor as never, { unitId: 'doc-1' });
await Promise.resolve();
expect(result).toBe(true);
expect(editArea).toBe(DocumentEditArea.BODY);
expect(calculate).not.toHaveBeenCalled();
expect(renderObject.mainComponent.makeDirty).toHaveBeenCalledWith(true);
});
});
describe('OpenHeaderFooterPanelCommand', () => {
function createOpenCommandTestBed(defaultHeaderId?: string) {
let editArea = DocumentEditArea.BODY;
const viewModel = {
getDataModel: () => ({
getSnapshot: () => ({ id: 'doc-1', documentStyle: { defaultHeaderId } }),
}),
setEditArea: vi.fn((next: DocumentEditArea) => {
editArea = next;
}),
};
const skeletonManager = {
getViewModel: () => viewModel,
getSkeleton: () => ({
getSkeletonData: () => ({ pages: [{ pageNumber: 1, pageNumberStart: 1 }] }),
}),
};
const selectionRenderService = {
getSegmentPage: vi.fn(() => 0),
setSegment: vi.fn(),
setSegmentPage: vi.fn(),
};
let dependencyIndex = 0;
const renderObject = {
with: vi.fn(() => dependencyIndex++ === 0 ? skeletonManager : selectionRenderService),
};
const commandService = { executeCommand: vi.fn() };
const instanceService = {
getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'doc-1' })),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => renderObject) };
const dependencies = [commandService, instanceService, renderManagerService];
const accessor = { get: vi.fn(() => dependencies.shift()) };
return { accessor, commandService, selectionRenderService, viewModel, getEditArea: () => editArea };
}
it('rolls back header editing when creating the missing header fails', async () => {
const testBed = createOpenCommandTestBed();
testBed.commandService.executeCommand.mockResolvedValueOnce(false);
await expect(OpenHeaderFooterPanelCommand.handler(testBed.accessor as never, {})).resolves.toBe(false);
expect(testBed.commandService.executeCommand).toHaveBeenCalledWith(
CoreHeaderFooterCommand.id,
expect.objectContaining({ unitId: 'doc-1' })
);
expect(testBed.selectionRenderService.setSegment).toHaveBeenLastCalledWith('');
expect(testBed.selectionRenderService.setSegmentPage).toHaveBeenLastCalledWith(-1);
expect(testBed.getEditArea()).toBe(DocumentEditArea.BODY);
});
it('selects an existing header before opening the sidebar', async () => {
const testBed = createOpenCommandTestBed('existing-header');
testBed.commandService.executeCommand.mockResolvedValueOnce(true);
await expect(OpenHeaderFooterPanelCommand.handler(testBed.accessor as never, {})).resolves.toBe(true);
expect(testBed.selectionRenderService.setSegment).toHaveBeenCalledWith('existing-header');
expect(testBed.commandService.executeCommand).toHaveBeenCalledTimes(1);
expect(testBed.getEditArea()).toBe(DocumentEditArea.HEADER);
});
});

View File

@@ -0,0 +1,791 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, IDocumentData, Univer } from '@univerjs/core';
import { awaitTime, DataStreamTreeTokenType, DocumentBlockRangeType, ICommandService } from '@univerjs/core';
import {
DocContentInsertService,
DocSelectionManagerService,
DocSkeletonManagerService,
RichTextEditingMutation,
} from '@univerjs/docs';
import { afterEach, describe, expect, it } from 'vitest';
import {
buildDocTableInsertBody,
canInsertTableAtOffset,
CreateDocTableCommand,
normalizeTableInsertOffset,
shouldCreateParagraphBeforeTable,
} from '../table/doc-table-create.command';
import {
DocTableDeleteColumnsCommand,
DocTableDeleteRowsCommand,
DocTableDeleteTableCommand,
} from '../table/doc-table-delete.command';
import {
DocTableInsertColumnCommand,
DocTableInsertColumnLeftCommand,
DocTableInsertColumnRightCommand,
DocTableInsertRowAboveCommand,
DocTableInsertRowBellowCommand,
DocTableInsertRowCommand,
} from '../table/doc-table-insert.command';
import {
CellPosition,
genEmptyTable,
genTableSource,
getCellOffsets,
getColumnWidths,
getDeleteColumnsActionParams,
getDeleteRowContentActionParams,
getDeleteRowsActionsParams,
getDeleteTableActionParams,
getEmptyTableCell,
getEmptyTableRow,
getInsertColumnActionsParams,
getInsertColumnBody,
getInsertRowActionsParams,
getInsertRowBody,
getRangeInfoFromRanges,
getTableColumn,
INSERT_COLUMN_POSITION,
INSERT_ROW_POSITION,
} from '../table/table';
import { createCommandTestBed } from './create-command-test-bed';
const TABLE_ID = 'table-1';
const TABLE_PREFIX = '';
const TABLE_SUFFIX = 'CD\r\n';
interface ITableFixture {
documentData: IDocumentData;
cellRanges: Array<{ startIndex: number; endIndex: number }>;
rowRanges: Array<{ startIndex: number; endIndex: number }>;
tableRange: { startIndex: number; endIndex: number };
}
let univer: Univer | undefined;
afterEach(() => {
univer?.dispose();
univer = undefined;
});
function createTableFixture(rowCount = 3, colCount = 3): ITableFixture {
const tableData = genEmptyTable(rowCount, colCount);
const tableSource = genTableSource(rowCount, colCount, 360);
const dataStream = `${TABLE_PREFIX}${tableData.dataStream}${TABLE_SUFFIX}`;
const tableStartIndex = TABLE_PREFIX.length;
const tableEndIndex = tableStartIndex + tableData.dataStream.length;
const documentData: IDocumentData = {
id: 'test-doc',
body: {
dataStream,
textRuns: [{
st: 0,
ed: dataStream.length - 2,
ts: {},
}],
paragraphs: [
...tableData.paragraphs.map((paragraph) => ({
...paragraph,
startIndex: paragraph.startIndex + tableStartIndex,
})),
{ paragraphId: 'para_docs_ui_table_tail', startIndex: dataStream.length - 2 },
],
sectionBreaks: [
...tableData.sectionBreaks.map((sectionBreak) => ({
...sectionBreak,
startIndex: sectionBreak.startIndex + tableStartIndex,
})),
{ sectionId: 'section_fixture_219', startIndex: dataStream.length - 1 },
],
tables: [{
startIndex: tableStartIndex,
endIndex: tableEndIndex,
tableId: TABLE_ID,
}],
customBlocks: [],
},
documentStyle: {
pageSize: { width: 540, height: 720 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
tableSource: {
[TABLE_ID]: {
...tableSource,
tableId: TABLE_ID,
},
},
};
return {
documentData,
cellRanges: collectTokenRanges(dataStream, DataStreamTreeTokenType.TABLE_CELL_START, DataStreamTreeTokenType.TABLE_CELL_END, tableStartIndex, tableEndIndex),
rowRanges: collectTokenRanges(dataStream, DataStreamTreeTokenType.TABLE_ROW_START, DataStreamTreeTokenType.TABLE_ROW_END, tableStartIndex, tableEndIndex),
tableRange: { startIndex: tableStartIndex, endIndex: tableEndIndex - 1 },
};
}
function collectTokenRanges(dataStream: string, startToken: string, endToken: string, from: number, to: number) {
const ranges: Array<{ startIndex: number; endIndex: number }> = [];
for (let i = from; i < to; i++) {
if (dataStream[i] !== startToken) {
continue;
}
const endIndex = dataStream.indexOf(endToken, i);
ranges.push({ startIndex: i, endIndex });
i = endIndex;
}
return ranges;
}
function createTableViewModel(fixture: ITableFixture) {
const testBed = createCommandTestBed(fixture.documentData);
univer = testBed.univer;
return testBed.get(DocSkeletonManagerService).getViewModel();
}
function createTableCommandBed(fixture: ITableFixture) {
const testBed = createCommandTestBed(fixture.documentData);
univer = testBed.univer;
const commandService = testBed.get(ICommandService);
commandService.registerCommand(DocTableInsertRowAboveCommand);
commandService.registerCommand(DocTableInsertRowBellowCommand);
commandService.registerCommand(DocTableInsertRowCommand);
commandService.registerCommand(DocTableInsertColumnLeftCommand);
commandService.registerCommand(DocTableInsertColumnRightCommand);
commandService.registerCommand(DocTableInsertColumnCommand);
commandService.registerCommand(DocTableDeleteRowsCommand);
commandService.registerCommand(DocTableDeleteColumnsCommand);
commandService.registerCommand(DocTableDeleteTableCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
return testBed;
}
function createTableCreationBed(documentData: IDocumentData) {
const testBed = createCommandTestBed(documentData, [[DocContentInsertService]]);
univer = testBed.univer;
const commandService = testBed.get(ICommandService);
commandService.registerCommand(CreateDocTableCommand);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
return testBed;
}
function setActiveTableRange(testBed: ReturnType<typeof createCommandTestBed>, startOffset: number, endOffset = startOffset) {
const selectionManager = testBed.get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{
startOffset,
endOffset,
collapsed: startOffset === endOffset,
isActive: true,
segmentId: '',
style: null as never,
}]);
}
function asRange(startOffset: number, endOffset = startOffset) {
return {
startOffset,
endOffset,
segmentId: '',
} as never;
}
function getOutsideTableOffset(fixture: ITableFixture) {
return fixture.documentData.body!.dataStream.length - 1;
}
describe('doc table create command helpers', () => {
it('normalizes offset zero to keep the initial empty paragraph above the table', () => {
expect(normalizeTableInsertOffset({ dataStream: '\r\n' }, 0)).toBe(1);
expect(normalizeTableInsertOffset({ dataStream: 'Title\r\n' }, 0)).toBe(0);
});
it('does not create another before-table paragraph at an existing paragraph boundary', () => {
expect(shouldCreateParagraphBeforeTable({ dataStream: '\r\n' }, 1)).toBe(false);
expect(shouldCreateParagraphBeforeTable({ dataStream: 'Title\r\n' }, 6)).toBe(false);
});
it('creates a before-table paragraph when inserting inside paragraph text', () => {
expect(shouldCreateParagraphBeforeTable({ dataStream: 'Title\r\n' }, 2)).toBe(true);
});
it('builds inserted table body with a normal paragraph after the table', () => {
const tableData = genEmptyTable(1, 1);
const body = buildDocTableInsertBody({
tableDataStream: tableData.dataStream,
tableParagraphs: tableData.paragraphs,
sectionBreaks: tableData.sectionBreaks,
tableId: 'table-1',
textRun: { st: 0, ed: tableData.dataStream.length, ts: {} },
});
expect(body.dataStream).toBe(`${tableData.dataStream}${DataStreamTreeTokenType.PARAGRAPH}`);
expect(body.tables).toEqual([{
startIndex: 0,
endIndex: tableData.dataStream.length,
tableId: 'table-1',
}]);
expect(body.paragraphs.at(-1)).toMatchObject({
startIndex: tableData.dataStream.length,
paragraphId: expect.stringMatching(/^para_/),
});
});
it('builds empty table source with matching row, column, and cell defaults', () => {
const table = genTableSource(2, 3, 480);
expect(table.tableRows).toHaveLength(2);
expect(table.tableRows[0].tableCells).toEqual([
getEmptyTableCell(),
getEmptyTableCell(),
getEmptyTableCell(),
]);
expect(table.tableRows[0].trHeight).toMatchObject({
val: { v: 30 },
});
expect(table.tableColumns.map((column) => column.size.width.v)).toEqual([160, 160, 160]);
expect(getEmptyTableRow(2).tableCells).toEqual([getEmptyTableCell(), getEmptyTableCell()]);
expect(getTableColumn(42).size.width.v).toBe(42);
});
it('gets range info from rectangular table ranges before falling back to text range', () => {
expect(getRangeInfoFromRanges(null, null)).toBeNull();
expect(getRangeInfoFromRanges(asRange(4, 9), [])).toEqual({
startOffset: 4,
endOffset: 9,
segmentId: '',
});
expect(getRangeInfoFromRanges(asRange(100, 110), [
asRange(25, 28),
asRange(12, 18),
])).toEqual({
startOffset: 12,
endOffset: 28,
segmentId: '',
});
expect(getRangeInfoFromRanges(asRange(1, 2), [{ startOffset: null, endOffset: 4, segmentId: '' } as never])).toBeUndefined();
});
it('builds insert row and column bodies with paragraph metadata inside new cells', () => {
const rowBody = getInsertRowBody(3);
const columnBody = getInsertColumnBody();
const expectedCellStream = `${DataStreamTreeTokenType.TABLE_CELL_START}\r\n${DataStreamTreeTokenType.TABLE_CELL_END}`;
expect(rowBody.dataStream).toBe(`${DataStreamTreeTokenType.TABLE_ROW_START}${expectedCellStream.repeat(3)}${DataStreamTreeTokenType.TABLE_ROW_END}`);
expect(rowBody.paragraphs.map((paragraph) => paragraph.startIndex)).toEqual([2, 6, 10]);
expect(rowBody.sectionBreaks.map((sectionBreak) => sectionBreak.startIndex)).toEqual([3, 7, 11]);
expect(rowBody.paragraphs.map((paragraph) => paragraph.paragraphStyle)).toEqual([
{ spaceAbove: { v: 3 }, lineSpacing: 2, spaceBelow: { v: 0 } },
{ spaceAbove: { v: 3 }, lineSpacing: 2, spaceBelow: { v: 0 } },
{ spaceAbove: { v: 3 }, lineSpacing: 2, spaceBelow: { v: 0 } },
]);
expect(columnBody.dataStream).toBe(expectedCellStream);
expect(columnBody.paragraphs[0].startIndex).toBe(1);
expect(columnBody.sectionBreaks[0].startIndex).toBe(2);
});
it('rescales existing table columns and the inserted column to keep the page width fixed', () => {
const result = getColumnWidths(600, [
getTableColumn(100),
getTableColumn(200),
getTableColumn(300),
], 1);
expect(result).toEqual({
widths: [75, 150, 225],
newColWidth: 150,
});
});
it('computes row insertion params from the selected table row', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
const middleCell = fixture.cellRanges[4];
expect(getInsertRowActionsParams(asRange(middleCell.startIndex + 1), INSERT_ROW_POSITION.ABOVE, viewModel)).toEqual({
offset: fixture.rowRanges[1].startIndex,
colCount: 3,
tableId: TABLE_ID,
insertRowIndex: 1,
});
expect(getInsertRowActionsParams(asRange(middleCell.startIndex + 1), INSERT_ROW_POSITION.BELLOW, viewModel)).toEqual({
offset: fixture.rowRanges[1].endIndex + 1,
colCount: 3,
tableId: TABLE_ID,
insertRowIndex: 2,
});
expect(getInsertRowActionsParams(asRange(getOutsideTableOffset(fixture)), INSERT_ROW_POSITION.ABOVE, viewModel)).toBeNull();
});
it('computes column insertion offsets for each selected table column cell', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
const columnCells = [fixture.cellRanges[1], fixture.cellRanges[4], fixture.cellRanges[7]];
expect(getInsertColumnActionsParams(asRange(columnCells[0].startIndex + 1), INSERT_COLUMN_POSITION.LEFT, viewModel)).toEqual({
offsets: [
columnCells[0].startIndex,
columnCells[1].startIndex - columnCells[0].startIndex,
columnCells[2].startIndex - columnCells[1].startIndex,
],
tableId: TABLE_ID,
columnIndex: 1,
rowCount: 3,
});
expect(getInsertColumnActionsParams(asRange(columnCells[0].startIndex + 1), INSERT_COLUMN_POSITION.RIGHT, viewModel)).toEqual({
offsets: [
columnCells[0].endIndex + 1,
columnCells[1].endIndex - columnCells[0].endIndex,
columnCells[2].endIndex - columnCells[1].endIndex,
],
tableId: TABLE_ID,
columnIndex: 1,
rowCount: 3,
});
expect(getInsertColumnActionsParams(asRange(getOutsideTableOffset(fixture)), INSERT_COLUMN_POSITION.LEFT, viewModel)).toBeNull();
});
it('computes delete row params and flags full-table row selections', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
expect(getDeleteRowsActionsParams(asRange(fixture.rowRanges[1].startIndex, fixture.rowRanges[1].endIndex), viewModel)).toEqual({
tableId: TABLE_ID,
rowIndexes: [1],
offset: fixture.rowRanges[1].startIndex,
len: fixture.rowRanges[1].endIndex - fixture.rowRanges[1].startIndex + 1,
cursor: fixture.tableRange.startIndex + 3,
selectWholeTable: false,
});
expect(getDeleteRowsActionsParams(asRange(fixture.rowRanges[0].startIndex, fixture.rowRanges[2].endIndex), viewModel)).toEqual({
tableId: TABLE_ID,
rowIndexes: [0, 1, 2],
offset: fixture.rowRanges[0].startIndex,
len: fixture.rowRanges.reduce((total, row) => total + row.endIndex - row.startIndex + 1, 0),
cursor: fixture.tableRange.startIndex + 3,
selectWholeTable: true,
});
expect(getDeleteRowsActionsParams(asRange(getOutsideTableOffset(fixture)), viewModel)).toBeNull();
});
it('computes delete column params across rows and flags full-table column selections', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
const columnCells = [fixture.cellRanges[1], fixture.cellRanges[4], fixture.cellRanges[7]];
expect(getDeleteColumnsActionParams(asRange(columnCells[0].startIndex, columnCells[2].endIndex), viewModel)).toEqual({
offsets: [
{ retain: columnCells[0].startIndex, delete: columnCells[0].endIndex - columnCells[0].startIndex + 1 },
{ retain: columnCells[1].startIndex - columnCells[0].endIndex - 1, delete: columnCells[1].endIndex - columnCells[1].startIndex + 1 },
{ retain: columnCells[2].startIndex - columnCells[1].endIndex - 1, delete: columnCells[2].endIndex - columnCells[2].startIndex + 1 },
],
tableId: TABLE_ID,
columnIndexes: [1],
cursor: fixture.tableRange.startIndex + 3,
selectWholeTable: false,
rowCount: 3,
});
expect(getDeleteColumnsActionParams(asRange(fixture.cellRanges[0].startIndex, fixture.cellRanges[8].endIndex), viewModel)).toMatchObject({
tableId: TABLE_ID,
columnIndexes: [0, 1, 2],
selectWholeTable: true,
rowCount: 3,
});
expect(getDeleteColumnsActionParams(asRange(getOutsideTableOffset(fixture)), viewModel)).toBeNull();
});
it('computes delete table params from a range inside the table', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
expect(getDeleteTableActionParams(asRange(fixture.cellRanges[4].startIndex + 1), viewModel)).toEqual({
tableId: TABLE_ID,
offset: fixture.tableRange.startIndex,
len: fixture.tableRange.endIndex - fixture.tableRange.startIndex + 1,
cursor: fixture.tableRange.startIndex,
});
expect(getDeleteTableActionParams(asRange(getOutsideTableOffset(fixture)), viewModel)).toBeNull();
});
it('computes delete row-content params for the selected cells in one row', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
const selectedCells = [fixture.cellRanges[3], fixture.cellRanges[4]];
expect(getDeleteRowContentActionParams(asRange(selectedCells[0].startIndex, selectedCells[1].endIndex), viewModel)).toEqual({
offsets: [
{ retain: selectedCells[0].startIndex + 1, delete: selectedCells[0].endIndex - selectedCells[0].startIndex - 3 },
{ retain: selectedCells[1].startIndex + 1, delete: selectedCells[1].endIndex - selectedCells[1].startIndex - 3 },
],
tableId: TABLE_ID,
cursor: fixture.tableRange.startIndex + 3,
rowCount: 3,
});
expect(getDeleteRowContentActionParams(asRange(getOutsideTableOffset(fixture)), viewModel)).toBeNull();
});
it('moves between neighboring table cells by returning editable cell content offsets', () => {
const fixture = createTableFixture();
const viewModel = createTableViewModel(fixture);
expect(getCellOffsets(viewModel, asRange(fixture.cellRanges[1].startIndex + 1), CellPosition.NEXT)).toEqual({
startOffset: fixture.cellRanges[2].startIndex + 1,
endOffset: fixture.cellRanges[2].endIndex - 2,
});
expect(getCellOffsets(viewModel, asRange(fixture.cellRanges[3].startIndex + 1), CellPosition.PREV)).toEqual({
startOffset: fixture.cellRanges[2].startIndex + 1,
endOffset: fixture.cellRanges[2].endIndex - 2,
});
expect(getCellOffsets(viewModel, asRange(0), CellPosition.NEXT)).toBeNull();
});
it('executes row insertion through the registered table command chain', async () => {
const fixture = createTableFixture();
const testBed = createTableCommandBed(fixture);
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, fixture.cellRanges[4].startIndex + 1);
const result = await commandService.executeCommand(DocTableInsertRowAboveCommand.id);
await awaitTime(0);
expect(result).toBe(true);
expect(testBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableRows).toHaveLength(4);
expect(testBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableRows[1].tableCells).toEqual([
getEmptyTableCell(),
getEmptyTableCell(),
getEmptyTableCell(),
]);
});
it('executes column insertion and keeps table width distributed across all columns', async () => {
const fixture = createTableFixture();
const testBed = createTableCommandBed(fixture);
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, fixture.cellRanges[1].startIndex + 1);
const result = await commandService.executeCommand(DocTableInsertColumnRightCommand.id);
await awaitTime(0);
const tableSource = testBed.doc.getSnapshot().tableSource?.[TABLE_ID];
expect(result).toBe(true);
expect(tableSource?.tableRows.map((row) => row.tableCells.length)).toEqual([4, 4, 4]);
expect(tableSource?.tableColumns.map((column) => column.size.width.v)).toEqual([90, 90, 90, 90]);
});
it('preserves the current table width when inserting a column into a narrow table', async () => {
const fixture = createTableFixture();
fixture.documentData.tableSource![TABLE_ID].tableColumns.forEach((column) => {
column.size.width.v = 80;
});
const testBed = createTableCommandBed(fixture);
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, fixture.cellRanges[1].startIndex + 1);
const result = await commandService.executeCommand(DocTableInsertColumnRightCommand.id);
await awaitTime(0);
const tableSource = testBed.doc.getSnapshot().tableSource?.[TABLE_ID];
expect(result).toBe(true);
expect(tableSource?.tableColumns.map((column) => column.size.width.v)).toEqual([60, 60, 60, 60]);
});
it('executes below-row and left-column insertion aliases', async () => {
const rowFixture = createTableFixture();
const rowTestBed = createTableCommandBed(rowFixture);
let commandService = rowTestBed.get(ICommandService);
setActiveTableRange(rowTestBed, rowFixture.cellRanges[4].startIndex + 1);
expect(await commandService.executeCommand(DocTableInsertRowBellowCommand.id)).toBe(true);
await awaitTime(0);
expect(rowTestBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableRows).toHaveLength(4);
rowTestBed.univer.dispose();
const columnFixture = createTableFixture();
const columnTestBed = createTableCommandBed(columnFixture);
commandService = columnTestBed.get(ICommandService);
setActiveTableRange(columnTestBed, columnFixture.cellRanges[1].startIndex + 1);
expect(await commandService.executeCommand(DocTableInsertColumnLeftCommand.id)).toBe(true);
await awaitTime(0);
expect(columnTestBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableColumns).toHaveLength(4);
expect(columnTestBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableRows.map((row) => row.tableCells.length)).toEqual([4, 4, 4]);
});
it('executes row, column, and table deletion commands against table structure', async () => {
const rowFixture = createTableFixture();
const rowTestBed = createTableCommandBed(rowFixture);
let commandService = rowTestBed.get(ICommandService);
setActiveTableRange(rowTestBed, rowFixture.rowRanges[1].startIndex, rowFixture.rowRanges[1].endIndex);
expect(await commandService.executeCommand(DocTableDeleteRowsCommand.id)).toBe(true);
await awaitTime(0);
expect(rowTestBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableRows).toHaveLength(2);
rowTestBed.univer.dispose();
const columnFixture = createTableFixture();
const columnTestBed = createTableCommandBed(columnFixture);
commandService = columnTestBed.get(ICommandService);
setActiveTableRange(columnTestBed, columnFixture.cellRanges[1].startIndex, columnFixture.cellRanges[7].endIndex);
expect(await commandService.executeCommand(DocTableDeleteColumnsCommand.id)).toBe(true);
await awaitTime(0);
expect(columnTestBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableColumns).toHaveLength(2);
expect(columnTestBed.doc.getSnapshot().tableSource?.[TABLE_ID].tableRows.map((row) => row.tableCells.length)).toEqual([2, 2, 2]);
columnTestBed.univer.dispose();
const tableFixture = createTableFixture();
const tableTestBed = createTableCommandBed(tableFixture);
commandService = tableTestBed.get(ICommandService);
setActiveTableRange(tableTestBed, tableFixture.tableRange.endIndex + 2);
expect(await commandService.executeCommand(DocTableDeleteTableCommand.id, {
targetRange: {
startOffset: tableFixture.tableRange.startIndex,
endOffset: tableFixture.tableRange.endIndex,
collapsed: false,
segmentId: '',
},
})).toBe(true);
await awaitTime(0);
expect(tableTestBed.doc.getBody()?.dataStream).toBe(TABLE_SUFFIX);
expect(testBedSnapshotTableIds(tableTestBed)).toEqual([]);
});
it('creates a table at the current document selection and fits it to page content width', async () => {
const testBed = createTableCreationBed(createParagraphDocument('Hello\r\n'));
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, 5);
setCommandSkeletonPage(testBed, 5, {
pageWidth: 540,
marginLeft: 90,
marginRight: 90,
});
await expect(commandService.executeCommand(CreateDocTableCommand.id, {
rowCount: 2,
colCount: 3,
})).resolves.toBe(true);
await awaitTime(0);
const snapshot = testBed.doc.getSnapshot();
const tableId = Object.keys(snapshot.tableSource ?? {})[0];
expect(snapshot.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_START);
expect(snapshot.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_END);
expect(snapshot.tableSource?.[tableId].tableRows).toHaveLength(2);
expect(snapshot.tableSource?.[tableId].tableRows.map((row) => row.tableCells.length)).toEqual([3, 3]);
expect(snapshot.tableSource?.[tableId].tableColumns.map((column) => column.size.width.v)).toEqual([120, 120, 120]);
});
it('creates a table from the paragraph menu insert range when there is no active selection', async () => {
const testBed = createTableCreationBed(createParagraphDocument('Hello\r\n'));
const commandService = testBed.get(ICommandService);
testBed.get(DocContentInsertService).setInsertRange({
unitId: 'test-doc',
startOffset: 0,
endOffset: 0,
});
setCommandSkeletonPage(testBed, 0, {
pageWidth: 500,
marginLeft: 70,
marginRight: 70,
});
await expect(commandService.executeCommand(CreateDocTableCommand.id, {
rowCount: 1,
colCount: 2,
})).resolves.toBe(true);
await awaitTime(0);
const snapshot = testBed.doc.getSnapshot();
const tableId = Object.keys(snapshot.tableSource ?? {})[0];
expect(snapshot.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_START);
expect(snapshot.body?.dataStream).toContain(DataStreamTreeTokenType.TABLE_END);
expect(snapshot.tableSource?.[tableId].tableRows).toHaveLength(1);
expect(snapshot.tableSource?.[tableId].tableColumns.map((column) => column.size.width.v)).toEqual([180, 180]);
});
it('does not create a table inside a block range', async () => {
const documentData = createParagraphDocument(`${DataStreamTreeTokenType.BLOCK_START}Callout\r${DataStreamTreeTokenType.BLOCK_END}\r\n`);
const testBed = createTableCreationBed({
...documentData,
body: {
...documentData.body!,
paragraphs: [{ paragraphId: 'para_block_range', startIndex: 8 }],
sectionBreaks: [{ sectionId: 'section_fixture_220', startIndex: 11 }],
blockRanges: [{ blockId: 'callout-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 0, endIndex: 9 }],
},
});
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, 2);
setCommandSkeletonPage(testBed, 2, {
pageWidth: 540,
marginLeft: 90,
marginRight: 90,
});
await expect(commandService.executeCommand(CreateDocTableCommand.id, {
rowCount: 1,
colCount: 1,
})).resolves.toBe(false);
expect(testBed.doc.getSnapshot().body?.tables).toBeUndefined();
expect(testBed.doc.getSnapshot().tableSource).toEqual({});
});
it('does not create a table inside an existing table range', async () => {
const fixture = createTableFixture(1, 1);
const testBed = createTableCreationBed(fixture.documentData);
const commandService = testBed.get(ICommandService);
const insertOffset = fixture.cellRanges[0].startIndex + 1;
setActiveTableRange(testBed, insertOffset);
setCommandSkeletonPage(testBed, insertOffset, {
pageWidth: 540,
marginLeft: 90,
marginRight: 90,
});
await expect(commandService.executeCommand(CreateDocTableCommand.id, {
rowCount: 1,
colCount: 1,
})).resolves.toBe(false);
expect(testBedSnapshotTableIds(testBed)).toEqual([TABLE_ID]);
});
it('allows insertion immediately after an existing table range', () => {
const fixture = createTableFixture(1, 1);
const tableEndOffset = fixture.documentData.body!.tables![0].endIndex;
expect(canInsertTableAtOffset(fixture.documentData.body!, tableEndOffset)).toBe(true);
});
it('allows insertion immediately before table and block start boundaries', () => {
const fixture = createTableFixture(1, 1);
const blockBody = {
blockRanges: [{ blockId: 'block-1', blockType: DocumentBlockRangeType.CALLOUT, startIndex: 1, endIndex: 3 }],
customBlocks: [],
tables: [],
};
expect(canInsertTableAtOffset(fixture.documentData.body!, fixture.tableRange.startIndex)).toBe(true);
expect(canInsertTableAtOffset(blockBody, 1)).toBe(true);
expect(canInsertTableAtOffset(blockBody, 2)).toBe(false);
});
it('does not create a table on a custom block', async () => {
const testBed = createTableCreationBed({
...createParagraphDocument('A\b\r\n'),
body: {
dataStream: 'A\b\r\n',
textRuns: [{ st: 0, ed: 2, ts: {} }],
paragraphs: [{ paragraphId: 'para_custom_block', startIndex: 2 }],
sectionBreaks: [{ sectionId: 'section_fixture_221', startIndex: 3 }],
customBlocks: [{ startIndex: 1, blockId: 'custom-block-1' }],
},
});
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, 1);
setCommandSkeletonPage(testBed, 1, {
pageWidth: 540,
marginLeft: 90,
marginRight: 90,
});
await expect(commandService.executeCommand(CreateDocTableCommand.id, {
rowCount: 1,
colCount: 1,
})).resolves.toBe(false);
expect(testBed.doc.getSnapshot().body?.tables).toBeUndefined();
expect(testBed.doc.getSnapshot().tableSource).toEqual({});
});
it('does not create a table without a render skeleton for the cursor', async () => {
const testBed = createTableCreationBed(createParagraphDocument('Hello\r\n'));
const commandService = testBed.get(ICommandService);
setActiveTableRange(testBed, 5);
const skeletonManager = testBed.get(DocSkeletonManagerService) as unknown as { getSkeleton: () => unknown };
skeletonManager.getSkeleton = () => null;
await expect(commandService.executeCommand(CreateDocTableCommand.id, {
rowCount: 1,
colCount: 1,
})).resolves.toBe(false);
expect(testBed.doc.getSnapshot().body?.tables).toBeUndefined();
expect(testBed.doc.getSnapshot().tableSource).toEqual({});
});
});
function testBedSnapshotTableIds(testBed: ReturnType<typeof createCommandTestBed>) {
return Object.keys(testBed.doc.getSnapshot().tableSource ?? {});
}
function createParagraphDocument(dataStream: string): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream,
textRuns: [{
st: 0,
ed: dataStream.length - 2,
ts: {},
}],
paragraphs: [{ paragraphId: 'para_docs_ui_create_table', startIndex: dataStream.length - 2 }],
sectionBreaks: [{ sectionId: 'section_fixture_222', startIndex: dataStream.length - 1 }],
customBlocks: [],
},
documentStyle: {
pageSize: { width: 540, height: 720 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
tableSource: {},
};
}
function setCommandSkeletonPage(testBed: ReturnType<typeof createCommandTestBed>, offset: number, pageInfo: { pageWidth: number; marginLeft: number; marginRight: number }) {
const page = { ...pageInfo };
const pageChild = { parent: page };
const paragraph = { parent: pageChild };
const line = { parent: paragraph };
const divide = { parent: line };
const glyph = { parent: divide };
const skeletonManager = testBed.get(DocSkeletonManagerService) as unknown as { getSkeleton: () => unknown };
skeletonManager.getSkeleton = () => ({
findNodeByCharIndex: (index: number) => index === offset ? glyph : null,
});
}

View File

@@ -0,0 +1,570 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IDocumentData, Injector, ITextStyle, Univer } from '@univerjs/core';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
BaselineOffset,
BooleanNumber,
createInternalEditorID,
DOC_RANGE_TYPE,
ICommandService,
IUniverInstanceService,
RedoCommand,
Tools,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
getStyleInTextRange,
ResetInlineFormatTextBackgroundColorCommand,
ResetInlineFormatTextColorCommand,
SetInlineFormatBoldCommand,
SetInlineFormatCommand,
SetInlineFormatFontFamilyCommand,
SetInlineFormatFontSizeCommand,
SetInlineFormatItalicCommand,
SetInlineFormatStrikethroughCommand,
SetInlineFormatSubscriptCommand,
SetInlineFormatSuperscriptCommand,
SetInlineFormatTextBackgroundColorCommand,
SetInlineFormatTextColorCommand,
SetInlineFormatTextFillCommand,
SetInlineFormatUnderlineCommand,
} from '../inline-format.command';
import { createCommandTestBed } from './create-command-test-bed';
describe('getStyleInTextRange', () => {
it('reads an expanded selection without cloning unrelated text runs', () => {
const body = {
dataStream: 'x'.repeat(10_000),
textRuns: Array.from({ length: 1_000 }, (_value, index) => ({
st: index * 10,
ed: index * 10 + 5,
ts: { fs: 10 + index % 3 },
})),
} satisfies IDocumentBody;
const range = {
startOffset: 5_001,
endOffset: 5_004,
collapsed: false,
} satisfies ITextRangeWithStyle;
const cloneSpy = vi.spyOn(Tools, 'deepClone');
expect(getStyleInTextRange(body, range, {})).toMatchObject({ fs: 12 });
expect(cloneSpy.mock.calls.length).toBeLessThan(10);
cloneSpy.mockRestore();
});
});
describe('Test inline format commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
function getFormatValueAt(key: keyof ITextStyle, pos: number) {
const univerInstanceService = get(IUniverInstanceService);
const docsModel = univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC);
if (docsModel?.getBody()?.textRuns == null) {
return;
}
for (const textRun of docsModel.getBody()?.textRuns ?? []) {
const { st, ed, ts = {} } = textRun;
if (st <= pos && ed >= pos) {
return ts[key];
}
}
}
beforeEach(() => {
const testBed = createCommandTestBed();
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetInlineFormatCommand);
commandService.registerCommand(SetInlineFormatBoldCommand);
commandService.registerCommand(SetInlineFormatItalicCommand);
commandService.registerCommand(SetInlineFormatUnderlineCommand);
commandService.registerCommand(SetInlineFormatStrikethroughCommand);
commandService.registerCommand(SetInlineFormatSubscriptCommand);
commandService.registerCommand(SetInlineFormatSuperscriptCommand);
commandService.registerCommand(SetInlineFormatFontFamilyCommand);
commandService.registerCommand(SetInlineFormatFontSizeCommand);
commandService.registerCommand(SetInlineFormatTextColorCommand);
commandService.registerCommand(SetInlineFormatTextFillCommand);
commandService.registerCommand(SetInlineFormatTextBackgroundColorCommand);
commandService.registerCommand(ResetInlineFormatTextColorCommand);
commandService.registerCommand(ResetInlineFormatTextBackgroundColorCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([
{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
},
]);
selectionManager.__TEST_ONLY_add([
{
startOffset: 20,
endOffset: 30,
collapsed: false,
},
]);
});
afterEach(() => univer.dispose());
describe('Public inline format wrapper commands', () => {
it('applies document text styles through the menu command wrappers', async () => {
let appliedCommandCount = 0;
try {
await commandService.executeCommand(SetInlineFormatBoldCommand.id);
appliedCommandCount++;
await commandService.executeCommand(SetInlineFormatItalicCommand.id);
appliedCommandCount++;
await commandService.executeCommand(SetInlineFormatUnderlineCommand.id);
appliedCommandCount++;
await commandService.executeCommand(SetInlineFormatStrikethroughCommand.id);
appliedCommandCount++;
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('it', 1)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('ul', 1)).toStrictEqual({ s: BooleanNumber.TRUE });
expect(getFormatValueAt('st', 1)).toStrictEqual({ s: BooleanNumber.TRUE });
await commandService.executeCommand(SetInlineFormatSubscriptCommand.id);
appliedCommandCount++;
expect(getFormatValueAt('va', 1)).toBe(BaselineOffset.SUBSCRIPT);
await commandService.executeCommand(SetInlineFormatSuperscriptCommand.id);
appliedCommandCount++;
expect(getFormatValueAt('va', 1)).toBe(BaselineOffset.SUPERSCRIPT);
await commandService.executeCommand(SetInlineFormatSuperscriptCommand.id);
appliedCommandCount++;
expect(getFormatValueAt('va', 1)).toBe(BaselineOffset.NORMAL);
await commandService.executeCommand(SetInlineFormatFontFamilyCommand.id, { value: 'Inter' });
appliedCommandCount++;
await commandService.executeCommand(SetInlineFormatFontSizeCommand.id, { value: 32 });
appliedCommandCount++;
expect(getFormatValueAt('ff', 1)).toBe('Inter');
expect(getFormatValueAt('fs', 1)).toBe(32);
await commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: '#224466' });
appliedCommandCount++;
await commandService.executeCommand(SetInlineFormatTextBackgroundColorCommand.id, { value: '#ffeeaa' });
appliedCommandCount++;
expect(getFormatValueAt('cl', 1)).toStrictEqual({ rgb: '#224466' });
expect(getFormatValueAt('bg', 1)).toStrictEqual({ rgb: '#ffeeaa' });
await commandService.executeCommand(ResetInlineFormatTextColorCommand.id);
appliedCommandCount++;
await commandService.executeCommand(ResetInlineFormatTextBackgroundColorCommand.id);
appliedCommandCount++;
expect(getFormatValueAt('cl', 1)).toStrictEqual({ rgb: null });
expect(getFormatValueAt('bg', 1)).toStrictEqual({ rgb: null });
await commandService.executeCommand(SetInlineFormatTextFillCommand.id, {
value: {
textFill: {
type: 'solid',
color: '#445566',
opacity: 0.8,
},
},
});
appliedCommandCount++;
expect(getFormatValueAt('textFill', 1)).toStrictEqual({
type: 'solid',
color: '#445566',
opacity: 0.8,
});
} finally {
for (let i = 0; i < appliedCommandCount; i++) {
await commandService.executeCommand(UndoCommand.id);
}
}
});
});
describe('Set Bold by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to bold', async () => {
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.FALSE);
expect(getFormatValueAt('bl', 21)).toBe(BooleanNumber.FALSE);
expect(getFormatValueAt('bl', 25)).toBe(BooleanNumber.TRUE);
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatBoldCommand.id,
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('bl', 21)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('bl', 25)).toBe(BooleanNumber.TRUE);
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.FALSE);
expect(getFormatValueAt('bl', 21)).toBe(BooleanNumber.FALSE);
expect(getFormatValueAt('bl', 25)).toBe(BooleanNumber.TRUE);
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('bl', 21)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('bl', 25)).toBe(BooleanNumber.TRUE);
});
it('formats text ranges without applying stale table rect ranges', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__replaceTextRangesWithNoRefresh({
textRanges: [{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
rangeType: DOC_RANGE_TYPE.TEXT,
}],
rectRanges: [{
startOffset: 20,
endOffset: 30,
collapsed: false,
rangeType: DOC_RANGE_TYPE.RECT,
tableId: 'table-1',
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
}],
segmentId: '',
segmentPage: -1,
isEditing: true,
style: {},
} as never, {
unitId: 'test-doc',
subUnitId: 'test-doc',
});
expect(getFormatValueAt('ff', 1)).toBe(undefined);
expect(getFormatValueAt('ff', 21)).toBe(undefined);
await commandService.executeCommand(SetInlineFormatCommand.id, {
segmentId: '',
preCommandId: SetInlineFormatFontFamilyCommand.id,
value: 'Arial',
});
expect(getFormatValueAt('ff', 1)).toBe('Arial');
expect(getFormatValueAt('ff', 21)).toBe(undefined);
await commandService.executeCommand(UndoCommand.id);
});
});
describe('Set Italic by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to italic', async () => {
expect(getFormatValueAt('it', 1)).toBe(undefined);
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatItalicCommand.id,
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('it', 1)).toBe(BooleanNumber.TRUE);
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('it', 1)).toBe(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('it', 1)).toBe(BooleanNumber.TRUE);
});
});
describe('Set Underline by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to underline', async () => {
expect(getFormatValueAt('ul', 1)).toBe(undefined);
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatUnderlineCommand.id,
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('ul', 1)).toStrictEqual({ s: BooleanNumber.TRUE });
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('ul', 1)).toStrictEqual(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('ul', 1)).toStrictEqual({ s: BooleanNumber.TRUE });
});
});
describe('Set Strickthrough by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to stroke', async () => {
expect(getFormatValueAt('st', 1)).toBe(undefined);
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatStrikethroughCommand.id,
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('st', 1)).toStrictEqual({ s: BooleanNumber.TRUE });
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('st', 1)).toStrictEqual(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('st', 1)).toStrictEqual({ s: BooleanNumber.TRUE });
});
});
describe('Set FontFamily by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to Arial', async () => {
expect(getFormatValueAt('ff', 1)).toBe(undefined);
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatFontFamilyCommand.id,
value: 'Arial',
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('ff', 1)).toBe('Arial');
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('ff', 1)).toBe(undefined);
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('ff', 1)).toBe('Arial');
});
});
describe('Set FontSize by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to 28', async () => {
expect(getFormatValueAt('fs', 1)).toBe(24);
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatFontSizeCommand.id,
value: 28,
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('fs', 1)).toBe(28);
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('fs', 1)).toBe(24);
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('fs', 1)).toBe(28);
});
});
describe('Set Text color by SetInlineFormatCommand', () => {
it('Should change text in range(0, 5) to #000000', async () => {
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: 'rgb(0, 40, 86)',
});
const commandParams = {
segmentId: '',
preCommandId: SetInlineFormatTextColorCommand.id,
value: '#000000',
};
await commandService.executeCommand(SetInlineFormatCommand.id, commandParams);
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: '#000000',
});
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: 'rgb(0, 40, 86)',
});
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: '#000000',
});
});
it('Should reset text color in selected ranges', async () => {
await commandService.executeCommand(SetInlineFormatCommand.id, {
segmentId: '',
preCommandId: SetInlineFormatTextColorCommand.id,
value: '#123456',
});
await commandService.executeCommand(ResetInlineFormatTextColorCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: null,
});
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: '#123456',
});
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({
rgb: null,
});
});
it('clears only solid text fills in a standalone document', async () => {
const docsModel = get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)!;
const body = docsModel.getBody()!;
const gradientFill = {
type: 'gradient' as const,
gradient: {
type: 'linear' as const,
stops: [
{ offset: 0, color: '#111111' },
{ offset: 1, color: '#eeeeee' },
],
},
};
const pictureFill = {
type: 'picture' as const,
picture: {
source: 'https://example.com/fill.png',
},
};
body.textRuns = [
{
st: 0,
ed: 2,
ts: {
bl: BooleanNumber.TRUE,
cl: { rgb: '#111111' },
textFill: { type: 'solid', color: '#222222' },
},
},
{
st: 2,
ed: 4,
ts: {
it: BooleanNumber.TRUE,
cl: { rgb: '#111111' },
textFill: gradientFill,
},
},
{
st: 4,
ed: 22,
ts: {
fs: 24,
cl: { rgb: '#111111' },
textFill: pictureFill,
},
},
{
st: 23,
ed: 68,
ts: {
fs: 24,
cl: { rgb: '#111111' },
},
},
];
await commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: '#ff0000' });
expect(getFormatValueAt('cl', 1)).toStrictEqual({ rgb: '#ff0000' });
expect(getFormatValueAt('bl', 1)).toBe(BooleanNumber.TRUE);
expect(getFormatValueAt('textFill', 1)).toBeUndefined();
expect(getFormatValueAt('textFill', 3)).toStrictEqual(gradientFill);
expect(getFormatValueAt('textFill', 4.5)).toStrictEqual(pictureFill);
await commandService.executeCommand(UndoCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({ rgb: '#111111' });
expect(getFormatValueAt('textFill', 1)).toStrictEqual({ type: 'solid', color: '#222222' });
await commandService.executeCommand(RedoCommand.id);
expect(getFormatValueAt('cl', 1)).toStrictEqual({ rgb: '#ff0000' });
expect(getFormatValueAt('textFill', 1)).toBeUndefined();
});
it('preserves solid text fills in an internal editor', async () => {
const unitId = createInternalEditorID('shape-text');
const internalDoc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: unitId,
body: {
dataStream: 'abc\r\n',
textRuns: [{
st: 0,
ed: 3,
ts: {
cl: { rgb: '#111111' },
textFill: { type: 'solid', color: '#222222' },
},
}],
paragraphs: [{ startIndex: 3, paragraphId: 'shape-text-paragraph' }],
},
});
const univerInstanceService = get(IUniverInstanceService);
univerInstanceService.focusUnit(unitId);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId, subUnitId: unitId });
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 3,
collapsed: false,
isActive: true,
}]);
await commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: '#ff0000' });
expect(internalDoc.getBody()?.textRuns?.[0].ts?.cl).toStrictEqual({ rgb: '#ff0000' });
expect(internalDoc.getBody()?.textRuns?.[0].ts?.textFill).toStrictEqual({
type: 'solid',
color: '#222222',
});
});
});
});

View File

@@ -0,0 +1,109 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } from '@univerjs/core';
import { awaitTime, BooleanNumber, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import {
DocSelectionManagerService,
InsertTextCommand,
RichTextEditingMutation,
SetTextSelectionsOperation,
} from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { InsertSpecialCharacterCommand } from '../insert-special-character.command';
import { createCommandTestBed } from './create-command-test-bed';
function getDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'Hello world\r\n',
textRuns: [{
st: 0,
ed: 11,
ts: {
bl: BooleanNumber.TRUE,
fs: 18,
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('InsertSpecialCharacterCommand', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
beforeEach(() => {
const testBed = createCommandTestBed(getDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(InsertTextCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
commandService.registerCommand(InsertSpecialCharacterCommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
});
afterEach(() => univer.dispose());
it('inserts the selected character at the active range with the current text style', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{
startOffset: 5,
endOffset: 5,
collapsed: true,
isActive: true,
segmentId: '',
style: null as never,
}]);
const result = await commandService.executeCommand(InsertSpecialCharacterCommand.id, { value: '😀' });
await awaitTime(0);
const body = get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)
?.getBody();
const insertedTextRun = body?.textRuns?.find(({ st, ed }) => st <= 5 && ed >= 7);
expect(result).toBe(true);
expect(body?.dataStream).toBe('Hello😀 world\r\n');
expect(insertedTextRun?.ts).toMatchObject({ bl: BooleanNumber.TRUE, fs: 18 });
});
it('does nothing when there is no active text range', async () => {
const result = await commandService.executeCommand(InsertSpecialCharacterCommand.id, { value: '∞' });
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,355 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } from '@univerjs/core';
import {
awaitTime,
ICommandService,
IUniverInstanceService,
PRESET_LIST_TYPE,
PresetListType,
UniverInstanceType,
validateDocBodyStructure,
} from '@univerjs/core';
import { DocContentInsertService, DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { InsertBulletBelowCommand } from '../insert-below/insert-bullet-below.command';
import {
BulletListCommand,
ChangeListNestingLevelCommand,
ChangeListNestingLevelType,
ChangeListTypeCommand,
CheckListCommand,
findNearestSectionBreak,
getParagraphsRelative,
InsertBulletListBellowCommand,
InsertCheckListBellowCommand,
InsertOrderListBellowCommand,
ListOperationCommand,
OrderListCommand,
QuickListCommand,
ToggleCheckListCommand,
} from '../list.command';
import { createCommandTestBed } from './create-command-test-bed';
function getDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'Alpha\rBeta\r\n',
paragraphs: [
{ paragraphId: 'para_docs_ui_fixture_20', startIndex: 5 },
{ paragraphId: 'para_docs_ui_fixture_21', startIndex: 10 },
],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('list commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let selectionId: number;
function getBody() {
const univerInstanceService = get(IUniverInstanceService);
return univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)?.getBody();
}
function setSelections(ranges: Array<{ startOffset: number; endOffset: number; collapsed: boolean }>) {
const selectionManager = get(DocSelectionManagerService);
const subUnitId = `test-doc-selection-${selectionId++}`;
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId,
});
selectionManager.__TEST_ONLY_add(ranges.map((range, index) => ({
...range,
isActive: index === 0,
segmentId: '',
style: null as never,
})));
}
beforeEach(() => {
const testBed = createCommandTestBed(getDocumentData(), [[DocContentInsertService]]);
univer = testBed.univer;
get = testBed.get;
selectionId = 0;
commandService = get(ICommandService);
commandService.registerCommand(ListOperationCommand);
commandService.registerCommand(ChangeListTypeCommand);
commandService.registerCommand(ChangeListNestingLevelCommand);
commandService.registerCommand(BulletListCommand);
commandService.registerCommand(CheckListCommand);
commandService.registerCommand(OrderListCommand);
commandService.registerCommand(ToggleCheckListCommand);
commandService.registerCommand(QuickListCommand);
commandService.registerCommand(InsertBulletListBellowCommand);
commandService.registerCommand(InsertOrderListBellowCommand);
commandService.registerCommand(InsertCheckListBellowCommand);
commandService.registerCommand(InsertBulletBelowCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
});
afterEach(() => univer.dispose());
it('toggles bullet lists on selected paragraphs through the public command chain', async () => {
setSelections([
{ startOffset: 0, endOffset: 4, collapsed: false },
{ startOffset: 6, endOffset: 9, collapsed: false },
]);
await commandService.executeCommand(BulletListCommand.id);
await awaitTime(0);
const paragraphs = getBody()?.paragraphs ?? [];
expect(paragraphs[0].bullet?.listType).toBe(PresetListType.BULLET_LIST);
expect(paragraphs[1].bullet?.listType).toBe(PresetListType.BULLET_LIST);
expect(paragraphs[0].bullet?.listId).toBe(paragraphs[1].bullet?.listId);
});
it.each([
['ordered', OrderListCommand.id, PresetListType.ORDER_LIST],
['unordered', BulletListCommand.id, PresetListType.BULLET_LIST],
['task', CheckListCommand.id, PresetListType.CHECK_LIST],
])('does not assign an absolute marker font size to %s lists', async (_label, commandId, listType) => {
setSelections([{ startOffset: 0, endOffset: 4, collapsed: false }]);
await commandService.executeCommand(commandId);
await awaitTime(0);
const bullet = getBody()?.paragraphs?.[0].bullet;
expect(bullet?.listType).toBe(listType);
expect(bullet?.textStyle?.fs).toBeUndefined();
});
it('changes checklist type and nesting level on an existing list', async () => {
setSelections([
{ startOffset: 0, endOffset: 4, collapsed: false },
{ startOffset: 6, endOffset: 9, collapsed: false },
]);
await commandService.executeCommand(CheckListCommand.id);
await commandService.executeCommand(ChangeListTypeCommand.id, {
listType: PresetListType.CHECK_LIST_CHECKED,
});
await commandService.executeCommand(ChangeListNestingLevelCommand.id, {
type: ChangeListNestingLevelType.increase,
});
await awaitTime(0);
const paragraphs = getBody()?.paragraphs ?? [];
expect(paragraphs[0].bullet?.listType).toBe(PresetListType.CHECK_LIST_CHECKED);
expect(paragraphs[0].bullet?.nestingLevel).toBeGreaterThanOrEqual(1);
});
it('creates quick list from a markdown-like paragraph', async () => {
setSelections([{ startOffset: 0, endOffset: 4, collapsed: false }]);
await commandService.executeCommand(CheckListCommand.id);
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].bullet?.listType).toBe(PresetListType.CHECK_LIST);
const paragraph = {
...(getBody()?.paragraphs?.[0] as NonNullable<NonNullable<ReturnType<typeof getBody>>['paragraphs']>[number]),
paragraphStart: 0,
paragraphEnd: 5,
};
setSelections([{ startOffset: 2, endOffset: 2, collapsed: true }]);
await commandService.executeCommand(QuickListCommand.id, {
listType: PresetListType.ORDER_LIST,
paragraph,
});
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].bullet?.listType).toBe(PresetListType.ORDER_LIST);
expect(getBody()?.paragraphs?.[0].bullet?.nestingLevel).toBe(0);
expect(getBody()?.paragraphs?.[0].bullet?.textStyle?.fs).toBeUndefined();
expect(getBody()?.paragraphs?.[0].paragraphStyle?.textStyle).toMatchObject(
PRESET_LIST_TYPE[PresetListType.ORDER_LIST].nestingLevel[0].paragraphProperties?.textStyle ?? {}
);
});
it('applies ordered lists and retypes selected list paragraphs through toolbar commands', async () => {
setSelections([
{ startOffset: 0, endOffset: 4, collapsed: false },
{ startOffset: 6, endOffset: 9, collapsed: false },
]);
await commandService.executeCommand(OrderListCommand.id);
await commandService.executeCommand(BulletListCommand.id, {
value: PresetListType.CHECK_LIST,
});
await commandService.executeCommand(CheckListCommand.id, {
value: PresetListType.CHECK_LIST_CHECKED,
});
await awaitTime(0);
const paragraphs = getBody()?.paragraphs ?? [];
expect(paragraphs.map((paragraph) => paragraph.bullet?.listType)).toEqual([
PresetListType.CHECK_LIST_CHECKED,
PresetListType.CHECK_LIST_CHECKED,
]);
expect(paragraphs[0].bullet?.listId).toBe(paragraphs[1].bullet?.listId);
});
it('toggles a checklist item between checked and unchecked states', async () => {
setSelections([{ startOffset: 0, endOffset: 4, collapsed: false }]);
await commandService.executeCommand(CheckListCommand.id);
await commandService.executeCommand(ToggleCheckListCommand.id, {
index: 5,
segmentId: '',
textRanges: [{ startOffset: 0, endOffset: 4, collapsed: false, segmentId: '' }],
});
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].bullet?.listType).toBe(PresetListType.CHECK_LIST_CHECKED);
await commandService.executeCommand(ToggleCheckListCommand.id, {
index: 5,
segmentId: '',
textRanges: [{ startOffset: 0, endOffset: 4, collapsed: false, segmentId: '' }],
});
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].bullet?.listType).toBe(PresetListType.CHECK_LIST);
});
it('inserts new list items below the current paragraph for every list menu action', async () => {
setSelections([{ startOffset: 2, endOffset: 2, collapsed: true }]);
await commandService.executeCommand(InsertBulletListBellowCommand.id);
await awaitTime(0);
expect(getBody()?.paragraphs).toHaveLength(3);
expect(getBody()?.paragraphs?.[1].bullet).toMatchObject({
listType: PresetListType.BULLET_LIST,
nestingLevel: 0,
});
setSelections([{ startOffset: 2, endOffset: 2, collapsed: true }]);
await commandService.executeCommand(InsertOrderListBellowCommand.id);
await awaitTime(0);
expect(getBody()?.paragraphs).toHaveLength(4);
expect(getBody()?.paragraphs?.[1].bullet).toMatchObject({
listType: PresetListType.ORDER_LIST,
nestingLevel: 0,
});
setSelections([{ startOffset: 2, endOffset: 2, collapsed: true }]);
await commandService.executeCommand(InsertCheckListBellowCommand.id);
await awaitTime(0);
expect(getBody()?.paragraphs).toHaveLength(5);
expect(getBody()?.paragraphs?.[1].bullet).toMatchObject({
listType: PresetListType.CHECK_LIST,
nestingLevel: 0,
});
});
it('inserts a list at an explicit paragraph gap without inheriting or converting the right paragraph', async () => {
const bodyBefore = getBody()!;
bodyBefore.paragraphs![1].paragraphStyle = { indentStart: { v: 60 } };
get(DocContentInsertService).setInsertRange({
unitId: 'test-doc',
startOffset: 6,
endOffset: 6,
});
expect(await commandService.executeCommand(InsertBulletListBellowCommand.id)).toBe(true);
await awaitTime(0);
const body = getBody()!;
expect(body.paragraphs?.find((paragraph) => paragraph.startIndex === 6)?.bullet?.listType).toBe(PresetListType.BULLET_LIST);
expect(body.paragraphs?.find((paragraph) => paragraph.paragraphId === 'para_docs_ui_fixture_21')?.paragraphStyle).toEqual({ indentStart: { v: 60 } });
expect(validateDocBodyStructure(body)).toEqual([]);
});
it('inserts a bullet below the focused list item and keeps the active list identity', async () => {
setSelections([{ startOffset: 0, endOffset: 4, collapsed: false }]);
await commandService.executeCommand(BulletListCommand.id);
await awaitTime(0);
const sourceBullet = getBody()?.paragraphs?.[0].bullet;
setSelections([{ startOffset: 2, endOffset: 2, collapsed: true }]);
await commandService.executeCommand(InsertBulletBelowCommand.id, {
listType: PresetListType.BULLET_LIST,
});
await awaitTime(0);
expect(getBody()?.paragraphs).toHaveLength(3);
expect(getBody()?.paragraphs?.[1].bullet).toEqual({
listType: PresetListType.BULLET_LIST,
listId: sourceBullet?.listId,
nestingLevel: sourceBullet?.nestingLevel,
});
});
it('does not insert a bullet below when the active selection spans text', async () => {
setSelections([{ startOffset: 0, endOffset: 4, collapsed: false }]);
const result = await commandService.executeCommand(InsertBulletBelowCommand.id, {
listType: PresetListType.BULLET_LIST,
});
expect(result).toBe(false);
expect(getBody()?.paragraphs).toHaveLength(2);
expect(getBody()?.dataStream).toBe('Alpha\rBeta\r\n');
});
it('extends selected paragraphs to adjacent items in the same list and finds nearest section breaks', () => {
const paragraphs = [
{ startIndex: 5, bullet: { listType: PresetListType.BULLET_LIST, listId: 'list-1', nestingLevel: 0 } },
{ startIndex: 10, bullet: { listType: PresetListType.BULLET_LIST, listId: 'list-1', nestingLevel: 0 } },
{ startIndex: 16 },
{ startIndex: 22, bullet: { listType: PresetListType.BULLET_LIST, listId: 'list-2', nestingLevel: 0 } },
] as NonNullable<NonNullable<ReturnType<typeof getBody>>['paragraphs']>;
const relative = getParagraphsRelative(
[{ startOffset: 6, endOffset: 9, collapsed: false, segmentId: '' }],
paragraphs,
'Alpha\rBeta\rGamma\r\n'
);
expect(relative.map((paragraph) => paragraph.startIndex)).toContain(5);
expect(relative.at(-1)?.startIndex).toBe(10);
expect(relative.length).toBeGreaterThanOrEqual(2);
expect(findNearestSectionBreak(11, [
{ startIndex: 3 } as never,
{ startIndex: 15 } as never,
{ startIndex: 25 } as never,
])?.startIndex).toBe(15);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,444 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } from '@univerjs/core';
import { DataStreamTreeTokenType, ICommandService, IUniverInstanceService, RedoCommand, UndoCommand, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation, SetTextSelectionsOperation } from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocIMEInputManagerService } from '../../../services/doc-ime-input-manager.service';
import { IMEInputCommand } from '../ime-input.command';
import {
buildReplaceSnapshotActions,
CoverContentCommand,
ReplaceSelectionCommand,
ReplaceSnapshotCommand,
ReplaceTextRunsCommand,
} from '../replace-content.command';
import { createCommandTestBed } from './create-command-test-bed';
function getDocumentData() {
const TEST_DOCUMENT_DATA_EN: IDocumentData = {
id: 'test-doc',
body: {
dataStream: '=SUM(A2:B4)\r\n',
textRuns: [],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
return TEST_DOCUMENT_DATA_EN;
}
function getTerminalColumnGroupDocumentData(): IDocumentData {
const T = DataStreamTreeTokenType;
const 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.SECTION_BREAK}`;
return {
id: 'test-doc',
body: {
dataStream,
paragraphs: [
{ paragraphId: 'terminal-column-first', startIndex: 3 },
{ paragraphId: 'terminal-column-second', startIndex: 7 },
],
sectionBreaks: [{ sectionId: 'terminal-column-section', startIndex: 10 }],
columnGroups: [{
columnGroupId: 'terminal-columns',
startIndex: 0,
endIndex: 9,
columns: [
{ columnId: 'terminal-column-1', widthRatio: 1 },
{ columnId: 'terminal-column-2', widthRatio: 1 },
],
}],
},
documentStyle: {
pageSize: { width: 540, height: 720 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('replace or cover content of document', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
function getDataStream() {
const univerInstanceService = get(IUniverInstanceService);
const docsModel = univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC);
const dataStream = docsModel?.getBody()?.dataStream;
return typeof dataStream === 'string' ? dataStream : '';
}
beforeEach(() => {
const testBed = createCommandTestBed(getDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(CoverContentCommand);
commandService.registerCommand(ReplaceSelectionCommand);
commandService.registerCommand(ReplaceSnapshotCommand);
commandService.registerCommand(ReplaceTextRunsCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: '',
});
selectionManager.__TEST_ONLY_add([
{
startOffset: 5,
endOffset: 5,
collapsed: true,
},
]);
});
afterEach(() => {
univer.dispose();
});
describe('cover content of document and clear undo and redo stack', () => {
it('Should pass the test case when cover content', async () => {
expect(getDataStream()!.length).toBe(13);
const commandParams = {
unitId: 'test-doc',
body: {
dataStream: '=AVERAGE(A4:B8)',
}, // Do not contain `\r\n` at the end.
textRanges: [],
segmentId: '',
};
await commandService.executeCommand(CoverContentCommand.id, commandParams);
expect(getDataStream().length).toBe(17);
await commandService.executeCommand(UndoCommand.id);
expect(getDataStream().length).toBe(17);
await commandService.executeCommand(RedoCommand.id);
expect(getDataStream().length).toBe(17);
});
});
describe('replace selected document content', () => {
it('replaces the document snapshot including style and table metadata', async () => {
const T = DataStreamTreeTokenType;
const columnDataStream = `${T.COLUMN_GROUP_START}${T.COLUMN_START}Left${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_START}Right${T.PARAGRAPH}${T.COLUMN_END}${T.COLUMN_GROUP_END}${T.PARAGRAPH}${T.SECTION_BREAK}`;
const tableSource = {
table_1: {
tableId: 'table_1',
tableRows: [],
tableColumns: [],
},
};
const columnGroups = [{
startIndex: 0,
endIndex: 16,
columnGroupId: 'column_group_1',
columns: [
{ columnId: 'column_1', widthRatio: 1 },
{ columnId: 'column_2', widthRatio: 1 },
],
gap: { v: 12 },
layout: 'fixed',
responsive: 'stack',
}];
const nextSnapshot = {
...getDocumentData(),
body: {
dataStream: columnDataStream,
textRuns: [],
paragraphs: [
{ paragraphId: 'para_replace_left', startIndex: 6 },
{ paragraphId: 'para_replace_right', startIndex: 14 },
{ paragraphId: 'para_replace_after', startIndex: 17 },
],
sectionBreaks: [{ sectionId: 'section_fixture_226', startIndex: 18 }],
customBlocks: [],
columnGroups,
},
documentStyle: {
pageSize: {
width: 500,
height: 700,
},
marginTop: 20,
marginBottom: 20,
marginRight: 30,
marginLeft: 30,
},
tableSource,
lists: {
list_1: {
listType: 'BULLET_LIST',
nestingLevel: [],
},
},
drawings: {},
drawingsOrder: [],
headers: {},
footers: {},
} as never;
await expect(commandService.executeCommand(ReplaceSnapshotCommand.id, {
unitId: 'test-doc',
snapshot: nextSnapshot,
textRanges: [{
startOffset: 17,
endOffset: 17,
collapsed: true,
}],
options: {
noHistory: true,
},
})).resolves.toBe(true);
const univerInstanceService = get(IUniverInstanceService);
const docsModel = univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC);
expect(docsModel?.getBody()?.dataStream).toBe(columnDataStream);
expect(docsModel?.getDocumentStyle().pageSize).toEqual({ width: 500, height: 700 });
expect(docsModel?.getSnapshot().tableSource).toEqual(tableSource);
expect(docsModel?.getBody()?.columnGroups).toEqual(columnGroups);
});
it('updates only the selection when the replacement snapshot body is unchanged', async () => {
await expect(commandService.executeCommand(ReplaceSnapshotCommand.id, {
unitId: 'test-doc',
snapshot: getDocumentData(),
textRanges: [{
startOffset: 1,
endOffset: 4,
collapsed: false,
}],
options: {},
})).resolves.toBe(true);
expect(getDataStream()).toBe('=SUM(A2:B4)\r\n');
});
it('builds replacement snapshot body changes without whole body replace', () => {
const previousSnapshot = getDocumentData();
const nextSnapshot = {
...previousSnapshot,
body: {
dataStream: 'Snapshot\r\n',
paragraphs: [{ paragraphId: 'para_replace_snapshot_shape', startIndex: 8 }],
sectionBreaks: [{ sectionId: 'section_fixture_227', startIndex: 9 }],
textRuns: [],
},
};
const actions = buildReplaceSnapshotActions(previousSnapshot, nextSnapshot);
const serializedActions = JSON.stringify(actions);
expect(actions).not.toBeNull();
expect(serializedActions).toContain('"et":"text-x"');
expect(serializedActions).not.toContain('["body",{"r"');
});
it('replaces the active selection through the real rich text mutation flow', async () => {
univer.dispose();
const testBed = createCommandTestBed(getDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(ReplaceSelectionCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: '',
});
selectionManager.__TEST_ONLY_add([
{
startOffset: 1,
endOffset: 4,
collapsed: false,
},
]);
const result = await commandService.executeCommand(ReplaceSelectionCommand.id, {
unitId: 'test-doc',
selection: {
startOffset: 1,
endOffset: 4,
collapsed: false,
},
body: {
dataStream: 'AVG',
},
textRanges: [{
startOffset: 4,
endOffset: 4,
collapsed: true,
}],
});
expect(result).toBeTruthy();
expect(getDataStream()).toBe('=AVG(A2:B4)\r\n');
});
it('replaces a fragmented whole-column-group selection once at the document start', async () => {
univer.dispose();
const original = getTerminalColumnGroupDocumentData();
const testBed = createCommandTestBed(original);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(ReplaceSelectionCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({ unitId: 'test-doc', subUnitId: '' });
selectionManager.__TEST_ONLY_add([{
startOffset: 2,
endOffset: 3,
collapsed: false,
isActive: true,
segmentId: '',
}]);
const selectionInfo = selectionManager.getSelectionInfo();
if (!selectionInfo) {
throw new Error('Selection info not found');
}
selectionManager.__replaceTextRangesWithNoRefresh({
...selectionInfo,
textRanges: [
{ startOffset: 2, endOffset: 3, collapsed: false, isActive: true, segmentId: '' },
{ startOffset: 6, endOffset: 7, collapsed: false, segmentId: '' },
],
rectRanges: [],
options: { wholeDocument: true },
}, { unitId: 'test-doc', subUnitId: '' });
expect(await commandService.executeCommand(ReplaceSelectionCommand.id, {
unitId: 'test-doc',
body: { dataStream: 'x' },
})).toBeTruthy();
const replacedSnapshot = get(IUniverInstanceService)
.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)
?.getSnapshot();
expect(replacedSnapshot?.body?.dataStream).toBe('x\r\n');
expect(replacedSnapshot?.body?.columnGroups).toEqual([]);
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDataStream()).toBe(original.body?.dataStream);
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDataStream()).toBe('x\r\n');
});
it('replaces a fragmented whole-column-group selection once during IME composition', async () => {
univer.dispose();
const testBed = createCommandTestBed(getTerminalColumnGroupDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(IMEInputCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const imeManager = get(DocIMEInputManagerService);
imeManager.setActiveRange({
startOffset: 2,
endOffset: 3,
collapsed: false,
isActive: true,
segmentId: '',
});
imeManager.setPreviousDocRanges([
{ startOffset: 2, endOffset: 3, collapsed: false, isActive: true, segmentId: '' },
{ startOffset: 6, endOffset: 7, collapsed: false, segmentId: '' },
]);
imeManager.setPreviousSelectionOptions({ wholeDocument: true });
expect(await commandService.executeCommand(IMEInputCommand.id, {
unitId: 'test-doc',
newText: '中',
oldTextLen: 0,
isCompositionStart: true,
isCompositionEnd: true,
})).toBe(true);
expect(getDataStream()).toBe('中\r\n');
expect(imeManager.getCompositionRange()).toMatchObject({
startOffset: 0,
endOffset: 0,
collapsed: true,
});
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDataStream()).toBe(getTerminalColumnGroupDocumentData().body?.dataStream);
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDataStream()).toBe('中\r\n');
});
it('replaces text runs without adding undo history', async () => {
await expect(commandService.executeCommand(ReplaceTextRunsCommand.id, {
unitId: 'test-doc',
body: {
dataStream: '=COUNT(A1:A2)',
textRuns: [{
st: 0,
ed: 13,
ts: {
fs: 18,
},
}],
},
textRanges: [{
startOffset: 13,
endOffset: 13,
collapsed: true,
}],
segmentId: '',
options: {},
})).resolves.toBe(true);
const univerInstanceService = get(IUniverInstanceService);
const docsModel = univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC);
const textRuns = docsModel?.getBody()?.textRuns ?? [];
expect(textRuns[0].st).toBe(0);
expect(textRuns.at(-1)?.ed).toBe(13);
expect(textRuns.every((textRun) => textRun.ts?.fs === 18)).toBe(true);
});
});
});

View File

@@ -0,0 +1,351 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector, Univer } from '@univerjs/core';
import { awaitTime, DataStreamTreeTokenType, DocumentBlockRangeType, ICommandService, IUniverInstanceService, NamedStyleType, SpacingRule, UniverInstanceType, validateDocBodyStructure } from '@univerjs/core';
import {
DocContentInsertService,
DocSelectionManagerService,
RichTextEditingMutation,
SetTextSelectionsOperation,
} from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
H1HeadingCommand,
H2HeadingCommand,
H3HeadingCommand,
H4HeadingCommand,
H5HeadingCommand,
NormalTextHeadingCommand,
QuickHeadingCommand,
SetParagraphNamedStyleCommand,
SubtitleHeadingCommand,
TitleHeadingCommand,
} from '../set-heading.command';
import { createCommandTestBed } from './create-command-test-bed';
function getHeadingDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'Heading\r\n',
paragraphs: [{
paragraphId: 'para_docs_ui_fixture_29',
startIndex: 7,
paragraphStyle: {
lineSpacing: 24,
spacingRule: SpacingRule.EXACT,
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function getQuickHeadingDocumentData(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: '# Heading\r\n',
paragraphs: [{
paragraphId: 'para_docs_ui_fixture_30',
startIndex: 9,
paragraphStyle: {
lineSpacing: 24,
spacingRule: SpacingRule.EXACT,
},
}],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
type BoundaryNodeType = 'blockRange' | 'paragraph' | 'table';
function getBoundaryDocumentData(leftType: BoundaryNodeType, rightType: BoundaryNodeType): { documentData: IDocumentData; insertOffset: number; rightParagraphId: string } {
const T = DataStreamTreeTokenType;
const body: NonNullable<IDocumentData['body']> = {
dataStream: '',
paragraphs: [],
sectionBreaks: [],
blockRanges: [],
tables: [],
};
let nextId = 0;
const append = (type: BoundaryNodeType, side: 'left' | 'right') => {
const startIndex = body.dataStream.length;
const paragraphId = `${side}-${type}-${nextId++}`;
const paragraphStyle = side === 'right'
? { indentStart: { v: 60 }, textStyle: { ff: 'monospace' } }
: undefined;
if (type === 'paragraph') {
body.dataStream += `${side}${T.PARAGRAPH}`;
body.paragraphs!.push({ paragraphId, paragraphStyle, startIndex: body.dataStream.length - 1 });
} else if (type === 'blockRange') {
body.dataStream += `${T.BLOCK_START}${side}${T.PARAGRAPH}${T.BLOCK_END}`;
body.paragraphs!.push({ paragraphId, paragraphStyle, startIndex: body.dataStream.length - 2 });
body.blockRanges!.push({
blockId: `${side}-block`,
blockType: DocumentBlockRangeType.CALLOUT,
startIndex,
endIndex: body.dataStream.length - 1,
});
} else {
body.dataStream += `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}${side}${T.PARAGRAPH}${T.SECTION_BREAK}${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
body.paragraphs!.push({ paragraphId, paragraphStyle, startIndex: body.dataStream.length - 5 });
body.sectionBreaks!.push({ sectionId: `section_${side}`, startIndex: body.dataStream.length - 4 });
body.tables!.push({ tableId: `${side}-table`, startIndex, endIndex: body.dataStream.length });
}
return paragraphId;
};
append(leftType, 'left');
const insertOffset = body.dataStream.length;
const rightParagraphId = append(rightType, 'right');
body.dataStream += `${T.PARAGRAPH}${T.SECTION_BREAK}`;
body.paragraphs!.push({ paragraphId: 'trailing', startIndex: body.dataStream.length - 2 });
body.sectionBreaks!.push({ sectionId: 'section_trailing', startIndex: body.dataStream.length - 1 });
return {
documentData: {
id: 'test-doc',
body,
documentStyle: getHeadingDocumentData().documentStyle,
},
insertOffset,
rightParagraphId,
};
}
describe('set heading commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
function getBody() {
const univerInstanceService = get(IUniverInstanceService);
return univerInstanceService.getUnit<DocumentDataModel>('test-doc', UniverInstanceType.UNIVER_DOC)?.getBody();
}
function resetDocument(documentData: IDocumentData) {
univer.dispose();
const testBed = createCommandTestBed(documentData, [[DocContentInsertService]]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetParagraphNamedStyleCommand);
commandService.registerCommand(H1HeadingCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
get(DocSelectionManagerService).__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
}
beforeEach(() => {
const testBed = createCommandTestBed(getHeadingDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetParagraphNamedStyleCommand);
commandService.registerCommand(H1HeadingCommand);
commandService.registerCommand(H2HeadingCommand);
commandService.registerCommand(H3HeadingCommand);
commandService.registerCommand(H4HeadingCommand);
commandService.registerCommand(H5HeadingCommand);
commandService.registerCommand(NormalTextHeadingCommand);
commandService.registerCommand(TitleHeadingCommand);
commandService.registerCommand(SubtitleHeadingCommand);
commandService.registerCommand(QuickHeadingCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
});
afterEach(() => univer.dispose());
it('applies a named paragraph style via the public heading command', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{ startOffset: 0, endOffset: 3, collapsed: false, isActive: true, segmentId: '', style: null as never }]);
await commandService.executeCommand(H1HeadingCommand.id);
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.namedStyleType).toBe(NamedStyleType.HEADING_1);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.headingId?.length).toBe(6);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.lineSpacing).toBeUndefined();
expect(getBody()?.paragraphs?.[0].paragraphStyle?.spacingRule).toBeUndefined();
});
it('applies heading levels and document title styles through public commands', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_add([{ startOffset: 0, endOffset: 7, collapsed: false, isActive: true, segmentId: '', style: null as never }]);
const headingCases = [
{ commandId: H2HeadingCommand.id, namedStyleType: NamedStyleType.HEADING_2 },
{ commandId: H3HeadingCommand.id, namedStyleType: NamedStyleType.HEADING_3 },
{ commandId: H4HeadingCommand.id, namedStyleType: NamedStyleType.HEADING_4 },
{ commandId: H5HeadingCommand.id, namedStyleType: NamedStyleType.HEADING_5 },
{ commandId: TitleHeadingCommand.id, namedStyleType: NamedStyleType.TITLE },
{ commandId: SubtitleHeadingCommand.id, namedStyleType: NamedStyleType.SUBTITLE },
];
for (const { commandId, namedStyleType } of headingCases) {
await commandService.executeCommand(commandId);
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.namedStyleType).toBe(namedStyleType);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.headingId?.length).toBe(6);
}
await commandService.executeCommand(NormalTextHeadingCommand.id);
await awaitTime(0);
expect(getBody()?.paragraphs?.[0].paragraphStyle).toEqual({
namedStyleType: NamedStyleType.NORMAL_TEXT,
});
});
it('inserts a styled paragraph when paragraph menu provides an insert range', async () => {
univer.dispose();
const testBed = createCommandTestBed(getHeadingDocumentData(), [[DocContentInsertService]]);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetParagraphNamedStyleCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const contentInsertService = get(DocContentInsertService);
contentInsertService.setInsertRange({
unitId: 'test-doc',
startOffset: 7,
endOffset: 7,
});
await commandService.executeCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.TITLE,
});
await awaitTime(0);
expect(getBody()?.dataStream).toBe('Heading\r\r\n');
expect(getBody()?.paragraphs?.[0].paragraphStyle?.namedStyleType).toBe(NamedStyleType.TITLE);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.headingId?.length).toBe(6);
});
it.each([
['paragraph', 'paragraph'],
['paragraph', 'blockRange'],
['paragraph', 'table'],
['blockRange', 'paragraph'],
['blockRange', 'blockRange'],
['blockRange', 'table'],
['table', 'paragraph'],
['table', 'blockRange'],
['table', 'table'],
] as Array<[BoundaryNodeType, BoundaryNodeType]>)('inserts an isolated H1 at the %s -> %s gap', async (leftType, rightType) => {
const { documentData, insertOffset, rightParagraphId } = getBoundaryDocumentData(leftType, rightType);
const rightStyle = documentData.body!.paragraphs!.find((paragraph) => paragraph.paragraphId === rightParagraphId)!.paragraphStyle;
resetDocument(documentData);
const selectionManager = get(DocSelectionManagerService);
let latestSelectionOffset: number | undefined;
const selectionSubscription = selectionManager.refreshSelection$.subscribe((event) => {
latestSelectionOffset = event?.docRanges[0]?.startOffset;
});
get(DocContentInsertService).setInsertRange({
unitId: 'test-doc',
startOffset: insertOffset,
endOffset: insertOffset,
});
expect(await commandService.executeCommand(H1HeadingCommand.id)).toBe(true);
await awaitTime(0);
const body = getBody()!;
const insertedParagraph = body.paragraphs?.find((paragraph) => paragraph.startIndex === insertOffset);
const rightParagraph = body.paragraphs?.find((paragraph) => paragraph.paragraphId === rightParagraphId);
expect(insertedParagraph?.paragraphStyle).toMatchObject({ namedStyleType: NamedStyleType.HEADING_1 });
expect(insertedParagraph?.paragraphStyle?.indentStart).toBeUndefined();
expect(rightParagraph?.paragraphStyle).toEqual(rightStyle);
expect(latestSelectionOffset).toBe(insertOffset);
expect(validateDocBodyStructure(body)).toEqual([]);
selectionSubscription.unsubscribe();
});
it('converts markdown-like quick headings through the real command chain', async () => {
univer.dispose();
const testBed = createCommandTestBed(getQuickHeadingDocumentData());
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
commandService.registerCommand(SetParagraphNamedStyleCommand);
commandService.registerCommand(QuickHeadingCommand);
commandService.registerCommand(SetTextSelectionsOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{ startOffset: 2, endOffset: 2, collapsed: true, isActive: true, segmentId: '', style: null as never }]);
await commandService.executeCommand(QuickHeadingCommand.id, {
value: NamedStyleType.HEADING_2,
});
await awaitTime(0);
expect(getBody()?.dataStream.startsWith('Heading')).toBe(true);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.namedStyleType).toBe(NamedStyleType.HEADING_2);
expect(getBody()?.paragraphs?.[0].paragraphStyle?.lineSpacing).toBeUndefined();
expect(getBody()?.paragraphs?.[0].paragraphStyle?.spacingRule).toBeUndefined();
});
});

View File

@@ -0,0 +1,60 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import { CommandType, ICommandService, sequenceExecuteAsync } from '@univerjs/core';
import { DocAutoFormatService } from '../../services/doc-auto-format.service';
const TabCommandId = 'doc.command.tab';
export interface ITabCommandParams {
shift?: boolean;
}
export const TabCommand: ICommand<ITabCommandParams> = {
id: TabCommandId,
type: CommandType.COMMAND,
async handler(accessor, params) {
const autoFormatService = accessor.get(DocAutoFormatService);
const mutations = autoFormatService.onAutoFormat(TabCommand.id, params);
return (await sequenceExecuteAsync(mutations, accessor.get(ICommandService))).result;
},
};
const AfterSpaceCommandId = 'doc.command.after-space';
export const AfterSpaceCommand: ICommand = {
id: AfterSpaceCommandId,
type: CommandType.COMMAND,
async handler(accessor) {
const autoFormatService = accessor.get(DocAutoFormatService);
const mutations = autoFormatService.onAutoFormat(AfterSpaceCommand.id);
return (await sequenceExecuteAsync(mutations, accessor.get(ICommandService))).result;
},
};
export const EnterCommand: ICommand = {
id: 'doc.command.enter',
type: CommandType.COMMAND,
async handler(accessor) {
const autoFormatService = accessor.get(DocAutoFormatService);
const mutations = autoFormatService.onAutoFormat(EnterCommand.id);
return (await sequenceExecuteAsync(mutations, accessor.get(ICommandService))).result;
},
};

View File

@@ -0,0 +1,247 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, IParagraphBorder, ITextRangeParam } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import { BuildTextUtils, CommandType, DataStreamTreeTokenType, getRichTextEditPath, ICommandService, IUniverInstanceService, JSONX, PresetListType, TextX, TextXActionType, UniverInstanceType, UpdateDocsAttributeType } from '@univerjs/core';
import { DocSelectionManagerService, generateParagraphs, RichTextEditingMutation } from '@univerjs/docs';
import { getTextRunAtPosition, isTopLevelStructuralGap } from '../../basics/paragraph';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
export { generateParagraphs };
export enum BreakLineInsertionMode {
SplitParagraph = 'split-paragraph',
InsertGap = 'insert-gap',
}
interface IBreakLineCommandParams {
horizontalLine?: IParagraphBorder;
insertionMode?: BreakLineInsertionMode;
textRange?: ITextRangeParam;
unitId?: string;
}
export const BreakLineCommand: ICommand<IBreakLineCommandParams> = {
id: 'doc.command.break-line',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: (accessor, params: IBreakLineCommandParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const selectionParams = params?.unitId
? { unitId: params.unitId, subUnitId: params.unitId }
: undefined;
const targetTextRanges = docSelectionManagerService.getTextRanges(selectionParams) ?? [];
const activeTextRange = params?.textRange ?? (selectionParams
? targetTextRanges.find((range) => range.isActive)
: docSelectionManagerService.getActiveTextRange());
const rectRanges = docSelectionManagerService.getRectRanges(selectionParams);
if (activeTextRange == null) {
return false;
}
// Just reset the cursor to the active text range start when select both text range and rect range.
if (rectRanges && rectRanges.length) {
const { startOffset } = activeTextRange;
docSelectionManagerService.replaceDocRanges([{
startOffset,
endOffset: startOffset,
}], selectionParams);
return true;
}
const { horizontalLine, insertionMode = BreakLineInsertionMode.SplitParagraph } = params ?? {};
const { segmentId } = activeTextRange;
const docDataModel = params?.unitId
? univerInstanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const originBody = docDataModel?.getSelfOrHeaderFooterModel(segmentId ?? '')?.getBody();
if (docDataModel == null || originBody == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const { startOffset, endOffset } = activeTextRange;
const paragraphs = originBody.paragraphs ?? [];
const isBareStructuralGap = isTopLevelStructuralGap(originBody.dataStream, startOffset) &&
originBody.dataStream[startOffset] !== DataStreamTreeTokenType.PARAGRAPH;
const insertsAtStructuralGap = insertionMode === BreakLineInsertionMode.InsertGap || isBareStructuralGap;
const prevParagraph = insertsAtStructuralGap
? undefined
: paragraphs.find((p) => p.startIndex >= startOffset);
if (!prevParagraph && !insertsAtStructuralGap) {
return false;
}
const isAtParagraphEnd = startOffset === prevParagraph?.startIndex;
const prevParagraphIndex = prevParagraph?.startIndex;
const defaultTextStyle = docMenuStyleService.getDefaultStyle();
const styleCache = docMenuStyleService.getStyleCache();
const curTextRun = getTextRunAtPosition(originBody, endOffset, defaultTextStyle, styleCache);
const insertBody: IDocumentBody = {
dataStream: DataStreamTreeTokenType.PARAGRAPH,
paragraphs: generateParagraphs(
DataStreamTreeTokenType.PARAGRAPH,
prevParagraph,
horizontalLine
),
textRuns: [{
st: 0,
ed: 1,
ts: {
...curTextRun.ts,
},
}],
};
if (docDataModel == null) {
return false;
}
const activeRange = selectionParams
? targetTextRanges.find((range) => range.isActive)
: docSelectionManagerService.getActiveTextRange();
if (originBody == null) {
return false;
}
const { collapsed } = activeTextRange;
const cursorMove = insertsAtStructuralGap ? 0 : insertBody.dataStream.length;
const textRanges = [
{
startOffset: startOffset + cursorMove,
endOffset: startOffset + cursorMove,
style: activeRange?.style,
collapsed,
},
];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
debounce: true,
trigger: BreakLineCommand.id,
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (collapsed) {
if (startOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: startOffset,
});
}
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: insertBody.dataStream.length,
});
} else {
const dos = BuildTextUtils.selection.delete([activeTextRange], originBody, 0, insertBody);
textX.push(...dos);
}
const preserveEmptyParagraphStyle = collapsed && isAtParagraphEnd;
const resetParagraphType = prevParagraph?.bullet?.listType === PresetListType.CHECK_LIST_CHECKED ||
prevParagraph?.paragraphStyle?.headingId;
if (prevParagraph && (preserveEmptyParagraphStyle || resetParagraphType)) {
if (prevParagraphIndex != null && activeTextRange.endOffset < prevParagraphIndex) {
textX.push({
t: TextXActionType.RETAIN,
len: prevParagraphIndex - activeTextRange.endOffset,
});
}
textX.push({
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
...preserveEmptyParagraphStyle
? {
textRuns: [{
st: 0,
ed: 1,
ts: { ...curTextRun.ts },
}],
}
: null,
...resetParagraphType
? {
paragraphs: [
{
...prevParagraph,
paragraphStyle: {
...prevParagraph.paragraphStyle,
...isAtParagraphEnd
? {
headingId: undefined,
namedStyleType: undefined,
}
: null,
},
startIndex: 0,
bullet: prevParagraph.paragraphStyle?.headingId
? undefined
: {
...prevParagraph.bullet!,
listType: PresetListType.CHECK_LIST,
},
},
],
}
: null,
},
coverType: UpdateDocsAttributeType.REPLACE,
});
}
doMutation.params.textRanges = [{
startOffset: startOffset + cursorMove,
endOffset: startOffset + cursorMove,
collapsed,
segmentId,
}];
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.segmentId = segmentId;
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,120 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, IContextService, IMultiCommand } from '@univerjs/core';
import { CommandType, DOC_RANGE_TYPE, EDITOR_ACTIVATED, FOCUSING_DOC, SliceBodyType } from '@univerjs/core';
import { CopyCommand, CutCommand, IClipboardInterfaceService, PasteCommand } from '@univerjs/ui';
import { IDocClipboardService } from '../../services/clipboard/clipboard.service';
import { getCurrentParagraph } from './util';
export function whenDocOrEditor(contextService: IContextService): boolean {
return contextService.getContextValue(FOCUSING_DOC) || contextService.getContextValue(EDITOR_ACTIVATED);
}
export function whenFocusEditor(contextService: IContextService): boolean {
return contextService.getContextValue(EDITOR_ACTIVATED);
}
const DOC_CLIPBOARD_PRIORITY = 999;
// Commands here should have higher priority than commands of sheets
// in packages/sheets-ui/src/commands/commands/clipboard.command.ts
export const DocCopyCommand: IMultiCommand = {
id: CopyCommand.id,
name: 'doc.command.copy',
type: CommandType.COMMAND,
multi: true,
priority: DOC_CLIPBOARD_PRIORITY,
preconditions: whenDocOrEditor,
handler: async (accessor: IAccessor) => {
const docClipboardService = accessor.get(IDocClipboardService);
return docClipboardService.copy();
},
};
export const DocCopyCurrentParagraphCommand = {
id: 'doc.command.copy-current-paragraph',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const docClipboardService = accessor.get(IDocClipboardService);
const paragraph = getCurrentParagraph(accessor);
if (!paragraph) {
return false;
}
return docClipboardService.copy(
SliceBodyType.copy,
[{
startOffset: paragraph.paragraphStart,
endOffset: paragraph.paragraphEnd + 1,
collapsed: false,
}]
);
},
};
export const DocCutCommand: IMultiCommand = {
id: CutCommand.id,
name: 'doc.command.cut',
type: CommandType.COMMAND,
multi: true,
priority: DOC_CLIPBOARD_PRIORITY,
preconditions: whenDocOrEditor,
handler: async (accessor: IAccessor) => {
const docClipboardService = accessor.get(IDocClipboardService);
return docClipboardService.cut();
},
};
export const DocCutCurrentParagraphCommand = {
id: 'doc.command.cut-current-paragraph',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const docClipboardService = accessor.get(IDocClipboardService);
const paragraph = getCurrentParagraph(accessor);
if (!paragraph) {
return false;
}
return docClipboardService.cut(
[{
startOffset: paragraph.paragraphStart,
endOffset: paragraph.paragraphEnd + 1,
collapsed: false,
rangeType: DOC_RANGE_TYPE.TEXT,
}]
);
},
};
export const DocPasteCommand: IMultiCommand = {
id: PasteCommand.id,
name: 'doc.command.paste',
type: CommandType.COMMAND,
multi: true,
priority: DOC_CLIPBOARD_PRIORITY,
preconditions: whenDocOrEditor,
handler: async (accessor: IAccessor) => {
const docClipboardService = accessor.get(IDocClipboardService);
const clipboardInterfaceService = accessor.get(IClipboardInterfaceService);
const clipboardItems = clipboardInterfaceService.supportClipboard
? await clipboardInterfaceService.read()
: [];
return docClipboardService.paste(clipboardItems);
},
};

View File

@@ -0,0 +1,926 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, ICustomTable, IDisposable, IDocumentBody, IDocumentData, IDrawingParam, IMutationInfo, ITextRange, JSONXActions, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { DocumentViewModel, IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IDocClipboardPasteBlockRangeMapping, IDocClipboardPasteCustomBlockMapping, IDocClipboardPasteCustomRangeMapping } from '../../services/clipboard/doc-paste-mutation-adapter.service';
import {
BuildTextUtils,
CommandType,
createParagraphId,
DataStreamTreeTokenType,
DocumentFlavor,
generateRandomId,
getCustomBlockIdsInSelections,
getRichTextEditPath,
getTableRangeInterval,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
JSONX,
MemoryCursor,
SHEET_EDITOR_UNITS,
TextX,
TextXActionType,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition } from '../../basics/paragraph';
import {
IDocClipboardPasteAdapterService,
} from '../../services/clipboard/doc-paste-mutation-adapter.service';
import { cloneClipboardNotes, omitClipboardNotes } from '../../services/clipboard/internal-fragment';
import { getCommandSkeleton } from '../util';
import { getDeleteRowContentActionParams, getDeleteRowsActionsParams, getDeleteTableActionParams } from './table/table';
function hasRangeInTable(ranges: readonly ITextRangeWithStyle[]): boolean {
return ranges.some((range) => {
const { startNodePosition } = range;
return startNodePosition ? startNodePosition?.path.indexOf('cells') > -1 : false;
});
}
export interface IInnerPasteCommandParams {
unitId?: string;
segmentId: string;
doc: Partial<IDocumentData>;
textRanges: ITextRangeWithStyle[];
customRangeMappings?: IDocClipboardPasteCustomRangeMapping[];
}
const UNITS = SHEET_EDITOR_UNITS;
// Actually, the command is to handle paste event.
export const InnerPasteCommand: ICommand<IInnerPasteCommandParams> = {
id: 'doc.command.inner-paste',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: async (accessor, params: IInnerPasteCommandParams) => {
const {
customRangeMappings = [],
segmentId,
textRanges,
doc,
unitId: targetUnitId,
} = params;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const pasteAdapterService = getPasteAdapterService(accessor);
const currentSelection = docSelectionManagerService.__getCurrentSelection();
let selectionParams;
if (targetUnitId) {
selectionParams = currentSelection?.unitId === targetUnitId
? currentSelection
: { unitId: targetUnitId, subUnitId: targetUnitId };
}
const selections = docSelectionManagerService.getTextRanges(selectionParams) ?? [];
const rectRanges = docSelectionManagerService.getRectRanges(selectionParams) ?? [];
const selectionInfo = docSelectionManagerService.getSelectionInfo(selectionParams);
const { body: sourceBody, tableSource, drawings } = doc;
let body = sourceBody;
if ((selections.length === 0 && rectRanges.length === 0) || body == null) {
return false;
}
const docDataModel = targetUnitId
? univerInstanceService.getUnit<DocumentDataModel>(targetUnitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const originBody = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || originBody == null) {
return false;
}
const unitId = docDataModel.getUnitId();
if (docDataModel.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL) {
body = omitClipboardNotes(body);
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
segmentId,
trigger: InnerPasteCommand.id,
},
};
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
const pastedNotes: NonNullable<IDocumentData['notes']> = {};
const resourceRedoMutations: IMutationInfo[] = [];
const resourceUndoMutations: IMutationInfo[] = [];
const resourceMutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }> = [];
const hasTable = !!body.tables?.length;
const hasCustomBlock = !!body.customBlocks?.length;
const hasBlockRange = !!body.blockRanges?.length;
// TODO: @JOCS A feature that has not yet been implemented,
// and it is currently not possible to paste tables in the header and footer.
if (hasTable && segmentId) {
return false;
}
// TODO: @JOCS A feature that has not yet been implemented.
// Can not paste tables into table cell now.
if (hasTable && (hasRangeInTable(selections) || rectRanges.some((range) => !range.spanEntireTable))) {
return false;
}
const replacesComplexSelection = rectRanges.length > 0 || selectionInfo?.options?.wholeDocument === true;
let selectionCutActions: JSONXActions = [];
let pasteSelections = selections;
if (replacesComplexSelection) {
const docSkeletonManagerService = getCommandSkeleton(accessor, unitId);
if (!docSkeletonManagerService) {
return false;
}
const wholeBodySelected = selectionInfo?.options?.wholeDocument === true || isWholeBodySelected(selections, rectRanges, originBody);
const insertOffset = wholeBodySelected ? 0 : getDocRangeInsertOffset(selections, rectRanges);
if (insertOffset == null) {
return false;
}
selectionCutActions = getCutActionsFromDocRanges(
selections,
rectRanges,
docDataModel,
docSkeletonManagerService.getViewModel(),
segmentId,
wholeBodySelected
);
pasteSelections = [{
startOffset: insertOffset,
endOffset: insertOffset,
collapsed: true,
segmentId,
}];
doMutation.params.textRanges = [{
startOffset: insertOffset + body.dataStream.length,
endOffset: insertOffset + body.dataStream.length,
collapsed: true,
segmentId,
style: selections.find((range) => range.isActive)?.style,
}];
}
for (let i = 0; i < pasteSelections.length; i++) {
const selection = pasteSelections[i];
const { startOffset, endOffset, collapsed } = selection;
const len = startOffset - memoryCursor.cursor;
const cloneBody = Tools.deepClone(body);
const blockRangeMappings: IDocClipboardPasteBlockRangeMapping[] = [];
const customBlockMappings: IDocClipboardPasteCustomBlockMapping[] = [];
const selectionCustomRangeMappings = (body.customRanges ?? []).map((range) => ({
sourceRange: customRangeMappings.find(({ targetRange }) => targetRange.rangeId === range.rangeId)?.sourceRange ?? range,
targetRange: BuildTextUtils.customRange.copyCustomRange(range),
}));
if (selectionCustomRangeMappings.length > 0) {
cloneBody.customRanges = selectionCustomRangeMappings.map(
({ targetRange }) => targetRange
);
}
if (docDataModel.getDocumentStyle().documentFlavor === DocumentFlavor.TRADITIONAL) {
Object.assign(pastedNotes, cloneClipboardNotes(cloneBody, doc.notes));
}
if (hasBlockRange) {
cloneBody.blockRanges!.forEach((targetBlockRange, index) => {
const sourceBlockRange = body.blockRanges![index];
targetBlockRange.blockId = generateRandomId(6);
blockRangeMappings.push({ sourceBlockRange, targetBlockRange });
});
}
if (hasTable) {
for (const t of cloneBody.tables!) {
const { tableId: oldTableId } = t;
const tableId = generateRandomId(6);
t.tableId = tableId;
const table = Tools.deepClone(tableSource![oldTableId]);
table.tableId = tableId;
const action = jsonX.insertOp(['tableSource', tableId], table);
rawActions.push(action!);
}
}
if (hasCustomBlock && drawings) {
const drawingLen = docDataModel.getSnapshot().drawingsOrder?.length ?? 0;
for (const block of cloneBody.customBlocks!) {
const { blockId } = block;
const drawingId = generateRandomId(6);
block.blockId = drawingId;
const sourceDrawing = drawings[blockId] as IDrawingParam;
const drawing = Tools.deepClone(sourceDrawing) as IDrawingParam;
drawing.drawingId = drawingId;
customBlockMappings.push({
sourceBlockId: blockId,
targetBlockId: drawingId,
sourceDrawing,
targetDrawing: drawing,
});
}
customBlockMappings.forEach(({ targetBlockId, targetDrawing }) => {
const action = jsonX.insertOp(['drawings', targetBlockId], targetDrawing);
const orderAction = jsonX.insertOp(['drawingsOrder', drawingLen], targetBlockId);
rawActions.push(action!);
rawActions.push(orderAction!);
});
}
if (
(
blockRangeMappings.length > 0 ||
customBlockMappings.length > 0 ||
selectionCustomRangeMappings.length > 0
) &&
pasteAdapterService
) {
const mutationInfos = pasteAdapterService.getPasteMutationInfos({
unitId,
segmentId,
doc,
sourceBody: body,
targetBody: cloneBody,
blockRangeMappings,
customRangeMappings: selectionCustomRangeMappings,
customBlockMappings,
});
if (mutationInfos.redoMutations.length > 0 || mutationInfos.undoMutations.length > 0) {
resourceRedoMutations.push(...mutationInfos.redoMutations);
resourceUndoMutations.push(...mutationInfos.undoMutations);
resourceMutationGroups.push(mutationInfos);
}
}
const customRange = getCustomRangeAtPosition(originBody.customRanges ?? [], endOffset, UNITS.includes(unitId));
const customDecorations = getCustomDecorationAtPosition(originBody.customDecorations ?? [], endOffset);
if (customRange) {
cloneBody.customRanges = [{
...customRange,
startIndex: 0,
endIndex: body.dataStream.length - 1,
}];
}
if (customDecorations.length) {
cloneBody.customDecorations = customDecorations.map((customDecoration) => ({
...customDecoration,
startIndex: 0,
endIndex: body.dataStream.length - 1,
}));
}
if (collapsed) {
textX.push({
t: TextXActionType.RETAIN,
len,
});
textX.push({
t: TextXActionType.INSERT,
body: cloneBody,
len: body.dataStream.length,
});
} else {
const dos = BuildTextUtils.selection.delete([selection], body, memoryCursor.cursor, cloneBody, pasteSelections.length === 1);
textX.push(...dos);
}
memoryCursor.reset();
memoryCursor.moveCursor(endOffset);
}
const path = getRichTextEditPath(docDataModel, segmentId);
if (Object.keys(pastedNotes).length > 0) {
if (segmentId) {
return false;
}
const existing = docDataModel.getSnapshot().notes;
if (existing == null) {
rawActions.push(jsonX.insertOp(['notes'], pastedNotes)!);
} else {
for (const [id, note] of Object.entries(pastedNotes)) {
rawActions.push(jsonX.insertOp(['notes', id], note)!);
}
}
}
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
const pasteActions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
doMutation.params.actions = selectionCutActions && selectionCutActions.length > 0
? JSONX.compose(selectionCutActions, pasteActions)
: pasteActions;
if (!executeResourceMutationGroups(resourceMutationGroups, commandService)) {
return false;
}
const historyId = `doc-paste-resource:${unitId}:${Date.now()}`;
let batchingDisposable: IDisposable | null = null;
if (resourceRedoMutations.length > 0 || resourceUndoMutations.length > 0) {
batchingDisposable = undoRedoService.__tempBatchingUndoRedo(unitId);
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: resourceRedoMutations,
undoMutations: resourceUndoMutations,
id: historyId,
});
}
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
if (!result && batchingDisposable != null) {
batchingDisposable.dispose();
undoRedoService.rollback(historyId, unitId);
return false;
}
batchingDisposable?.dispose();
return Boolean(result);
},
};
function getPasteAdapterService(accessor: IAccessor) {
try {
return accessor.get(IDocClipboardPasteAdapterService);
} catch {
return null;
}
}
function executeResourceMutationGroups(
mutationGroups: Array<{ redoMutations: IMutationInfo[]; undoMutations: IMutationInfo[] }>,
commandService: ICommandService
): boolean {
const executedUndoGroups: IMutationInfo[][] = [];
for (const mutationGroup of mutationGroups) {
const result = executeMutations(mutationGroup.redoMutations, commandService);
if (!result) {
executeMutationGroups([...executedUndoGroups].reverse(), commandService);
return false;
}
executedUndoGroups.push(mutationGroup.undoMutations);
}
return true;
}
function executeMutationGroups(mutationGroups: IMutationInfo[][], commandService: ICommandService): void {
mutationGroups.forEach((mutations) => {
executeMutations(mutations, commandService);
});
}
function executeMutations(mutations: IMutationInfo[], commandService: ICommandService): boolean {
for (const mutation of mutations) {
if (!commandService.syncExecuteCommand(mutation.id, mutation.params)) {
return false;
}
}
return true;
}
// TODO: WORKAROUND to fix https://github.com/dream-num/univer-pro/issues/2560.
function adjustSelectionByTable(selection: ITextRange, tables: ICustomTable[]): ITextRange {
const { startOffset, endOffset } = selection;
const endsWithTable = tables.some((t) => t.startIndex === endOffset);
const newEndOffset = Math.max(startOffset, endsWithTable ? endOffset - 1 : endOffset);
return {
...selection,
endOffset: newEndOffset,
collapsed: startOffset === newEndOffset,
};
}
function getCutActionsFromTextRanges(
selections: ITextRange[],
docDataModel: DocumentDataModel,
segmentId: string
) {
const originBody = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
if (originBody == null) {
return rawActions;
}
const { tables = [] } = originBody;
const adjustedSelections = selections.map((selection) => adjustSelectionByTable(selection, tables));
textX.push(...BuildTextUtils.selection.delete(adjustedSelections, originBody, 0, null, false));
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
const removedCustomBlockIds = getCustomBlockIdsInSelections(originBody, selections);
const drawings = docDataModel.getDrawings() ?? {};
const drawingOrder = docDataModel.getDrawingsOrder() ?? [];
const sortedRemovedCustomBlockIds = removedCustomBlockIds.sort((a, b) => {
if (drawingOrder.indexOf(a) > drawingOrder.indexOf(b)) {
return -1;
} else if (drawingOrder.indexOf(a) < drawingOrder.indexOf(b)) {
return 1;
}
return 0;
});
if (sortedRemovedCustomBlockIds.length > 0) {
for (const blockId of sortedRemovedCustomBlockIds) {
const drawing = drawings[blockId];
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawing == null || drawingIndex < 0) {
continue;
}
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
rawActions.push(removeDrawingAction!);
rawActions.push(removeDrawingOrderAction!);
}
}
return rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
}
const IMPLICIT_WHOLE_BODY_SELECTION_TOKENS = new Set<string>([
DataStreamTreeTokenType.PARAGRAPH,
DataStreamTreeTokenType.SECTION_BREAK,
DataStreamTreeTokenType.BLOCK_START,
DataStreamTreeTokenType.BLOCK_END,
DataStreamTreeTokenType.COLUMN_GROUP_START,
DataStreamTreeTokenType.COLUMN_START,
DataStreamTreeTokenType.COLUMN_END,
DataStreamTreeTokenType.COLUMN_GROUP_END,
]);
function isWholeBodySelected(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>,
body: IDocumentBody
): boolean {
const intervals = (Array.isArray(textRanges) ? textRanges : [])
.filter((range) => !range.collapsed)
.map(({ startOffset, endOffset }) => ({ startOffset, endOffset }));
for (const rectRange of Array.isArray(rectRanges) ? rectRanges : []) {
if (!rectRange.spanEntireTable) {
continue;
}
const table = (body.tables ?? []).find((item) =>
(rectRange.tableId && item.tableId === rectRange.tableId) || item.startIndex === rectRange.startOffset
);
if (table) {
intervals.push(getTableRangeInterval(table));
}
}
intervals.sort((left, right) => left.startOffset - right.startOffset || left.endOffset - right.endOffset);
const editableEnd = Math.max(0, body.dataStream.length - 2);
let intervalIndex = 0;
for (let offset = 0; offset < editableEnd; offset++) {
while (intervals[intervalIndex]?.endOffset <= offset) {
intervalIndex++;
}
const interval = intervals[intervalIndex];
if (interval && interval.startOffset <= offset && offset < interval.endOffset) {
continue;
}
if (!IMPLICIT_WHOLE_BODY_SELECTION_TOKENS.has(body.dataStream[offset])) {
return false;
}
}
return editableEnd > 0;
}
function getWholeBodyCutActions(
selections: readonly ITextRangeWithStyle[],
docDataModel: DocumentDataModel,
segmentId: string
): JSONXActions {
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (!body) {
return [];
}
const emptyBody: IDocumentBody = {
dataStream: DataStreamTreeTokenType.PARAGRAPH,
paragraphs: [{
paragraphId: createParagraphId(new Set((body.paragraphs ?? []).map((paragraph) => paragraph.paragraphId))),
startIndex: 0,
}],
};
const deleteLength = Math.max(0, body.dataStream.length - 1);
const textX = new TextX();
textX.push({ t: TextXActionType.INSERT, len: emptyBody.dataStream.length, body: emptyBody });
textX.push({ t: TextXActionType.DELETE, len: deleteLength });
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(docDataModel, segmentId);
const rawActions: JSONXActions[] = [];
const editAction = jsonX.editOp(textX.serialize(), path);
if (editAction) {
rawActions.push(editAction);
}
const drawings = docDataModel.getDrawings() ?? {};
const drawingOrder = docDataModel.getDrawingsOrder() ?? [];
const removedCustomBlockIds = getCustomBlockIdsInSelections(body, [{
...(selections[0] ?? { collapsed: false }),
startOffset: 0,
endOffset: deleteLength,
collapsed: false,
}]).sort((left, right) => drawingOrder.indexOf(right) - drawingOrder.indexOf(left));
for (const blockId of removedCustomBlockIds) {
const drawing = drawings[blockId];
const drawingIndex = drawingOrder.indexOf(blockId);
if (drawing == null || drawingIndex < 0) {
continue;
}
const removeDrawingAction = jsonX.removeOp(['drawings', blockId], drawing);
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], blockId);
if (removeDrawingAction) {
rawActions.push(removeDrawingAction);
}
if (removeDrawingOrderAction) {
rawActions.push(removeDrawingOrderAction);
}
}
for (const table of body.tables ?? []) {
const removeTableSourceAction = jsonX.removeOp(['tableSource', table.tableId]);
if (removeTableSourceAction) {
rawActions.push(removeTableSourceAction);
}
}
let actions: JSONXActions | null = null;
for (const action of rawActions) {
actions = actions == null ? action : JSONX.compose(actions, action);
}
return actions ?? [];
}
// eslint-disable-next-line max-lines-per-function
function getCutActionsFromRectRanges(
ranges: IRectRangeWithStyle[],
docDataModel: DocumentDataModel,
viewModel: DocumentViewModel,
segmentId: string
): JSONXActions {
const rawActions: JSONXActions = [];
const segmentBody = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (segmentBody == null) {
return rawActions;
}
const textX = new TextX();
const jsonX = JSONX.getInstance();
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
for (const range of ranges) {
const { startOffset, endOffset, spanEntireRow, spanEntireTable } = range;
if (startOffset == null || endOffset == null) {
continue;
}
if (spanEntireTable) {
// Remove entire table.
const actionParams = getDeleteTableActionParams({ startOffset, endOffset, segmentId }, viewModel);
if (actionParams == null) {
continue;
}
const { offset, len, tableId } = actionParams;
if (offset - memoryCursor.cursor > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset - memoryCursor.cursor,
});
}
textX.push({
t: TextXActionType.DELETE,
len,
});
const action = jsonX.removeOp(['tableSource', tableId]);
rawActions.push(action!);
memoryCursor.moveCursorTo(offset + len);
} else if (spanEntireRow) {
// Remove selected rows.
const actionParams = getDeleteRowsActionsParams({ startOffset, endOffset, segmentId }, viewModel);
if (actionParams == null) {
continue;
}
const { offset, rowIndexes, len, tableId } = actionParams;
if (offset - memoryCursor.cursor > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset - memoryCursor.cursor,
});
}
textX.push({
t: TextXActionType.DELETE,
len,
});
// Step 3: delete table rows;
for (const index of rowIndexes.reverse()) {
const action = jsonX.removeOp(['tableSource', tableId, 'tableRows', index]);
rawActions.push(action!);
}
memoryCursor.moveCursorTo(offset + len);
} else {
// Only delete content in rect range.
const actionParams = getDeleteRowContentActionParams({ startOffset, endOffset, segmentId }, viewModel);
if (actionParams == null) {
continue;
}
const { offsets } = actionParams;
for (const offset of offsets) {
const { retain, delete: delLen } = offset;
if (retain - memoryCursor.cursor > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: retain - memoryCursor.cursor,
});
}
textX.push({
t: TextXActionType.DELETE,
len: delLen,
});
memoryCursor.moveCursorTo(retain + delLen);
}
}
}
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
return rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
}
export function getCutActionsFromDocRanges(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>,
docDataModel: DocumentDataModel,
viewModel: DocumentViewModel,
segmentId: string,
wholeBodySelected = false
): JSONXActions {
let rawActions: JSONXActions = [];
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (
body &&
Array.isArray(textRanges) &&
Array.isArray(rectRanges) &&
(wholeBodySelected || isWholeBodySelected(textRanges, rectRanges, body))
) {
return getWholeBodyCutActions(textRanges, docDataModel, segmentId);
}
if (Array.isArray(textRanges) && textRanges?.length !== 0) {
rawActions = getCutActionsFromTextRanges(textRanges, docDataModel, segmentId);
}
if (Array.isArray(rectRanges) && rectRanges?.length !== 0) {
const actions = getCutActionsFromRectRanges(rectRanges, docDataModel, viewModel, segmentId);
if (rawActions == null || rawActions.length === 0) {
rawActions = actions;
} else {
rawActions = JSONX.compose(
rawActions,
JSONX.transform(actions, rawActions, 'right')!
) as JSONXActions;
}
}
return rawActions;
}
export function getReplaceDocRangesActions(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>,
docDataModel: DocumentDataModel,
viewModel: DocumentViewModel,
segmentId: string,
insertBody: IDocumentBody,
wholeBodySelected = false
) {
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
const insertOffset = wholeBodySelected || (body && isWholeBodySelected(textRanges, rectRanges, body))
? 0
: getDocRangeInsertOffset(textRanges, rectRanges);
if (insertOffset == null) {
return null;
}
const cutActions = getCutActionsFromDocRanges(
textRanges,
rectRanges,
docDataModel,
viewModel,
segmentId,
wholeBodySelected
);
const textX = new TextX();
if (insertOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: insertOffset,
});
}
if (insertBody.dataStream.length > 0) {
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: insertBody.dataStream.length,
});
}
const insertAction = JSONX.getInstance().editOp(
textX.serialize(),
getRichTextEditPath(docDataModel, segmentId)
);
const actions = insertAction == null
? cutActions
: cutActions == null || cutActions.length === 0
? insertAction
: JSONX.compose(cutActions, insertAction);
return {
actions,
insertOffset,
};
}
export function getDocRangeInsertOffset(
textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>,
rectRanges: Readonly<Nullable<IRectRangeWithStyle[]>>
): Nullable<number> {
const ranges = [
...(Array.isArray(textRanges) ? textRanges : []),
...(Array.isArray(rectRanges) ? rectRanges : []),
].filter((range) => range.startOffset != null && range.endOffset != null);
const insertOffset = ranges.reduce(
(offset, range) => Math.min(offset, range.startOffset),
Number.POSITIVE_INFINITY
);
return Number.isFinite(insertOffset) ? insertOffset : null;
}
export interface IInnerCutCommandParams {
unitId?: string;
segmentId: string;
textRanges: ITextRangeWithStyle[];
selections?: ITextRange[];
rectRanges?: IRectRangeWithStyle[];
wholeBodySelected?: boolean;
}
export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
id: 'doc.command.inner-cut',
type: CommandType.COMMAND,
handler: (accessor, params: IInnerCutCommandParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const targetUnitId = params.unitId;
const currentSelection = docSelectionManagerService.__getCurrentSelection();
let selectionParams;
if (targetUnitId) {
selectionParams = currentSelection?.unitId === targetUnitId
? currentSelection
: { unitId: targetUnitId, subUnitId: targetUnitId };
}
const selectionInfo = docSelectionManagerService.getSelectionInfo(selectionParams);
const {
segmentId,
textRanges,
selections = docSelectionManagerService.getTextRanges(selectionParams),
rectRanges = docSelectionManagerService.getRectRanges(selectionParams),
wholeBodySelected = selectionInfo?.options?.wholeDocument === true,
} = params;
if (
(!Array.isArray(selections) || selections.length === 0)
&& (!Array.isArray(rectRanges) || rectRanges.length === 0)
) {
return false;
}
const docDataModel = targetUnitId
? univerInstanceService.getUnit<DocumentDataModel>(targetUnitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const docSkeletonManagerService = getCommandSkeleton(accessor, unitId);
if (docSkeletonManagerService == null) {
return false;
}
const viewModel = docSkeletonManagerService.getViewModel();
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
trigger: CutContentCommand.id,
},
};
doMutation.params.actions = getCutActionsFromDocRanges(
selections,
rectRanges,
docDataModel,
viewModel,
segmentId,
wholeBodySelected
);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,514 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IDocumentData, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { CommandType, getBodySliceForTextXAction, ICommandService, IUniverInstanceService, JSONX, TextX, Tools, UniverInstanceType } from '@univerjs/core';
import { DocBlockMoveValidatorService, RichTextEditingMutation } from '@univerjs/docs';
export interface IMoveDocBlockCommandParams {
unitId?: string;
sourceRange: {
startOffset: number;
endOffset: number;
};
targetOffset: number;
}
export interface IMoveDocBlockActionResult {
nextDocumentData: IDocumentData;
movedRange: {
startOffset: number;
endOffset: number;
};
}
export interface IMoveDocBlockTextChange {
movedRange: {
startOffset: number;
endOffset: number;
};
sourceRange: {
startOffset: number;
endOffset: number;
};
targetOffset: number;
}
const DOC_BLOCK_MOVE_BODY_PATCH_KEYS = [
'paragraphs',
'sectionBreaks',
'tables',
'columnGroups',
'customBlocks',
'blockRanges',
'customRanges',
'customDecorations',
'textRuns',
] as const satisfies Array<keyof NonNullable<IDocumentData['body']>>;
export const MoveDocBlockCommand: ICommand<IMoveDocBlockCommandParams> = {
id: 'doc.command.move-block',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
if (!params) {
return false;
}
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const { unitId, sourceRange, targetOffset } = params;
const doc = unitId
? univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!doc) {
return false;
}
const previousDocumentData = doc.getSnapshot();
const moveResult = buildMoveDocBlockActions({
documentData: previousDocumentData,
sourceRange,
targetOffset,
});
const { nextDocumentData, movedRange } = accessor.get(DocBlockMoveValidatorService).transformMoveResult({
unitId: doc.getUnitId(),
sourceRange,
targetOffset,
previousDocumentData,
result: moveResult,
});
const actions = buildReplaceDocumentBodyActions(previousDocumentData, nextDocumentData, {
sourceRange,
targetOffset,
movedRange,
});
if (!actions) {
return false;
}
const textRanges: ITextRangeWithStyle[] = [{
startOffset: movedRange.startOffset,
endOffset: movedRange.endOffset,
collapsed: false,
}];
return Boolean(commandService.syncExecuteCommand<IRichTextEditingMutationParams>(RichTextEditingMutation.id, {
unitId: doc.getUnitId(),
actions,
textRanges,
}));
},
};
export function buildMoveDocBlockActions(params: {
documentData: IDocumentData;
sourceRange: {
startOffset: number;
endOffset: number;
};
targetOffset: number;
}): IMoveDocBlockActionResult {
const nextDocumentData = Tools.deepClone(params.documentData);
const body = nextDocumentData.body;
if (!body?.dataStream) {
return {
nextDocumentData,
movedRange: params.sourceRange,
};
}
const dataStreamLength = body.dataStream.length;
const startOffset = clamp(params.sourceRange.startOffset, 0, dataStreamLength);
const endOffset = clamp(params.sourceRange.endOffset, startOffset, dataStreamLength);
const targetOffset = clamp(params.targetOffset, 0, dataStreamLength);
if (startOffset === endOffset || (targetOffset >= startOffset && targetOffset <= endOffset)) {
return {
nextDocumentData,
movedRange: {
startOffset,
endOffset,
},
};
}
const movingText = body.dataStream.slice(startOffset, endOffset);
const moveLength = movingText.length;
const insertOffset = targetOffset > endOffset ? targetOffset - moveLength : targetOffset;
const withoutMovingText = body.dataStream.slice(0, startOffset) + body.dataStream.slice(endOffset);
body.dataStream = withoutMovingText.slice(0, insertOffset) + movingText + withoutMovingText.slice(insertOffset);
remapBodyIndexesAfterMove(body, startOffset, endOffset, targetOffset, insertOffset, moveLength);
return {
nextDocumentData,
movedRange: {
startOffset: insertOffset,
endOffset: insertOffset + moveLength,
},
};
}
export function buildReplaceDocumentBodyActions(previousDocumentData: IDocumentData, nextDocumentData: IDocumentData, move?: IMoveDocBlockTextChange): JSONXActions | null {
const jsonX = JSONX.getInstance();
const previousBody = previousDocumentData.body;
const nextBody = nextDocumentData.body;
if (!previousBody || !nextBody) {
return null;
}
const rawActions: JSONXActions[] = [];
let intermediateDocumentData = Tools.deepClone(previousDocumentData);
const moveTextAction = move ? buildMoveBodyTextAction(previousBody, nextBody, move) : null;
if (moveTextAction) {
rawActions.push(moveTextAction);
intermediateDocumentData = JSONX.apply(intermediateDocumentData, moveTextAction) as unknown as IDocumentData;
}
const residualTextAction = buildSingleBodyTextChangeAction(intermediateDocumentData.body, nextBody);
if (residualTextAction) {
rawActions.push(residualTextAction);
intermediateDocumentData = JSONX.apply(intermediateDocumentData, residualTextAction) as unknown as IDocumentData;
}
if (!moveTextAction) {
collectBodyPatchActions(intermediateDocumentData.body, nextBody, rawActions);
}
return rawActions.reduce((acc, cur) => JSONX.compose(acc, cur), null as JSONXActions);
}
function buildMoveBodyTextAction(previousBody: IDocumentBody, nextBody: IDocumentBody, move: IMoveDocBlockTextChange): JSONXActions | null {
const dataStreamLength = previousBody.dataStream.length;
const startOffset = clamp(move.sourceRange.startOffset, 0, dataStreamLength);
const endOffset = clamp(move.sourceRange.endOffset, startOffset, dataStreamLength);
const targetOffset = clamp(move.targetOffset, 0, dataStreamLength);
const moveLength = endOffset - startOffset;
if (moveLength <= 0 || (targetOffset >= startOffset && targetOffset <= endOffset)) {
return null;
}
const textX = new TextX();
const insertOffset = move.movedRange.startOffset;
const insertBody = getBodySliceForTextXAction(nextBody, insertOffset, insertOffset + moveLength, false);
if (targetOffset < startOffset) {
textX.retain(targetOffset);
textX.insert(moveLength, insertBody);
textX.retain(startOffset - targetOffset);
textX.delete(moveLength);
} else {
textX.retain(startOffset);
textX.delete(moveLength);
textX.retain(targetOffset - endOffset);
textX.insert(moveLength, insertBody);
}
return JSONX.getInstance().editOp(textX.serialize(), ['body']);
}
function buildSingleBodyTextChangeAction(previousBody: IDocumentBody | undefined, nextBody: IDocumentBody): JSONXActions | null {
const textChange = getSingleDataStreamChange(previousBody?.dataStream, nextBody.dataStream);
if (!textChange) {
return null;
}
const textX = new TextX();
textX.retain(textChange.start);
if (textChange.insertLength > 0) {
textX.insert(
textChange.insertLength,
getBodySliceForTextXAction(nextBody, textChange.start, textChange.start + textChange.insertLength, false)
);
}
if (textChange.deleteLength > 0) {
textX.delete(textChange.deleteLength);
}
return JSONX.getInstance().editOp(textX.serialize(), ['body']);
}
function getSingleDataStreamChange(previousDataStream: string | undefined, nextDataStream: string | undefined): { start: number; deleteLength: number; insertLength: number } | null {
if (previousDataStream == null || nextDataStream == null || previousDataStream === nextDataStream) {
return null;
}
let start = 0;
while (
start < previousDataStream.length &&
start < nextDataStream.length &&
previousDataStream[start] === nextDataStream[start]
) {
start++;
}
let previousEnd = previousDataStream.length;
let nextEnd = nextDataStream.length;
while (
previousEnd > start &&
nextEnd > start &&
previousDataStream[previousEnd - 1] === nextDataStream[nextEnd - 1]
) {
previousEnd--;
nextEnd--;
}
return {
start,
deleteLength: previousEnd - start,
insertLength: nextEnd - start,
};
}
function collectBodyPatchActions(previousBody: IDocumentBody | undefined, nextBody: IDocumentBody, actions: JSONXActions[]): void {
for (const key of DOC_BLOCK_MOVE_BODY_PATCH_KEYS) {
collectPatchActions(
JSONX.getInstance(),
['body', key],
previousBody?.[key],
nextBody[key],
actions
);
}
}
function collectPatchActions(
jsonX: JSONX,
path: (string | number)[],
oldValue: unknown,
newValue: unknown,
actions: JSONXActions[]
): void {
if (isEmptyArrayEquivalent(oldValue, newValue)) {
return;
}
if (Tools.diffValue(oldValue, newValue)) {
return;
}
if (oldValue == null) {
actions.push(jsonX.insertOp(path, newValue));
return;
}
if (newValue == null) {
actions.push(jsonX.removeOp(path, oldValue));
return;
}
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
collectArrayPatchActions(jsonX, path, oldValue, newValue, actions);
return;
}
if (isPlainObject(oldValue) && isPlainObject(newValue)) {
const keys = new Set([...Object.keys(oldValue), ...Object.keys(newValue)]);
keys.forEach((key) => {
collectPatchActions(
jsonX,
[...path, key],
(oldValue as Record<string, unknown>)[key],
(newValue as Record<string, unknown>)[key],
actions
);
});
return;
}
actions.push(jsonX.replaceOp(path, oldValue, newValue));
}
function collectArrayPatchActions(
jsonX: JSONX,
path: (string | number)[],
oldItems: unknown[],
newItems: unknown[],
actions: JSONXActions[]
): void {
if (oldItems.length === newItems.length) {
oldItems.forEach((item, index) => collectPatchActions(jsonX, [...path, index], item, newItems[index], actions));
return;
}
let prefix = 0;
while (prefix < oldItems.length && prefix < newItems.length && Tools.diffValue(oldItems[prefix], newItems[prefix])) {
prefix++;
}
let oldSuffix = oldItems.length - 1;
let newSuffix = newItems.length - 1;
while (oldSuffix >= prefix && newSuffix >= prefix && Tools.diffValue(oldItems[oldSuffix], newItems[newSuffix])) {
oldSuffix--;
newSuffix--;
}
for (let index = oldSuffix; index >= prefix; index--) {
actions.push(jsonX.removeOp([...path, index], oldItems[index]));
}
for (let index = prefix; index <= newSuffix; index++) {
actions.push(jsonX.insertOp([...path, index], newItems[index]));
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
function isEmptyArrayEquivalent(oldValue: unknown, newValue: unknown): boolean {
return (
(oldValue == null && Array.isArray(newValue) && newValue.length === 0) ||
(newValue == null && Array.isArray(oldValue) && oldValue.length === 0)
);
}
function remapBodyIndexesAfterMove(
body: IDocumentBody,
startOffset: number,
endOffset: number,
targetOffset: number,
insertOffset: number,
moveLength: number
) {
body.paragraphs = body.paragraphs?.map((paragraph) => ({
...paragraph,
startIndex: remapIndexAfterMove(paragraph.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
})).sort((left, right) => left.startIndex - right.startIndex);
body.sectionBreaks = body.sectionBreaks?.map((sectionBreak) => ({
...sectionBreak,
startIndex: remapIndexAfterMove(sectionBreak.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
})).sort((left, right) => left.startIndex - right.startIndex);
body.customBlocks = body.customBlocks?.map((customBlock) => ({
...customBlock,
startIndex: remapIndexAfterMove(customBlock.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
})).sort((left, right) => left.startIndex - right.startIndex);
body.tables = body.tables?.map((table) => remapExclusiveRange(table, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.columnGroups = body.columnGroups?.map((columnGroup) => remapExclusiveRange(columnGroup, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.blockRanges = body.blockRanges?.map((blockRange) => remapInclusiveRange(blockRange, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.customRanges = body.customRanges?.map((customRange) => remapInclusiveRange(customRange, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.customDecorations = body.customDecorations?.map((customDecoration) => remapInclusiveRange(customDecoration, startOffset, endOffset, targetOffset, insertOffset, moveLength))
.sort((left, right) => left.startIndex - right.startIndex);
body.textRuns = body.textRuns?.map((textRun) => {
const remapped = remapExclusiveRange({ startIndex: textRun.st, endIndex: textRun.ed }, startOffset, endOffset, targetOffset, insertOffset, moveLength);
return {
...textRun,
st: remapped.startIndex,
ed: remapped.endIndex,
};
}).sort((left, right) => left.st - right.st);
}
function remapExclusiveRange<T extends { startIndex: number; endIndex: number }>(
range: T,
startOffset: number,
endOffset: number,
targetOffset: number,
insertOffset: number,
moveLength: number
): T {
if (range.startIndex >= startOffset && range.endIndex <= endOffset) {
return {
...range,
startIndex: insertOffset + range.startIndex - startOffset,
endIndex: insertOffset + range.endIndex - startOffset,
};
}
return {
...range,
startIndex: remapIndexAfterMove(range.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
endIndex: remapExclusiveEndIndexAfterMove(range.endIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
};
}
function remapInclusiveRange<T extends { startIndex: number; endIndex: number }>(
range: T,
startOffset: number,
endOffset: number,
targetOffset: number,
insertOffset: number,
moveLength: number
): T {
if (range.startIndex >= startOffset && range.endIndex < endOffset) {
return {
...range,
startIndex: insertOffset + range.startIndex - startOffset,
endIndex: insertOffset + range.endIndex - startOffset,
};
}
return {
...range,
startIndex: remapIndexAfterMove(range.startIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
endIndex: remapIndexAfterMove(range.endIndex, startOffset, endOffset, targetOffset, insertOffset, moveLength),
};
}
function remapIndexAfterMove(index: number, startOffset: number, endOffset: number, targetOffset: number, insertOffset: number, moveLength: number): number {
if (index >= startOffset && index < endOffset) {
return insertOffset + index - startOffset;
}
if (targetOffset < startOffset && index >= targetOffset && index < startOffset) {
return index + moveLength;
}
if (targetOffset > endOffset && index >= endOffset && index < targetOffset) {
return index - moveLength;
}
return index;
}
function remapExclusiveEndIndexAfterMove(index: number, startOffset: number, endOffset: number, targetOffset: number, insertOffset: number, moveLength: number): number {
if (index > startOffset && index <= endOffset) {
return insertOffset + index - startOffset;
}
if (targetOffset > endOffset && index >= endOffset && index <= targetOffset) {
return index - moveLength;
}
return remapIndexAfterMove(index, startOffset, endOffset, targetOffset, insertOffset, moveLength);
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}

View File

@@ -0,0 +1,950 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IDocumentBlockRange, IDocumentBody, IMultiCommand, IMutationInfo, IParagraph, ITextRange, JSONXActions, Nullable } from '@univerjs/core';
import type { IDeleteTextCommandParams, IRichTextEditingMutationParams, IUpdateTextCommandParams } from '@univerjs/docs';
import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import {
BlockType,
BuildTextUtils,
CommandType,
DataStreamTreeTokenType,
DeleteDirection,
getBlockRangeInterval,
getParagraphContentStartOffset,
getRichTextEditPath,
HorizontalAlign,
ICommandService,
IUniverInstanceService,
JSONX,
PositionedObjectLayoutType,
sequenceExecuteAsync,
SHEET_EDITOR_UNITS,
TextX,
TextXActionType,
Tools,
UniverInstanceType,
UpdateDocsAttributeType,
} from '@univerjs/core';
import {
DeleteTextCommand,
DocHistoryAction,
DocSelectionManagerService,
RichTextEditingMutation,
UpdateTextCommand,
} from '@univerjs/docs';
import { getParagraphByGlyph, hasListGlyph, isFirstGlyph, isIndentByGlyph } from '@univerjs/engine-render';
import { DocAutoFormatService } from '../../services/doc-auto-format.service';
import { isHorizontalLineParagraph } from '../../utils/horizontal-line';
import { getCommandSkeleton } from '../util';
import { CutContentCommand } from './clipboard.inner.command';
import { getCurrentParagraph } from './util';
export interface IDeleteCustomBlockParams {
direction: DeleteDirection;
range: ITextRangeWithStyle;
unitId: string;
drawingId: string;
}
// The activeRange need collapsed.
export const DeleteCustomBlockCommand: ICommand<IDeleteCustomBlockParams> = {
id: 'doc.command.delete-custom-block',
type: CommandType.COMMAND,
handler: async (accessor, params: IDeleteCustomBlockParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const { direction, range, unitId, drawingId } = params;
const activeRange = docSelectionManagerService.getActiveTextRange();
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (activeRange == null || documentDataModel == null) {
return false;
}
const { startOffset, segmentId, style } = activeRange;
const cursor = direction === DeleteDirection.LEFT ? startOffset - 1 : startOffset;
const textRanges = [
{
startOffset: cursor,
endOffset: cursor,
style,
},
] as ITextRangeWithStyle[];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
prevTextRanges: [range],
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
if (startOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: direction === DeleteDirection.LEFT ? startOffset - 1 : startOffset,
});
}
textX.push({
t: TextXActionType.DELETE,
len: 1,
});
const path = getRichTextEditPath(documentDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
const drawing = (documentDataModel.getDrawings() ?? {})[drawingId];
const drawingOrder = documentDataModel.getDrawingsOrder();
const drawingIndex = drawingOrder!.indexOf(drawingId);
const removeDrawingAction = jsonX.removeOp(['drawings', drawingId], drawing);
const removeDrawingOrderAction = jsonX.removeOp(['drawingsOrder', drawingIndex], drawingId);
rawActions.push(removeDrawingAction!);
rawActions.push(removeDrawingOrderAction!);
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
interface IMergeTwoParagraphParams {
direction: DeleteDirection;
range: ITextRangeWithStyle;
}
export const MergeTwoParagraphCommand: ICommand<IMergeTwoParagraphParams> = {
id: 'doc.command.merge-two-paragraph',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params: IMergeTwoParagraphParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const { direction, range } = params;
const activeRange = docSelectionManagerService.getActiveTextRange();
const ranges = docSelectionManagerService.getTextRanges();
if (activeRange == null || ranges == null) {
return false;
}
const { segmentId, style } = activeRange;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const originBody = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || originBody == null) {
return false;
}
const dataStream = originBody.dataStream;
const actualRange = activeRange;
const unitId = docDataModel.getUnitId();
const { startOffset, collapsed } = actualRange;
if (!collapsed) {
return false;
}
const startIndex = direction === DeleteDirection.LEFT ? startOffset : startOffset + 1;
let curParagraph;
let nextParagraph;
for (const paragraph of originBody.paragraphs!) {
if (paragraph.startIndex >= startIndex) {
nextParagraph = paragraph;
break;
}
curParagraph = paragraph;
}
if (curParagraph == null || nextParagraph == null) {
return false;
}
const cursor = direction === DeleteDirection.LEFT ? startOffset - 1 : startOffset;
const textRanges = [
{
startOffset: cursor,
endOffset: cursor,
style,
},
] as ITextRangeWithStyle[];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
segmentId,
actions: [],
textRanges,
prevTextRanges: [range],
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (curParagraph.startIndex > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: curParagraph.startIndex,
});
}
textX.push({
t: TextXActionType.DELETE,
len: 1,
});
if (nextParagraph.startIndex > curParagraph.startIndex + 1) {
textX.push({
t: TextXActionType.RETAIN,
len: nextParagraph.startIndex - curParagraph.startIndex - 1,
});
}
const tokens = Object.values(DataStreamTreeTokenType) as string[];
const lastToken = dataStream[curParagraph.startIndex - 1];
if ((lastToken && !tokens.includes(lastToken)) || lastToken === ' ') {
textX.push({
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [
{
...Tools.deepClone(curParagraph),
startIndex: 0,
},
],
},
});
}
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export const RemoveHorizontalLineCommand: ICommand = {
id: 'doc.command.remove-horizontal-line',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const activeRange = docSelectionManagerService.getActiveTextRange();
const ranges = docSelectionManagerService.getTextRanges();
if (activeRange == null || ranges == null) {
return false;
}
const { segmentId, style } = activeRange;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const originBody = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || originBody == null) {
return false;
}
const actualRange = activeRange;
const unitId = docDataModel.getUnitId();
const { startOffset, collapsed } = actualRange;
if (!collapsed) {
return false;
}
const paragraph = originBody.paragraphs?.find((p) => p.startIndex === startOffset - 1);
if (paragraph == null) {
return false;
}
const textRanges = [
{
startOffset,
endOffset: startOffset,
style,
},
] as ITextRangeWithStyle[];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
historyAction: DocHistoryAction.DeleteDivider,
actions: [],
textRanges,
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (paragraph.startIndex > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: paragraph.startIndex,
});
}
textX.push({
t: TextXActionType.RETAIN,
len: 1,
coverType: UpdateDocsAttributeType.REPLACE,
body: {
dataStream: '',
paragraphs: [
{
...Tools.deepClone({
...paragraph,
paragraphStyle: {
...paragraph.paragraphStyle,
borderBottom: undefined,
},
}),
startIndex: 0,
},
],
},
});
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export function getCursorWhenDelete(textRanges: Readonly<Nullable<ITextRangeWithStyle[]>>, rectRanges: readonly IRectRangeWithStyle[]): number {
let cursor = 0;
if (textRanges == null || textRanges.length === 0) {
if (typeof rectRanges[0].startOffset === 'number') {
// Put the cursor at the first rect range start.
const rectRange = rectRanges[0]!;
const { spanEntireRow, spanEntireTable } = rectRange;
if (spanEntireTable) {
// Put the cursor at the first line of deleted table paragraph.
cursor = rectRange.startOffset! - 3; // 3 is TABLE START, ROW START, CELL START.
} else if (spanEntireRow) {
// Put the cursor at the last row's end cell before deleted rows.
if (rectRange.startRow > 0) {
cursor = rectRange.startOffset! - 6; // 6 is ROW START, CELL START, CELL END, ROW END, \r, \n.
} else {
cursor = rectRange.startOffset!;
}
} else {
cursor = rectRanges[0].startOffset;
}
}
} else if (textRanges.length > 0 && rectRanges.length > 0) {
const textRange = textRanges[0]!;
const rectRange = rectRanges[0]!;
if (textRange.startOffset != null && rectRange.startOffset != null) {
if (textRange.startOffset < rectRange.startOffset) {
// Put the cursor at the first text range start.
cursor = textRange.startOffset;
} else if (textRange.startOffset >= rectRange.startOffset) {
const { spanEntireRow, spanEntireTable } = rectRange;
if (spanEntireTable) {
// Put the cursor at the first line of deleted table paragraph.
cursor = rectRange.startOffset - 3; // 3 is TABLE START, ROW START, CELL START.
} else if (spanEntireRow) {
// Put the cursor at the last row's end cell before deleted rows.
cursor = rectRange.startOffset - 6; // 6 is ROW START, CELL START, CELL END, ROW END, \r, \n.
}
}
}
}
return cursor;
}
export function isDeleteOffsetInsideBlockRange(body: IDocumentBody, offset: number): boolean {
return body.blockRanges?.some((blockRange) => {
const interval = getBlockRangeInterval(blockRange);
return interval.startOffset < offset && offset < interval.endOffset - 1;
}) ?? false;
}
// Handle BACKSPACE key.
export const DeleteLeftCommand: ICommand = {
id: 'doc.command.delete-left',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: async (accessor) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
let result = true;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const docSkeletonManagerService = getCommandSkeleton(accessor, unitId);
const activeRange = docSelectionManagerService.getActiveTextRange();
const rectRanges = docSelectionManagerService.getRectRanges();
const ranges = docSelectionManagerService.getTextRanges();
const skeleton = docSkeletonManagerService?.getSkeleton();
if (skeleton == null) {
return false;
}
if (rectRanges?.length) {
const cursor = getCursorWhenDelete(ranges, rectRanges);
const segmentId = rectRanges[0].segmentId;
const textRanges = [
{
startOffset: cursor,
endOffset: cursor,
},
];
return commandService.executeCommand(CutContentCommand.id, {
segmentId,
textRanges,
});
}
if (activeRange == null || ranges == null) {
return false;
}
const autoFormatResult = await executeDeleteAutoFormat(accessor, DeleteLeftCommand.id);
if (autoFormatResult != null) {
return autoFormatResult;
}
const { segmentId, style, segmentPage } = activeRange;
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (body == null) {
return false;
}
const actualRange = activeRange;
const { startOffset, collapsed } = actualRange;
if (collapsed && shouldResetEmptyCenteredParagraphAlignment(docDataModel)) {
const emptyCenteredParagraph = getEmptyCenteredParagraphAtOffset(body, startOffset);
if (emptyCenteredParagraph != null) {
return resetEmptyCenteredParagraphAlignment(commandService, docDataModel, segmentId ?? '', emptyCenteredParagraph, style);
}
}
const curGlyph = skeleton.findNodeByCharIndex(startOffset, segmentId, segmentPage);
// is in bullet list?
const isBullet = hasListGlyph(curGlyph);
// is in indented paragraph?
const isIndent = isIndentByGlyph(curGlyph, body);
let cursor = startOffset;
// Get the deleted glyph. It maybe null or undefined when the curGlyph is first glyph in skeleton.
const preGlyph = skeleton.findNodeByCharIndex(startOffset - 1, segmentId, segmentPage);
const isInBlockRange = isDeleteOffsetInsideBlockRange(body, startOffset);
const isUpdateParagraph =
!isInBlockRange && isFirstGlyph(curGlyph) && preGlyph !== curGlyph && (isBullet === true || isIndent === true);
if (isUpdateParagraph && collapsed) {
const paragraph = getParagraphByGlyph(curGlyph, body);
if (paragraph == null) {
return false;
}
const paragraphIndex = paragraph?.startIndex;
const updateParagraph: IParagraph = { startIndex: 0, paragraphId: paragraph.paragraphId };
const paragraphStyle = paragraph.paragraphStyle;
if (isBullet === true) {
if (paragraphStyle) {
updateParagraph.paragraphStyle = { ...paragraphStyle };
// TODO: It maybe need to update codes bellow when we support nested list.
const { hanging } = paragraphStyle;
if (hanging) {
updateParagraph.paragraphStyle.indentStart = { ...hanging };
updateParagraph.paragraphStyle.hanging = undefined;
}
}
} else if (isIndent === true) {
const bullet = paragraph.bullet;
if (bullet) {
updateParagraph.bullet = bullet;
}
if (paragraphStyle) {
updateParagraph.paragraphStyle = { ...paragraphStyle };
delete updateParagraph.paragraphStyle.hanging;
delete updateParagraph.paragraphStyle.indentStart;
}
}
const textRanges = [
{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
style,
},
];
result = await commandService.executeCommand<IUpdateTextCommandParams>(UpdateTextCommand.id, {
unitId: docDataModel.getUnitId(),
updateBody: {
dataStream: '',
paragraphs: [{ ...updateParagraph }],
},
range: {
startOffset: paragraphIndex,
endOffset: paragraphIndex + 1,
collapsed: true,
},
textRanges,
coverType: UpdateDocsAttributeType.REPLACE,
segmentId,
});
} else {
if (collapsed === true) {
// No need to delete when the cursor is at the first position of the first paragraph.
if (preGlyph == null) {
return true;
}
if (preGlyph.content === DataStreamTreeTokenType.PARAGRAPH) {
const paragraph = body.paragraphs?.find((p) => p.startIndex === startOffset - 1);
const paragraphStart = paragraph == null ? 0 : getParagraphContentStartOffset(body, paragraph);
const paragraphDataStream = paragraph == null
? ''
: body.dataStream.slice(paragraphStart, paragraph.startIndex + 1);
if (isHorizontalLineParagraph(paragraphDataStream, paragraph)) {
result = await commandService.executeCommand(RemoveHorizontalLineCommand.id);
} else {
result = await commandService.executeCommand(MergeTwoParagraphCommand.id, {
direction: DeleteDirection.LEFT,
range: actualRange,
});
}
} else if (preGlyph.streamType === '\b') {
const drawing = docDataModel.getSnapshot().drawings?.[preGlyph.drawingId ?? ''];
if (drawing == null) {
return true;
}
const customBlock = docDataModel.getBody()?.customBlocks?.find((block) => block.blockId === preGlyph.drawingId);
const isInlineDrawingOrCustom = drawing.layoutType === PositionedObjectLayoutType.INLINE || customBlock?.blockType === BlockType.CUSTOM;
if (isInlineDrawingOrCustom) {
const unitId = docDataModel.getUnitId();
result = await commandService.executeCommand(DeleteCustomBlockCommand.id, {
direction: DeleteDirection.LEFT,
range: activeRange,
unitId,
drawingId: preGlyph.drawingId,
});
} else {
const prePreGlyph = skeleton.findNodeByCharIndex(startOffset - 2);
if (prePreGlyph == null) {
return true;
}
cursor -= preGlyph.count;
cursor -= prePreGlyph.count;
result = await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: docDataModel.getUnitId(),
range: {
...activeRange,
startOffset: activeRange.startOffset - 1,
endOffset: activeRange.endOffset - 1,
},
segmentId,
direction: DeleteDirection.LEFT,
len: prePreGlyph.count,
});
}
} else {
cursor -= preGlyph.count;
result = await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: docDataModel.getUnitId(),
range: actualRange,
segmentId,
direction: DeleteDirection.LEFT,
len: preGlyph.count,
});
}
} else {
const textRanges = getTextRangesWhenDelete(actualRange, [actualRange]);
// If the selection is not closed, the effect of Delete and
// BACKSPACE is the same as CUT, so the CUT command is executed.
result = await commandService.executeCommand(CutContentCommand.id, {
segmentId,
textRanges,
selections: [actualRange],
});
}
}
return result;
},
};
// handle Delete key
export const DeleteRightCommand: ICommand = {
id: 'doc.command.delete-right',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: async (accessor) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) {
return false;
}
const docSkeletonManagerService = getCommandSkeleton(accessor, docDataModel.getUnitId());
const commandService = accessor.get(ICommandService);
const activeRange = docSelectionManagerService.getActiveTextRange();
const rectRanges = docSelectionManagerService.getRectRanges();
const ranges = docSelectionManagerService.getTextRanges();
const skeleton = docSkeletonManagerService?.getSkeleton();
if (rectRanges?.length) {
const cursor = getCursorWhenDelete(ranges, rectRanges);
const segmentId = rectRanges[0].segmentId;
const textRanges = [
{
startOffset: cursor,
endOffset: cursor,
},
];
return commandService.executeCommand(CutContentCommand.id, {
segmentId,
textRanges,
});
}
if (activeRange == null || skeleton == null || ranges == null) {
return false;
}
const autoFormatResult = await executeDeleteAutoFormat(accessor, DeleteRightCommand.id);
if (autoFormatResult != null) {
return autoFormatResult;
}
const { segmentId, style, segmentPage } = activeRange;
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (!docDataModel || !body) {
return false;
}
const actualRange = activeRange;
const { startOffset, endOffset, collapsed } = actualRange;
if (collapsed && shouldResetEmptyCenteredParagraphAlignment(docDataModel)) {
const emptyCenteredParagraph = getEmptyCenteredParagraphAtOffset(body, startOffset);
if (emptyCenteredParagraph != null) {
return resetEmptyCenteredParagraphAlignment(commandService, docDataModel, segmentId ?? '', emptyCenteredParagraph, style);
}
}
// No need to delete when the cursor is at the last position of the last paragraph.
if (startOffset === body.dataStream.length - 2 && collapsed) {
return true;
}
let result: boolean = false;
if (collapsed === true) {
const needDeleteGlyph = skeleton.findNodeByCharIndex(startOffset, segmentId, segmentPage)!;
const nextGlyph = skeleton.findNodeByCharIndex(startOffset + 1);
// Handle delete key at cell content end.
if (needDeleteGlyph.streamType === DataStreamTreeTokenType.PARAGRAPH && nextGlyph?.streamType === DataStreamTreeTokenType.SECTION_BREAK) {
return false;
}
if (needDeleteGlyph.content === '\r') {
result = await commandService.executeCommand(MergeTwoParagraphCommand.id, {
direction: DeleteDirection.RIGHT,
range: activeRange,
});
} else if (needDeleteGlyph.streamType === '\b') {
const drawing = docDataModel.getSnapshot().drawings?.[needDeleteGlyph.drawingId ?? ''];
if (drawing == null) {
return true;
}
const isInlineDrawing = drawing.layoutType === PositionedObjectLayoutType.INLINE;
if (isInlineDrawing) {
const unitId = docDataModel.getUnitId();
result = await commandService.executeCommand(DeleteCustomBlockCommand.id, {
direction: DeleteDirection.RIGHT,
range: activeRange,
unitId,
drawingId: needDeleteGlyph.drawingId,
});
} else {
if (nextGlyph == null) {
return true;
}
result = await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: docDataModel.getUnitId(),
range: {
...activeRange,
startOffset: startOffset + 1,
endOffset: endOffset + 1,
},
segmentId,
direction: DeleteDirection.RIGHT,
len: nextGlyph.count,
});
}
} else {
result = await commandService.executeCommand<IDeleteTextCommandParams>(DeleteTextCommand.id, {
unitId: docDataModel.getUnitId(),
range: actualRange,
segmentId,
direction: DeleteDirection.RIGHT,
len: needDeleteGlyph.count,
});
}
} else {
const textRanges = getTextRangesWhenDelete(actualRange, [actualRange]);
// If the selection is not closed, the effect of Delete and
// BACKSPACE is the same as CUT, so the CUT command is executed.
result = await commandService.executeCommand(CutContentCommand.id, {
segmentId,
textRanges,
selections: [actualRange],
});
}
return result;
},
};
async function executeDeleteAutoFormat(accessor: IAccessor, commandId: string): Promise<boolean | null> {
if (!accessor.has(DocAutoFormatService)) {
return null;
}
const commandService = accessor.get(ICommandService);
const mutations = accessor.get(DocAutoFormatService).onAutoFormat(commandId);
if (!mutations.length) {
return null;
}
return (await sequenceExecuteAsync(mutations, commandService)).result;
}
// get cursor position when BACKSPACE/DELETE excuse the CutContentCommand.
function getTextRangesWhenDelete(activeRange: ITextRangeWithStyle, ranges: readonly ITextRange[]) {
let cursor = activeRange.endOffset;
for (const range of ranges) {
const { startOffset, endOffset } = range;
if (startOffset == null || endOffset == null) {
continue;
}
if (endOffset <= activeRange.endOffset) {
cursor -= endOffset - startOffset;
}
}
const textRanges = [
{
startOffset: cursor,
endOffset: cursor,
style: activeRange.style,
},
];
return textRanges;
}
function getEmptyCenteredParagraphAtOffset(body: IDocumentBody, offset: number) {
const paragraphs = body.paragraphs ?? [];
for (let i = 0; i < paragraphs.length; i++) {
const paragraph = paragraphs[i];
const paragraphTextStart = i === 0 ? 0 : paragraphs[i - 1].startIndex + 1;
if (
paragraph.startIndex === paragraphTextStart &&
paragraph.startIndex === offset &&
paragraph.paragraphStyle?.horizontalAlign === HorizontalAlign.CENTER
) {
return paragraph;
}
}
}
function shouldResetEmptyCenteredParagraphAlignment(docDataModel: DocumentDataModel) {
return !SHEET_EDITOR_UNITS.includes(docDataModel.getUnitId());
}
function resetEmptyCenteredParagraphAlignment(
commandService: ICommandService,
docDataModel: DocumentDataModel,
segmentId: string,
paragraph: IParagraph,
style: ITextRangeWithStyle['style']
) {
return commandService.executeCommand<IUpdateTextCommandParams>(UpdateTextCommand.id, {
unitId: docDataModel.getUnitId(),
updateBody: {
dataStream: '',
paragraphs: [{
...paragraph,
startIndex: 0,
paragraphStyle: {
...paragraph.paragraphStyle,
horizontalAlign: HorizontalAlign.LEFT,
},
}],
},
range: {
startOffset: paragraph.startIndex,
endOffset: paragraph.startIndex + 1,
collapsed: true,
},
textRanges: [{
startOffset: paragraph.startIndex,
endOffset: paragraph.startIndex,
collapsed: true,
style,
}],
coverType: UpdateDocsAttributeType.REPLACE,
segmentId,
});
}
export interface IDeleteCurrentParagraphCommandParams {
unitId?: string;
blockRange?: IDocumentBlockRange;
}
export const DeleteCurrentParagraphCommand: IMultiCommand<IDeleteCurrentParagraphCommandParams> = {
id: 'doc.command.delete-current-paragraph',
name: 'doc.command.delete-current-paragraph',
multi: true,
priority: 0,
type: CommandType.COMMAND,
handler: async (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) {
return false;
}
const dataStream = docDataModel.getBody()?.dataStream ?? '';
const paragraph = getCurrentParagraph(accessor);
if (!paragraph) {
return false;
}
const actions = BuildTextUtils.selection.delete(
[{
startOffset: paragraph.paragraphStart,
endOffset: dataStream[paragraph.paragraphEnd + 1] === '\n'
? paragraph.paragraphEnd
: paragraph.paragraphEnd + 1,
collapsed: false,
}],
docDataModel.getBody()!,
0,
undefined,
true
);
const path = getRichTextEditPath(docDataModel);
const params: IRichTextEditingMutationParams = {
unitId: docDataModel.getUnitId(),
actions: JSONX.getInstance().editOp(actions, path),
textRanges: [{
startOffset: paragraph.paragraphStart,
endOffset: paragraph.paragraphStart,
collapsed: true,
}],
};
return commandService.syncExecuteCommand(RichTextEditingMutation.id, params);
},
};

View File

@@ -0,0 +1,251 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand } from '@univerjs/core';
import type { ICreateHeaderFooterCommandParams, IHeaderFooterProps } from '@univerjs/docs';
import {
BooleanNumber,
CommandType,
generateRandomId,
ICommandService,
IUniverInstanceService,
resolveSectionHeaderFooterReferences,
UniverInstanceType,
} from '@univerjs/core';
import { CreateHeaderFooterCommand, DocSelectionManagerService, DocSkeletonManagerService, getTopLevelSectionBreaks, HeaderFooterType } from '@univerjs/docs';
import { DocumentEditArea, IRenderManagerService } from '@univerjs/engine-render';
import { findFirstCursorOffset } from '../../basics/selection';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
import { getHeaderFooterTarget } from '../../utils/section-header-footer';
import { SidebarDocHeaderFooterPanelOperation } from '../operations/doc-header-footer-panel.operation';
export interface ICoreHeaderFooterParams {
unitId: string;
createType?: HeaderFooterType;
segmentId?: string;
sectionId?: string;
headerFooterProps?: IHeaderFooterProps;
}
export const CoreHeaderFooterCommandId = 'doc.command.core-header-footer';
/**
* The command to update header and footer or create them.
*/
export const CoreHeaderFooterCommand: ICommand<ICoreHeaderFooterParams> = {
id: CoreHeaderFooterCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params: ICoreHeaderFooterParams) => {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const { unitId, segmentId, createType, headerFooterProps, sectionId: requestedSectionId } = params;
const docSkeletonManagerService = renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const docViewModel = docSkeletonManagerService?.getViewModel();
if (docDataModel == null || docViewModel == null) {
return false;
}
const editArea = docViewModel.getEditArea();
const snapshot = docDataModel.getSnapshot();
const sectionId = requestedSectionId;
const sections = snapshot.body ? getTopLevelSectionBreaks(snapshot.body) : [];
const sectionIndex = sectionId == null ? -1 : sections.findIndex((section) => section.sectionId === sectionId);
const section = sectionIndex < 0 ? undefined : sections[sectionIndex];
const headerFooterConfig = sectionId == null
? snapshot.documentStyle
: {
...snapshot.documentStyle,
...section,
...resolveSectionHeaderFooterReferences(snapshot.documentStyle, sections, sectionIndex),
};
let resolvedCreateType = createType;
if (headerFooterProps != null) {
Object.keys(headerFooterProps).forEach((key) => {
const value = headerFooterProps[key as keyof IHeaderFooterProps];
// need create first page header/footer if useFirstPageHeaderFooter is true and firstPageHeaderId is not set.
if (resolvedCreateType == null && key === 'useFirstPageHeaderFooter' && value === BooleanNumber.TRUE && !headerFooterConfig.firstPageHeaderId) {
resolvedCreateType = editArea === DocumentEditArea.HEADER ? HeaderFooterType.FIRST_PAGE_HEADER : HeaderFooterType.FIRST_PAGE_FOOTER;
} else if (resolvedCreateType == null && key === 'evenAndOddHeaders' && value === BooleanNumber.TRUE && !headerFooterConfig.evenPageHeaderId) {
resolvedCreateType = editArea === DocumentEditArea.HEADER ? HeaderFooterType.EVEN_PAGE_HEADER : HeaderFooterType.EVEN_PAGE_FOOTER;
}
});
}
const result = commandService.syncExecuteCommand<ICreateHeaderFooterCommandParams>(CreateHeaderFooterCommand.id, {
unitId,
segmentId,
createType: resolvedCreateType,
headerFooterProps,
createMode: sectionId == null ? 'pair' : 'single',
sectionId,
});
return result;
},
};
interface IOpenHeaderFooterPanelParams { }
export const OpenHeaderFooterPanelCommand: ICommand<IOpenHeaderFooterPanelParams> = {
id: 'doc.command.open-header-footer-panel',
type: CommandType.COMMAND,
handler: async (accessor, _params: IOpenHeaderFooterPanelParams) => {
const commandService = accessor.get(ICommandService);
const instanceService = accessor.get(IUniverInstanceService);
const renderManagerService = accessor.get(IRenderManagerService);
const docDataModel = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const renderObject = renderManagerService.getRenderUnitById(unitId);
const skeletonManager = renderObject?.with(DocSkeletonManagerService);
const viewModel = skeletonManager?.getViewModel();
const skeleton = skeletonManager?.getSkeleton();
const selectionRenderService = renderObject?.with(DocSelectionRenderService);
const pages = skeleton?.getSkeletonData()?.pages;
if (viewModel == null || selectionRenderService == null || !pages?.length) {
return false;
}
const currentPage = selectionRenderService.getSegmentPage();
const pageNumber = currentPage >= 0 && currentPage < pages.length ? currentPage : 0;
const page = pages[pageNumber];
viewModel.setEditArea(DocumentEditArea.HEADER);
const { createType, headerFooterId, sectionId } = getHeaderFooterTarget(
viewModel,
DocumentEditArea.HEADER,
pageNumber,
page
);
selectionRenderService.setSegmentPage(pageNumber);
if (createType != null) {
const segmentId = generateRandomId(6);
selectionRenderService.setSegment(segmentId);
const created = await commandService.executeCommand(CoreHeaderFooterCommand.id, {
unitId,
createType,
segmentId,
sectionId,
});
if (!created) {
selectionRenderService.setSegment('');
selectionRenderService.setSegmentPage(-1);
viewModel.setEditArea(DocumentEditArea.BODY);
return false;
}
} else if (headerFooterId != null) {
selectionRenderService.setSegment(headerFooterId);
}
return commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'open' });
},
};
interface ICloseHeaderFooterParams {
unitId?: string;
}
export const CloseHeaderFooterCommand: ICommand<ICloseHeaderFooterParams> = {
id: 'doc.command.close-header-footer',
type: CommandType.COMMAND,
handler: async (accessor, params: ICloseHeaderFooterParams) => {
const commandService = accessor.get(ICommandService);
const renderManagerService = accessor.get(IRenderManagerService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const instanceService = accessor.get(IUniverInstanceService);
const unitId = params?.unitId ?? instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC)?.getUnitId();
if (unitId == null) {
return false;
}
const renderObject = renderManagerService.getRenderUnitById(unitId);
if (renderObject == null) {
return false;
}
const docSkeletonManagerService = renderObject.with(DocSkeletonManagerService);
const viewModel = docSkeletonManagerService?.getViewModel();
if (viewModel == null) {
return false;
}
if (viewModel.getEditArea() === DocumentEditArea.BODY) {
return false;
}
const skeleton = docSkeletonManagerService?.getSkeleton();
if (skeleton == null) {
return false;
}
const { scene } = renderObject;
const transformer = scene.getTransformerByCreate();
const docSelectionRenderService = renderObject.with(DocSelectionRenderService);
// TODO: @JOCS, these codes bellow should be automatically executed?
docSelectionManagerService.replaceDocRanges([]); // Clear text selection.
transformer.clearSelectedObjects();
docSelectionRenderService.setSegment('');
docSelectionRenderService.setSegmentPage(-1);
viewModel.setEditArea(DocumentEditArea.BODY);
// Switching edit areas does not change document content. Reuse the
// published body pages instead of synchronously paginating a large
// document again. An uninitialized skeleton still needs its first
// calculation.
const publishedPageCount = typeof skeleton.getSkeletonData === 'function'
? skeleton.getSkeletonData()?.pages.length ?? 0
: 0;
if (publishedPageCount === 0) {
skeleton.calculate();
}
renderObject.mainComponent?.makeDirty(true);
queueMicrotask(() => {
const docDataModel = instanceService.getUnit<DocumentDataModel>(unitId);
const snapshot = docDataModel?.getSnapshot();
if (snapshot == null) {
return;
}
const offset = findFirstCursorOffset(snapshot);
docSelectionManagerService.replaceDocRanges([
{
startOffset: offset,
endOffset: offset,
},
]);
});
commandService.executeCommand(SidebarDocHeaderFooterPanelOperation.id, { value: 'close' });
return true;
},
};

View File

@@ -0,0 +1,85 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, ITextRangeParam } from '@univerjs/core';
import { CommandType, DashStyleType, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocContentInsertService } from '@univerjs/docs';
import { BreakLineCommand, BreakLineInsertionMode } from './break-line.command';
import { getCurrentParagraph } from './util';
interface IHorizontalCommandParams {
insertionMode?: BreakLineInsertionMode;
insertRange?: ITextRangeParam;
}
export const HorizontalLineCommand: ICommand<IHorizontalCommandParams> = {
id: 'doc.command.horizontal-line',
type: CommandType.COMMAND,
handler: (accessor, params: IHorizontalCommandParams) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(BreakLineCommand.id, {
horizontalLine: {
padding: 5,
color: {
rgb: '#CDD0D8',
},
width: 1,
dashStyle: DashStyleType.SOLID,
},
insertionMode: params?.insertionMode,
textRange: params?.insertRange,
});
},
};
export const InsertHorizontalLineBellowCommand: ICommand<IHorizontalCommandParams> = {
id: 'doc.command.insert-horizontal-line-bellow',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
const doc = accessor.get(IUniverInstanceService).getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
let contentInsertRange: ReturnType<DocContentInsertService['consumeInsertRange']> = null;
try {
contentInsertRange = accessor.get(DocContentInsertService).consumeInsertRange(doc?.getUnitId());
} catch {
contentInsertRange = null;
}
if (contentInsertRange) {
return commandService.syncExecuteCommand(HorizontalLineCommand.id, {
insertionMode: BreakLineInsertionMode.InsertGap,
insertRange: {
startOffset: contentInsertRange.startOffset,
endOffset: contentInsertRange.endOffset,
},
});
}
const paragraph = getCurrentParagraph(accessor);
if (!paragraph) {
return false;
}
return commandService.syncExecuteCommand(HorizontalLineCommand.id, {
insertRange: {
startOffset: paragraph.startIndex + 1,
endOffset: paragraph.startIndex + 1,
},
});
},
};

View File

@@ -0,0 +1,270 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
DocumentDataModel,
ICommand,
ICommandInfo,
IDocumentBody,
IDocumentStyle,
ISize,
ITable,
ITables,
JSONXActions,
PageOrientType,
} from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import {
CommandType,
DocumentFlavor,
ICommandService,
IUniverInstanceService,
JSONX,
MODERN_DOCUMENT_DEFAULT_MARGIN,
UniverInstanceType,
} from '@univerjs/core';
import { DocHistoryAction, getTopLevelSectionBreaks, RichTextEditingMutation } from '@univerjs/docs';
export interface IDocPageSetupCommandParams {
pageSize: ISize;
pageOrient: PageOrientType;
documentFlavor?: DocumentFlavor;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
}
const PAGE_FILL_TABLE_TOLERANCE = 2;
interface IPageContentWidthConfig {
documentFlavor?: DocumentFlavor;
pageSize?: ISize;
marginLeft?: number;
marginRight?: number;
}
function getPageContentWidth(config: IPageContentWidthConfig) {
const pageWidth = config.pageSize?.width ?? 0;
if (!pageWidth) {
return 0;
}
if (config.documentFlavor === DocumentFlavor.MODERN) {
return Math.max(0, pageWidth - MODERN_DOCUMENT_DEFAULT_MARGIN * 2);
}
return Math.max(0, pageWidth - (config.marginLeft ?? 0) - (config.marginRight ?? 0));
}
function getTableColumnTotalWidth(table: ITable) {
return table.tableColumns.reduce((total, column) => total + (column.size.width.v ?? 0), 0);
}
function isPageFillTable(table: ITable, oldContentWidth: number) {
if (oldContentWidth <= 0 || table.tableColumns.length === 0) {
return false;
}
const columnTotalWidth = getTableColumnTotalWidth(table);
const tableWidth = columnTotalWidth || table.size.width.v;
return Math.abs(tableWidth - oldContentWidth) <= PAGE_FILL_TABLE_TOLERANCE;
}
function resizePageFillTables(jsonX: JSONX, rawActions: NonNullable<JSONXActions>, tableSource: ITables | undefined, oldContentWidth: number, newContentWidth: number) {
if (!tableSource || oldContentWidth <= 0 || newContentWidth <= 0 || Math.abs(oldContentWidth - newContentWidth) <= PAGE_FILL_TABLE_TOLERANCE) {
return;
}
Object.entries(tableSource).forEach(([tableId, table]) => {
if (!isPageFillTable(table, oldContentWidth)) {
return;
}
const totalColumnWidth = getTableColumnTotalWidth(table);
if (totalColumnWidth <= 0) {
return;
}
const tableWidthAction = jsonX.replaceOp(['tableSource', tableId, 'size', 'width', 'v'], table.size.width.v, newContentWidth);
tableWidthAction && rawActions.push(tableWidthAction);
table.tableColumns.forEach((column, index) => {
const nextWidth = (column.size.width.v / totalColumnWidth) * newContentWidth;
const action = jsonX.replaceOp(['tableSource', tableId, 'tableColumns', index, 'size', 'width', 'v'], column.size.width.v, nextWidth);
action && rawActions.push(action);
});
});
}
function updateTopLevelSectionPageSetup(
jsonX: JSONX,
rawActions: NonNullable<JSONXActions>,
body: IDocumentBody | null | undefined,
config: Pick<IDocumentStyle, 'marginBottom' | 'marginLeft' | 'marginRight' | 'marginTop' | 'pageOrient' | 'pageSize'>
) {
if (!body?.sectionBreaks?.length) {
return;
}
const sectionIds = new Set(getTopLevelSectionBreaks(body).map(({ sectionId }) => sectionId));
body.sectionBreaks.forEach((section, index) => {
if (!sectionIds.has(section.sectionId)) {
return;
}
const nextSection = { ...section, ...config };
const action = jsonX.replaceOp(['body', 'sectionBreaks', index], section, nextSection);
action && rawActions.push(action);
});
}
export const DocPageSetupCommand: ICommand<IDocPageSetupCommandParams> = {
id: 'docs.command.page-setup',
type: CommandType.COMMAND,
// eslint-disable-next-line complexity, max-lines-per-function
handler: (accessor, params) => {
if (!params) return false;
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) return false;
const { documentFlavor, marginLeft, marginRight, marginBottom, marginTop, pageOrient, pageSize } = params;
const jsonX = JSONX.getInstance();
const documentStyle = docDataModel.getDocumentStyle();
const snapshot = docDataModel.getSnapshot();
const newDocumentStyle: IDocumentStyle = {
...documentStyle,
documentFlavor: documentFlavor ?? documentStyle.documentFlavor,
marginBottom,
marginLeft,
marginRight,
marginTop,
pageOrient,
pageSize,
};
const {
marginBottom: oldMarginBottom,
marginLeft: oldMarginLeft,
marginRight: oldMarginRight,
marginTop: oldMarginTop,
pageOrient: oldPageOrient,
pageSize: oldPageSize,
documentFlavor: oldDocumentFlavor,
} = documentStyle;
const rawActions: NonNullable<JSONXActions> = [];
resizePageFillTables(
jsonX,
rawActions,
snapshot.tableSource,
getPageContentWidth(documentStyle),
getPageContentWidth(newDocumentStyle)
);
if (newDocumentStyle.documentFlavor !== DocumentFlavor.MODERN) {
// Imported DOCX files commonly store page geometry on every section.
// Updating only documentStyle leaves those explicit values in charge,
// so the Page Setup dialog appears to succeed without changing a page.
updateTopLevelSectionPageSetup(jsonX, rawActions, snapshot.body, {
marginBottom,
marginLeft,
marginRight,
marginTop,
pageOrient,
pageSize,
});
}
if (documentFlavor !== undefined) {
if (oldDocumentFlavor === undefined) {
const action = jsonX.insertOp(['documentStyle', 'documentFlavor'], documentFlavor);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'documentFlavor'], oldDocumentFlavor, documentFlavor);
action && rawActions.push(action);
}
}
if (oldMarginBottom === undefined) {
const action = jsonX.insertOp(['documentStyle', 'marginBottom'], marginBottom);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'marginBottom'], oldMarginBottom, marginBottom);
action && rawActions.push(action);
}
if (oldMarginLeft === undefined) {
const action = jsonX.insertOp(['documentStyle', 'marginLeft'], marginLeft);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'marginLeft'], oldMarginLeft, marginLeft);
action && rawActions.push(action);
}
if (oldMarginRight === undefined) {
const action = jsonX.insertOp(['documentStyle', 'marginRight'], marginRight);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'marginRight'], oldMarginRight, marginRight);
action && rawActions.push(action);
}
if (oldMarginTop === undefined) {
const action = jsonX.insertOp(['documentStyle', 'marginTop'], marginTop);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'marginTop'], oldMarginTop, marginTop);
action && rawActions.push(action);
}
if (oldPageSize === undefined) {
const action = jsonX.insertOp(['documentStyle', 'pageSize'], pageSize);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'pageSize'], oldPageSize, pageSize);
action && rawActions.push(action);
}
if (oldPageOrient === undefined) {
const action = jsonX.insertOp(['documentStyle', 'pageOrient'], pageOrient);
action && rawActions.push(action);
} else {
const action = jsonX.replaceOp(['documentStyle', 'pageOrient'], oldPageOrient, pageOrient);
action && rawActions.push(action);
}
const doMutation: ICommandInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId: docDataModel.getUnitId(),
historyAction: DocHistoryAction.UpdatePageLayout,
actions: [],
textRanges: undefined,
},
};
doMutation.params!.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IMutationInfo, IParagraphStyle } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import { BuildTextUtils, CommandType, getRichTextEditPath, ICommandService, IUniverInstanceService, JSONX, MemoryCursor, TextX, TextXActionType, UniverInstanceType, UpdateDocsAttributeType } from '@univerjs/core';
import { DocHistoryAction, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
export interface IDocParagraphSettingCommandParams {
paragraph?: Partial<Pick<IParagraphStyle, 'hanging' | 'horizontalAlign' | 'spaceBelow' | 'spaceAbove' | 'indentEnd' | 'indentStart' | 'lineSpacing' | 'indentFirstLine' | 'snapToGrid' | 'spacingRule'>>;
};
export const DocParagraphSettingCommand: ICommand<IDocParagraphSettingCommandParams> = {
id: 'doc-paragraph-setting.command',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, config) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const docRanges = docSelectionManagerService.getDocRanges();
if (!docDataModel || docRanges.length === 0 || !config) {
return false;
}
const segmentId = docRanges[0].segmentId;
const unitId = docDataModel.getUnitId();
const segment = docDataModel.getSelfOrHeaderFooterModel(segmentId);
const segmentBody = segment?.getBody();
const allParagraphs = segmentBody?.paragraphs ?? [];
const dataStream = segmentBody?.dataStream ?? '';
const paragraphs = BuildTextUtils.range.getParagraphsInRanges(docRanges, allParagraphs, dataStream) ?? [];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
historyAction: DocHistoryAction.FormatParagraph,
actions: [],
textRanges: docRanges,
},
};
if (!config.paragraph || Object.keys(config.paragraph).length === 0) {
return false;
}
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
const textX = new TextX();
const jsonX = JSONX.getInstance();
for (const paragraph of paragraphs) {
const { startIndex } = paragraph;
textX.push({
t: TextXActionType.RETAIN,
len: startIndex - memoryCursor.cursor,
});
textX.push({
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
paragraphs: [{
...paragraph,
paragraphStyle: {
...paragraph.paragraphStyle,
...config.paragraph,
},
startIndex: 0,
}],
},
coverType: UpdateDocsAttributeType.REPLACE,
});
memoryCursor.moveCursorTo(startIndex + 1);
}
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return !!result;
},
};

View File

@@ -0,0 +1,381 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, ICustomColumnGroup, ICustomTable, IDocumentBody } from '@univerjs/core';
import type { ISuccinctDocRangeParam } from '@univerjs/engine-render';
import { CommandType, DataStreamTreeTokenType, DOC_RANGE_TYPE, getParagraphContentStartOffsets, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { getWordBoundaryByIndex } from '../../services/selection/word-boundary';
interface ISelectAllCommandParams {
segmentId?: string;
wholeDocument?: boolean;
}
export const DocSelectAllCommand: ICommand<ISelectAllCommandParams> = {
id: 'doc.command.select-all',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const docRanges = docSelectionManagerService.getDocRanges();
const activeRange = docRanges.find((range) => range.isActive) ?? docRanges[0];
if (docDataModel == null) {
return false;
}
const segmentId = params?.segmentId ?? activeRange?.segmentId;
if (segmentId == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (body == null) {
return false;
}
const { dataStream } = body;
if (dataStream === '\r\n') {
return true;
}
let textRanges: ISuccinctDocRangeParam[];
let wholeDocument: boolean;
if (params?.wholeDocument) {
textRanges = getWholeDocumentRanges(body);
wholeDocument = true;
} else {
if (activeRange == null) {
return false;
}
const scopes = getSelectAllScopes(body, activeRange);
const currentScopeIndex = scopes.findIndex((scope) => isSameRanges(docRanges, scope));
textRanges = scopes[Math.min(currentScopeIndex + 1, scopes.length - 1)];
wholeDocument = textRanges === scopes[scopes.length - 1];
}
docSelectionManagerService.replaceDocRanges(textRanges, {
unitId,
subUnitId: unitId,
}, false, { wholeDocument });
return true;
},
};
export const DocSelectWordCommand: ICommand = {
id: 'doc.command.select-word',
type: CommandType.COMMAND,
handler: (accessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const selectionManager = accessor.get(DocSelectionManagerService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const activeRange = selectionManager.getActiveTextRange();
if (!docDataModel || !activeRange?.collapsed || activeRange.startOffset == null) {
return false;
}
const body = docDataModel.getSelfOrHeaderFooterModel(activeRange.segmentId)?.getBody();
if (!body) {
return false;
}
const paragraphStarts = getParagraphContentStartOffsets(body);
const paragraph = body.paragraphs?.find((item) => {
const start = paragraphStarts.get(item.startIndex) ?? 0;
return start <= activeRange.startOffset && activeRange.startOffset <= item.startIndex;
});
if (!paragraph) {
return false;
}
const paragraphStart = paragraphStarts.get(paragraph.startIndex) ?? 0;
const content = body.dataStream.slice(paragraphStart, paragraph.startIndex);
const index = activeRange.startOffset - paragraphStart;
const boundary = getWordBoundaryByIndex(content, index, paragraphStart) ??
getWordBoundaryByIndex(content, index - 1, paragraphStart);
if (!boundary) {
return false;
}
const unitId = docDataModel.getUnitId();
selectionManager.replaceDocRanges([{
...boundary,
rangeType: DOC_RANGE_TYPE.TEXT,
}], { unitId, subUnitId: unitId }, false);
return true;
},
};
function getWholeDocumentRanges(body: IDocumentBody): ISuccinctDocRangeParam[] {
return getRangesForOffsets(body, 0, body.dataStream.length - 2);
}
function getRangesForOffsets(body: IDocumentBody, startOffset: number, endOffset: number): ISuccinctDocRangeParam[] {
const columnGroups = [...(body.columnGroups ?? [])].sort((left, right) => left.startIndex - right.startIndex);
const ranges: ISuccinctDocRangeParam[] = [];
let offset = startOffset;
for (const columnGroup of columnGroups) {
if (columnGroup.endIndex < startOffset || columnGroup.startIndex > endOffset) {
continue;
}
if (offset < columnGroup.startIndex) {
ranges.push(...getRangesWithTables(body, offset, Math.min(endOffset, columnGroup.startIndex - 1)));
}
for (const column of getColumnRanges(body, columnGroup)) {
const start = Math.max(startOffset, column.startOffset!);
const end = Math.min(endOffset, column.endOffset!);
if (start <= end) {
ranges.push(...getRangesWithTables(body, start, end));
}
}
offset = columnGroup.endIndex + 1;
}
if (offset <= endOffset) {
ranges.push(...getRangesWithTables(body, offset, endOffset));
}
return ranges;
}
function getRangesWithTables(body: IDocumentBody, startOffset: number, endOffset: number): ISuccinctDocRangeParam[] {
const ranges: ISuccinctDocRangeParam[] = [];
let offset = startOffset;
for (const table of [...(body.tables ?? [])].sort((left, right) => left.startIndex - right.startIndex)) {
const { startIndex, endIndex } = table;
if (startIndex < startOffset || endIndex > endOffset) {
continue;
}
if (offset !== startIndex) {
ranges.push(...getTextRangesByParagraphs(body, offset, startIndex - 1));
}
ranges.push(getTableRectRange(table));
offset = endIndex;
}
if (offset <= endOffset) {
ranges.push(...getTextRangesByParagraphs(body, offset, endOffset));
}
return ranges;
}
function getTextRangesByParagraphs(body: IDocumentBody, startOffset: number, endOffset: number): ISuccinctDocRangeParam[] {
if (startOffset > endOffset) {
return [];
}
const paragraphs = [...(body.paragraphs ?? [])].sort((left, right) => left.startIndex - right.startIndex);
const ranges: ISuccinctDocRangeParam[] = [];
let offset = startOffset;
for (const paragraph of paragraphs) {
if (paragraph.startIndex < offset) {
continue;
}
if (paragraph.startIndex > endOffset) {
break;
}
ranges.push({
startOffset: offset,
endOffset: paragraph.startIndex,
rangeType: DOC_RANGE_TYPE.TEXT,
});
offset = paragraph.startIndex + 1;
}
if (offset <= endOffset) {
ranges.push({
startOffset: offset,
endOffset,
rangeType: DOC_RANGE_TYPE.TEXT,
});
}
return ranges;
}
function getSelectAllScopes(body: IDocumentBody, activeRange: ISuccinctDocRangeParam): ISuccinctDocRangeParam[][] {
const scopes: ISuccinctDocRangeParam[][] = [];
const addScope = (scope: ISuccinctDocRangeParam[] | null) => {
if (scope?.length && !scopes.some((existing) => isSameRanges(existing, scope))) {
scopes.push(scope);
}
};
const startOffset = activeRange.startOffset;
const endOffset = activeRange.endOffset;
if (startOffset == null || endOffset == null) {
return [getWholeDocumentRanges(body)];
}
const table = (body.tables ?? []).find((item) => isRangeInside(startOffset, endOffset, item.startIndex, item.endIndex));
const customBlock = (body.customBlocks ?? []).find((item) => item.startIndex >= startOffset && item.startIndex <= endOffset);
const blockRange = (body.blockRanges ?? []).find((item) => isRangeInside(startOffset, endOffset, item.startIndex, item.endIndex));
if (table) {
addScope([getTableRectRange(table)]);
} else if (customBlock) {
addScope([{
endOffset: customBlock.startIndex,
rangeType: DOC_RANGE_TYPE.TEXT,
startOffset: customBlock.startIndex,
}]);
} else if (blockRange) {
addScope([{
endOffset: Math.max(blockRange.startIndex + 1, blockRange.endIndex - 1),
rangeType: DOC_RANGE_TYPE.TEXT,
startOffset: blockRange.startIndex + 1,
}]);
} else {
const paragraphRange = clampParagraphRangeByTables(getParagraphRangeAtOffset(body, startOffset), body.tables ?? [], startOffset);
addScope(paragraphRange ? [{ ...paragraphRange, rangeType: DOC_RANGE_TYPE.TEXT }] : null);
}
const columnGroup = (body.columnGroups ?? []).find((item) => isRangeInside(startOffset, endOffset, item.startIndex, item.endIndex));
if (columnGroup) {
const columns = getColumnRanges(body, columnGroup);
const column = columns.find((item) => isRangeInside(startOffset, endOffset, item.startOffset!, item.endOffset!));
if (column) {
addScope(getRangesForOffsets(body, column.startOffset!, column.endOffset!));
}
addScope(getRangesForOffsets(body, columnGroup.startIndex, columnGroup.endIndex));
}
addScope(getWholeDocumentRanges(body));
return scopes;
}
function getColumnRanges(body: IDocumentBody, columnGroup: ICustomColumnGroup): ISuccinctDocRangeParam[] {
const ranges: ISuccinctDocRangeParam[] = [];
let startOffset: number | undefined;
for (let index = columnGroup.startIndex; index <= columnGroup.endIndex; index++) {
const token = body.dataStream[index];
if (token === DataStreamTreeTokenType.COLUMN_START) {
startOffset = index + 1;
} else if (token === DataStreamTreeTokenType.COLUMN_END && startOffset != null) {
ranges.push({ startOffset, endOffset: index - 1, rangeType: DOC_RANGE_TYPE.TEXT });
startOffset = undefined;
}
}
return ranges;
}
function clampParagraphRangeByTables(
paragraphRange: Pick<ISuccinctDocRangeParam, 'endOffset' | 'startOffset'> | null,
tables: ICustomTable[],
activeOffset: number
): Pick<ISuccinctDocRangeParam, 'endOffset' | 'startOffset'> | null {
if (!paragraphRange) {
return null;
}
const nextRange = { ...paragraphRange };
for (const table of tables) {
if (activeOffset < table.startIndex && nextRange.startOffset! < table.startIndex && table.startIndex <= nextRange.endOffset!) {
nextRange.endOffset = table.startIndex - 1;
} else if (activeOffset >= table.endIndex && nextRange.startOffset! <= table.endIndex && table.endIndex < nextRange.endOffset!) {
nextRange.startOffset = table.endIndex;
}
}
return nextRange.startOffset! <= nextRange.endOffset! ? nextRange : null;
}
function getTableRectRange(table: ICustomTable): ISuccinctDocRangeParam {
return {
startOffset: table.startIndex + 3,
endOffset: table.endIndex - 5,
rangeType: DOC_RANGE_TYPE.RECT,
};
}
function getParagraphRangeAtOffset(body: IDocumentBody, offset: number): Pick<ISuccinctDocRangeParam, 'endOffset' | 'startOffset'> | null {
const sortedParagraphs = [...(body.paragraphs ?? [])].sort((left, right) => left.startIndex - right.startIndex);
const paragraphStartOffsets = getParagraphContentStartOffsets(body);
for (const paragraph of sortedParagraphs) {
const startOffset = paragraphStartOffsets.get(paragraph.startIndex) ?? 0;
if (startOffset <= offset && offset <= paragraph.startIndex) {
return {
endOffset: paragraph.startIndex,
startOffset,
};
}
}
return null;
}
function isRangeInside(startOffset: number, endOffset: number, scopeStart: number, scopeEnd: number): boolean {
return startOffset >= scopeStart && endOffset <= scopeEnd;
}
function isSameRanges(currentRanges: ISuccinctDocRangeParam[], nextRanges: ISuccinctDocRangeParam[]): boolean {
if (currentRanges.length !== nextRanges.length) {
return isSameTextRangeCoverage(currentRanges, nextRanges);
}
return currentRanges.every((currentRange, index) => {
const nextRange = nextRanges[index];
const sameType = getRangeType(currentRange) === getRangeType(nextRange);
const sameTextEnd = sameType &&
getRangeType(nextRange) === DOC_RANGE_TYPE.TEXT &&
!(currentRange as ISuccinctDocRangeParam & { collapsed?: boolean }).collapsed &&
currentRange.endOffset! + 1 === nextRange.endOffset;
return currentRange.startOffset === nextRange.startOffset &&
(currentRange.endOffset === nextRange.endOffset || sameTextEnd) &&
sameType;
});
}
function isSameTextRangeCoverage(currentRanges: ISuccinctDocRangeParam[], nextRanges: ISuccinctDocRangeParam[]): boolean {
if (nextRanges.length !== 1 || getRangeType(nextRanges[0]) !== DOC_RANGE_TYPE.TEXT) {
return false;
}
const targetRange = nextRanges[0];
if (targetRange.startOffset == null || targetRange.endOffset == null) {
return false;
}
if (currentRanges.some((range) => getRangeType(range) !== DOC_RANGE_TYPE.TEXT || range.startOffset == null || range.endOffset == null)) {
return false;
}
const startOffset = Math.min(...currentRanges.map((range) => range.startOffset!));
const endOffset = Math.max(...currentRanges.map((range) => range.endOffset!));
return startOffset === targetRange.startOffset &&
endOffset === targetRange.endOffset &&
currentRanges.every((range) => range.startOffset! >= targetRange.startOffset! && range.endOffset! <= targetRange.endOffset!);
}
function getRangeType(range: ISuccinctDocRangeParam): DOC_RANGE_TYPE {
return range.rangeType ?? DOC_RANGE_TYPE.TEXT;
}

View File

@@ -0,0 +1,260 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, ICommandInfo } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render';
import { BuildTextUtils, CommandType, getRichTextEditPath, ICommandService, IUniverInstanceService, JSONX, SHEET_EDITOR_UNITS, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition, getTextRunAtPosition } from '../../basics/paragraph';
import { DocIMEInputManagerService } from '../../services/doc-ime-input-manager.service';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
import { getDocRangeInsertOffset, getReplaceDocRangesActions } from './clipboard.inner.command';
export interface IIMEInputCommandParams {
unitId: string;
newText: string;
oldTextLen: number;
isCompositionStart: boolean;
isCompositionEnd: boolean;
isCompositionCanceled?: boolean;
}
export const IMEInputCommand: ICommand<IIMEInputCommandParams> = {
id: 'doc.command.ime-input',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params: IIMEInputCommandParams) => {
const { unitId, newText, oldTextLen, isCompositionEnd, isCompositionStart, isCompositionCanceled } = params;
const commandService = accessor.get(ICommandService);
const renderManagerService = accessor.get(IRenderManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const imeInputManagerService = renderManagerService.getRenderUnitById(unitId)?.with(DocIMEInputManagerService);
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
if (docDataModel == null || imeInputManagerService == null) {
return false;
}
const previousActiveRange = imeInputManagerService.getCompositionRange();
if (previousActiveRange == null) {
return false;
}
if (isCompositionCanceled) {
const historyParams = imeInputManagerService.fetchComposedUndoRedoMutationParams();
if (historyParams == null) {
return true;
}
const {
undoMutationParams,
previousActiveRange,
previousDocRanges,
previousSelectionOptions,
} = historyParams;
const rollbackMutationParams: IRichTextEditingMutationParams = {
...undoMutationParams,
textRanges: previousDocRanges.length ? previousDocRanges : [previousActiveRange],
options: previousSelectionOptions ?? undefined,
noHistory: true,
trigger: IMEInputCommand.id,
};
return Boolean(commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(RichTextEditingMutation.id, rollbackMutationParams));
}
const { style, segmentId } = previousActiveRange;
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (body == null) {
return false;
}
const insertRange = previousActiveRange;
Object.assign(previousActiveRange, insertRange);
const { startOffset, endOffset } = previousActiveRange;
const previousDocRanges = imeInputManagerService.getPreviousDocRanges();
const previousRectRanges = previousDocRanges.filter(isRectRange);
const previousTextRanges = previousDocRanges.filter((range) => !isRectRange(range));
const wholeBodySelected = imeInputManagerService.getPreviousSelectionOptions()?.wholeDocument === true;
const replacesComplexSelection = isCompositionStart && (wholeBodySelected || previousRectRanges.length > 0 || previousTextRanges.length > 1);
let replacementOffset = replacesComplexSelection
? wholeBodySelected
? 0
: getDocRangeInsertOffset(previousTextRanges, previousRectRanges)
: startOffset;
if (replacementOffset == null) {
return false;
}
const len = newText.length;
const textRanges: ITextRangeWithStyle[] = [
{
startOffset: replacementOffset + len,
endOffset: replacementOffset + len,
collapsed: true,
style,
segmentId,
},
];
const doMutation: ICommandInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
segmentId,
trigger: IMEInputCommand.id,
},
};
const defaultTextStyle = docMenuStyleService.getDefaultStyle();
const styleCache = docMenuStyleService.getStyleCache();
const styleOffset = replacesComplexSelection ? replacementOffset : startOffset + oldTextLen;
const curCustomRange = getCustomRangeAtPosition(body.customRanges ?? [], styleOffset, SHEET_EDITOR_UNITS.includes(unitId));
const curTextRun = getTextRunAtPosition(
body,
replacesComplexSelection ? replacementOffset : isCompositionStart ? endOffset : startOffset + oldTextLen,
defaultTextStyle,
styleCache,
SHEET_EDITOR_UNITS.includes(unitId)
);
const customDecorations = getCustomDecorationAtPosition(body.customDecorations ?? [], styleOffset);
const insertBody = {
dataStream: newText,
textRuns: curTextRun
? [{
...curTextRun,
st: 0,
ed: newText.length,
}]
: [],
customRanges: curCustomRange
? [{
...curCustomRange,
startIndex: 0,
endIndex: newText.length - 1,
}]
: [],
customDecorations: customDecorations.map((customDecoration) => ({
...customDecoration,
startIndex: 0,
endIndex: newText.length - 1,
})),
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
if (replacesComplexSelection) {
const docSkeletonManagerService = renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
if (!docSkeletonManagerService) {
return false;
}
const replacement = getReplaceDocRangesActions(
previousTextRanges,
previousRectRanges,
docDataModel,
docSkeletonManagerService.getViewModel(),
segmentId ?? '',
insertBody,
wholeBodySelected
);
if (!replacement) {
return false;
}
replacementOffset = replacement.insertOffset;
doMutation.params!.actions = replacement.actions;
doMutation.params!.textRanges = [{
startOffset: replacementOffset + len,
endOffset: replacementOffset + len,
collapsed: true,
style,
segmentId,
}];
} else if (!previousActiveRange.collapsed && isCompositionStart) {
const dos = BuildTextUtils.selection.delete([previousActiveRange], body, 0, null, false);
textX.push(...dos);
doMutation.params!.textRanges = [{
startOffset: replacementOffset + len,
endOffset: replacementOffset + len,
collapsed: true,
segmentId,
}];
} else {
textX.push({
t: TextXActionType.RETAIN,
len: startOffset,
});
}
if (!replacesComplexSelection && oldTextLen > 0) {
textX.push({
t: TextXActionType.DELETE,
len: oldTextLen,
});
}
if (!replacesComplexSelection) {
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: newText.length,
});
}
if (!replacesComplexSelection) {
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params!.actions = jsonX.editOp(textX.serialize(), path);
}
doMutation.params!.noHistory = !isCompositionEnd;
doMutation.params!.isCompositionEnd = isCompositionEnd;
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
imeInputManagerService.pushUndoRedoMutationParams(result, doMutation.params!);
if (replacesComplexSelection) {
imeInputManagerService.setCompositionRange({
...previousActiveRange,
startOffset: replacementOffset,
endOffset: replacementOffset,
collapsed: true,
});
}
return Boolean(result);
},
};
function isRectRange(range: ITextRangeWithStyle): range is IRectRangeWithStyle {
return 'tableId' in range;
}

View File

@@ -0,0 +1,612 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, IStyleBase, ITextDecoration, ITextRun, ITextStyle, Nullable } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
BaselineOffset,
BooleanNumber,
CommandType,
DOC_RANGE_TYPE,
getRichTextEditPath,
getTextRunSlice,
ICommandService,
isInternalEditorID,
IUniverInstanceService,
JSONX,
MemoryCursor,
TextX,
TextXActionType,
Tools,
UniverInstanceType,
UpdateDocsAttributeType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
import { IEditorService } from '../../services/editor/editor-manager.service';
function handleInlineFormat(
preCommandId: string,
params: object | undefined,
commandService: ICommandService
) {
// eslint-disable-next-line ts/no-use-before-define
return commandService.executeCommand(SetInlineFormatCommand.id, {
preCommandId,
...(params ?? {}),
});
}
export interface ISetInlineFormatCommandParams {
preCommandId: string;
value?: string | Partial<ITextStyle> | null;
}
const SetInlineFormatBoldCommandId = 'doc.command.set-inline-format-bold';
export const SetInlineFormatBoldCommand: ICommand = {
id: SetInlineFormatBoldCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatBoldCommandId,
params,
commandService
);
},
};
const SetInlineFormatItalicCommandId = 'doc.command.set-inline-format-italic';
export const SetInlineFormatItalicCommand: ICommand = {
id: SetInlineFormatItalicCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatItalicCommandId,
params,
commandService
);
},
};
const SetInlineFormatUnderlineCommandId = 'doc.command.set-inline-format-underline';
export const SetInlineFormatUnderlineCommand: ICommand = {
id: SetInlineFormatUnderlineCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatUnderlineCommandId,
params,
commandService
);
},
};
const SetInlineFormatStrikethroughCommandId = 'doc.command.set-inline-format-strikethrough';
export const SetInlineFormatStrikethroughCommand: ICommand = {
id: SetInlineFormatStrikethroughCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatStrikethroughCommandId,
params,
commandService
);
},
};
const SetInlineFormatSubscriptCommandId = 'doc.command.set-inline-format-subscript';
export const SetInlineFormatSubscriptCommand: ICommand = {
id: SetInlineFormatSubscriptCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatSubscriptCommandId,
params,
commandService
);
},
};
const SetInlineFormatSuperscriptCommandId = 'doc.command.set-inline-format-superscript';
export const SetInlineFormatSuperscriptCommand: ICommand = {
id: SetInlineFormatSuperscriptCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatSuperscriptCommandId,
params,
commandService
);
},
};
const SetInlineFormatFontSizeCommandId = 'doc.command.set-inline-format-fontsize';
export const SetInlineFormatFontSizeCommand: ICommand = {
id: SetInlineFormatFontSizeCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatFontSizeCommandId,
params,
commandService
);
},
};
const SetInlineFormatFontFamilyCommandId = 'doc.command.set-inline-format-font-family';
export const SetInlineFormatFontFamilyCommand: ICommand = {
id: SetInlineFormatFontFamilyCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatFontFamilyCommandId,
params,
commandService
);
},
};
const SetInlineFormatTextColorCommandId = 'doc.command.set-inline-format-text-color';
export const SetInlineFormatTextColorCommand: ICommand = {
id: SetInlineFormatTextColorCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatTextColorCommandId,
params,
commandService
);
},
};
const ResetInlineFormatTextColorCommandId = 'doc.command.reset-inline-format-text-color';
export const ResetInlineFormatTextColorCommand: ICommand = {
id: ResetInlineFormatTextColorCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
ResetInlineFormatTextColorCommandId,
params,
commandService
);
},
};
const SetInlineFormatTextFillCommandId = 'doc.command.set-inline-format-text-fill';
export const SetInlineFormatTextFillCommand: ICommand = {
id: SetInlineFormatTextFillCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatTextFillCommandId,
params,
commandService
);
},
};
const SetInlineFormatTextBackgroundColorCommandId = 'doc.command.set-inline-format-text-background-color';
export const SetInlineFormatTextBackgroundColorCommand: ICommand = {
id: SetInlineFormatTextBackgroundColorCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
SetInlineFormatTextBackgroundColorCommandId,
params,
commandService
);
},
};
const ResetInlineFormatTextBackgroundColorCommandId = 'doc.command.reset-inline-format-text-background-color';
export const ResetInlineFormatTextBackgroundColorCommand: ICommand = {
id: ResetInlineFormatTextBackgroundColorCommandId,
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const commandService = accessor.get(ICommandService);
return handleInlineFormat(
ResetInlineFormatTextBackgroundColorCommandId,
params,
commandService
);
},
};
const COMMAND_ID_TO_FORMAT_KEY_MAP: Record<string, keyof IStyleBase> = {
[SetInlineFormatBoldCommand.id]: 'bl',
[SetInlineFormatItalicCommand.id]: 'it',
[SetInlineFormatUnderlineCommand.id]: 'ul',
[SetInlineFormatStrikethroughCommand.id]: 'st',
[SetInlineFormatFontSizeCommand.id]: 'fs',
[SetInlineFormatFontFamilyCommand.id]: 'ff',
[SetInlineFormatTextColorCommand.id]: 'cl',
[ResetInlineFormatTextColorCommand.id]: 'cl',
[SetInlineFormatTextBackgroundColorCommand.id]: 'bg',
[ResetInlineFormatTextBackgroundColorCommand.id]: 'bg',
[SetInlineFormatSubscriptCommand.id]: 'va',
[SetInlineFormatSuperscriptCommand.id]: 'va',
};
export const SetInlineFormatCommand: ICommand<ISetInlineFormatCommandParams> = {
id: 'doc.command.set-inline-format',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: async (accessor, params: ISetInlineFormatCommandParams) => {
const { value, preCommandId } = params;
const commandService = accessor.get(ICommandService);
const editorService = accessor.has(IEditorService) ? accessor.get(IEditorService) : null;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const textRanges = docSelectionManagerService.getTextRanges() ?? [];
const docRanges = textRanges.length > 0
? textRanges.filter((range) => range.startOffset != null && range.endOffset != null)
: docSelectionManagerService.getDocRanges();
const activeRange = docRanges.find((r) => r.isActive) ?? docRanges[0];
if (docRanges.length === 0) {
return false;
}
const { segmentId } = docRanges[0];
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const body = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (body == null) {
return false;
}
const unitId = docDataModel.getUnitId();
let formatValue;
let formatPatch: Partial<ITextStyle> | undefined;
switch (preCommandId) {
case SetInlineFormatBoldCommand.id: // fallthrough
case SetInlineFormatItalicCommand.id: // fallthrough
case SetInlineFormatUnderlineCommand.id: // fallthrough
case SetInlineFormatStrikethroughCommand.id: // fallthrough
case SetInlineFormatSubscriptCommand.id: // fallthrough
case SetInlineFormatSuperscriptCommand.id: {
const defaultStyle = docMenuStyleService.getDefaultStyle();
const curTextStyle = getStyleInTextRange(
body,
activeRange,
defaultStyle
);
formatValue = getReverseFormatValueInSelection(
curTextStyle,
preCommandId
);
break;
}
case SetInlineFormatFontSizeCommand.id:
case SetInlineFormatFontFamilyCommand.id: {
formatValue = value;
break;
}
case SetInlineFormatTextColorCommand.id:
case SetInlineFormatTextBackgroundColorCommand.id: {
formatValue = {
rgb: value,
};
break;
}
case SetInlineFormatTextFillCommand.id: {
formatPatch = value as Partial<ITextStyle>;
break;
}
case ResetInlineFormatTextColorCommand.id:
case ResetInlineFormatTextBackgroundColorCommand.id: {
formatValue = {
rgb: null,
};
break;
}
default: {
throw new Error(`Unknown command: ${preCommandId} in handleInlineFormat`);
}
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: docRanges,
},
};
const textX = new TextX();
const jsonX = JSONX.getInstance();
const textStylePatch = formatPatch ?? {
[COMMAND_ID_TO_FORMAT_KEY_MAP[preCommandId]]: formatValue,
};
const shouldClearSolidTextFill = preCommandId === SetInlineFormatTextColorCommand.id
&& !isInternalEditorID(unitId)
&& !editorService?.isEditor(unitId);
const pushTextStyleUpdate = (len: number, ts: Partial<ITextStyle>, replace = false) => {
if (len <= 0) {
return;
}
textX.push({
t: TextXActionType.RETAIN,
body: {
dataStream: '',
textRuns: [{
st: 0,
ed: len,
ts,
}],
},
len,
...(replace ? { coverType: UpdateDocsAttributeType.REPLACE } : {}),
});
};
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
for (const range of docRanges) {
let { startOffset, endOffset, rangeType } = range;
if (startOffset == null || endOffset == null) {
continue;
}
// Use to fix https://github.com/dream-num/univer-pro/issues/3101
if (rangeType === DOC_RANGE_TYPE.RECT) {
startOffset = startOffset - 1;
}
if (startOffset === endOffset) {
// Cache the menu style for next input.
const cacheStyle = docMenuStyleService.getStyleCache();
const key = COMMAND_ID_TO_FORMAT_KEY_MAP[preCommandId];
if (formatPatch) {
docMenuStyleService.setStyleCache(formatPatch);
} else {
docMenuStyleService.setStyleCache(
{
[key]: cacheStyle?.[key] !== undefined && isNeedReverseValue(key)
? getReverseFormatValue(
cacheStyle,
key,
preCommandId
)
: formatValue,
}
);
}
continue;
}
const len = startOffset - memoryCursor.cursor;
if (len !== 0) {
textX.push({
t: TextXActionType.RETAIN,
len,
});
}
if (shouldClearSolidTextFill) {
let currentOffset = startOffset;
for (const textRun of body.textRuns ?? []) {
if (textRun.ts?.textFill?.type !== 'solid') {
continue;
}
const solidStart = Math.max(currentOffset, textRun.st);
const solidEnd = Math.min(endOffset, textRun.ed);
if (solidStart >= solidEnd) {
continue;
}
pushTextStyleUpdate(solidStart - currentOffset, textStylePatch);
const replacementStyle = {
...textRun.ts,
...textStylePatch,
};
delete replacementStyle.textFill;
pushTextStyleUpdate(solidEnd - solidStart, replacementStyle, true);
currentOffset = solidEnd;
}
pushTextStyleUpdate(endOffset - currentOffset, textStylePatch);
} else {
pushTextStyleUpdate(endOffset - startOffset, textStylePatch);
}
memoryCursor.reset();
memoryCursor.moveCursor(endOffset);
}
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
function isTextDecoration(value: unknown | ITextDecoration): value is ITextDecoration {
return value !== null && typeof value === 'object';
}
function isNeedReverseValue(key: keyof IStyleBase): boolean {
return !(/fs|ff|cl|bg/.test(key));
}
function getReverseFormatValue(ts: Nullable<ITextStyle>, key: keyof IStyleBase, preCommandId: string) {
if (/bl|it/.test(key)) {
return ts?.[key] === BooleanNumber.TRUE ? BooleanNumber.FALSE : BooleanNumber.TRUE;
}
if (/ul|st/.test(key)) {
return isTextDecoration(ts?.[key]) && (ts?.[key] as ITextDecoration).s === BooleanNumber.TRUE
? {
s: BooleanNumber.FALSE,
}
: {
s: BooleanNumber.TRUE,
};
}
if (/va/.test(key)) {
if (preCommandId === SetInlineFormatSubscriptCommand.id) {
return ts?.[key] === BaselineOffset.SUBSCRIPT
? BaselineOffset.NORMAL
: BaselineOffset.SUBSCRIPT;
} else {
return ts?.[key] === BaselineOffset.SUPERSCRIPT
? BaselineOffset.NORMAL
: BaselineOffset.SUPERSCRIPT;
}
}
}
// eslint-disable-next-line complexity
export function getStyleInTextRange(
body: IDocumentBody,
textRange: ITextRangeWithStyle,
defaultStyle: ITextStyle
): ITextStyle {
const { startOffset, endOffset, collapsed } = textRange;
if (collapsed) {
const textRuns = body.textRuns ?? [];
let textRun: Nullable<ITextRun> = null;
for (let i = textRuns.length - 1; i >= 0; i--) {
const curTextRun = textRuns[i];
if (curTextRun.st < startOffset && startOffset <= curTextRun.ed) {
textRun = curTextRun;
break;
}
}
return textRun?.ts ? { ...defaultStyle, ...textRun.ts } : defaultStyle;
}
// Menu state only reads text style. Building a full body slice also scans and
// clones tables, paragraphs, decorations and custom ranges for every toolbar
// observable. The text-run slice preserves normalization without touching those
// unrelated structures.
const textRuns = getTextRunSlice(body, startOffset, endOffset) ?? [];
const style = Tools.deepClone(defaultStyle);
// Get the min font size in range.
const fsArr = textRuns.map((t) => t?.ts?.fs).filter(Boolean) as number[];
style.fs = style.fs ? Math.max(style.fs!, ...fsArr) : fsArr.length ? Math.max(...fsArr) : undefined;
style.fs = !style.fs || Number.isNaN(style.fs) ? undefined : style.fs;
style.ff = textRuns.find((t) => t.ts?.ff != null)?.ts?.ff ?? style.ff;
style.it = textRuns.length && textRuns.every((t) => t.ts?.it === BooleanNumber.TRUE) ? BooleanNumber.TRUE : BooleanNumber.FALSE;
style.bl = textRuns.length && textRuns.every((t) => t.ts?.bl === BooleanNumber.TRUE) ? BooleanNumber.TRUE : BooleanNumber.FALSE;
style.ul = textRuns.length && textRuns.every((t) => t.ts?.ul?.s === BooleanNumber.TRUE) ? textRuns[0].ts?.ul : style.ul;
style.st = textRuns.length && textRuns.every((t) => t.ts?.st?.s === BooleanNumber.TRUE) ? textRuns[0].ts?.st : style.st;
style.bg = textRuns.find((t) => t.ts?.bg != null)?.ts?.bg ?? style.bg;
style.cl = textRuns.find((t) => t.ts?.cl != null)?.ts?.cl ?? style.cl;
style.textFill = textRuns.find((t) => t.ts?.textFill != null)?.ts?.textFill ?? style.textFill;
const vas = textRuns.filter((t) => t?.ts?.va != null);
if (vas.length > 0 && vas.length === textRuns.length) {
const va = vas[0].ts?.va;
let isSame = true;
for (let i = 1; i < vas.length; i++) {
if (vas[i].ts?.va !== va) {
isSame = false;
break;
}
}
if (isSame) {
style.va = va;
}
}
return style;
}
/**
* When clicking on a Bold menu item, you should un-bold if there is bold in the selections,
* or bold if there is no bold text. This method is used to get the reverse style value calculated
* from textRuns in the selection
*/
function getReverseFormatValueInSelection(
textStyle: ITextStyle,
preCommandId: string
): BooleanNumber | ITextDecoration | BaselineOffset {
const key: keyof IStyleBase = COMMAND_ID_TO_FORMAT_KEY_MAP[preCommandId];
const reverseValue = getReverseFormatValue(textStyle, key, preCommandId)!;
return reverseValue;
}

View File

@@ -0,0 +1,100 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, PresetListType } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import { BuildTextUtils, CommandType, createParagraphId, getRichTextEditPath, ICommandService, IUniverInstanceService, JSONX, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
interface IInsertBulletBelowCommandParams {
listType: PresetListType;
}
export const InsertBulletBelowCommand: ICommand<IInsertBulletBelowCommandParams> = {
type: CommandType.COMMAND,
id: 'doc.command.insert-bullet-below',
handler: (accessor, params) => {
if (!params) {
return false;
}
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) {
return false;
}
const listType: string = params.listType;
const textRanges = docSelectionManagerService.getTextRanges();
if (!(textRanges?.length === 1 && textRanges[0].startOffset === textRanges[0].endOffset)) {
return false;
}
const segment = docDataModel.getSelfOrHeaderFooterModel(textRanges[0].segmentId);
const paragraphs = segment?.getBody()?.paragraphs ?? [];
const dataStream = segment?.getBody()?.dataStream ?? '';
const currentParagraph = BuildTextUtils.range.getParagraphsInRange(textRanges[0], paragraphs, dataStream)[0];
if (!currentParagraph) {
return false;
}
const insertBody: IDocumentBody = {
dataStream: '\r',
paragraphs: [{
startIndex: 0,
paragraphId: createParagraphId(new Set(paragraphs.map((paragraph) => paragraph.paragraphId))),
bullet: {
listType,
listId: listType === currentParagraph.bullet?.listType ? currentParagraph.bullet.listId : '',
nestingLevel: currentParagraph.bullet ? currentParagraph.bullet.nestingLevel : 0,
},
}],
};
const textX = BuildTextUtils.selection.replace({
selection: {
collapsed: true,
startOffset: currentParagraph.startIndex + 1,
endOffset: currentParagraph.startIndex + 1,
},
body: insertBody,
doc: docDataModel,
});
if (!textX) {
return false;
}
const jsonX = JSONX.getInstance();
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId: docDataModel.getUnitId(),
actions: jsonX.editOp(textX.serialize(), getRichTextEditPath(docDataModel, textRanges[0].segmentId)),
textRanges: [{
startOffset: currentParagraph.startIndex + 1,
endOffset: currentParagraph.startIndex + 1,
collapsed: true,
segmentId: textRanges[0].segmentId,
}],
},
};
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return result;
},
};

View File

@@ -0,0 +1,59 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import type { IReplaceSelectionCommandParams } from './replace-content.command';
import { CommandType, CustomRangeType, generateRandomId, ICommandService } from '@univerjs/core';
import { DocHistoryAction } from '@univerjs/docs';
import { ReplaceSelectionCommand } from './replace-content.command';
export interface IInsertCustomRangeCommandParams {
unitId: string;
rangeId?: string;
textRanges?: ITextRangeWithStyle[];
properties?: Record<string, any>;
text: string;
wholeEntity?: boolean;
}
export const InsertCustomRangeCommand: ICommand<IInsertCustomRangeCommandParams> = {
id: 'doc.command.insert-custom-range',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) return false;
const commandService = accessor.get(ICommandService);
const { unitId, rangeId = generateRandomId(), textRanges, properties, text, wholeEntity } = params;
const replaceSelectionParams: IReplaceSelectionCommandParams = {
unitId,
historyAction: DocHistoryAction.InsertCustomRange,
textRanges,
body: {
dataStream: text,
customRanges: [{
startIndex: 0,
endIndex: text.length - 1,
rangeId,
rangeType: CustomRangeType.CUSTOM,
properties,
wholeEntity,
}],
},
};
return commandService.syncExecuteCommand(ReplaceSelectionCommand.id, replaceSelectionParams);
},
};

View File

@@ -0,0 +1,87 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand } from '@univerjs/core';
import type { IInsertTextCommandParams } from '@univerjs/docs';
import { CommandType, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, InsertTextCommand } from '@univerjs/docs';
import { getCustomDecorationAtPosition, getCustomRangeAtPosition, getTextRunAtPosition } from '../../basics/paragraph';
import { DocMenuStyleService } from '../../services/doc-menu-style.service';
export const InsertSpecialCharacterCommand: ICommand<{
value: string;
}> = {
id: 'doc.command.insert-special-character',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params?.value) {
return false;
}
const commandService = accessor.get(ICommandService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const activeRange = docSelectionManagerService.getActiveTextRange();
if (!docDataModel || !activeRange) {
return false;
}
const { segmentId } = activeRange;
const originBody = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (!originBody) {
return false;
}
const { value } = params;
const curTextRun = getTextRunAtPosition(
originBody,
activeRange.endOffset,
docMenuStyleService.getDefaultStyle(),
docMenuStyleService.getStyleCache()
);
const curCustomRange = getCustomRangeAtPosition(originBody.customRanges ?? [], activeRange.endOffset);
const curCustomDecorations = getCustomDecorationAtPosition(originBody.customDecorations ?? [], activeRange.endOffset);
return commandService.executeCommand<IInsertTextCommandParams>(InsertTextCommand.id, {
unitId: docDataModel.getUnitId(),
body: {
dataStream: value,
textRuns: [{
...curTextRun,
st: 0,
ed: value.length,
}],
customRanges: curCustomRange
? [{
...curCustomRange,
startIndex: 0,
endIndex: value.length - 1,
}]
: [],
customDecorations: curCustomDecorations.map((customDecoration) => ({
...customDecoration,
startIndex: 0,
endIndex: value.length - 1,
})),
},
range: activeRange,
segmentId,
});
},
};

View File

@@ -0,0 +1,614 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IMutationInfo, IParagraph, IParagraphRange, ISectionBreak } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
BuildTextUtils,
CommandType,
createParagraphId,
generateRandomId,
getRichTextEditPath,
ICommandService,
IUniverInstanceService,
JSONX,
MemoryCursor,
PRESET_LIST_TYPE,
PresetListType,
sortRulesFactory,
TextX,
TextXActionType,
UniverInstanceType,
} from '@univerjs/core';
import { DocContentInsertService, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCurrentParagraph } from './util';
interface IListOperationCommandParams {
listType: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const ListOperationCommand: ICommand<IListOperationCommandParams> = {
id: 'doc.command.list-operation',
type: CommandType.COMMAND,
handler: (accessor, params: IListOperationCommandParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const listType: string = params.listType;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const docRanges = params.docRange ?? docSelectionManagerService.getDocRanges() ?? [];
if (docDataModel == null || docRanges.length === 0) {
return false;
}
const segmentId = docRanges[0].segmentId;
const segment = docDataModel.getSelfOrHeaderFooterModel(segmentId);
const paragraphs = segment?.getBody()?.paragraphs ?? [];
const dataStream = segment?.getBody()?.dataStream ?? '';
if (!paragraphs.length) {
return false;
}
const currentParagraphs = BuildTextUtils.range.getParagraphsInRanges(docRanges, paragraphs, dataStream);
const unitId = docDataModel.getUnitId();
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: docRanges,
isEditing: false,
},
};
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
const textX = BuildTextUtils.paragraph.bullet.switch({
paragraphs: currentParagraphs,
listType,
document: docDataModel,
segmentId,
});
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
interface IChangeListTypeCommandParams {
listType: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const ChangeListTypeCommand: ICommand<IChangeListTypeCommandParams> = {
id: 'doc.command.change-list-type',
type: CommandType.COMMAND,
handler: (accessor, params: IChangeListTypeCommandParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const { listType } = params;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const activeRanges = params.docRange ?? docSelectionManagerService.getDocRanges();
if (docDataModel == null || activeRanges == null || !activeRanges.length) {
return false;
}
const { segmentId } = activeRanges[0];
const segment = docDataModel.getSelfOrHeaderFooterModel(segmentId);
const paragraphs = segment?.getBody()?.paragraphs ?? [];
const dataStream = segment?.getBody()?.dataStream ?? '';
if (!paragraphs.length) {
return false;
}
const currentParagraphs = BuildTextUtils.range.getParagraphsInRanges(activeRanges, paragraphs, dataStream);
const unitId = docDataModel.getUnitId();
const textX = BuildTextUtils.paragraph.bullet.set({
paragraphs: currentParagraphs,
listType,
segmentId,
document: docDataModel,
});
if (!textX) {
return false;
}
const jsonX = JSONX.getInstance();
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: activeRanges,
isEditing: false,
},
};
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export enum ChangeListNestingLevelType {
increase = 1,
decrease = -1,
}
interface IChangeListNestingLevelCommandParams {
type: ChangeListNestingLevelType;
}
export const ChangeListNestingLevelCommand: ICommand<IChangeListNestingLevelCommandParams> = {
id: 'doc.command.change-list-nesting-level',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) {
return false;
}
const { type } = params;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const activeRange = docSelectionManagerService.getActiveTextRange();
if (docDataModel == null || activeRange == null) {
return false;
}
const { segmentId } = activeRange;
const selections = docSelectionManagerService.getDocRanges() ?? [];
const segment = docDataModel.getSelfOrHeaderFooterModel(segmentId);
const paragraphs = segment?.getBody()?.paragraphs ?? [];
const dataStream = segment?.getBody()?.dataStream ?? '';
if (!paragraphs.length) {
return false;
}
const currentParagraphs = BuildTextUtils.range.getParagraphsInRange(activeRange, paragraphs, dataStream);
const unitId = docDataModel.getUnitId();
const jsonX = JSONX.getInstance();
const textX = BuildTextUtils.paragraph.bullet.changeNestLevel({
paragraphs: currentParagraphs,
type,
segmentId,
document: docDataModel,
});
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: selections,
isEditing: false,
},
};
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
interface IBulletListCommandParams {
value?: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const BulletListCommand: ICommand<IBulletListCommandParams> = {
id: 'doc.command.bullet-list',
type: CommandType.COMMAND,
handler: (accessor, params) => {
const commandService = accessor.get(ICommandService);
if (params?.value) {
return commandService.syncExecuteCommand(ChangeListTypeCommand.id, {
listType: params.value,
docRange: params.docRange,
});
}
return commandService.syncExecuteCommand(ListOperationCommand.id, {
listType: PresetListType.BULLET_LIST,
docRange: params?.docRange,
});
},
};
export const CheckListCommand: ICommand<IBulletListCommandParams> = {
id: 'doc.command.check-list',
type: CommandType.COMMAND,
handler: (accessor, params) => {
const commandService = accessor.get(ICommandService);
if (params?.value) {
return commandService.syncExecuteCommand(ChangeListTypeCommand.id, {
listType: params.value,
docRange: params.docRange,
});
}
return commandService.syncExecuteCommand(ListOperationCommand.id, {
listType: PresetListType.CHECK_LIST,
docRange: params?.docRange,
});
},
};
export interface IToggleCheckListCommandParams {
index: number;
segmentId?: string;
textRanges?: ITextRangeWithStyle[];
}
export const ToggleCheckListCommand: ICommand<IToggleCheckListCommandParams> = {
id: 'doc.command.toggle-check-list',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) {
return false;
}
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const { index, segmentId, textRanges } = params;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const paragraphs = docDataModel.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.paragraphs;
if (paragraphs == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: textRanges ?? [],
segmentId,
isEditing: false,
},
};
const textX = BuildTextUtils.paragraph.bullet.toggleChecklist({
document: docDataModel,
paragraphIndex: index,
segmentId,
});
if (!textX) {
return false;
}
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
interface IOrderListCommandParams {
value?: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const OrderListCommand: ICommand<IOrderListCommandParams> = {
id: 'doc.command.order-list',
type: CommandType.COMMAND,
handler: (accessor, params) => {
const commandService = accessor.get(ICommandService);
if (params?.value) {
return commandService.syncExecuteCommand(ChangeListTypeCommand.id, {
listType: params.value,
docRange: params.docRange,
});
}
return commandService.syncExecuteCommand(ListOperationCommand.id, {
listType: PresetListType.ORDER_LIST,
docRange: params?.docRange,
});
},
};
interface IQuickListCommandParams {
listType: PresetListType;
paragraph: IParagraphRange;
}
export const QuickListCommand: ICommand<IQuickListCommandParams> = {
id: 'doc.command.quick-list',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler(accessor, params) {
if (!params) {
return false;
}
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const activeRange = docSelectionManagerService.getActiveTextRange();
if (docDataModel == null || activeRange == null) {
return false;
}
const { segmentId, startOffset } = activeRange;
const { listType, paragraph } = params;
const { paragraphStart, paragraphEnd } = paragraph;
const textX = new TextX();
const jsonX = JSONX.getInstance();
const { paragraphStyle = {} } = paragraph;
const paragraphProperties = PRESET_LIST_TYPE[listType].nestingLevel[0].paragraphProperties || {};
const bulletParagraphTextStyle = paragraphProperties.textStyle;
const ID_LENGTH = 6;
let listId = generateRandomId(ID_LENGTH);
const paragraphs = docDataModel.getBody()?.paragraphs ?? [];
const curIndex = paragraphs.findIndex((i) => i.startIndex === paragraph.startIndex);
const prevParagraph = paragraphs[curIndex - 1];
const nextParagraph = paragraphs[curIndex + 1];
if (prevParagraph && prevParagraph.bullet && prevParagraph.bullet.listType.indexOf(listType) === 0) {
listId = prevParagraph.bullet.listId;
} else if (nextParagraph && nextParagraph.bullet && nextParagraph.bullet.listType.indexOf(listType) === 0) {
listId = nextParagraph.bullet.listId;
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId: docDataModel.getUnitId(),
actions: [],
textRanges: [{
startOffset: paragraphStart,
endOffset: paragraphStart,
collapsed: true,
}],
isEditing: false,
},
};
textX.push({
t: TextXActionType.RETAIN,
len: paragraphStart,
});
textX.push({
t: TextXActionType.DELETE,
len: startOffset - paragraphStart,
});
if (paragraphEnd > startOffset) {
textX.push({
t: TextXActionType.RETAIN,
len: paragraphEnd - startOffset,
});
}
textX.push({
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
paragraphs: [
{
startIndex: 0,
paragraphId: paragraph.paragraphId,
paragraphStyle: {
...paragraphStyle,
textStyle: {
...paragraphStyle.textStyle,
...bulletParagraphTextStyle,
},
indentFirstLine: undefined,
},
bullet: {
...(paragraph.bullet ?? {
nestingLevel: 0,
}),
listType,
listId,
},
},
],
},
});
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
function insertList(accessor: IAccessor, listType: PresetListType, trigger: string) {
const commandService = accessor.get(ICommandService);
const docDataModel = accessor.get(IUniverInstanceService).getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) {
return false;
}
let contentInsertRange: ReturnType<DocContentInsertService['consumeInsertRange']> = null;
try {
contentInsertRange = accessor.get(DocContentInsertService).consumeInsertRange(docDataModel.getUnitId());
} catch {
contentInsertRange = null;
}
const paragraph = contentInsertRange ? null : getCurrentParagraph(accessor);
if (!contentInsertRange && !paragraph) {
return false;
}
const sourceParagraph = paragraph || undefined;
const insertOffset = contentInsertRange?.startOffset ?? sourceParagraph!.startIndex + 1;
const sourceBullet = sourceParagraph?.bullet;
const textX = BuildTextUtils.selection.replace({
doc: docDataModel,
selection: {
startOffset: insertOffset,
endOffset: contentInsertRange?.endOffset ?? insertOffset,
collapsed: true,
},
body: {
dataStream: '\r',
paragraphs: [
{
startIndex: 0,
paragraphId: createParagraphId(new Set()),
paragraphStyle: {
...(sourceParagraph?.paragraphStyle ?? {}),
},
bullet: {
listType,
listId: sourceBullet?.listType === listType ? sourceBullet.listId : generateRandomId(6),
nestingLevel: sourceBullet?.listType === listType ? sourceBullet.nestingLevel : 0,
},
},
],
},
});
if (!textX) {
return false;
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId: docDataModel.getUnitId(),
actions: [],
textRanges: [{
startOffset: insertOffset,
endOffset: insertOffset,
collapsed: true,
}],
isEditing: false,
trigger,
},
};
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(docDataModel);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
}
export const InsertBulletListBellowCommand: ICommand<IQuickListCommandParams> = {
id: 'doc.command.insert-bullet-list-bellow',
type: CommandType.COMMAND,
handler: (accessor) => {
return insertList(accessor, PresetListType.BULLET_LIST, InsertBulletListBellowCommand.id);
},
};
export const InsertOrderListBellowCommand: ICommand<IQuickListCommandParams> = {
id: 'doc.command.insert-order-list-bellow',
type: CommandType.COMMAND,
handler: (accessor) => {
return insertList(accessor, PresetListType.ORDER_LIST, InsertOrderListBellowCommand.id);
},
};
export const InsertCheckListBellowCommand: ICommand<IQuickListCommandParams> = {
id: 'doc.command.insert-check-list-bellow',
type: CommandType.COMMAND,
handler: (accessor) => {
return insertList(accessor, PresetListType.CHECK_LIST, InsertCheckListBellowCommand.id);
},
};
export function getParagraphsRelative(ranges: ITextRangeWithStyle[], paragraphs: IParagraph[], dataStream: string) {
const selectionParagraphs: IParagraph[] = BuildTextUtils.range.getParagraphsInRanges(ranges, paragraphs, dataStream);
const startIndex = paragraphs.indexOf(selectionParagraphs[0]);
const endIndex = paragraphs.indexOf(selectionParagraphs[selectionParagraphs.length - 1]);
if (selectionParagraphs[0].bullet) {
for (let i = startIndex - 1; i >= 0; i--) {
const prevParagraph = paragraphs[i];
if (prevParagraph.bullet && prevParagraph.bullet.listId === selectionParagraphs[0].bullet.listId) {
selectionParagraphs.unshift(prevParagraph);
}
}
}
const lastParagraph = selectionParagraphs[selectionParagraphs.length - 1];
if (lastParagraph.bullet) {
for (let i = endIndex + 1; i < paragraphs.length; i++) {
const nextParagraph = paragraphs[i];
if (nextParagraph.bullet && nextParagraph.bullet.listId === lastParagraph.bullet.listId) {
selectionParagraphs.push(nextParagraph);
}
}
}
return selectionParagraphs;
}
export function findNearestSectionBreak(currentIndex: number, sectionBreaks: ISectionBreak[]) {
const sortedSectionBreaks = sectionBreaks.sort(sortRulesFactory('startIndex'));
for (let i = 0; i < sortedSectionBreaks.length; i++) {
const sectionBreak = sectionBreaks[i];
if (sectionBreak.startIndex >= currentIndex) {
return sectionBreak;
}
}
}

View File

@@ -0,0 +1,198 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IMutationInfo, IParagraphStyle } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import {
BuildTextUtils,
CommandType,
getRichTextEditPath,
HorizontalAlign,
ICommandService,
IUniverInstanceService,
JSONX,
MemoryCursor,
TextX,
TextXActionType,
UniverInstanceType,
UpdateDocsAttributeType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
interface IAlignOperationCommandParams {
alignType: HorizontalAlign;
}
export const AlignOperationCommand: ICommand<IAlignOperationCommandParams> = {
id: 'doc.command.align-action',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: (accessor, params: IAlignOperationCommandParams) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const { alignType } = params;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const allRanges = docSelectionManagerService.getDocRanges();
if (allRanges.length === 0) {
return false;
}
const segmentId = allRanges[0].segmentId;
const segment = docDataModel.getSelfOrHeaderFooterModel(segmentId);
const paragraphs = segment?.getBody()?.paragraphs ?? [];
const dataStream = segment?.getBody()?.dataStream ?? '';
if (paragraphs == null) {
return false;
}
const currentParagraphs = BuildTextUtils.range.getParagraphsInRanges(allRanges, paragraphs, dataStream);
const unitId = docDataModel.getUnitId();
const isAlreadyAligned = currentParagraphs.every((paragraph) => paragraph.paragraphStyle?.horizontalAlign === alignType);
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: allRanges,
},
};
const memoryCursor = new MemoryCursor();
memoryCursor.reset();
const textX = new TextX();
const jsonX = JSONX.getInstance();
for (const paragraph of currentParagraphs) {
const { startIndex } = paragraph;
textX.push({
t: TextXActionType.RETAIN,
len: startIndex - memoryCursor.cursor,
});
// See: univer/packages/engine-render/src/components/docs/block/paragraph/layout-ruler.ts line:802 comments.
const paragraphStyle: IParagraphStyle = {
...paragraph.paragraphStyle,
horizontalAlign: isAlreadyAligned ? HorizontalAlign.UNSPECIFIED : alignType,
};
textX.push({
t: TextXActionType.RETAIN,
len: 1,
body: {
dataStream: '',
paragraphs: [
{
...paragraph,
paragraphStyle,
startIndex: 0,
},
],
},
coverType: UpdateDocsAttributeType.REPLACE,
});
memoryCursor.moveCursorTo(startIndex + 1);
}
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
interface IAlignLeftCommandParams { }
export const AlignLeftCommand: ICommand<IAlignLeftCommandParams> = {
id: 'doc.command.align-left',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(AlignOperationCommand.id, {
alignType: HorizontalAlign.LEFT,
});
},
};
interface IAlignCenterCommandParams { }
export const AlignCenterCommand: ICommand<IAlignCenterCommandParams> = {
id: 'doc.command.align-center',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(AlignOperationCommand.id, {
alignType: HorizontalAlign.CENTER,
});
},
};
interface IAlignRightCommandParams { }
export const AlignRightCommand: ICommand<IAlignRightCommandParams> = {
id: 'doc.command.align-right',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(AlignOperationCommand.id, {
alignType: HorizontalAlign.RIGHT,
});
},
};
interface IAlignJustifyCommandParams { }
export const AlignJustifyCommand: ICommand<IAlignJustifyCommandParams> = {
id: 'doc.command.align-justify',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(AlignOperationCommand.id, {
alignType: HorizontalAlign.JUSTIFIED,
});
},
};

View File

@@ -0,0 +1,426 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IDocumentData, IMutationInfo, ITextRange, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
BuildTextUtils,
CommandType,
getRichTextEditPath,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
JSONX,
TextX,
TextXActionType,
ThemeService,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCommandSkeleton } from '../util';
import { getReplaceDocRangesActions } from './clipboard.inner.command';
export interface IReplaceSnapshotCommandParams {
unitId: string;
snapshot: IDocumentData;
textRanges: ITextRangeWithStyle[];
segmentId?: string;
options: { [key: string]: boolean };
}
export const ReplaceSnapshotCommand: ICommand<IReplaceSnapshotCommandParams> = {
id: 'doc.command-replace-snapshot',
type: CommandType.COMMAND,
handler: (accessor, params: IReplaceSnapshotCommandParams) => {
const { unitId, snapshot, textRanges, segmentId = '', options } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const prevSnapshot = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getSnapshot();
if (docDataModel == null || prevSnapshot == null) {
return false;
}
const { body, tableSource, footers, headers, lists, drawings, drawingsOrder, documentStyle } = Tools.deepClone(snapshot);
const {
body: prevBody,
tableSource: prevTableSource,
footers: prevFooters,
headers: prevHeaders,
lists: prevLists,
drawings: prevDrawings,
drawingsOrder: prevDrawingsOrder,
documentStyle: prevDocumentStyle,
} = prevSnapshot;
if (body == null || prevBody == null) {
return false;
}
// Handle body is equal to previous prevBody, only set the text ranges.
if (Tools.diffValue(body, prevBody) && textRanges) {
docSelectionManagerService.replaceDocRanges(textRanges, {
unitId,
subUnitId: unitId,
}, false);
return true;
}
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
if (options) {
doMutation.params.options = options;
}
doMutation.params.actions = buildReplaceSnapshotActions({
body: prevBody,
documentStyle: prevDocumentStyle,
tableSource: prevTableSource,
footers: prevFooters,
headers: prevHeaders,
lists: prevLists,
drawings: prevDrawings,
drawingsOrder: prevDrawingsOrder,
} as IDocumentData, {
body,
documentStyle,
tableSource,
footers,
headers,
lists,
drawings,
drawingsOrder,
} as IDocumentData);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export function buildReplaceSnapshotActions(previousSnapshot: IDocumentData, snapshot: IDocumentData): JSONXActions | null {
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions[] = [];
const bodyAction = buildReplaceSnapshotBodyAction(previousSnapshot.body, snapshot.body);
if (bodyAction) {
rawActions.push(bodyAction);
}
collectTopLevelReplaceAction(jsonX, rawActions, ['documentStyle'], previousSnapshot.documentStyle, snapshot.documentStyle);
collectTopLevelReplaceAction(jsonX, rawActions, ['tableSource'], previousSnapshot.tableSource, snapshot.tableSource);
collectTopLevelReplaceAction(jsonX, rawActions, ['footers'], previousSnapshot.footers, snapshot.footers);
collectTopLevelReplaceAction(jsonX, rawActions, ['headers'], previousSnapshot.headers, snapshot.headers);
collectTopLevelReplaceAction(jsonX, rawActions, ['lists'], previousSnapshot.lists, snapshot.lists);
collectTopLevelReplaceAction(jsonX, rawActions, ['drawings'], previousSnapshot.drawings, snapshot.drawings);
collectTopLevelReplaceAction(jsonX, rawActions, ['drawingsOrder'], previousSnapshot.drawingsOrder, snapshot.drawingsOrder);
return rawActions.reduce((acc, cur) => JSONX.compose(acc, cur as JSONXActions), null as JSONXActions);
}
function buildReplaceSnapshotBodyAction(previousBody: IDocumentBody | undefined, body: IDocumentBody | undefined): JSONXActions | null {
if (!previousBody || !body || Tools.diffValue(previousBody, body)) {
return null;
}
const textX = new TextX();
if (previousBody.dataStream.length > 0) {
textX.delete(previousBody.dataStream.length);
}
if (body.dataStream.length > 0) {
textX.insert(body.dataStream.length, body);
}
return JSONX.getInstance().editOp(textX.serialize(), ['body']);
}
function collectTopLevelReplaceAction(
jsonX: JSONX,
rawActions: JSONXActions[],
path: string[],
previousValue: unknown,
value: unknown
): void {
if (Tools.diffValue(previousValue, value)) {
return;
}
const action = jsonX.replaceOp(path, previousValue, value);
if (action != null) {
rawActions.push(action);
}
}
interface ICoverContentCommandParams {
unitId: string;
body: IDocumentBody; // Do not contain `\r\n` at the end.
segmentId?: string;
textRanges?: ITextRangeWithStyle[];
}
// Cover all content with new body, and clear undo/redo stack.
export const CoverContentCommand: ICommand<ICoverContentCommandParams> = {
id: 'doc.command-cover-content',
type: CommandType.COMMAND,
handler: (accessor, params: ICoverContentCommandParams) => {
const { unitId, body, segmentId = '', textRanges } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const docDatModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const prevBody = docDatModel?.getSnapshot().body;
if (docDatModel == null || prevBody == null) {
return false;
}
const doMutation = getMutationParams(unitId, segmentId, docDatModel, prevBody, body);
// No need to set the cursor or selection.
doMutation.params.noNeedSetTextRange = true;
doMutation.params.noHistory = true;
doMutation.params.textRanges = textRanges;
commandService.syncExecuteCommand<IRichTextEditingMutationParams, IRichTextEditingMutationParams>(
doMutation.id,
doMutation.params
);
undoRedoService.clearUndoRedo(unitId);
return true;
},
};
function getMutationActions(segmentId: string, docDatModel: DocumentDataModel, prevBody: IDocumentBody, body: IDocumentBody) {
const textX = new TextX();
const jsonX = JSONX.getInstance();
const deleteLen = prevBody?.dataStream.length - 2;
if (deleteLen > 0) {
textX.push({
t: TextXActionType.DELETE,
len: deleteLen,
});
}
if (body.dataStream.length > 0) {
textX.push({
t: TextXActionType.INSERT,
body,
len: body.dataStream.length,
});
}
const path = getRichTextEditPath(docDatModel, segmentId);
return jsonX.editOp(textX.serialize(), path);
}
function getMutationParams(unitId: string, segmentId: string, docDatModel: DocumentDataModel, prevBody: IDocumentBody, body: IDocumentBody) {
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: [],
},
};
const actions = getMutationActions(segmentId, docDatModel, prevBody, body);
doMutation.params.actions = actions;
return doMutation;
}
export interface IReplaceSelectionCommandParams {
unitId: string;
historyAction?: string;
selection?: ITextRange;
body: IDocumentBody; // Do not contain `\r\n` at the end.
textRanges?: ITextRangeWithStyle[];
segmentId?: string;
}
export const ReplaceSelectionCommand: ICommand<IReplaceSelectionCommandParams> = {
id: 'doc.command.replace-selection',
type: CommandType.COMMAND,
handler(accessor, params) {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const { unitId, body: insertBody, historyAction, textRanges, segmentId } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
if (!docDataModel) {
return false;
}
const selection = params.selection ?? docSelectionManagerService.getActiveTextRange();
const targetSegmentId = segmentId ?? docSelectionManagerService.getActiveTextRange()?.segmentId ?? '';
const body = docDataModel.getSelfOrHeaderFooterModel(targetSegmentId)?.getBody();
if (!selection || !body) {
return false;
}
const selectionInfo = docSelectionManagerService.getSelectionInfo();
const selectedTextRanges = params.selection
? [params.selection]
: docSelectionManagerService.getTextRanges() ?? [];
const selectedRectRanges = params.selection
? []
: docSelectionManagerService.getRectRanges() ?? [];
const hasSelectedStructure = !selection.collapsed && (
Boolean(body.blockRanges?.length) ||
Boolean(body.columnGroups?.length) ||
Boolean(body.customBlocks?.length) ||
Boolean(body.tables?.length)
);
const hasComplexSelection = hasSelectedStructure || selectedRectRanges.length > 0 || selectedTextRanges.length > 1 || selectionInfo?.options?.wholeDocument === true;
const docSkeletonManagerService = hasComplexSelection ? getCommandSkeleton(accessor, unitId) : null;
const replacement = docSkeletonManagerService
? getReplaceDocRangesActions(
selectedTextRanges,
selectedRectRanges,
docDataModel,
docSkeletonManagerService.getViewModel(),
targetSegmentId,
insertBody,
selectionInfo?.options?.wholeDocument === true
)
: null;
const insertOffset = replacement?.insertOffset ?? selection.startOffset;
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
historyAction,
actions: [],
textRanges: textRanges ?? [{
startOffset: insertOffset + insertBody.dataStream.length,
endOffset: insertOffset + insertBody.dataStream.length,
collapsed: true,
style: docSelectionManagerService.getActiveTextRange()?.style,
segmentId: targetSegmentId,
}],
segmentId: targetSegmentId,
debounce: true,
trigger: ReplaceSelectionCommand.id,
},
};
if (replacement) {
doMutation.params.actions = replacement.actions;
} else {
const textX = new TextX();
textX.push(...BuildTextUtils.selection.delete([selection], body, 0, insertBody));
doMutation.params.actions = JSONX.getInstance().editOp(
textX.serialize(),
getRichTextEditPath(docDataModel, targetSegmentId)
);
}
return commandService.syncExecuteCommand(doMutation.id, doMutation.params);
},
};
interface IReplaceTextRunsCommandParams {
unitId: string;
body: IDocumentBody; // Do not contain `\r\n` at the end.
textRanges: ITextRangeWithStyle[];
segmentId?: string;
options: { [key: string]: boolean };
}
export const ReplaceTextRunsCommand: ICommand<IReplaceTextRunsCommandParams> = {
id: 'doc.command.replace-text-runs',
type: CommandType.COMMAND,
handler: (accessor, params: IReplaceTextRunsCommandParams) => {
const { unitId, body, textRanges, segmentId = '', options } = params;
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const themeService = accessor.get(ThemeService);
// const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const docDataModel = univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const prevBody = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || prevBody == null) {
return false;
}
const textX = BuildTextUtils.selection.replaceTextRuns({
doc: docDataModel,
body,
selection: {
startOffset: 0,
endOffset: prevBody.dataStream.length - 2,
collapsed: false,
},
themeService,
});
if (!textX) {
return false;
}
const doMutation = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
noHistory: true,
} as IRichTextEditingMutationParams,
};
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
doMutation.params.textRanges = textRanges;
if (options) {
doMutation.params.options = options;
}
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand } from '@univerjs/core';
import type { ISetDocZoomRatioOperationParams } from '../operations/set-doc-zoom-ratio.operation';
import { CommandType, ICommandService, IUndoRedoService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import {
SetDocZoomRatioOperation,
SetDocZoomRatioUndoMutationFactory,
} from '../operations/set-doc-zoom-ratio.operation';
export interface ISetDocZoomRatioCommandParams {
zoomRatio?: number;
documentId?: string;
}
export const SetDocZoomRatioCommand: ICommand = {
type: CommandType.COMMAND,
id: 'doc.command.set-zoom-ratio',
handler: async (accessor: IAccessor, params?: ISetDocZoomRatioCommandParams) => {
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const { zoomRatio = 1, documentId } = params || {};
const docDatModel = documentId
? univerInstanceService.getUnit<DocumentDataModel>(documentId, UniverInstanceType.UNIVER_DOC)
: univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDatModel) return false;
const unitId = docDatModel.getUnitId();
const setZoomRatioMutationParams: ISetDocZoomRatioOperationParams = {
zoomRatio,
unitId,
};
const undoMutationParams = SetDocZoomRatioUndoMutationFactory(accessor, setZoomRatioMutationParams);
const result = commandService.syncExecuteCommand<ISetDocZoomRatioOperationParams>(SetDocZoomRatioOperation.id, setZoomRatioMutationParams);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [{ id: SetDocZoomRatioOperation.id, params: undoMutationParams }],
redoMutations: [{ id: SetDocZoomRatioOperation.id, params: setZoomRatioMutationParams }],
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,337 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IMutationInfo, ITextRangeParam } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
BuildTextUtils,
CommandType,
createParagraphId,
generateRandomId,
getRichTextEditPath,
ICommandService,
IUniverInstanceService,
JSONX,
NamedStyleType,
TextX,
TextXActionType,
UniverInstanceType,
} from '@univerjs/core';
import { consumeContentInsertRange, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
export interface ISetParagraphNamedStyleCommandParams {
value: NamedStyleType;
textRanges?: ITextRangeParam[];
}
export const SetParagraphNamedStyleCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.set-paragraph-named-style',
type: CommandType.COMMAND,
handler(accessor, params) {
if (!params) {
return false;
}
const univerInstanceService = accessor.get(IUniverInstanceService);
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!doc) {
return false;
}
const unitId = doc.getUnitId();
const selectionService = accessor.get(DocSelectionManagerService);
const contentInsertRange = params.textRanges ? null : consumeContentInsertRange(accessor, unitId);
if (contentInsertRange) {
return insertNamedStyleParagraph(
accessor,
doc,
params.value,
contentInsertRange.startOffset,
contentInsertRange.endOffset,
contentInsertRange.segmentId
);
}
const selections = params.textRanges ?? selectionService.getTextRanges({ unitId, subUnitId: unitId });
if (!selections?.length) {
return false;
}
const segmentId = selections[0].segmentId;
const documentDataModel = doc.getSelfOrHeaderFooterModel(segmentId);
if (!documentDataModel) {
return false;
}
const textX = BuildTextUtils.paragraph.style.set({
document: documentDataModel,
textRanges: selections,
style: {
namedStyleType: params.value,
headingId: !params.value || params.value === NamedStyleType.NORMAL_TEXT ? undefined : generateRandomId(6),
spaceAbove: undefined,
spaceBelow: undefined,
lineSpacing: undefined,
spacingRule: undefined,
},
paragraphTextRun: {},
});
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
actions: [],
textRanges: selections as ITextRangeWithStyle[],
unitId,
segmentId,
},
};
const jsonX = JSONX.getInstance();
const path = getRichTextEditPath(doc, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const commandService = accessor.get(ICommandService);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
function insertNamedStyleParagraph(
accessor: IAccessor,
doc: DocumentDataModel,
namedStyleType: NamedStyleType,
startOffset: number,
endOffset: number,
segmentId = ''
): boolean {
const textX = BuildTextUtils.selection.replace({
doc,
selection: {
startOffset,
endOffset,
collapsed: startOffset === endOffset,
segmentId,
},
body: {
dataStream: '\r',
paragraphs: [{
startIndex: 0,
paragraphId: createParagraphId(new Set()),
paragraphStyle: {
namedStyleType,
headingId: !namedStyleType || namedStyleType === NamedStyleType.NORMAL_TEXT ? undefined : generateRandomId(6),
},
}],
},
});
if (!textX) {
return false;
}
const jsonX = JSONX.getInstance();
const commandService = accessor.get(ICommandService);
const success = Boolean(commandService.syncExecuteCommand<IRichTextEditingMutationParams>(RichTextEditingMutation.id, {
actions: jsonX.editOp(textX.serialize(), getRichTextEditPath(doc, segmentId)),
isEditing: false,
textRanges: [{
startOffset,
endOffset: startOffset,
collapsed: true,
}],
segmentId,
unitId: doc.getUnitId(),
}));
if (success) {
accessor.get(DocSelectionManagerService).replaceDocRanges([{
startOffset,
endOffset: startOffset,
}], undefined, false);
}
return success;
}
export const QuickHeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.quick-heading',
type: CommandType.COMMAND,
handler(accessor, params) {
if (!params) {
return false;
}
const { value } = params;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const activeRange = docSelectionManagerService.getActiveTextRange();
if (docDataModel == null || activeRange == null || !activeRange.collapsed) {
return false;
}
const { segmentId, startOffset } = activeRange;
const segment = docDataModel.getSelfOrHeaderFooterModel(segmentId);
const paragraphs = segment?.getBody()?.paragraphs ?? [];
const dataStream = segment?.getBody()?.dataStream ?? '';
const paragraph = BuildTextUtils.paragraph.util.getParagraphsInRange(activeRange, paragraphs, dataStream)[0];
if (!paragraph) {
return false;
}
const { paragraphStart } = paragraph;
// const selection =
const textX = new TextX();
const jsonX = JSONX.getInstance();
const { paragraphStyle = {} } = paragraph;
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId: docDataModel.getUnitId(),
actions: [],
textRanges: [{
startOffset: paragraphStart,
endOffset: paragraphStart,
collapsed: true,
}],
isEditing: false,
},
};
textX.push({
t: TextXActionType.RETAIN,
len: paragraphStart,
});
BuildTextUtils.paragraph.style.set({
textRanges: [activeRange],
document: docDataModel,
style: {
...paragraphStyle,
headingId: generateRandomId(6),
namedStyleType: value,
lineSpacing: undefined,
spacingRule: undefined,
},
cursor: startOffset,
deleteLen: startOffset - paragraphStart,
textX,
paragraphTextRun: {},
});
const path = getRichTextEditPath(docDataModel, segmentId);
doMutation.params.actions = jsonX.editOp(textX.serialize(), path);
const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export const QUICK_HEADING_MAP = {
'#': NamedStyleType.HEADING_1,
'##': NamedStyleType.HEADING_2,
'###': NamedStyleType.HEADING_3,
'####': NamedStyleType.HEADING_4,
'#####': NamedStyleType.HEADING_5,
};
export const H1HeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.h1-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.HEADING_1,
});
},
};
export const H2HeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.h2-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.HEADING_2,
});
},
};
export const H3HeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.h3-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.HEADING_3,
});
},
};
export const H4HeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.h4-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.HEADING_4,
});
},
};
export const H5HeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.h5-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.HEADING_5,
});
},
};
export const NormalTextHeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.normal-text-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.NORMAL_TEXT,
});
},
};
export const TitleHeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.title',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.TITLE,
});
},
};
export const SubtitleHeadingCommand: ICommand<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.subtitle-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.SUBTITLE,
});
},
};

View File

@@ -0,0 +1,178 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, ICommandInfo, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import {
CommandType,
DocumentFlavor,
ICommandService,
IUniverInstanceService,
JSONX,
ObjectRelativeFromV,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
export interface ISwitchDocModeCommandParams { }
export const SwitchDocModeCommand: ICommand<ISwitchDocModeCommandParams> = {
id: 'doc.command.switch-mode',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function, complexity
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
const renderManagerService = accessor.get(IRenderManagerService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const skeleton = renderManagerService.getRenderUnitById(unitId)
?.with(DocSkeletonManagerService)
.getSkeleton();
const docSelectionRenderService = renderManagerService.getRenderUnitById(unitId)?.with(DocSelectionRenderService);
if (skeleton == null || docSelectionRenderService == null) {
return false;
}
const segmentId = docSelectionRenderService?.getSegment();
const segmentPage = docSelectionRenderService?.getSegmentPage();
const oldDocumentFlavor = docDataModel.getSnapshot().documentStyle.documentFlavor;
const docRanges = docSelectionManagerService.getDocRanges();
const doMutation: ICommandInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges: (oldDocumentFlavor === DocumentFlavor.TRADITIONAL && !!segmentId) ? [] : docRanges,
},
};
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
let action;
if (oldDocumentFlavor === undefined) {
action = jsonX.insertOp(['documentStyle', 'documentFlavor'], DocumentFlavor.MODERN);
} else {
if (oldDocumentFlavor === DocumentFlavor.MODERN) {
action = jsonX.replaceOp(['documentStyle', 'documentFlavor'], oldDocumentFlavor, DocumentFlavor.TRADITIONAL);
} else {
action = jsonX.replaceOp(['documentStyle', 'documentFlavor'], oldDocumentFlavor, DocumentFlavor.MODERN);
}
}
if (action) {
rawActions.push(action);
} else {
return false;
}
// Change all drawings' position to relative to paragraph if necessary.
// And only need to change drawing in body.
if (oldDocumentFlavor !== DocumentFlavor.MODERN) {
const snapshot = docDataModel.getSnapshot();
const { drawings = {}, body } = snapshot;
const customBlocks = body?.customBlocks ?? [];
for (const drawingId in drawings) {
const drawing = drawings[drawingId];
// if (drawing.layoutType === PositionedObjectLayoutType.INLINE) {
// continue;
// }
const customBlock = customBlocks.find((block) => block.blockId === drawingId);
// If drawing is not in body, skip it.
if (customBlock == null) {
continue;
}
const drawingPositionV = drawing.docTransform.positionV;
const { relativeFrom: prevRelativeFrom, posOffset: prevPosOffset } = drawingPositionV;
if (prevRelativeFrom === ObjectRelativeFromV.PARAGRAPH) {
continue;
}
const { startIndex } = customBlock;
const glyph = skeleton.findNodeByCharIndex(startIndex, segmentId, segmentPage);
const line = glyph?.parent?.parent;
const column = line?.parent;
const paragraphStartLine = column?.lines.find((l) => l.paragraphIndex === line?.paragraphIndex && l.paragraphStart);
const page = column?.parent?.parent;
if (glyph == null || line == null || paragraphStartLine == null || column == null || page == null) {
continue;
}
let delta = 0;
if (prevRelativeFrom === ObjectRelativeFromV.LINE) {
delta -= line.top;
} else if (prevRelativeFrom === ObjectRelativeFromV.PAGE) {
delta += page.marginTop;
}
delta += paragraphStartLine.top;
const newPositionV = {
...drawingPositionV,
relativeFrom: ObjectRelativeFromV.PARAGRAPH,
posOffset: (prevPosOffset ?? 0) - delta,
};
const updateDrawingAction = jsonX.replaceOp(['drawings', drawingId, 'docTransform', 'positionV'], drawingPositionV, newPositionV);
if (updateDrawingAction) {
rawActions.push(updateDrawingAction);
}
}
}
doMutation.params!.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,256 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, IParagraph, ISectionBreak, ITextRun, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { CommandType, containsInteriorInsertionOffset, createParagraphId, DataStreamTreeTokenType, getBlockRangeInterval, getParagraphContentStartOffset, getRichTextEditPath, getTableRangeInterval, ICommandService, IUniverInstanceService, JSONX, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core';
import { DocContentInsertService, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getTextRunAtPosition } from '../../../basics/paragraph';
import { DocMenuStyleService } from '../../../services/doc-menu-style.service';
import { getCommandSkeleton } from '../../util';
import { generateParagraphs } from '../break-line.command';
import { genEmptyTable, genTableSource } from './table';
export const CreateDocTableCommandId = 'doc.command.create-table';
export interface ICreateDocTableCommandParams {
rowCount: number;
colCount: number;
}
export interface IDocTableInsertBodyParams {
tableDataStream: string;
tableParagraphs: IParagraph[];
sectionBreaks: ISectionBreak[];
tableId: string;
textRun: ITextRun;
existingParagraphIds?: Set<string>;
}
export function buildDocTableInsertBody(params: IDocTableInsertBodyParams) {
const { tableDataStream, tableParagraphs, sectionBreaks, tableId, textRun } = params;
const existingParagraphIds = params.existingParagraphIds ?? new Set(tableParagraphs.map((paragraph) => paragraph.paragraphId));
const dataStream = `${tableDataStream}${DataStreamTreeTokenType.PARAGRAPH}`;
const tableEnd = tableDataStream.length;
return {
dataStream,
paragraphs: [
...tableParagraphs,
{
startIndex: tableEnd,
paragraphId: createParagraphId(existingParagraphIds),
},
],
sectionBreaks,
textRuns: [{
...textRun,
st: 0,
ed: tableEnd,
}],
tables: [{
startIndex: 0,
endIndex: tableEnd,
tableId,
}],
};
}
export function shouldCreateParagraphBeforeTable(body: { dataStream: string }, startOffset: number): boolean {
return startOffset <= 0 || body.dataStream[startOffset - 1] !== DataStreamTreeTokenType.PARAGRAPH;
}
export function normalizeTableInsertOffset(body: { dataStream: string }, startOffset: number): number {
return startOffset === 0 && body.dataStream[0] === DataStreamTreeTokenType.PARAGRAPH ? 1 : startOffset;
}
export function canInsertTableAtOffset(body: Pick<IDocumentBody, 'tables' | 'blockRanges' | 'customBlocks'>, offset: number): boolean {
return !(
isOffsetInTableRange(body.tables, offset) ||
isOffsetInIndexRange(body.blockRanges, offset) ||
isOffsetOnPointRange(body.customBlocks, offset)
);
}
function isOffsetInTableRange(ranges: Array<{ startIndex: number; endIndex: number }> | undefined, offset: number): boolean {
return Boolean(ranges?.some((range) => containsInteriorInsertionOffset(getTableRangeInterval(range), offset)));
}
function isOffsetInIndexRange(ranges: Array<{ startIndex: number; endIndex: number }> | undefined, offset: number): boolean {
return Boolean(ranges?.some((range) => containsInteriorInsertionOffset(getBlockRangeInterval(range), offset)));
}
function isOffsetOnPointRange(ranges: Array<{ startIndex: number }> | undefined, offset: number): boolean {
return Boolean(ranges?.some((range) => range.startIndex === offset));
}
/**
* The command to create a table at cursor point.
*/
export const CreateDocTableCommand: ICommand<ICreateDocTableCommandParams> = {
id: CreateDocTableCommandId,
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params: ICreateDocTableCommandParams) => {
const { rowCount, colCount } = params;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const docMenuStyleService = accessor.get(DocMenuStyleService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (docDataModel == null) {
return false;
}
let contentInsertRange: ReturnType<DocContentInsertService['consumeInsertRange']> = null;
try {
contentInsertRange = accessor.get(DocContentInsertService).consumeInsertRange(docDataModel.getUnitId());
} catch {
contentInsertRange = null;
}
const activeRange = docSelectionManagerService.getActiveTextRange();
if (activeRange == null && contentInsertRange == null) {
return false;
}
const segmentId = contentInsertRange?.segmentId ?? activeRange?.segmentId ?? '';
const segmentPage = activeRange?.segmentPage;
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (body == null) {
return false;
}
const unitId = docDataModel.getUnitId();
const docSkeletonManagerService = getCommandSkeleton(accessor, unitId);
const skeleton = docSkeletonManagerService?.getSkeleton();
if (skeleton == null) {
return false;
}
const startOffset = normalizeTableInsertOffset(body, contentInsertRange?.startOffset ?? activeRange!.startOffset);
if (!canInsertTableAtOffset(body, startOffset)) {
return false;
}
const paragraphs = body.paragraphs ?? [];
const prevParagraph = paragraphs.find((paragraph) => {
const paragraphStartOffset = getParagraphContentStartOffset(body, paragraph);
return paragraphStartOffset < startOffset && startOffset <= paragraph.startIndex;
});
const curGlyph = skeleton.findNodeByCharIndex(startOffset, segmentId, segmentPage);
// const line = curGlyph?.parent?.parent;
// const preGlyph = skeleton.findNodeByCharIndex(startOffset - 1, segmentId, segmentPage);
// const isInParagraph = preGlyph && preGlyph.content !== '\r';
if (curGlyph == null) {
return false;
}
const needCreateParagraph = shouldCreateParagraphBeforeTable(body, startOffset);
const textX = new TextX();
const jsonX = JSONX.getInstance();
const rawActions: JSONXActions = [];
// 4 is cal by `\r + TableStart + RowStart + CellStart`, 3 is cal by `TableStart + RowStart + CellStart`.
const cursor = startOffset + (needCreateParagraph ? 4 : 3);
const textRanges: ITextRangeWithStyle[] = [{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
}];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
// Step 1: Break lines if necessary.
if (startOffset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: startOffset,
});
}
// Insert a paragraph before table.
if (needCreateParagraph) {
textX.push({
t: TextXActionType.INSERT,
body: {
dataStream: DataStreamTreeTokenType.PARAGRAPH,
paragraphs: generateParagraphs(DataStreamTreeTokenType.PARAGRAPH, prevParagraph),
},
len: 1,
});
}
// Step 2: Insert table.
const defaultTextStyle = docMenuStyleService.getDefaultStyle();
const styleCache = docMenuStyleService.getStyleCache();
const curTextRun = getTextRunAtPosition(
body,
startOffset,
defaultTextStyle,
styleCache
);
const { dataStream: tableDataStream, paragraphs: tableParagraphs, sectionBreaks } = genEmptyTable(rowCount, colCount);
const page = curGlyph.parent?.parent?.parent?.parent?.parent;
if (page == null) {
return false;
}
const { pageWidth, marginLeft, marginRight } = page;
const tableSource = genTableSource(rowCount, colCount, pageWidth - marginLeft - marginRight);
const tableInsertBody = buildDocTableInsertBody({
tableDataStream,
tableParagraphs,
sectionBreaks,
tableId: tableSource.tableId,
textRun: curTextRun,
existingParagraphIds: new Set(body.paragraphs?.map((paragraph) => paragraph.paragraphId)),
});
textX.push({
t: TextXActionType.INSERT,
body: tableInsertBody,
len: tableInsertBody.dataStream.length,
});
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
// Step 3: Insert table source;
const insertTableSource = jsonX.insertOp(['tableSource', tableSource.tableId], tableSource);
rawActions.push(insertTableSource!);
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,342 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IMutationInfo, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import { CommandType, getRichTextEditPath, ICommandService, IUniverInstanceService, JSONX, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCommandSkeleton } from '../../util';
import { getDeleteColumnsActionParams, getDeleteRowsActionsParams, getDeleteTableActionParams, getRangeInfoFromRanges } from './table';
export interface IDocTableDeleteRowsCommandParams {}
export const DocTableDeleteRowsCommand: ICommand<IDocTableDeleteRowsCommandParams> = {
id: 'doc.table.delete-rows',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const activeRectRanges = docSelectionManagerService.getRectRanges();
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const rangeInfo = getRangeInfoFromRanges(activeTextRange, activeRectRanges);
if (rangeInfo == null) {
return false;
}
const { segmentId } = rangeInfo;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || body == null) {
return false;
}
const docSkeletonManagerService = getCommandSkeleton(accessor, docDataModel.getUnitId());
if (docSkeletonManagerService == null) {
return false;
}
const viewModel = docSkeletonManagerService.getViewModel();
const unitId = docDataModel?.getUnitId();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const actionParams = getDeleteRowsActionsParams(rangeInfo, viewModel);
if (actionParams == null) {
return false;
}
const { offset, rowIndexes, len, tableId, cursor, selectWholeTable } = actionParams;
if (selectWholeTable) {
// eslint-disable-next-line ts/no-use-before-define
return commandService.executeCommand(DocTableDeleteTableCommand.id);
}
const rawActions: JSONXActions = [];
const textRanges: ITextRangeWithStyle[] = [{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
}];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
if (offset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset,
});
}
textX.push({
t: TextXActionType.DELETE,
len,
});
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
// Step 3: delete table rows;
for (const index of rowIndexes.reverse()) {
const action = jsonX.removeOp(['tableSource', tableId, 'tableRows', index]);
rawActions.push(action!);
}
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export interface IDocTableDeleteColumnsCommandParams {}
export const DocTableDeleteColumnsCommand: ICommand<IDocTableDeleteColumnsCommandParams> = {
id: 'doc.table.delete-columns',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const activeRectRanges = docSelectionManagerService.getRectRanges();
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const rangeInfo = getRangeInfoFromRanges(activeTextRange, activeRectRanges);
if (rangeInfo == null) {
return false;
}
const { segmentId } = rangeInfo;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || body == null) {
return false;
}
const docSkeletonManagerService = getCommandSkeleton(accessor, docDataModel.getUnitId());
if (docSkeletonManagerService == null) {
return false;
}
const viewModel = docSkeletonManagerService.getViewModel();
const unitId = docDataModel?.getUnitId();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const actionParams = getDeleteColumnsActionParams(rangeInfo, viewModel);
if (actionParams == null) {
return false;
}
const { offsets, columnIndexes, tableId, cursor, rowCount, selectWholeTable } = actionParams;
if (selectWholeTable) {
// eslint-disable-next-line ts/no-use-before-define
return commandService.executeCommand(DocTableDeleteTableCommand.id);
}
const rawActions: JSONXActions = [];
const textRanges: ITextRangeWithStyle[] = [{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
}];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
for (const offset of offsets) {
const { retain, delete: deleteLen } = offset;
if (retain > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: retain,
});
}
textX.push({
t: TextXActionType.DELETE,
len: deleteLen,
});
}
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
// Step 3: delete table columns;
columnIndexes.reverse();
for (let i = 0; i < rowCount; i++) {
for (const index of columnIndexes) {
const action = jsonX.removeOp(['tableSource', tableId, 'tableRows', i, 'tableCells', index]);
rawActions.push(action!);
}
}
for (const index of columnIndexes) {
const action = jsonX.removeOp(['tableSource', tableId, 'tableColumns', index]);
rawActions.push(action!);
}
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export interface IDocTableDeleteTableCommandParams {
targetRange?: ITextRangeWithStyle;
}
export const DocTableDeleteTableCommand: ICommand<IDocTableDeleteTableCommandParams> = {
id: 'doc.table.delete-table',
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params) => {
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const activeRectRanges = docSelectionManagerService.getRectRanges();
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const rangeInfo = getRangeInfoFromRanges(
params?.targetRange ?? activeTextRange,
params?.targetRange ? [] : activeRectRanges
);
if (rangeInfo == null) {
return false;
}
const { segmentId } = rangeInfo;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || body == null) {
return false;
}
const docSkeletonManagerService = getCommandSkeleton(accessor, docDataModel.getUnitId());
if (docSkeletonManagerService == null) {
return false;
}
const viewModel = docSkeletonManagerService.getViewModel();
const unitId = docDataModel?.getUnitId();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const actionParams = getDeleteTableActionParams(rangeInfo, viewModel);
if (actionParams == null) {
return false;
}
const { offset, len, tableId, cursor } = actionParams;
const rawActions: JSONXActions = [];
const textRanges: ITextRangeWithStyle[] = [{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
}];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
if (offset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset,
});
}
textX.push({
t: TextXActionType.DELETE,
len,
});
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
// Step 3: delete table;
const action = jsonX.removeOp(['tableSource', tableId]);
rawActions.push(action!);
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,351 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IMutationInfo, JSONXActions } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
CommandType,
getRichTextEditPath,
ICommandService,
IUniverInstanceService,
JSONX,
TextX,
TextXActionType,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { getCommandSkeleton } from '../../util';
import {
getColumnWidths,
getEmptyTableCell,
getEmptyTableRow,
getInsertColumnActionsParams,
getInsertColumnBody,
getInsertRowActionsParams,
getInsertRowBody,
getRangeInfoFromRanges,
getTableColumn,
INSERT_COLUMN_POSITION,
INSERT_ROW_POSITION,
} from './table';
// Insert rows and columns are in this file.
export const DocTableInsertRowCommandId = 'doc.command.table-insert-row';
export const DocTableInsertColumnCommandId = 'doc.command.table-insert-column';
export const DocTableInsertRowAboveCommandId = 'doc.command.table-insert-row-above';
export const DocTableInsertRowBellowCommandId = 'doc.command.table-insert-row-bellow';
export const DocTableInsertColumnLeftCommandId = 'doc.command.table-insert-column-left';
export const DocTableInsertColumnRightCommandId = 'doc.command.table-insert-column-right';
export interface IDocTableInsertRowAboveCommandParams {}
export const DocTableInsertRowAboveCommand: ICommand<IDocTableInsertRowAboveCommandParams> = {
id: DocTableInsertRowAboveCommandId,
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(DocTableInsertRowCommandId, { position: INSERT_ROW_POSITION.ABOVE });
},
};
export interface IDocTableInsertRowBellowCommandParams {}
export const DocTableInsertRowBellowCommand: ICommand<IDocTableInsertRowBellowCommandParams> = {
id: DocTableInsertRowBellowCommandId,
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(DocTableInsertRowCommandId, { position: INSERT_ROW_POSITION.BELLOW });
},
};
export interface IDocTableInsertColumnLeftCommandParams {}
export const DocTableInsertColumnLeftCommand: ICommand<IDocTableInsertColumnLeftCommandParams> = {
id: DocTableInsertColumnLeftCommandId,
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(DocTableInsertColumnCommandId, { position: INSERT_COLUMN_POSITION.LEFT });
},
};
export interface IDocTableInsertColumnRightCommandParams {}
export const DocTableInsertColumnRightCommand: ICommand<IDocTableInsertColumnRightCommandParams> = {
id: DocTableInsertColumnRightCommandId,
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(DocTableInsertColumnCommandId, { position: INSERT_COLUMN_POSITION.RIGHT });
},
};
export interface IDocTableInsertRowCommandParams {
position: INSERT_ROW_POSITION;
}
/**
* The command to insert table row.
*/
export const DocTableInsertRowCommand: ICommand<IDocTableInsertRowCommandParams> = {
id: DocTableInsertRowCommandId,
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params: IDocTableInsertRowCommandParams) => {
const { position } = params;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const activeRectRanges = docSelectionManagerService.getRectRanges();
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const rangeInfo = getRangeInfoFromRanges(activeTextRange, activeRectRanges);
if (rangeInfo == null) {
return false;
}
const { segmentId } = rangeInfo;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || body == null) {
return false;
}
const docSkeletonManagerService = getCommandSkeleton(accessor, docDataModel.getUnitId());
if (docSkeletonManagerService == null) {
return false;
}
const viewModel = docSkeletonManagerService.getViewModel();
const unitId = docDataModel?.getUnitId();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const actionParams = getInsertRowActionsParams(rangeInfo, position, viewModel);
if (actionParams == null) {
return false;
}
const { offset, colCount, tableId, insertRowIndex } = actionParams;
const rawActions: JSONXActions = [];
const cursor = offset + 2;
const textRanges: ITextRangeWithStyle[] = [{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
}];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
if (offset > 0) {
textX.push({
t: TextXActionType.RETAIN,
len: offset,
});
}
const insertBody = getInsertRowBody(colCount);
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: insertBody.dataStream.length,
});
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
// Step 3: Insert table row;
const insertRow = getEmptyTableRow(colCount);
const insertTableSource = jsonX.insertOp(['tableSource', tableId, 'tableRows', insertRowIndex], insertRow);
rawActions.push(insertTableSource!);
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};
export interface IDocTableInsertColumnCommandParams {
position: INSERT_COLUMN_POSITION;
}
/**
* The command to insert table column.
*/
export const DocTableInsertColumnCommand: ICommand<IDocTableInsertColumnCommandParams> = {
id: DocTableInsertColumnCommandId,
type: CommandType.COMMAND,
// eslint-disable-next-line max-lines-per-function
handler: async (accessor, params: IDocTableInsertColumnCommandParams) => {
const { position } = params;
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const activeRectRanges = docSelectionManagerService.getRectRanges();
const activeTextRange = docSelectionManagerService.getActiveTextRange();
const rangeInfo = getRangeInfoFromRanges(activeTextRange, activeRectRanges);
if (rangeInfo == null) {
return false;
}
const { segmentId } = rangeInfo;
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const body = docDataModel?.getSelfOrHeaderFooterModel(segmentId)?.getBody();
if (docDataModel == null || body == null) {
return false;
}
const docSkeletonManagerService = getCommandSkeleton(accessor, docDataModel.getUnitId());
if (docSkeletonManagerService == null) {
return false;
}
const viewModel = docSkeletonManagerService.getViewModel();
const unitId = docDataModel?.getUnitId();
const textX = new TextX();
const jsonX = JSONX.getInstance();
const actionParams = getInsertColumnActionsParams(rangeInfo, position, viewModel);
if (actionParams == null) {
return false;
}
const { offsets, columnIndex, tableId, rowCount } = actionParams;
const rawActions: JSONXActions = [];
const cursor = offsets[0] + 1;
const textRanges: ITextRangeWithStyle[] = [{
startOffset: cursor,
endOffset: cursor,
collapsed: true,
}];
const doMutation: IMutationInfo<IRichTextEditingMutationParams> = {
id: RichTextEditingMutation.id,
params: {
unitId,
actions: [],
textRanges,
},
};
for (const offset of offsets) {
textX.push({
t: TextXActionType.RETAIN,
len: offset,
});
const insertBody = getInsertColumnBody();
textX.push({
t: TextXActionType.INSERT,
body: insertBody,
len: insertBody.dataStream.length,
});
}
const path = getRichTextEditPath(docDataModel, segmentId);
rawActions.push(jsonX.editOp(textX.serialize(), path)!);
// Step 3: Insert table cell;
for (let i = 0; i < rowCount; i++) {
const insertCell = getEmptyTableCell();
const insertTableSource = jsonX.insertOp(['tableSource', tableId, 'tableRows', i, 'tableCells', columnIndex], insertCell);
rawActions.push(insertTableSource!);
}
const snapshot = docDataModel.getSnapshot();
const documentStyle = snapshot.documentStyle;
const { marginLeft = 0, marginRight = 0 } = documentStyle;
const tableColumns = snapshot?.tableSource?.[tableId]?.tableColumns;
if (!tableColumns) {
return false;
}
const pageWidth = (documentStyle.pageSize?.width ?? 800) - marginLeft - marginRight;
const tableWidth = tableColumns.reduce((sum, column) => sum + column.size.width.v, 0);
const targetWidth = tableWidth > 0 ? tableWidth : pageWidth;
const { newColWidth, widths } = getColumnWidths(targetWidth, tableColumns, columnIndex);
// Update pre columns width.
for (let i = 0; i < widths.length; i++) {
const action = jsonX.replaceOp(['tableSource', tableId, 'tableColumns', i, 'size', 'width', 'v'], tableColumns[i].size.width.v, widths[i]);
rawActions.push(action!);
}
const insertCol = getTableColumn(newColWidth);
const insertTableColumn = jsonX.insertOp(['tableSource', tableId, 'tableColumns', columnIndex], insertCol);
rawActions.push(insertTableColumn!);
doMutation.params.actions = rawActions.reduce((acc, cur) => {
return JSONX.compose(acc, cur as JSONXActions);
}, null as JSONXActions);
const result = commandService.syncExecuteCommand<
IRichTextEditingMutationParams,
IRichTextEditingMutationParams
>(doMutation.id, doMutation.params);
return Boolean(result);
},
};

View File

@@ -0,0 +1,92 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, Nullable } from '@univerjs/core';
import type { IOffsets } from './table';
import { CommandType, ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { getCommandSkeleton } from '../../util';
import { DocTableInsertRowCommand } from './doc-table-insert.command';
import { CellPosition, getCellOffsets, INSERT_ROW_POSITION } from './table';
export interface IDocTableTabCommandParams {
shift: boolean;
}
export const DocTableTabCommand: ICommand<IDocTableTabCommandParams> = {
id: 'doc.table.tab-in-table',
type: CommandType.COMMAND,
handler: async (accessor, params: IDocTableTabCommandParams) => {
const { shift } = params;
const textSelectionManager = accessor.get(DocSelectionManagerService);
const docRanges = textSelectionManager.getDocRanges();
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const docDataModel = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!docDataModel) {
return false;
}
const activeRange = docRanges.find((range) => range.isActive) ?? docRanges[0];
const unitId = docDataModel.getUnitId();
const docSkeletonManagerService = getCommandSkeleton(accessor, unitId);
const skeleton = docSkeletonManagerService?.getSkeleton();
const viewModel = skeleton?.getViewModel().getSelfOrHeaderFooterViewModel(activeRange?.segmentId);
if (viewModel == null) {
return false;
}
if (activeRange == null) {
return false;
}
let offsets: Nullable<IOffsets> = null;
if (shift) {
offsets = getCellOffsets(viewModel, activeRange, CellPosition.PREV);
} else {
offsets = getCellOffsets(viewModel, activeRange, CellPosition.NEXT);
}
if (offsets) {
const { startOffset, endOffset } = offsets;
const textRanges = [{
startOffset,
endOffset,
}];
textSelectionManager.replaceDocRanges(textRanges);
return true;
}
if (shift === false) {
const result = await commandService.executeCommand(DocTableInsertRowCommand.id, {
position: INSERT_ROW_POSITION.BELLOW,
});
return result;
}
return true;
},
};

View File

@@ -0,0 +1,794 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IParagraph, ISectionBreak, ITable, ITableCell, ITableColumn, ITableRow, Nullable } from '@univerjs/core';
import type { DataStreamTreeNode, DocumentViewModel, ITextRangeWithStyle } from '@univerjs/engine-render';
import { createParagraphId, createSectionId, DataStreamTreeTokenType, generateRandomId, ObjectRelativeFromH, ObjectRelativeFromV, TableAlignmentType, TableRowHeightRule, TableSizeType, TableTextWrapType, Tools } from '@univerjs/core';
export enum INSERT_ROW_POSITION {
ABOVE,
BELLOW,
}
export enum INSERT_COLUMN_POSITION {
LEFT,
RIGHT,
}
export function genEmptyTable(rowCount: number, colCount: number) {
let dataStream: string = DataStreamTreeTokenType.TABLE_START;
const paragraphs: IParagraph[] = [];
const sectionBreaks: ISectionBreak[] = [];
const existingParagraphIds = new Set<string>();
const existingSectionIds = new Set<string>();
for (let i = 0; i < rowCount; i++) {
dataStream += DataStreamTreeTokenType.TABLE_ROW_START;
for (let j = 0; j < colCount; j++) {
dataStream += `${DataStreamTreeTokenType.TABLE_CELL_START}\r\n${DataStreamTreeTokenType.TABLE_CELL_END}`;
paragraphs.push({
startIndex: dataStream.length - 3,
paragraphId: createParagraphId(existingParagraphIds),
paragraphStyle: {
spaceAbove: { v: 3 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
});
sectionBreaks.push({
sectionId: createSectionId(existingSectionIds),
startIndex: dataStream.length - 2,
});
}
dataStream += DataStreamTreeTokenType.TABLE_ROW_END;
}
dataStream += DataStreamTreeTokenType.TABLE_END;
return {
dataStream,
paragraphs,
sectionBreaks,
};
}
export function getEmptyTableCell() {
const tableCell: ITableCell = {
margin: {
start: {
v: 10,
},
end: {
v: 10,
},
top: {
v: 5,
},
bottom: {
v: 5,
},
},
};
return tableCell;
}
export function getEmptyTableRow(col: number) {
const tableCell = getEmptyTableCell();
const tableRow: ITableRow = {
tableCells: [...new Array(col).fill(null).map(() => Tools.deepClone(tableCell))],
trHeight: {
val: { v: 30 },
hRule: TableRowHeightRule.AUTO,
},
};
return tableRow;
}
export function getTableColumn(width: number) {
const tableColumn: ITableColumn = {
size: {
type: TableSizeType.SPECIFIED,
width: {
v: width,
},
},
};
return tableColumn;
}
export function genTableSource(rowCount: number, colCount: number, pageContentWidth: number) {
const tableColumn: ITableColumn = getTableColumn(pageContentWidth / colCount);
const tableRow = getEmptyTableRow(colCount);
const tableRows = [...new Array(rowCount).fill(null).map(() => Tools.deepClone(tableRow))];
const tableColumns = [...new Array(colCount).fill(null).map(() => Tools.deepClone(tableColumn))];
const tableId = generateRandomId(6);
const table: ITable = {
tableRows,
tableColumns,
tableId,
align: TableAlignmentType.START,
indent: {
v: 0,
},
textWrap: TableTextWrapType.NONE,
position: {
positionH: {
relativeFrom: ObjectRelativeFromH.PAGE,
posOffset: 0,
},
positionV: {
relativeFrom: ObjectRelativeFromV.PAGE,
posOffset: 0,
},
},
dist: {
distB: 0,
distL: 0,
distR: 0,
distT: 0,
},
cellMargin: {
start: {
v: 10,
},
end: {
v: 10,
},
top: {
v: 5,
},
bottom: {
v: 5,
},
},
size: {
type: TableSizeType.UNSPECIFIED,
width: {
v: pageContentWidth,
},
},
};
return table;
}
interface IRangeInfo {
startOffset: number;
endOffset: number;
segmentId: string;
}
export function getRangeInfoFromRanges(textRange: Nullable<ITextRangeWithStyle>, rectRanges: Readonly<Nullable<ITextRangeWithStyle[]>>): Nullable<IRangeInfo> {
if (!textRange && !rectRanges) {
return null;
}
if (rectRanges && rectRanges.length > 0) {
let startOffset = Number.POSITIVE_INFINITY;
let endOffset = Number.NEGATIVE_INFINITY;
const segmentId = '';
for (const rectRange of rectRanges) {
const { startOffset: st, endOffset: ed, segmentId: sid } = rectRange;
if (st == null || ed == null || sid == null) {
continue;
}
startOffset = Math.min(startOffset, st);
endOffset = Math.max(endOffset, ed);
}
if (Number.isFinite(startOffset) && Number.isFinite(endOffset)) {
return {
startOffset,
endOffset,
segmentId,
};
}
} else if (textRange) {
const { startOffset, endOffset, segmentId } = textRange;
if (startOffset == null || endOffset == null || segmentId == null) {
return null;
}
return {
startOffset,
endOffset,
segmentId,
};
}
}
export function getInsertRowBody(col: number) {
let dataStream: string = DataStreamTreeTokenType.TABLE_ROW_START;
const paragraphs: IParagraph[] = [];
const sectionBreaks: ISectionBreak[] = [];
const existingParagraphIds = new Set<string>();
const existingSectionIds = new Set<string>();
for (let i = 0; i < col; i++) {
dataStream += `${DataStreamTreeTokenType.TABLE_CELL_START}\r\n${DataStreamTreeTokenType.TABLE_CELL_END}`;
paragraphs.push({
startIndex: dataStream.length - 3,
paragraphId: createParagraphId(existingParagraphIds),
paragraphStyle: {
spaceAbove: { v: 3 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
});
sectionBreaks.push({
sectionId: createSectionId(existingSectionIds),
startIndex: dataStream.length - 2,
});
}
dataStream += DataStreamTreeTokenType.TABLE_ROW_END;
return {
dataStream,
paragraphs,
sectionBreaks,
};
}
export function getInsertColumnBody() {
const dataStream = `${DataStreamTreeTokenType.TABLE_CELL_START}\r\n${DataStreamTreeTokenType.TABLE_CELL_END}`;
const paragraphs: IParagraph[] = [];
const sectionBreaks: ISectionBreak[] = [];
paragraphs.push({
startIndex: 1,
paragraphId: createParagraphId(new Set()),
paragraphStyle: {
spaceAbove: { v: 3 },
lineSpacing: 2,
spaceBelow: { v: 0 },
},
});
sectionBreaks.push({
sectionId: createSectionId(new Set()),
startIndex: 2,
});
return {
dataStream,
paragraphs,
sectionBreaks,
};
}
export function getInsertRowActionsParams(rangeInfo: IRangeInfo, position: INSERT_ROW_POSITION, viewModel: DocumentViewModel) {
const { startOffset, endOffset, segmentId } = rangeInfo;
const vm = viewModel.getSelfOrHeaderFooterViewModel(segmentId);
const index = position === INSERT_ROW_POSITION.ABOVE ? startOffset : endOffset;
let tableRow = null;
const tableId = viewModel.getBody()?.tables?.find((t) => index >= t.startIndex && index <= t.endIndex)?.tableId;
let rowIndex = 0;
// TODO: handle nested tables
for (const section of vm.getChildren()) {
for (const paragraph of section.children) {
const { children } = paragraph;
const table = children[0];
if (table) {
for (const row of table.children) {
if (row.startIndex <= index && index <= row.endIndex) {
rowIndex = table.children.indexOf(row);
tableRow = row;
break;
}
}
}
if (tableRow) {
break;
}
}
if (tableRow) {
break;
}
}
if (tableRow == null || tableId == null) {
return null;
}
return {
offset: position === INSERT_ROW_POSITION.ABOVE ? tableRow.startIndex : tableRow.endIndex + 1,
colCount: tableRow.children.length,
tableId,
insertRowIndex: position === INSERT_ROW_POSITION.ABOVE ? rowIndex : rowIndex + 1,
};
}
// eslint-disable-next-line complexity
export function getInsertColumnActionsParams(rangeInfo: IRangeInfo, position: INSERT_COLUMN_POSITION, viewModel: DocumentViewModel) {
const { startOffset, endOffset, segmentId } = rangeInfo;
const vm = viewModel.getSelfOrHeaderFooterViewModel(segmentId);
const index = position === INSERT_COLUMN_POSITION.LEFT ? startOffset : endOffset;
const tableId = viewModel.getBody()?.tables?.find((t) => index >= t.startIndex && index <= t.endIndex)?.tableId;
const offsets: number[] = [];
let table: Nullable<DataStreamTreeNode> = null;
let columnIndex = -1;
for (const section of vm.getChildren()) {
for (const paragraph of section.children) {
const { children } = paragraph;
const tableNode = children[0];
if (tableNode) {
if (index < tableNode.startIndex || index > tableNode.endIndex) {
continue;
}
table = tableNode;
for (const row of tableNode.children) {
for (const cell of row.children) {
const cellIndex = row.children.indexOf(cell);
if (index >= cell.startIndex && index <= cell.endIndex) {
columnIndex = cellIndex;
break;
}
}
if (columnIndex !== -1) {
break;
}
}
}
if (table) {
break;
}
}
if (table) {
break;
}
}
if (table == null || tableId == null || columnIndex === -1) {
return null;
}
let cursor = 0;
for (const row of table.children) {
const cell = row.children[columnIndex];
const insertIndex = position === INSERT_COLUMN_POSITION.LEFT ? cell.startIndex : cell.endIndex + 1;
offsets.push(insertIndex - cursor);
cursor = insertIndex;
}
return {
offsets,
tableId,
columnIndex,
rowCount: table.children.length,
};
}
export function getColumnWidths(pageWidth: number, tableColumns: ITableColumn[], insertColumnIndex: number) {
const widths: number[] = [];
let newColWidth = tableColumns[insertColumnIndex].size.width.v;
let totalWidth = 0;
for (let i = 0; i < tableColumns.length; i++) {
totalWidth += tableColumns[i].size.width.v;
}
totalWidth += newColWidth;
for (let i = 0; i < tableColumns.length; i++) {
widths.push((tableColumns[i].size.width.v / totalWidth) * pageWidth);
}
newColWidth = (newColWidth / totalWidth) * pageWidth;
return {
widths,
newColWidth,
};
}
// eslint-disable-next-line complexity
export function getDeleteRowsActionsParams(rangeInfo: IRangeInfo, viewModel: DocumentViewModel) {
const { startOffset, endOffset, segmentId } = rangeInfo;
const vm = viewModel.getSelfOrHeaderFooterViewModel(segmentId);
const tableId = viewModel.getBody()?.tables?.find((t) => startOffset >= t.startIndex && endOffset <= t.endIndex)?.tableId;
const rowIndexes: number[] = [];
let offset = -1;
let len = 0;
let cursor = -1;
let selectWholeTable = false;
for (const section of vm.getChildren()) {
for (const paragraph of section.children) {
const { children } = paragraph;
const table = children[0];
if (table) {
if (startOffset < table.startIndex || endOffset > table.endIndex) {
continue;
}
cursor = table.startIndex + 3;
for (const row of table.children) {
const rowIndex = table.children.indexOf(row);
const { startIndex, endIndex } = row;
if (startOffset >= startIndex && startOffset <= endIndex) {
offset = startIndex;
rowIndexes.push(rowIndex);
len += endIndex - startIndex + 1;
} else if (startIndex > startOffset && endIndex < endOffset) {
rowIndexes.push(rowIndex);
len += endIndex - startIndex + 1;
} else if (endOffset >= startIndex && endOffset <= endIndex) {
rowIndexes.push(rowIndex);
len += endIndex - startIndex + 1;
}
if (rowIndexes.length === table.children.length) {
selectWholeTable = true;
}
}
}
if (rowIndexes.length) {
break;
}
}
if (rowIndexes.length) {
break;
}
}
if (tableId == null || rowIndexes.length === 0) {
return null;
}
return {
tableId,
rowIndexes,
offset,
len,
cursor,
selectWholeTable,
};
}
interface IRetainDeleteOffset {
retain: number;
delete: number;
}
// eslint-disable-next-line max-lines-per-function, complexity
export function getDeleteColumnsActionParams(rangeInfo: IRangeInfo, viewModel: DocumentViewModel) {
const { startOffset, endOffset, segmentId } = rangeInfo;
const vm = viewModel.getSelfOrHeaderFooterViewModel(segmentId);
const tableId = viewModel.getBody()?.tables?.find((t) => startOffset >= t.startIndex && endOffset <= t.endIndex)?.tableId;
const offsets: IRetainDeleteOffset[] = [];
let table: Nullable<DataStreamTreeNode> = null;
const columnIndexes: number[] = [];
let cursor = -1;
let startColumnIndex = -1;
let endColumnIndex = -1;
for (const section of vm.getChildren()) {
for (const paragraph of section.children) {
const { children } = paragraph;
const tableNode = children[0];
if (tableNode) {
if (startOffset < tableNode.startIndex || endOffset > tableNode.endIndex) {
continue;
}
table = tableNode;
for (const row of tableNode.children) {
for (const cell of row.children) {
const cellIndex = row.children.indexOf(cell);
if (startOffset >= cell.startIndex && startOffset <= cell.endIndex) {
startColumnIndex = cellIndex;
}
if (endOffset >= cell.startIndex && endOffset <= cell.endIndex) {
endColumnIndex = cellIndex;
}
}
}
}
if (table) {
break;
}
}
if (table) {
break;
}
}
if (table == null || tableId == null) {
return null;
}
for (let i = startColumnIndex; i <= endColumnIndex; i++) {
columnIndexes.push(i);
}
let delta = 0;
for (const row of table.children) {
const startCell = row.children[startColumnIndex];
const endCell = row.children[endColumnIndex];
offsets.push({
retain: startCell.startIndex - delta,
delete: endCell.endIndex - startCell.startIndex + 1,
});
delta = endCell.endIndex + 1;
}
cursor = table.startIndex + 3;
return {
offsets,
tableId,
columnIndexes,
cursor,
selectWholeTable: columnIndexes.length === table.children[0].children.length,
rowCount: table.children.length,
};
}
export function getDeleteTableActionParams(rangeInfo: IRangeInfo, viewModel: DocumentViewModel) {
const { startOffset, endOffset, segmentId } = rangeInfo;
const vm = viewModel.getSelfOrHeaderFooterViewModel(segmentId);
const tableId = viewModel.getBody()?.tables?.find((t) => startOffset >= t.startIndex && endOffset <= t.endIndex)?.tableId;
let offset = -1;
let len = 0;
let cursor = -1;
for (const section of vm.getChildren()) {
for (const paragraph of section.children) {
const { children } = paragraph;
const table = children[0];
if (table) {
if (startOffset < table.startIndex || endOffset > table.endIndex) {
continue;
}
offset = table.startIndex;
len = table.endIndex - table.startIndex + 1;
cursor = table.startIndex;
}
if (table) {
break;
}
}
if (len > 0) {
break;
}
}
if (tableId == null) {
return null;
}
return {
tableId,
offset,
len,
cursor,
};
}
// eslint-disable-next-line complexity
export function getDeleteRowContentActionParams(rangeInfo: IRangeInfo, viewModel: DocumentViewModel) {
const { startOffset, endOffset, segmentId } = rangeInfo;
const vm = viewModel.getSelfOrHeaderFooterViewModel(segmentId);
const tableId = viewModel.getBody()?.tables?.find((t) => startOffset >= t.startIndex && endOffset <= t.endIndex)?.tableId;
const offsets: IRetainDeleteOffset[] = [];
let table: Nullable<DataStreamTreeNode> = null;
let cursor = -1;
let rowIndex = -1;
let startColumnIndex = -1;
let endColumnIndex = -1;
for (const section of vm.getChildren()) {
for (const paragraph of section.children) {
const { children } = paragraph;
const tableNode = children[0];
if (tableNode) {
if (startOffset < tableNode.startIndex || endOffset > tableNode.endIndex) {
continue;
}
table = tableNode;
for (const row of tableNode.children) {
const rIndex = tableNode.children.indexOf(row);
for (const cell of row.children) {
const cellIndex = row.children.indexOf(cell);
if (startOffset >= cell.startIndex && startOffset <= cell.endIndex) {
rowIndex = rIndex;
startColumnIndex = cellIndex;
}
if (endOffset >= cell.startIndex && endOffset <= cell.endIndex) {
// StartOffset and endOffset are in the same row.
endColumnIndex = cellIndex;
}
}
}
}
if (table) {
break;
}
}
if (table) {
break;
}
}
if (table == null || tableId == null || rowIndex === -1) {
return null;
}
const row = table.children[rowIndex];
for (let i = startColumnIndex; i <= endColumnIndex; i++) {
const cell = row.children[i];
offsets.push({
retain: cell.startIndex + 1,
delete: cell.endIndex - cell.startIndex - 3,
});
}
cursor = table.startIndex + 3;
return {
offsets,
tableId,
cursor,
rowCount: table.children.length,
};
}
export interface IOffsets {
startOffset: number;
endOffset: number;
}
export enum CellPosition {
NEXT,
PREV,
}
// eslint-disable-next-line complexity, max-lines-per-function
export function getCellOffsets(viewModel: DocumentViewModel, range: ITextRangeWithStyle, position: CellPosition): Nullable<IOffsets> {
const { startOffset } = range;
let targetTable = null;
for (const section of viewModel.getChildren()) {
for (const paragraph of section.children) {
const table = paragraph.children[0];
if (table) {
if (startOffset > table.startIndex && startOffset < table.endIndex) {
targetTable = table;
break;
}
}
}
if (targetTable) {
break;
}
}
if (targetTable == null) {
return null;
}
let cellIndex = -1;
let rowIndex = -1;
let targetRow = null;
for (const row of targetTable.children) {
for (const cell of row.children) {
if (startOffset > cell.startIndex && startOffset < cell.endIndex) {
cellIndex = row.children.indexOf(cell);
rowIndex = targetTable.children.indexOf(row);
targetRow = row;
break;
}
}
if (cellIndex > -1) {
break;
}
}
if (cellIndex === -1 || rowIndex === -1 || targetRow == null) {
return null;
}
let newCell = null;
if (position === CellPosition.NEXT) {
newCell = targetRow.children[cellIndex + 1];
if (!newCell) {
const nextRow = targetTable.children[rowIndex + 1];
if (nextRow) {
newCell = nextRow.children[0];
}
}
} else {
newCell = targetRow.children[cellIndex - 1];
if (!newCell) {
const prevRow = targetTable.children[rowIndex - 1];
if (prevRow) {
newCell = prevRow.children[prevRow.children.length - 1];
}
}
}
if (newCell) {
const { startIndex, endIndex } = newCell;
return {
startOffset: startIndex + 1,
endOffset: endIndex - 2,
};
}
}

View File

@@ -0,0 +1,32 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor } from '@univerjs/core';
import { BuildTextUtils, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
export function getCurrentParagraph(accessor: IAccessor) {
const instanceService = accessor.get(IUniverInstanceService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const range = docSelectionManagerService.getActiveTextRange();
const doc = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!range || !range.collapsed || !doc || range.segmentId) {
return false;
}
const paragraph = BuildTextUtils.range.getParagraphsInRange(range, doc.getBody()?.paragraphs ?? [], doc.getBody()?.dataStream ?? '')[0];
return paragraph;
}

View File

@@ -0,0 +1,161 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentBody, IDocumentData, Injector, Univer } from '@univerjs/core';
import {
DataStreamTreeTokenType,
DocumentFlavor,
ICommandService,
IUndoRedoService,
RedoCommand,
SectionType,
UndoCommand,
} from '@univerjs/core';
import {
DocSelectionManagerService,
InsertDocumentColumnBreakCommand,
InsertDocumentSectionBreakCommand,
RichTextEditingMutation,
} from '@univerjs/docs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
InsertContinuousSectionBreakMenuFactory,
InsertEvenPageSectionBreakMenuFactory,
InsertNextColumnSectionBreakMenuFactory,
InsertNextPageSectionBreakMenuFactory,
InsertOddPageSectionBreakMenuFactory,
} from '../../../menu/menu';
import { createCommandTestBed } from '../../commands/__tests__/create-command-test-bed';
import { InsertDocumentColumnBreakOperation, InsertDocumentSectionBreakOperation } from '../insert-break.operation';
function createTraditionalDocument(): IDocumentData {
return {
id: 'test-doc',
body: {
dataStream: 'One\r\nTwo\r\n',
paragraphs: [
{ paragraphId: 'paragraph-one', startIndex: 3 },
{ paragraphId: 'paragraph-two', startIndex: 8 },
],
sectionBreaks: [
{ sectionId: 'section_one', startIndex: 4 },
{ sectionId: 'section_two', startIndex: 9 },
],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 794, height: 1123 },
marginTop: 96,
marginBottom: 96,
marginLeft: 96,
marginRight: 96,
},
};
}
describe('document break operations', () => {
let univer: Univer;
let injector: Injector;
let get: Injector['get'];
let commandService: ICommandService;
let documentDataModel: DocumentDataModel;
beforeEach(() => {
const testBed = createCommandTestBed(createTraditionalDocument());
univer = testBed.univer;
injector = testBed.injector;
get = testBed.get;
documentDataModel = testBed.doc;
commandService = get(ICommandService);
get(IUndoRedoService);
commandService.registerCommand(RichTextEditingMutation);
commandService.registerCommand(InsertDocumentColumnBreakCommand);
commandService.registerCommand(InsertDocumentSectionBreakCommand);
commandService.registerCommand(InsertDocumentColumnBreakOperation);
commandService.registerCommand(InsertDocumentSectionBreakOperation);
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: 'test-doc',
subUnitId: 'test-doc',
});
selectionManager.__TEST_ONLY_add([{
startOffset: 5,
endOffset: 5,
collapsed: true,
isActive: true,
segmentId: '',
}]);
});
afterEach(() => univer.dispose());
it('inserts a column break at the active caret', () => {
expect(commandService.syncExecuteCommand(InsertDocumentColumnBreakOperation.id)).toBe(true);
const body = getDocumentBody();
expect(body.dataStream[5]).toBe(DataStreamTreeTokenType.COLUMN_BREAK);
expect(body.customRanges).toContainEqual(expect.objectContaining({
startIndex: 5,
endIndex: 5,
properties: { breakType: 'column' },
}));
});
it('inserts and configures a section in one undo and redo step', () => {
const initialSections = structuredClone(getDocumentBody().sectionBreaks);
expect(commandService.syncExecuteCommand(InsertDocumentSectionBreakOperation.id, {
sectionType: SectionType.NEXT_COLUMN,
})).toBe(true);
expect(getDocumentBody().sectionBreaks).toEqual([
expect.objectContaining({ sectionId: initialSections?.[0].sectionId, startIndex: 4 }),
expect.objectContaining({ startIndex: 5 }),
expect.objectContaining({ sectionId: initialSections?.[1].sectionId, startIndex: 10, sectionType: SectionType.NEXT_COLUMN }),
]);
expect(commandService.syncExecuteCommand(UndoCommand.id)).toBe(true);
expect(getDocumentBody().sectionBreaks).toEqual(initialSections);
expect(commandService.syncExecuteCommand(RedoCommand.id)).toBe(true);
expect(getDocumentBody().sectionBreaks).toEqual([
expect.objectContaining({ sectionId: initialSections?.[0].sectionId, startIndex: 4 }),
expect.objectContaining({ startIndex: 5 }),
expect.objectContaining({ sectionId: initialSections?.[1].sectionId, startIndex: 10, sectionType: SectionType.NEXT_COLUMN }),
]);
});
it('routes all five Section Break menu items through the shared operation', () => {
const items = [
{ item: InsertContinuousSectionBreakMenuFactory(injector), sectionType: SectionType.CONTINUOUS },
{ item: InsertNextColumnSectionBreakMenuFactory(injector), sectionType: SectionType.NEXT_COLUMN },
{ item: InsertNextPageSectionBreakMenuFactory(injector), sectionType: SectionType.NEXT_PAGE },
{ item: InsertEvenPageSectionBreakMenuFactory(injector), sectionType: SectionType.EVEN_PAGE },
{ item: InsertOddPageSectionBreakMenuFactory(injector), sectionType: SectionType.ODD_PAGE },
];
for (const { item, sectionType } of items) {
expect(item.commandId).toBe(InsertDocumentSectionBreakOperation.id);
expect(item.params).toEqual({ sectionType });
}
});
function getDocumentBody(): IDocumentBody {
const body = documentDataModel.getBody();
if (!body) {
throw new Error('Expected the test document to have a body.');
}
return body;
}
});

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, ICommandService, IConfirmService, LocaleService } from '@univerjs/core';
import { COMPONENT_DOC_CREATE_TABLE_CONFIRM } from '../../views/table/create/component-name';
import { CreateDocTableCommand } from '../commands/table/doc-table-create.command';
const COMPONENT_DOC_CREATE_TABLE_CONFIRM_ID = 'doc.component.create-table-confirm';
export const DocCreateTableOperation: ICommand = {
id: 'doc.operation.create-table',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const localeService = accessor.get(LocaleService);
const confirmService = accessor.get(IConfirmService);
const commandService = accessor.get(ICommandService);
const tableCreateParams = {
rowCount: 3,
colCount: 5,
};
const handleRowColChange = (rowCount: number, colCount: number) => {
tableCreateParams.rowCount = rowCount;
tableCreateParams.colCount = colCount;
};
await confirmService.open({
id: COMPONENT_DOC_CREATE_TABLE_CONFIRM_ID,
children: {
label: {
name: COMPONENT_DOC_CREATE_TABLE_CONFIRM,
props: {
handleRowColChange,
tableCreateParams,
},
},
},
width: 'auto',
title: { title: localeService.t<LocaleKey>('docs-ui.toolbar.table.insert') },
onConfirm: () => {
commandService.executeCommand(CreateDocTableCommand.id, tableCreateParams);
confirmService.close(COMPONENT_DOC_CREATE_TABLE_CONFIRM_ID);
},
onClose: () => {
confirmService.close(COMPONENT_DOC_CREATE_TABLE_CONFIRM_ID);
},
});
return true;
},
};

View File

@@ -0,0 +1,61 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { Direction, IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
export type DocCursorMoveGranularity = 'character' | 'word' | 'line' | 'paragraph' | 'document';
export interface IMoveCursorOperationParams {
direction: Direction;
granularity?: DocCursorMoveGranularity;
}
// TODO@wzhudev: it should be moved to a command then trigger the operation.
/**
* The operation to move cursor in the current document.
*/
export const MoveCursorOperation: IOperation<IMoveCursorOperationParams> = {
id: 'doc.operation.move-cursor',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params) {
return false;
}
return true;
},
};
/**
* The operation to move selection in the current document.
*/
export const MoveSelectionOperation: IOperation<IMoveCursorOperationParams> = {
id: 'doc.operation.move-selection',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params) {
return false;
}
return true;
},
};

View File

@@ -0,0 +1,50 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { CommandType, LocaleService } from '@univerjs/core';
import { ISidebarService } from '@univerjs/ui';
import { COMPONENT_DOC_HEADER_FOOTER_PANEL } from '../../views/header-footer/panel/component-name';
export interface IUIComponentCommandParams {
value: string;
}
export const SidebarDocHeaderFooterPanelOperation: ICommand = {
id: 'sidebar.operation.doc-header-footer-panel',
type: CommandType.OPERATION,
handler: async (accessor: IAccessor, params: IUIComponentCommandParams) => {
const sidebarService = accessor.get(ISidebarService);
const localeService = accessor.get(LocaleService);
switch (params.value) {
case 'open':
sidebarService.open({
header: { title: localeService.t<LocaleKey>('docs-ui.headerFooter.panel') },
children: { label: COMPONENT_DOC_HEADER_FOOTER_PANEL },
onClose: () => {},
width: 400,
});
break;
case 'close':
default:
sidebarService.close();
break;
}
return true;
},
};

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { DocParagraphSettingController } from '../../controllers/doc-paragraph-setting.controller';
export const DocParagraphSettingPanelOperation: ICommand = {
id: 'sidebar.operation.doc-paragraph-setting-panel',
type: CommandType.OPERATION,
handler: (accessor: IAccessor) => {
const docParagraphSettingController = accessor.get(DocParagraphSettingController);
docParagraphSettingController.openPanel();
return true;
},
};

View File

@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { DocSectionSettingController } from '../../controllers/doc-section-setting.controller';
export const DocSectionSettingPanelOperation: ICommand = {
id: 'sidebar.operation.doc-section-setting-panel',
type: CommandType.OPERATION,
handler: (accessor) => {
accessor.get(DocSectionSettingController).openPanel();
return true;
},
};

View File

@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, SectionType } from '@univerjs/core';
import {
CommandType,
createSectionId,
DocumentFlavor,
ICommandService,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import {
DocSelectionManagerService,
InsertDocumentColumnBreakCommand,
InsertDocumentSectionBreakCommand,
} from '@univerjs/docs';
export interface IInsertDocumentSectionBreakOperationParams {
sectionType: SectionType;
}
function getCollapsedBodyOffset(selectionManager: DocSelectionManagerService): number | null {
const range = selectionManager.getActiveTextRange();
if (!range?.collapsed || range.segmentId || !Number.isInteger(range.startOffset)) {
return null;
}
return range.startOffset;
}
export const InsertDocumentColumnBreakOperation: ICommand = {
id: 'docs.operation.insert-column-break',
type: CommandType.OPERATION,
handler: (accessor) => {
const instanceService = accessor.get(IUniverInstanceService);
const selectionManager = accessor.get(DocSelectionManagerService);
const commandService = accessor.get(ICommandService);
const document = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const offset = getCollapsedBodyOffset(selectionManager);
if (!document || document.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL || offset == null) {
return false;
}
return commandService.syncExecuteCommand(InsertDocumentColumnBreakCommand.id, {
unitId: document.getUnitId(),
offset,
});
},
};
export const InsertDocumentSectionBreakOperation: ICommand<IInsertDocumentSectionBreakOperationParams> = {
id: 'docs.operation.insert-section-break',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params) {
return false;
}
const instanceService = accessor.get(IUniverInstanceService);
const selectionManager = accessor.get(DocSelectionManagerService);
const commandService = accessor.get(ICommandService);
const document = instanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const offset = getCollapsedBodyOffset(selectionManager);
const body = document?.getBody();
if (!document || !body || document.getDocumentStyle().documentFlavor !== DocumentFlavor.TRADITIONAL || offset == null) {
return false;
}
const sectionId = createSectionId(new Set(body.sectionBreaks?.map((section) => section.sectionId)));
return commandService.syncExecuteCommand(InsertDocumentSectionBreakCommand.id, {
unitId: document.getUnitId(),
offset,
sectionId,
nextSectionType: params.sectionType,
});
},
};

View File

@@ -0,0 +1,81 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, ModernDocumentWidthMode, PaperType } from '@univerjs/core';
import type { IConfirmPartMethodOptions } from '@univerjs/ui';
import type { LocaleKey } from '../../locale/types';
import type { IDocPageSetupCommandParams } from '../commands/doc-page-setup.command';
import {
CommandType,
DocumentFlavor,
ICommandService,
IConfirmService,
LocaleService,
MODERN_DOCUMENT_WIDTH,
PAGE_SIZE,
} from '@univerjs/core';
import { PAGE_SETTING_COMPONENT_ID } from '../../views/PageSettings';
import { DocPageSetupCommand } from '../commands/doc-page-setup.command';
export const DocOpenPageSettingCommand: ICommand = {
id: 'docs.operation.open-page-setting',
type: CommandType.OPERATION,
handler: (accessor) => {
const confirmService = accessor.get(IConfirmService) as IConfirmService<IConfirmPartMethodOptions>;
const commandService = accessor.get(ICommandService);
const localeService = accessor.get(LocaleService);
const disposable = confirmService.open({
id: PAGE_SETTING_COMPONENT_ID,
title: {
label: localeService.t<LocaleKey>('docs-ui.page-settings.document-setting'),
},
children: {
label: PAGE_SETTING_COMPONENT_ID,
},
width: 528,
onClose: () => {
disposable.dispose();
},
onConfirm: async (result) => {
if (!result) return;
const paperSize = result.mode === DocumentFlavor.MODERN
? {
width: MODERN_DOCUMENT_WIDTH[result.modernWidth as ModernDocumentWidthMode],
height: PAGE_SIZE.A4.height,
}
: PAGE_SIZE[result.paperSize as PaperType];
const applied = await commandService.executeCommand(DocPageSetupCommand.id, {
documentFlavor: result.mode,
pageOrient: result.orientation,
marginTop: result.margins.top,
marginBottom: result.margins.bottom,
marginLeft: result.margins.left,
marginRight: result.margins.right,
pageSize: paperSize,
} as IDocPageSetupCommandParams);
if (applied) {
disposable.dispose();
}
},
confirmText: localeService.t<LocaleKey>('docs-ui.page-settings.confirm'),
cancelText: localeService.t<LocaleKey>('docs-ui.page-settings.cancel'),
});
return true;
},
};

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand } from '@univerjs/core';
import { CommandType, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core';
import { getDocumentParagraphPermissionObjectId, SetDocumentPermissionCommand } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { UnitObject } from '@univerjs/protocol';
import { openObjectPermissionDialog } from '@univerjs/ui';
import { DocEventManagerService } from '../../services/doc-event-manager.service';
export const OpenDocParagraphPermissionOperation: ICommand = {
id: 'doc.operation.open-paragraph-permission',
type: CommandType.OPERATION,
handler(accessor) {
const model = accessor.get(IUniverInstanceService).getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const target = model && accessor.get(IRenderManagerService).getRenderUnitById(model.getUnitId())?.with(DocEventManagerService).contextMenuParagraph;
if (!target) {
return false;
}
const { unitId, segmentId, paragraphId } = target;
const exists = () => accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(unitId)?.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.paragraphs?.some((item) => item.paragraphId === paragraphId) === true;
if (!exists()) {
return false;
}
return openObjectPermissionDialog(accessor, {
target: { unitId, objectId: getDocumentParagraphPermissionObjectId(segmentId, paragraphId), objectType: UnitObject.DocumentParagraph },
name: accessor.get(LocaleService).t('docs-ui.objectPermission.paragraph'),
commandId: SetDocumentPermissionCommand.id,
exists,
});
},
};

View File

@@ -0,0 +1,56 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand } from '@univerjs/core';
import type { IObjectPermissionButtonProps } from '@univerjs/ui';
import { CommandType, isInternalEditorID, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { getDocumentSectionPermissionObjectId, SetDocumentPermissionCommand } from '@univerjs/docs';
import { UnitObject } from '@univerjs/protocol';
import { openObjectPermissionPanel } from '@univerjs/ui';
export const OpenDocPermissionPanelOperation: ICommand = {
id: 'doc.operation.open-permission-panel',
type: CommandType.OPERATION,
handler(accessor) {
const currentModel = accessor.get(IUniverInstanceService).getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!currentModel || isInternalEditorID(currentModel.getUnitId())) {
return false;
}
const model = currentModel;
const unitId = model.getUnitId();
function* getTargets(): Iterable<IObjectPermissionButtonProps> {
const snapshot = model.getSnapshot();
const commandId = SetDocumentPermissionCommand.id;
const segments = new Set(['', ...Object.keys(snapshot.headers ?? {}), ...Object.keys(snapshot.footers ?? {})]);
for (const segmentId of segments) {
const body = segmentId ? snapshot.headers?.[segmentId]?.body ?? snapshot.footers?.[segmentId]?.body : snapshot.body;
for (const section of body?.sectionBreaks ?? []) {
if (!section.sectionId) {
continue;
}
const objectId = getDocumentSectionPermissionObjectId(segmentId, section.sectionId);
yield {
target: { unitId, objectId, objectType: UnitObject.DocumentSection },
name: '',
commandId,
exists: () => model.getSelfOrHeaderFooterModel(segmentId)?.getBody()?.sectionBreaks?.some((item) => item.sectionId === section.sectionId) === true,
};
}
}
}
return openObjectPermissionPanel(accessor, { unitId, target: { unitId, objectId: unitId, objectType: UnitObject.DocumentSection }, getTargets });
},
};

View File

@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, IOperation } from '@univerjs/core';
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
export interface ISetDocZoomRatioOperationParams {
zoomRatio: number;
unitId: string;
}
export const SetDocZoomRatioUndoMutationFactory = (
accessor: IAccessor,
params: ISetDocZoomRatioOperationParams
): ISetDocZoomRatioOperationParams => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const documentModel = univerInstanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
documentModel?.setZoomRatio(params.zoomRatio);
return { ...params };
};
export const SetDocZoomRatioOperation: IOperation<ISetDocZoomRatioOperationParams> = {
id: 'doc.operation.set-zoom-ratio',
type: CommandType.OPERATION,
handler: (accessor, params: ISetDocZoomRatioOperationParams) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const documentModel = univerInstanceService.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
if (!documentModel) {
return false;
}
documentModel.setZoomRatio(params.zoomRatio);
return true;
},
};

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
/**
* Get the skeleton of the command's target.
* @param accessor The injection accessor.
* @param unitId Unit ID.
*/
export function getCommandSkeleton(accessor: IAccessor, unitId: string) {
const renderManagerService = accessor.get(IRenderManagerService);
return renderManagerService.getRenderUnitById(unitId)?.with(DocSkeletonManagerService);
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DependencyOverride } from '@univerjs/core';
import type { MenuConfig } from '@univerjs/ui';
export const DOCS_UI_PLUGIN_CONFIG_KEY = 'docs-ui.config';
export const DOCS_UI_PLUGIN_NAME = 'DOC_UI_PLUGIN';
export const configSymbol = Symbol(DOCS_UI_PLUGIN_CONFIG_KEY);
export type DocFitMode = 'none' | 'fit-width';
export type DocFitTarget = 'viewport' | 'container';
export type DocFitAlign = 'center' | 'start';
export type DocFitPaddingX = number | `${number}%`;
export interface IDocFitToWidthOptions {
mode?: DocFitMode;
target?: DocFitTarget;
paddingX?: DocFitPaddingX;
minScale?: number;
maxScale?: number;
align?: DocFitAlign;
}
export interface IUniverDocsUIConfig {
menu?: MenuConfig;
container?: HTMLElement | string;
toc?: boolean;
footer?: boolean;
wordCount?: boolean;
placeholder?: boolean;
fitToWidth?: IDocFitToWidthOptions;
override?: DependencyOverride;
}
export const DEFAULT_DOC_FIT_TO_WIDTH_OPTIONS: Required<Omit<IDocFitToWidthOptions, 'maxScale'>> & Pick<IDocFitToWidthOptions, 'maxScale'> = {
mode: 'none',
target: 'viewport',
paddingX: 20,
minScale: 1,
maxScale: undefined,
align: 'center',
};
export const defaultPluginConfig: IUniverDocsUIConfig = {
toc: false,
footer: true,
wordCount: true,
placeholder: true,
fitToWidth: DEFAULT_DOC_FIT_TO_WIDTH_OPTIONS,
};
export const defaultPluginMobileConfig: IUniverDocsUIConfig = {
...defaultPluginConfig,
fitToWidth: {
...DEFAULT_DOC_FIT_TO_WIDTH_OPTIONS,
mode: 'fit-width',
paddingX: 12,
minScale: 0,
maxScale: 1,
},
};

View File

@@ -0,0 +1,18 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 DOC_CARET_MENU_ID = 'docs.contextMenu.caret';
export const MOBILE_DOC_PINCH_ZOOMING = 'MOBILE_DOC_PINCH_ZOOMING';

View File

@@ -0,0 +1,154 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { QuickListTypeMap } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { AfterSpaceCommand, EnterCommand, TabCommand } from '../../commands/commands/auto-format.command';
import { BreakLineCommand } from '../../commands/commands/break-line.command';
import { ChangeListNestingLevelCommand, ChangeListNestingLevelType, ListOperationCommand, QuickListCommand } from '../../commands/commands/list.command';
import { QUICK_HEADING_MAP, QuickHeadingCommand } from '../../commands/commands/set-heading.command';
import { DocTableTabCommand } from '../../commands/commands/table/doc-table-tab.command';
import { DocAutoFormatController } from '../doc-auto-format.controller';
interface IAutoFormatRule {
id: string;
priority?: number;
match: (context: unknown) => boolean;
getMutations: (context: unknown) => Array<Record<string, unknown>>;
}
describe('doc auto format controller', () => {
it('registers five auto-format rules and maps list, table, heading, and enter behaviors', () => {
const rules: IAutoFormatRule[] = [];
const service = {
registerAutoFormat: vi.fn((rule) => {
rules.push(rule);
return { dispose: vi.fn() };
}),
};
const renderManagerService = {
getRenderUnitById: vi.fn(() => ({
with: vi.fn(() => ({
getSkeleton: () => ({ id: 'skeleton' }),
})),
})),
};
const controller = new DocAutoFormatController(service as never, renderManagerService as never);
expect(controller).toBeInstanceOf(DocAutoFormatController);
expect(service.registerAutoFormat).toHaveBeenCalledTimes(5);
const listTabRule = rules.find((rule) => rule.id === TabCommand.id && rule.priority === 100)!;
const tableTabRule = rules.find((rule) => rule.id === TabCommand.id && rule.priority === 99)!;
const spaceRule = rules.find((rule) => rule.id === AfterSpaceCommand.id)!;
const exitListRule = rules.find((rule) => rule.id === EnterCommand.id && rule.priority == null)!;
const defaultEnterRule = rules.find((rule) => rule.id === EnterCommand.id && rule.priority === -9999)!;
expect(listTabRule.match({
selection: { startOffset: 4 },
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_31', paragraphStart: 4, startIndex: 4, bullet: { listId: 'list-1' } }],
unit: {
getBody: () => ({
paragraphs: [
{ paragraphId: 'para_docs_ui_fixture_32', startIndex: 1, bullet: { listId: 'list-1' } },
{ paragraphId: 'para_docs_ui_fixture_33', startIndex: 4, bullet: { listId: 'list-1' } },
],
}),
},
})).toBe(true);
expect(listTabRule.getMutations({ commandParams: { shift: true } })).toEqual([{
id: ChangeListNestingLevelCommand.id,
params: {
type: ChangeListNestingLevelType.decrease,
},
}]);
expect(tableTabRule.match({
selection: {
startNodePosition: { path: ['pages', 0, 'cells', 0] },
},
unit: {
getUnitId: () => 'test-doc',
},
})).toBe(true);
expect(tableTabRule.getMutations({ commandParams: { shift: true } })).toEqual([{
id: DocTableTabCommand.id,
params: { shift: true },
}]);
const quickListText = Object.keys(QuickListTypeMap)[0];
expect(spaceRule.match({
selection: { collapsed: true, startOffset: quickListText.length + 1 },
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_34', paragraphStart: 0 }],
unit: {
getBody: () => ({ dataStream: `${quickListText} \r\n` }),
},
})).toBe(true);
expect(spaceRule.getMutations({
selection: { startOffset: quickListText.length + 1 },
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_35', paragraphStart: 0, startIndex: 0 }],
unit: {
getBody: () => ({ dataStream: `${quickListText} \r\n` }),
},
})).toEqual([{
id: QuickListCommand.id,
params: {
listType: QuickListTypeMap[quickListText as keyof typeof QuickListTypeMap],
paragraph: { paragraphId: 'para_docs_ui_fixture_35', paragraphStart: 0, startIndex: 0 },
},
}]);
const quickHeadingText = Object.keys(QUICK_HEADING_MAP)[0];
expect(spaceRule.getMutations({
selection: { startOffset: quickHeadingText.length + 1 },
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_36', paragraphStart: 0 }],
unit: {
getBody: () => ({ dataStream: `${quickHeadingText} \r\n` }),
},
})).toEqual([{
id: QuickHeadingCommand.id,
params: {
value: QUICK_HEADING_MAP[quickHeadingText as keyof typeof QUICK_HEADING_MAP],
},
}]);
expect(exitListRule.match({
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_37', bullet: { listType: 'bullet', nestingLevel: 0 }, paragraphStart: 0, paragraphEnd: 0 }],
})).toBe(true);
expect(exitListRule.getMutations({
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_38', bullet: { listType: 'bullet', nestingLevel: 1 } }],
})).toEqual([{
id: ChangeListNestingLevelCommand.id,
params: {
type: ChangeListNestingLevelType.decrease,
},
}]);
expect(exitListRule.getMutations({
paragraphs: [{ paragraphId: 'para_docs_ui_fixture_39', bullet: { listType: 'bullet', nestingLevel: 0 } }],
})).toEqual([{
id: ListOperationCommand.id,
params: {
listType: 'bullet',
},
}]);
expect(defaultEnterRule.match({})).toBe(true);
expect(defaultEnterRule.getMutations({})).toEqual([{
id: BreakLineCommand.id,
}]);
});
});

View File

@@ -0,0 +1,282 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
DocumentFlavor,
ICommandService,
Injector,
IUniverInstanceService,
LocaleService,
ThemeService,
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocumentEditArea, IRenderManagerService, Path, Rect } from '@univerjs/engine-render';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CloseHeaderFooterCommand } from '../../commands/commands/doc-header-footer.command';
import { IEditorService } from '../../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
import { TextBubbleShape } from '../../views/header-footer/text-bubble';
import { DocHeaderFooterController } from '../doc-header-footer.controller';
const neoGetDocObjectMock = vi.hoisted(() => vi.fn());
vi.mock('../../basics/component-tools', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../basics/component-tools')>();
return {
...actual,
neoGetDocObject: neoGetDocObjectMock,
};
});
function createCtx() {
return {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
};
}
function createController(options: {
editArea?: DocumentEditArea;
documentFlavor?: DocumentFlavor;
editor?: boolean;
} = {}) {
const pageRender$ = new Subject<{
ctx: ReturnType<typeof createCtx>;
pageLeft: number;
pageTop: number;
page: {
pageWidth: number;
pageHeight: number;
marginTop: number;
marginBottom: number;
};
}>();
const commandHandlers: Array<(command: { id: string; params?: unknown }) => void> = [];
const document = {
pageRender$,
onDblclick$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
getOffsetConfig: vi.fn(() => ({ documentTransform: { clone: vi.fn() } })),
};
const scene = {
getViewports: vi.fn(() => []),
};
neoGetDocObjectMock.mockReturnValue({ document, scene });
const unit = {
getSnapshot: vi.fn(() => ({
documentStyle: {
documentFlavor: options.documentFlavor ?? DocumentFlavor.TRADITIONAL,
},
})),
};
const viewModel = {
getEditArea: vi.fn(() => options.editArea ?? DocumentEditArea.BODY),
};
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
};
const context = {
unitId: 'doc-1',
unit,
mainComponent: document,
} as never;
const injector = new Injector([
[ICommandService, { useValue: commandService }],
[IEditorService, { useValue: {
isEditor: vi.fn(() => options.editor ?? false),
getEditor: vi.fn(() => null),
} }],
[IUniverInstanceService, { useValue: {
getCurrentTypeOfUnit$: vi.fn(() => new BehaviorSubject({ getUnitId: () => 'doc-1' })),
getUnit: vi.fn(() => unit),
} }],
[IRenderManagerService, { useValue: {
getRenderUnitById: vi.fn(() => ({ mainComponent: document })),
} }],
[DocSkeletonManagerService, { useValue: {
getViewModel: vi.fn(() => viewModel),
} }],
[DocSelectionRenderService, { useValue: {
setSegment: vi.fn(),
setSegmentPage: vi.fn(),
setCursorManually: vi.fn(),
} }],
[LocaleService, { useValue: {
t: vi.fn((key: string) => key),
} }],
[ThemeService],
]);
const controller = injector.createInstance(DocHeaderFooterController, context);
return { controller, pageRender$, commandHandlers, commandService, document, themeService: injector.get(ThemeService) };
}
describe('DocHeaderFooterController', () => {
afterEach(() => {
vi.restoreAllMocks();
neoGetDocObjectMock.mockReset();
});
it('closes header/footer editing after rich text changes in modern document mode', () => {
const { controller, commandHandlers, commandService } = createController({
editArea: DocumentEditArea.HEADER,
documentFlavor: DocumentFlavor.MODERN,
});
commandHandlers[0]({ id: RichTextEditingMutation.id });
expect(commandService.executeCommand).toHaveBeenCalledWith(CloseHeaderFooterCommand.id, {
unitId: 'doc-1',
});
controller.dispose();
});
it('covers header and footer areas while editing the document body', () => {
const { controller, pageRender$, themeService } = createController({ editArea: DocumentEditArea.BODY });
const rectSpy = vi.spyOn(Rect, 'drawWith').mockImplementation(() => undefined);
const pathSpy = vi.spyOn(Path, 'drawWith').mockImplementation(() => undefined);
const textSpy = vi.spyOn(TextBubbleShape, 'drawWith').mockImplementation(() => undefined);
const ctx = createCtx();
const theme = themeService.getCurrentTheme();
themeService.setTheme({
...theme,
gray: {
...theme.gray,
0: '#123456',
},
});
pageRender$.next({
ctx,
pageLeft: 12,
pageTop: 24,
page: {
pageWidth: 200,
pageHeight: 300,
marginTop: 30,
marginBottom: 40,
},
});
expect(ctx.translate).toHaveBeenCalledWith(11.5, 23.5);
expect(rectSpy).toHaveBeenCalledTimes(2);
expect(rectSpy.mock.calls[0][1]).toMatchObject({
width: 200,
height: 30,
fill: 'rgba(18,52,86,0.5)',
});
expect(rectSpy.mock.calls[1][1]).toMatchObject({
width: 200,
height: 40,
fill: 'rgba(18,52,86,0.5)',
});
expect(pathSpy).not.toHaveBeenCalled();
expect(textSpy).not.toHaveBeenCalled();
controller.dispose();
});
it('covers the body and draws header/footer guides while editing header or footer', () => {
const { controller, pageRender$, themeService } = createController({ editArea: DocumentEditArea.HEADER });
const rectSpy = vi.spyOn(Rect, 'drawWith').mockImplementation(() => undefined);
const pathSpy = vi.spyOn(Path, 'drawWith').mockImplementation(() => undefined);
const textSpy = vi.spyOn(TextBubbleShape, 'drawWith').mockImplementation(() => undefined);
const ctx = createCtx();
const theme = themeService.getCurrentTheme();
themeService.setTheme({
...theme,
primary: {
...theme.primary,
600: '#123456',
},
});
pageRender$.next({
ctx,
pageLeft: 0,
pageTop: 0,
page: {
pageWidth: 200,
pageHeight: 300,
marginTop: 30,
marginBottom: 40,
},
});
expect(rectSpy).toHaveBeenCalledWith(ctx, expect.objectContaining({
top: 30,
width: 200,
height: 230,
}));
expect(pathSpy).toHaveBeenCalledTimes(2);
expect(pathSpy).toHaveBeenCalledWith(ctx, expect.objectContaining({ stroke: '#123456' }));
expect(textSpy).toHaveBeenCalledWith(ctx, expect.objectContaining({
text: 'docs-ui.headerFooter.header',
color: 'rgba(18,52,86,0.08)',
}));
expect(textSpy).toHaveBeenCalledWith(ctx, expect.objectContaining({
text: 'docs-ui.headerFooter.footer',
color: 'rgba(18,52,86,0.08)',
}));
controller.dispose();
});
it('keeps theme color lookups out of the page render hot path', () => {
const { controller, pageRender$, themeService } = createController({ editArea: DocumentEditArea.BODY });
const getColorFromTheme = vi.spyOn(themeService, 'getColorFromTheme');
vi.spyOn(Rect, 'drawWith').mockImplementation(() => undefined);
const theme = themeService.getCurrentTheme();
const config = {
ctx: createCtx(),
pageLeft: 0,
pageTop: 0,
page: {
pageWidth: 200,
pageHeight: 300,
marginTop: 30,
marginBottom: 40,
},
};
themeService.setTheme({
...theme,
gray: {
...theme.gray,
0: '#123456',
},
});
getColorFromTheme.mockClear();
pageRender$.next(config);
pageRender$.next(config);
expect(getColorFromTheme).not.toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,309 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { awaitTime } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { IMEInputCommand } from '../../commands/commands/ime-input.command';
import { DocIMEInputController } from '../render-controllers/doc-ime-input.controller';
function createRange() {
return {
startOffset: 0,
endOffset: 0,
collapsed: true,
segmentId: '',
style: null,
};
}
interface ITestEditorInputConfig {
activeRange: ReturnType<typeof createRange>;
event?: { data: string };
rangeList?: ReturnType<typeof createRange>[];
}
describe('doc ime input controller', () => {
it('skips duplicate writes when compositionend data matches the last update', async () => {
const onCompositionstart$ = new Subject<ITestEditorInputConfig>();
const onCompositionupdate$ = new Subject<ITestEditorInputConfig>();
const onCompositionend$ = new Subject<ITestEditorInputConfig>();
const activeRange = createRange();
let storedActiveRange: ReturnType<typeof createRange> | null = null;
const docSelectionRenderService = {
onCompositionstart$,
onCompositionupdate$,
onCompositionend$,
getAllRectRanges: vi.fn(() => []),
};
const docImeInputManagerService = {
setActiveRange: vi.fn((range) => {
storedActiveRange = range;
}),
getActiveRange: vi.fn(() => storedActiveRange),
getPreviousDocRanges: vi.fn(() => []),
getPreviousSelectionOptions: vi.fn(() => null),
setPreviousDocRanges: vi.fn(),
setPreviousSelectionOptions: vi.fn(),
clearUndoRedoMutationParamsCache: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(() => Promise.resolve(true)),
};
const docStateEmitService = {
emitStateChangeInfo: vi.fn(),
};
const docSelectionManagerService = {
getSelectionInfo: vi.fn(() => ({ options: null })),
};
const controller = new DocIMEInputController(
{
unitId: 'doc-unit',
} as never,
docSelectionRenderService as never,
docImeInputManagerService as never,
{
getSkeleton: vi.fn(() => ({})),
} as never,
docStateEmitService as never,
docSelectionManagerService as never,
commandService as never
);
onCompositionstart$.next({
activeRange,
});
onCompositionupdate$.next({
event: { data: '한' },
activeRange,
});
await awaitTime(0);
onCompositionend$.next({
event: { data: '한' },
activeRange,
});
await awaitTime(0);
expect(commandService.executeCommand).toHaveBeenCalledTimes(1);
expect(commandService.executeCommand).toHaveBeenCalledWith(IMEInputCommand.id, {
unitId: 'doc-unit',
newText: '한',
oldTextLen: 0,
isCompositionStart: true,
isCompositionEnd: false,
});
expect(docStateEmitService.emitStateChangeInfo).toHaveBeenCalledWith({
commandId: RichTextEditingMutation.id,
unitId: 'doc-unit',
segmentId: '',
trigger: IMEInputCommand.id,
redoState: {
actions: [],
textRanges: [activeRange],
},
undoState: {
actions: [],
textRanges: [activeRange],
},
isCompositionEnd: true,
});
expect(docImeInputManagerService.clearUndoRedoMutationParamsCache).toHaveBeenCalledTimes(2);
expect(docImeInputManagerService.setActiveRange).toHaveBeenLastCalledWith(null);
controller.dispose();
});
it('keeps the compositionend write when the final data differs from the latest update', async () => {
const onCompositionstart$ = new Subject<ITestEditorInputConfig>();
const onCompositionupdate$ = new Subject<ITestEditorInputConfig>();
const onCompositionend$ = new Subject<ITestEditorInputConfig>();
const activeRange = createRange();
let storedActiveRange: ReturnType<typeof createRange> | null = null;
const docSelectionRenderService = {
onCompositionstart$,
onCompositionupdate$,
onCompositionend$,
getAllRectRanges: vi.fn(() => []),
};
const docImeInputManagerService = {
setActiveRange: vi.fn((range) => {
storedActiveRange = range;
}),
getActiveRange: vi.fn(() => storedActiveRange),
getPreviousDocRanges: vi.fn(() => []),
getPreviousSelectionOptions: vi.fn(() => null),
setPreviousDocRanges: vi.fn(),
setPreviousSelectionOptions: vi.fn(),
clearUndoRedoMutationParamsCache: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(() => Promise.resolve(true)),
};
const docStateEmitService = {
emitStateChangeInfo: vi.fn(),
};
const docSelectionManagerService = {
getSelectionInfo: vi.fn(() => ({ options: null })),
};
const controller = new DocIMEInputController(
{
unitId: 'doc-unit',
} as never,
docSelectionRenderService as never,
docImeInputManagerService as never,
{
getSkeleton: vi.fn(() => ({})),
} as never,
docStateEmitService as never,
docSelectionManagerService as never,
commandService as never
);
onCompositionstart$.next({
activeRange,
});
onCompositionupdate$.next({
event: { data: 'ㅎ' },
activeRange,
});
await awaitTime(0);
onCompositionend$.next({
event: { data: '한' },
activeRange,
});
await awaitTime(0);
expect(commandService.executeCommand).toHaveBeenCalledTimes(2);
expect(commandService.executeCommand).toHaveBeenNthCalledWith(2, IMEInputCommand.id, {
unitId: 'doc-unit',
newText: '한',
oldTextLen: 1,
isCompositionStart: false,
isCompositionEnd: true,
});
expect(docStateEmitService.emitStateChangeInfo).not.toHaveBeenCalled();
controller.dispose();
});
it('rolls back an active composition when compositionend data is empty', async () => {
const onCompositionstart$ = new Subject<ITestEditorInputConfig>();
const onCompositionupdate$ = new Subject<ITestEditorInputConfig>();
const onCompositionend$ = new Subject<ITestEditorInputConfig>();
const activeRange = createRange();
const docSelectionRenderService = {
onCompositionstart$,
onCompositionupdate$,
onCompositionend$,
getAllRectRanges: vi.fn(() => []),
};
const docImeInputManagerService = {
setActiveRange: vi.fn(),
getActiveRange: vi.fn(() => activeRange),
getPreviousDocRanges: vi.fn(() => []),
getPreviousSelectionOptions: vi.fn(() => null),
setPreviousDocRanges: vi.fn(),
setPreviousSelectionOptions: vi.fn(),
clearUndoRedoMutationParamsCache: vi.fn(),
};
const commandService = {
executeCommand: vi.fn(() => Promise.resolve(true)),
};
const docStateEmitService = {
emitStateChangeInfo: vi.fn(),
};
const controller = new DocIMEInputController(
{ unitId: 'doc-unit' } as never,
docSelectionRenderService as never,
docImeInputManagerService as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
docStateEmitService as never,
{ getSelectionInfo: vi.fn(() => ({ options: null })) } as never,
commandService as never
);
onCompositionstart$.next({ activeRange });
onCompositionupdate$.next({ event: { data: 'nihao' }, activeRange });
await awaitTime(0);
onCompositionupdate$.next({ event: { data: '' }, activeRange });
await awaitTime(0);
onCompositionend$.next({ event: { data: '' }, activeRange });
await awaitTime(0);
expect(commandService.executeCommand).toHaveBeenCalledTimes(2);
expect(commandService.executeCommand).toHaveBeenNthCalledWith(2, IMEInputCommand.id, {
unitId: 'doc-unit',
newText: '',
oldTextLen: 5,
isCompositionStart: false,
isCompositionEnd: true,
isCompositionCanceled: true,
});
expect(docStateEmitService.emitStateChangeInfo).not.toHaveBeenCalled();
expect(docImeInputManagerService.clearUndoRedoMutationParamsCache).toHaveBeenCalledTimes(2);
expect(docImeInputManagerService.setActiveRange).toHaveBeenLastCalledWith(null);
controller.dispose();
});
it('ends an empty composition without creating a history entry', async () => {
const onCompositionstart$ = new Subject<ITestEditorInputConfig>();
const onCompositionupdate$ = new Subject<ITestEditorInputConfig>();
const onCompositionend$ = new Subject<ITestEditorInputConfig>();
const activeRange = createRange();
const commandService = {
executeCommand: vi.fn(() => Promise.resolve(true)),
};
const docStateEmitService = {
emitStateChangeInfo: vi.fn(),
};
const controller = new DocIMEInputController(
{ unitId: 'doc-unit' } as never,
{
onCompositionstart$,
onCompositionupdate$,
onCompositionend$,
getAllRectRanges: vi.fn(() => []),
} as never,
{
setActiveRange: vi.fn(),
getActiveRange: vi.fn(() => activeRange),
getPreviousDocRanges: vi.fn(() => []),
getPreviousSelectionOptions: vi.fn(() => null),
setPreviousDocRanges: vi.fn(),
setPreviousSelectionOptions: vi.fn(),
clearUndoRedoMutationParamsCache: vi.fn(),
} as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
docStateEmitService as never,
{ getSelectionInfo: vi.fn(() => ({ options: null })) } as never,
commandService as never
);
onCompositionstart$.next({ activeRange });
onCompositionend$.next({ event: { data: '' }, activeRange });
await awaitTime(0);
expect(commandService.executeCommand).not.toHaveBeenCalled();
expect(docStateEmitService.emitStateChangeInfo).not.toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,629 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DataStreamTreeTokenType, Direction } from '@univerjs/core';
import { DocumentSkeletonPageType, LineType } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { DocMoveCursorController } from '../doc-move-cursor.controller';
function createControllerHarness() {
return Object.create(DocMoveCursorController.prototype) as Record<string, (...args: unknown[]) => unknown>;
}
function createOffsetSkeleton(validOffsets: number[]) {
const validOffsetSet = new Set(validOffsets);
return {
findNodePositionByCharIndex: vi.fn((offset: number) => validOffsetSet.has(offset) ? { offset } : undefined),
};
}
describe('DocMoveCursorController movement helpers', () => {
it('resolves Chinese word boundaries with the shared Segmenter behavior', () => {
const controller = createControllerHarness();
const line = { paragraphIndex: 0, st: 10, divides: [] as unknown[], parent: null as unknown };
const column = { lines: [line] };
line.parent = column;
const glyphs = ['中', '文', '测', '试'].map((content) => ({
count: 1,
content,
streamType: DataStreamTreeTokenType.LETTER,
}));
const divide = { st: 10, glyphGroup: glyphs, parent: line };
line.divides = [divide];
glyphs.forEach((glyph) => {
Object.assign(glyph, { parent: divide });
});
const skeleton = {
findNodeByCharIndex: vi.fn(() => glyphs[1]),
};
expect(controller._getWordBoundaryOffset(skeleton, 11, Direction.RIGHT, '', -1, 100)).toBe(12);
expect(controller._getWordBoundaryOffset(skeleton, 13, Direction.LEFT, '', -1, 100)).toBe(12);
});
it('resolves visual line start and end from skeleton glyph positions', () => {
const controller = createControllerHarness();
const firstGlyph = {
count: 1,
content: 'A',
streamType: DataStreamTreeTokenType.LETTER,
};
const paragraphGlyph = {
count: 1,
content: '\r',
streamType: DataStreamTreeTokenType.PARAGRAPH,
};
const lastGlyph = {
count: 1,
content: 'B',
streamType: DataStreamTreeTokenType.LETTER,
};
const line = { divides: [] as unknown[] };
const divide = { st: 5, glyphGroup: [firstGlyph, lastGlyph, paragraphGlyph], parent: line };
line.divides = [divide];
[firstGlyph, lastGlyph, paragraphGlyph].forEach((glyph) => {
Object.assign(glyph, { parent: divide });
});
const skeleton = {
findNodeByCharIndex: vi.fn(() => lastGlyph),
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === firstGlyph ? 0 : 1 })),
findCharIndexByPosition: vi.fn((position) => position.isBack ? 5 : 7),
};
expect(controller._getLineBoundaryOffset(skeleton, 6, Direction.LEFT, '', -1, 100)).toBe(5);
expect(controller._getLineBoundaryOffset(skeleton, 6, Direction.RIGHT, '', -1, 100)).toBe(7);
});
it('ignores block range boundary glyphs when matching vertical cursor position', () => {
const controller = createControllerHarness();
const firstGlyph = {
count: 1,
content: 'A',
left: 0,
streamType: DataStreamTreeTokenType.LETTER,
};
const lastTextGlyph = {
count: 1,
content: 'B',
left: 20,
streamType: DataStreamTreeTokenType.LETTER,
};
const blockEndGlyph = {
count: 1,
content: DataStreamTreeTokenType.BLOCK_END,
left: 88,
streamType: DataStreamTreeTokenType.BLOCK_END,
};
const line = { divides: [] as unknown[] };
const divide = { left: 0, glyphGroup: [firstGlyph, lastTextGlyph, blockEndGlyph], parent: line };
line.divides = [divide];
[firstGlyph, lastTextGlyph, blockEndGlyph].forEach((glyph) => {
Object.assign(glyph, { parent: divide });
});
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === lastTextGlyph ? 1 : -1 })),
};
expect(controller._matchPositionByLeftOffset(skeleton, line, 90, { segmentPage: -1 })).toEqual({ glyph: 1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(lastTextGlyph, -1);
});
it('uses a paragraph glyph as the vertical cursor target for empty paragraph lines', () => {
const controller = createControllerHarness();
const paragraphGlyph = {
count: 1,
content: DataStreamTreeTokenType.PARAGRAPH,
left: 0,
streamType: DataStreamTreeTokenType.PARAGRAPH,
};
const line = { divides: [] as unknown[] };
const divide = { left: 0, glyphGroup: [paragraphGlyph], parent: line };
line.divides = [divide];
Object.assign(paragraphGlyph, { parent: divide });
const skeleton = {
findPositionByGlyph: vi.fn(() => ({ glyph: 0 })),
};
expect(controller._matchPositionByLeftOffset(skeleton, line, 0, { segmentPage: -1 })).toEqual({ glyph: 0 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(paragraphGlyph, -1);
});
it('enters the nearest column group column when moving vertically onto the block line', () => {
const controller = createControllerHarness();
const bodyGlyph = createGlyph('A', 118);
const firstColumnGlyph = createGlyph('L', 0);
const secondColumnGlyph = createGlyph('R', 16);
const beforeLine = createLine([bodyGlyph], 10, 0);
const blockLine = createBlockLine(11, 40);
const bodyColumn = createColumn([beforeLine, blockLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const firstColumnPage = createPage([createColumn([createLine([firstColumnGlyph], 20, 0)])], DocumentSkeletonPageType.CELL);
const secondColumnPage = createPage([createColumn([createLine([secondColumnGlyph], 30, 0)])], DocumentSkeletonPageType.CELL);
const columnGroupColumnA = { columnId: 'a', left: 0, top: 0, width: 80, height: 20, st: 20, ed: 25, page: firstColumnPage };
const columnGroupColumnB = { columnId: 'b', left: 100, top: 0, width: 80, height: 20, st: 30, ed: 35, page: secondColumnPage };
const columnGroup = {
columnGroupId: 'cg-1',
columns: [columnGroupColumnA, columnGroupColumnB],
ed: 40,
height: 20,
left: 0,
st: 11,
top: 10,
width: 180,
};
Object.assign(columnGroupColumnA, { parent: columnGroup });
Object.assign(columnGroupColumnB, { parent: columnGroup });
Object.assign(firstColumnPage, { parent: columnGroupColumnA });
Object.assign(secondColumnPage, { parent: columnGroupColumnB });
Object.assign(columnGroup, { parent: bodyPage });
bodyPage.skeColumnGroups.set('cg-1', columnGroup as never);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === secondColumnGlyph ? 'second' : 'other', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
bodyGlyph,
{ segmentPage: -1 },
true
)).toEqual({ glyph: 'second', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(secondColumnGlyph, -1);
});
it('leaves a column group column for the following body line when moving past its end', () => {
const controller = createControllerHarness();
const columnGlyph = createGlyph('C', 12);
const afterGlyph = createGlyph('Z', 112);
const blockLine = createBlockLine(11, 40);
const afterLine = createLine([afterGlyph], 41, 0);
const bodyColumn = createColumn([blockLine, afterLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const columnPage = createPage([createColumn([createLine([columnGlyph], 30, 0)])], DocumentSkeletonPageType.CELL);
const columnGroupColumn = { columnId: 'b', left: 100, top: 0, width: 80, height: 20, st: 30, ed: 35, page: columnPage };
const columnGroup = {
columnGroupId: 'cg-1',
columns: [columnGroupColumn],
ed: 40,
height: 20,
left: 0,
st: 11,
top: 10,
width: 180,
};
Object.assign(columnGroupColumn, { parent: columnGroup });
Object.assign(columnPage, { parent: columnGroupColumn });
Object.assign(columnGroup, { parent: bodyPage });
bodyPage.skeColumnGroups.set('cg-1', columnGroup as never);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === afterGlyph ? 'after' : 'other', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
columnGlyph,
{ segmentPage: -1 },
true
)).toEqual({ glyph: 'after', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(afterGlyph, -1);
});
it('enters the nearest column group column from below when moving upward', () => {
const controller = createControllerHarness();
const afterGlyph = createGlyph('Z', 112);
const columnGlyph = createGlyph('C', 12);
const blockLine = createBlockLine(11, 40);
const afterLine = createLine([afterGlyph], 41, 0);
const bodyColumn = createColumn([blockLine, afterLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const columnPage = createPage([createColumn([createLine([columnGlyph], 30, 0)])], DocumentSkeletonPageType.CELL);
const columnGroupColumn = { columnId: 'b', left: 100, top: 0, width: 80, height: 20, st: 30, ed: 35, page: columnPage };
const columnGroup = createColumnGroup([columnGroupColumn], bodyPage);
bodyPage.skeColumnGroups.set('cg-1', columnGroup as never);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === columnGlyph ? 'column' : 'other', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
afterGlyph,
{ segmentPage: -1 },
false
)).toEqual({ glyph: 'column', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(columnGlyph, -1);
});
it('leaves a column group column for the previous body line when moving above its start', () => {
const controller = createControllerHarness();
const beforeGlyph = createGlyph('A', 112);
const columnGlyph = createGlyph('C', 12);
const beforeLine = createLine([beforeGlyph], 10, 0);
const blockLine = createBlockLine(11, 40);
const bodyColumn = createColumn([beforeLine, blockLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const columnPage = createPage([createColumn([createLine([columnGlyph], 30, 0)])], DocumentSkeletonPageType.CELL);
const columnGroupColumn = { columnId: 'b', left: 100, top: 0, width: 80, height: 20, st: 30, ed: 35, page: columnPage };
const columnGroup = createColumnGroup([columnGroupColumn], bodyPage);
bodyPage.skeColumnGroups.set('cg-1', columnGroup as never);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === beforeGlyph ? 'before' : 'other', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
columnGlyph,
{ segmentPage: -1 },
false
)).toEqual({ glyph: 'before', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(beforeGlyph, -1);
});
it('stops safely at a terminal column group when no following body line exists', () => {
const controller = createControllerHarness();
const columnGlyph = createGlyph('C', 12);
const blockLine = createBlockLine(11, 40);
const bodyColumn = createColumn([blockLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const columnPage = createPage([createColumn([createLine([columnGlyph], 30, 0)])], DocumentSkeletonPageType.CELL);
const columnGroupColumn = { columnId: 'b', left: 100, top: 0, width: 80, height: 20, st: 30, ed: 35, page: columnPage };
const columnGroup = createColumnGroup([columnGroupColumn], bodyPage);
bodyPage.skeColumnGroups.set('cg-1', columnGroup as never);
const skeleton = {
findPositionByGlyph: vi.fn(),
};
expect(controller._getTopOrBottomPosition(
skeleton,
columnGlyph,
{ segmentPage: -1 },
true
)).toBeUndefined();
expect(skeleton.findPositionByGlyph).not.toHaveBeenCalled();
});
it('enters the nearest docs table cell when moving vertically from a body line', () => {
const controller = createControllerHarness();
const bodyGlyph = createGlyph('A', 118);
const firstCellGlyph = createGlyph('L', 8);
const secondCellGlyph = createGlyph('R', 8);
const beforeLine = createLine([bodyGlyph], 10, 0);
const bodyColumn = createColumn([beforeLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const firstCell = createCellPage([createLine([firstCellGlyph], 20, 0)], 0, 80);
const secondCell = createCellPage([createLine([secondCellGlyph], 30, 0)], 100, 80);
createTable(bodyPage, [createTableRow([firstCell, secondCell], 0)], 12, 40);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === secondCellGlyph ? 'second' : 'other', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
bodyGlyph,
{ segmentPage: -1 },
true
)).toEqual({ glyph: 'second', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(secondCellGlyph, -1);
});
it('leaves a docs table cell with the cell-local cursor mapped back to the body line', () => {
const controller = createControllerHarness();
const cellGlyph = createGlyph('C', 12);
const wrongBodyGlyph = createGlyph('W', 12);
const rightBodyGlyph = createGlyph('R', 112);
const afterLine = createLine([wrongBodyGlyph, rightBodyGlyph], 41, 0);
const bodyColumn = createColumn([afterLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const cell = createCellPage([createLine([cellGlyph], 20, 0)], 100, 80);
createTable(bodyPage, [createTableRow([cell], 0)], 12, 40);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === rightBodyGlyph ? 'right' : 'wrong', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
cellGlyph,
{ segmentPage: -1 },
true
)).toEqual({ glyph: 'right', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(rightBodyGlyph, -1);
});
it('moves to the nearest docs table cell when crossing a row boundary', () => {
const controller = createControllerHarness();
const currentGlyph = createGlyph('C', 5);
const wrongCellGlyph = createGlyph('W', 8);
const rightCellGlyph = createGlyph('R', 8);
const bodyPage = createPage([createColumn([])], DocumentSkeletonPageType.BODY);
const currentCell = createCellPage([createLine([currentGlyph], 20, 0)], 80, 20);
const lowerLeftCell = createCellPage([createLine([wrongCellGlyph], 30, 0)], 0, 50);
const lowerRightCell = createCellPage([createLine([rightCellGlyph], 40, 0)], 100, 50);
createTable(bodyPage, [
createTableRow([currentCell], 0),
createTableRow([lowerLeftCell, lowerRightCell], 20),
], 12, 60);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === rightCellGlyph ? 'right' : 'wrong', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
currentGlyph,
{ segmentPage: -1 },
true
)).toEqual({ glyph: 'right', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(rightCellGlyph, -1);
});
it('enters the nearest docs table cell from below when moving upward', () => {
const controller = createControllerHarness();
const bodyGlyph = createGlyph('A', 118);
const firstCellGlyph = createGlyph('L', 8);
const secondCellGlyph = createGlyph('R', 8);
const afterLine = createLine([bodyGlyph], 41, 0);
const bodyColumn = createColumn([afterLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const firstCell = createCellPage([createLine([firstCellGlyph], 20, 0)], 0, 80);
const secondCell = createCellPage([createLine([secondCellGlyph], 30, 0)], 100, 80);
createTable(bodyPage, [createTableRow([firstCell, secondCell], 0)], 12, 40);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === secondCellGlyph ? 'second' : 'other', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
bodyGlyph,
{ segmentPage: -1 },
false
)).toEqual({ glyph: 'second', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(secondCellGlyph, -1);
});
it('leaves a docs table cell upward with the cell-local cursor mapped back to the body line', () => {
const controller = createControllerHarness();
const cellGlyph = createGlyph('C', 12);
const wrongBodyGlyph = createGlyph('W', 12);
const rightBodyGlyph = createGlyph('R', 112);
const beforeLine = createLine([wrongBodyGlyph, rightBodyGlyph], 10, 0);
const bodyColumn = createColumn([beforeLine]);
const bodyPage = createPage([bodyColumn], DocumentSkeletonPageType.BODY);
const cell = createCellPage([createLine([cellGlyph], 20, 0)], 100, 80);
createTable(bodyPage, [createTableRow([cell], 0)], 13, 40);
const skeleton = {
findPositionByGlyph: vi.fn((glyph) => ({ glyph: glyph === rightBodyGlyph ? 'right' : 'wrong', segmentPage: -1 })),
};
expect(controller._getTopOrBottomPosition(
skeleton,
cellGlyph,
{ segmentPage: -1 },
false
)).toEqual({ glyph: 'right', isBack: true, segmentPage: -1 });
expect(skeleton.findPositionByGlyph).toHaveBeenCalledWith(rightBodyGlyph, -1);
});
it('keeps column boundary tokens out of the default cursor skip list', () => {
const controller = createControllerHarness();
const skipTokens = controller._getCursorSkipTokens();
expect(skipTokens).not.toContain(DataStreamTreeTokenType.COLUMN_GROUP_START);
expect(skipTokens).not.toContain(DataStreamTreeTokenType.COLUMN_START);
expect(skipTokens).not.toContain(DataStreamTreeTokenType.COLUMN_END);
expect(skipTokens).not.toContain(DataStreamTreeTokenType.COLUMN_GROUP_END);
});
it('resolves document start and end offsets', () => {
const controller = createControllerHarness();
expect(controller._getCursorOffsetByGranularity({}, 8, Direction.UP, 'document', '', -1, 20)).toBe(0);
expect(controller._getCursorOffsetByGranularity({}, 8, Direction.DOWN, 'document', '', -1, 20)).toBe(18);
});
it('resolves paragraph movement offsets from data-stream paragraph boundaries', () => {
const controller = createControllerHarness();
const dataStream = `Alpha${DataStreamTreeTokenType.PARAGRAPH}Beta${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`;
expect(controller._getParagraphBoundaryOffset(dataStream, 3, Direction.UP)).toBe(0);
expect(controller._getParagraphBoundaryOffset(dataStream, 0, Direction.UP)).toBe(0);
expect(controller._getParagraphBoundaryOffset(dataStream, 6, Direction.UP)).toBe(0);
expect(controller._getParagraphBoundaryOffset(dataStream, 6, Direction.DOWN)).toBe(11);
expect(controller._getParagraphBoundaryOffset(dataStream, 11, Direction.DOWN)).toBe(11);
});
it('normalizes document boundaries to renderable cursor offsets', () => {
const controller = createControllerHarness();
const dataStream = `${DataStreamTreeTokenType.BLOCK_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.BLOCK_END}${DataStreamTreeTokenType.SECTION_BREAK}`;
const skeleton = createOffsetSkeleton([1]);
expect(controller._normalizeRenderableCursorOffset(skeleton, dataStream, [], 0, Direction.UP, '', -1)).toBe(1);
expect(controller._normalizeRenderableCursorOffset(skeleton, dataStream, [], dataStream.length - 2, Direction.DOWN, '', -1)).toBe(1);
});
it('keeps renderable cursor targets out of table and column structure tokens', () => {
const controller = createControllerHarness();
const tableStream = `${DataStreamTreeTokenType.TABLE_START}${DataStreamTreeTokenType.TABLE_ROW_START}${DataStreamTreeTokenType.TABLE_CELL_START}A${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.TABLE_CELL_END}${DataStreamTreeTokenType.TABLE_ROW_END}${DataStreamTreeTokenType.TABLE_END}${DataStreamTreeTokenType.SECTION_BREAK}`;
const columnStream = `${DataStreamTreeTokenType.COLUMN_GROUP_START}${DataStreamTreeTokenType.COLUMN_START}B${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.COLUMN_END}${DataStreamTreeTokenType.COLUMN_GROUP_END}${DataStreamTreeTokenType.SECTION_BREAK}`;
expect(controller._normalizeRenderableCursorOffset(createOffsetSkeleton([3]), tableStream, [], 0, Direction.DOWN, '', -1)).toBe(3);
expect(controller._normalizeRenderableCursorOffset(createOffsetSkeleton([2]), columnStream, [], 0, Direction.DOWN, '', -1)).toBe(2);
});
it('moves whole-entity custom range targets to renderable range boundaries', () => {
const controller = createControllerHarness();
const dataStream = `ABCD${DataStreamTreeTokenType.PARAGRAPH}${DataStreamTreeTokenType.SECTION_BREAK}`;
const customRanges = [{ wholeEntity: true, startIndex: 1, endIndex: 2 }];
const skeleton = createOffsetSkeleton([0, 1, 3, 4]);
expect(controller._normalizeRenderableCursorOffset(skeleton, dataStream, customRanges, 2, Direction.UP, '', -1)).toBe(1);
expect(controller._normalizeRenderableCursorOffset(skeleton, dataStream, customRanges, 2, Direction.DOWN, '', -1)).toBe(3);
});
it('returns undefined when no renderable cursor target exists', () => {
const controller = createControllerHarness();
const dataStream = `${DataStreamTreeTokenType.TABLE_START}${DataStreamTreeTokenType.TABLE_END}${DataStreamTreeTokenType.SECTION_BREAK}`;
expect(controller._normalizeRenderableCursorOffset(createOffsetSkeleton([]), dataStream, [], 0, Direction.DOWN, '', -1)).toBeUndefined();
});
});
function createGlyph(content: string, left: number) {
return {
content,
count: 1,
left,
streamType: DataStreamTreeTokenType.LETTER,
};
}
function createLine(glyphs: unknown[], st: number, top: number) {
const line = {
divides: [] as unknown[],
ed: st + glyphs.length,
parent: null as unknown,
st,
top,
type: LineType.PARAGRAPH,
};
const divide = { glyphGroup: glyphs, left: 0, parent: line };
line.divides = [divide];
glyphs.forEach((glyph) => Object.assign(glyph as object, { parent: divide }));
return line;
}
function createBlockLine(st: number, ed: number) {
return {
divides: [],
ed,
parent: null as unknown,
paragraphIndex: ed,
st,
type: LineType.BLOCK,
};
}
function createColumn(lines: unknown[]) {
const column = {
lines,
parent: null as unknown,
};
lines.forEach((line) => Object.assign(line as object, { parent: column }));
return column;
}
function createPage(columns: unknown[], type: DocumentSkeletonPageType) {
const section = {
columns,
parent: null as unknown,
};
const page = {
sections: [section],
skeColumnGroups: new Map(),
skeTables: new Map(),
type,
};
Object.assign(section, { parent: page });
columns.forEach((column) => Object.assign(column as object, { parent: section }));
return page;
}
function createCellPage(lines: unknown[], left: number, width: number) {
return Object.assign(createPage([createColumn(lines)], DocumentSkeletonPageType.CELL), {
left,
marginLeft: 0,
marginRight: 0,
pageWidth: width,
});
}
function createTableRow(cells: ReturnType<typeof createCellPage>[], top: number) {
const row = {
cells,
height: 20,
index: 0,
isRepeatRow: false,
parent: null as unknown,
rowSource: {},
st: 0,
ed: 0,
top,
};
cells.forEach((cell) => Object.assign(cell, { parent: row }));
return row;
}
function createTable(
page: ReturnType<typeof createPage>,
rows: ReturnType<typeof createTableRow>[],
st: number,
ed: number
) {
const table = {
height: 40,
left: 0,
parent: page,
rows,
st,
ed,
tableId: 'table-1',
tableSource: {},
top: 0,
width: 180,
};
rows.forEach((row, index) => Object.assign(row, { index, parent: table }));
page.skeTables.set('table-1', table as never);
return table;
}
function createColumnGroup(columns: object[], bodyPage: ReturnType<typeof createPage>) {
const columnGroup = {
columnGroupId: 'cg-1',
columns,
ed: 40,
height: 20,
left: 0,
st: 11,
top: 10,
width: 180,
};
columns.forEach((column) => {
const columnGroupColumn = column as { page: object; parent?: unknown };
columnGroupColumn.parent = columnGroup;
Object.assign(columnGroupColumn.page, { parent: columnGroupColumn });
});
Object.assign(columnGroup, { parent: bodyPage });
return columnGroup;
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDocumentData } from '@univerjs/core';
import { CustomRangeType, JSONX, TextX } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getDocumentMutationLayoutImpact } from '../render-controllers/doc-mutation-layout';
describe('document mutation layout range index', () => {
it.each(['first-note', 'another-note', 'edit-note'])('starts %s layout at the body reference instead of the start of the document', (operation) => {
const note = { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Explanation\r\n' } };
const jsonX = JSONX.getInstance();
let actions = jsonX.insertOp(['notes'], { note });
if (operation === 'another-note') {
actions = jsonX.insertOp(['notes', 'note'], note);
} else if (operation === 'edit-note') {
actions = jsonX.editOp(new TextX().insert(1, { dataStream: 'X' }).serialize(), ['notes', 'note', 'body']);
}
const impact = getDocumentMutationLayoutImpact(actions, {
body: { dataStream: '', customRanges: [{
rangeId: 'ref',
rangeType: CustomRangeType.FOOTNOTE,
startIndex: 90_000,
endIndex: 90_000,
properties: { noteId: 'note' },
}] },
});
expect(impact).toEqual({ global: false, range: { start: 90_000, end: 90_001 }, unresolvedLocal: false });
});
it('indexes table ranges once for a composed table mutation', () => {
const tableCount = 1_000;
let indexedReads = 0;
const tables = new Proxy(Array.from({ length: tableCount }, (_, index) => ({
tableId: `table-${index}`,
startIndex: index * 10,
endIndex: index * 10 + 9,
})), {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) {
indexedReads++;
}
return Reflect.get(target, property, receiver);
},
});
const jsonX = JSONX.getInstance();
const actions = JSONX.compose(JSONX.compose(
jsonX.replaceOp(['tableSource', 'table-999', 'tableColumns', 0], { width: 100 }, { width: 110 }),
jsonX.replaceOp(['tableSource', 'table-999', 'tableColumns', 1], { width: 100 }, { width: 120 })
), jsonX.replaceOp(['tableSource', 'table-999', 'tableColumns', 2], { width: 100 }, { width: 130 }));
indexedReads = 0;
const impact = getDocumentMutationLayoutImpact(actions, {
body: { dataStream: '', tables } as unknown as NonNullable<IDocumentData['body']>,
styles: {},
});
expect(impact).toEqual({
global: false,
range: { start: 9_990, end: 9_999 },
unresolvedLocal: false,
});
expect(indexedReads).toBeLessThanOrEqual(tableCount + 1);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,244 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import {
AddImageIcon,
AlignTextBothIcon,
BoldIcon,
ColumnIcon,
CopyDoubleIcon,
CutIcon,
DeleteColumnDoubleIcon,
DeleteIcon,
DeleteRowDoubleIcon,
DeleteTableDoubleIcon,
DocSettingIcon,
FontColorDoubleIcon,
GridIcon,
H1Icon,
H2Icon,
H3Icon,
H4Icon,
H5Icon,
HeaderFooterIcon,
HorizontallyIcon,
InsertDoubleIcon,
InsertRowAboveDoubleIcon,
InsertRowBelowDoubleIcon,
ItalicIcon,
KeyboardIcon,
LeftInsertColumnDoubleIcon,
LeftJustifyingIcon,
LineIndentDecreaseIcon,
LineIndentIncreaseIcon,
MenuIcon,
MoreLeftIcon,
MoreRightIcon,
NoColorDoubleIcon,
OrderIcon,
PaintBucketDoubleIcon,
PasteSpecialDoubleIcon,
ReduceDoubleIcon,
ReduceIcon,
RightInsertColumnDoubleIcon,
RightJustifyingIcon,
ShapeIcon,
SmileIcon,
StrikethroughIcon,
SubscriptIcon,
SuperscriptIcon,
SymbolsIcon,
TextTypeIcon,
TodoListDoubleIcon,
UnderlineIcon,
UnorderIcon,
} from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DOC_LAYOUT_RECOVERY_COMPONENT, DocLayoutRecovery } from '../views/DocLayoutRecovery';
import { FLOAT_MENU_COMPONENT_KEY, FloatToolbar } from '../views/float-toolbar/FloatToolbar';
import { COMPONENT_DOC_HEADER_FOOTER_PANEL } from '../views/header-footer/panel/component-name';
import { DocHeaderFooterPanel } from '../views/header-footer/panel/DocHeaderFooterPanel';
import {
DefaultTextColorIcon,
DocParagraphBackgroundColorSwatchIcon0,
DocParagraphBackgroundColorSwatchIcon1,
DocParagraphBackgroundColorSwatchIcon10,
DocParagraphBackgroundColorSwatchIcon11,
DocParagraphBackgroundColorSwatchIcon12,
DocParagraphBackgroundColorSwatchIcon13,
DocParagraphBackgroundColorSwatchIcon14,
DocParagraphBackgroundColorSwatchIcon15,
DocParagraphBackgroundColorSwatchIcon2,
DocParagraphBackgroundColorSwatchIcon3,
DocParagraphBackgroundColorSwatchIcon4,
DocParagraphBackgroundColorSwatchIcon5,
DocParagraphBackgroundColorSwatchIcon6,
DocParagraphBackgroundColorSwatchIcon7,
DocParagraphBackgroundColorSwatchIcon8,
DocParagraphBackgroundColorSwatchIcon9,
DocParagraphTextColorSwatchIcon0,
DocParagraphTextColorSwatchIcon1,
DocParagraphTextColorSwatchIcon2,
DocParagraphTextColorSwatchIcon3,
DocParagraphTextColorSwatchIcon4,
DocParagraphTextColorSwatchIcon5,
DocParagraphTextColorSwatchIcon6,
HeaderTextColorIcon,
SubtitleTypeIcon,
TitleTypeIcon,
} from '../views/Icon';
import {
BULLET_LIST_TYPE_COMPONENT,
BulletListTypePicker,
ORDER_LIST_TYPE_COMPONENT,
OrderListTypePicker,
} from '../views/list-type-picker/index';
import { PAGE_SETTING_COMPONENT_ID, PageSettings } from '../views/PageSettings';
import {
DOC_PARAGRAPH_MENU_COMPONENT_KEY,
DOC_TABLE_BLOCK_MENU_COMPONENT_KEY,
} from '../views/paragraph-menu/component-keys';
import { ParagraphSettingIndex } from '../views/paragraph-setting/index';
import { ParagraphMenu, TableBlockMenu } from '../views/ParagraphMenu';
import { DOC_SECTION_SETTING_COMPONENT } from '../views/section-setting/component-name';
import { SectionSettingIndex } from '../views/section-setting/index';
import { COMPONENT_DOC_CREATE_TABLE_CONFIRM } from '../views/table/create/component-name';
import { DocCreateTableConfirm } from '../views/table/create/TableCreate';
const paragraphSettingIndexKey = 'doc_ui_paragraph-setting-panel';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerParts();
this._registerIcons();
}
private _registerParts(): void {
const componentManager = this._componentManager;
this.disposeWithMe(componentManager.register(COMPONENT_DOC_CREATE_TABLE_CONFIRM, DocCreateTableConfirm));
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
AlignTextBothIcon,
AddImageIcon,
BoldIcon,
CopyDoubleIcon,
ColumnIcon,
CutIcon,
DeleteColumnDoubleIcon,
DeleteIcon,
DeleteRowDoubleIcon,
DeleteTableDoubleIcon,
DocSettingIcon,
FontColorDoubleIcon,
GridIcon,
H1Icon,
H2Icon,
H3Icon,
H4Icon,
H5Icon,
HeaderFooterIcon,
HorizontallyIcon,
InsertDoubleIcon,
InsertRowAboveDoubleIcon,
InsertRowBelowDoubleIcon,
ItalicIcon,
KeyboardIcon,
LeftInsertColumnDoubleIcon,
LeftJustifyingIcon,
LineIndentDecreaseIcon,
LineIndentIncreaseIcon,
MenuIcon,
TextTypeIcon,
NoColorDoubleIcon,
OrderIcon,
PaintBucketDoubleIcon,
PasteSpecialDoubleIcon,
ReduceDoubleIcon,
ReduceIcon,
RightInsertColumnDoubleIcon,
RightJustifyingIcon,
ShapeIcon,
SmileIcon,
StrikethroughIcon,
SubscriptIcon,
SuperscriptIcon,
SymbolsIcon,
TitleTypeIcon,
SubtitleTypeIcon,
TodoListDoubleIcon,
UnderlineIcon,
UnorderIcon,
DefaultTextColorIcon,
HeaderTextColorIcon,
MoreRightIcon,
MoreLeftIcon,
'DocParagraphTextColorSwatchIcon.0': DocParagraphTextColorSwatchIcon0,
'DocParagraphTextColorSwatchIcon.1': DocParagraphTextColorSwatchIcon1,
'DocParagraphTextColorSwatchIcon.2': DocParagraphTextColorSwatchIcon2,
'DocParagraphTextColorSwatchIcon.3': DocParagraphTextColorSwatchIcon3,
'DocParagraphTextColorSwatchIcon.4': DocParagraphTextColorSwatchIcon4,
'DocParagraphTextColorSwatchIcon.5': DocParagraphTextColorSwatchIcon5,
'DocParagraphTextColorSwatchIcon.6': DocParagraphTextColorSwatchIcon6,
'DocParagraphBackgroundColorSwatchIcon.0': DocParagraphBackgroundColorSwatchIcon0,
'DocParagraphBackgroundColorSwatchIcon.1': DocParagraphBackgroundColorSwatchIcon1,
'DocParagraphBackgroundColorSwatchIcon.2': DocParagraphBackgroundColorSwatchIcon2,
'DocParagraphBackgroundColorSwatchIcon.3': DocParagraphBackgroundColorSwatchIcon3,
'DocParagraphBackgroundColorSwatchIcon.4': DocParagraphBackgroundColorSwatchIcon4,
'DocParagraphBackgroundColorSwatchIcon.5': DocParagraphBackgroundColorSwatchIcon5,
'DocParagraphBackgroundColorSwatchIcon.6': DocParagraphBackgroundColorSwatchIcon6,
'DocParagraphBackgroundColorSwatchIcon.7': DocParagraphBackgroundColorSwatchIcon7,
'DocParagraphBackgroundColorSwatchIcon.8': DocParagraphBackgroundColorSwatchIcon8,
'DocParagraphBackgroundColorSwatchIcon.9': DocParagraphBackgroundColorSwatchIcon9,
'DocParagraphBackgroundColorSwatchIcon.10': DocParagraphBackgroundColorSwatchIcon10,
'DocParagraphBackgroundColorSwatchIcon.11': DocParagraphBackgroundColorSwatchIcon11,
'DocParagraphBackgroundColorSwatchIcon.12': DocParagraphBackgroundColorSwatchIcon12,
'DocParagraphBackgroundColorSwatchIcon.13': DocParagraphBackgroundColorSwatchIcon13,
'DocParagraphBackgroundColorSwatchIcon.14': DocParagraphBackgroundColorSwatchIcon14,
'DocParagraphBackgroundColorSwatchIcon.15': DocParagraphBackgroundColorSwatchIcon15,
}));
}
private _registerComponents(): void {
if (!this._componentManager.get(COMPONENT_DOC_HEADER_FOOTER_PANEL)) {
this.disposeWithMe(this._componentManager.register(COMPONENT_DOC_HEADER_FOOTER_PANEL, DocHeaderFooterPanel));
}
this.disposeWithMe(this._componentManager.register(paragraphSettingIndexKey, ParagraphSettingIndex));
this.disposeWithMe(this._componentManager.register(DOC_SECTION_SETTING_COMPONENT, SectionSettingIndex));
([
[BULLET_LIST_TYPE_COMPONENT, BulletListTypePicker],
[ORDER_LIST_TYPE_COMPONENT, OrderListTypePicker],
[DOC_PARAGRAPH_MENU_COMPONENT_KEY, ParagraphMenu],
[DOC_TABLE_BLOCK_MENU_COMPONENT_KEY, TableBlockMenu],
[FLOAT_MENU_COMPONENT_KEY, FloatToolbar],
[PAGE_SETTING_COMPONENT_ID, PageSettings],
[DOC_LAYOUT_RECOVERY_COMPONENT, DocLayoutRecovery],
] as const).forEach(([key, comp]) => {
this.disposeWithMe(
this._componentManager.register(key, comp)
);
});
}
}

View File

@@ -0,0 +1,237 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 '@univerjs/core';
import type { ITabCommandParams } from '../commands/commands/auto-format.command';
import { Disposable, Inject, QuickListTypeMap } from '@univerjs/core';
import { DocSkeletonManagerService } from '@univerjs/docs';
import { IRenderManagerService } from '@univerjs/engine-render';
import { AfterSpaceCommand, EnterCommand, TabCommand } from '../commands/commands/auto-format.command';
import { BreakLineCommand } from '../commands/commands/break-line.command';
import {
ChangeListNestingLevelCommand,
ChangeListNestingLevelType,
ListOperationCommand,
QuickListCommand,
} from '../commands/commands/list.command';
import { QUICK_HEADING_MAP, QuickHeadingCommand } from '../commands/commands/set-heading.command';
import { DocTableTabCommand } from '../commands/commands/table/doc-table-tab.command';
import { DocAutoFormatService } from '../services/doc-auto-format.service';
import { isInSameTableCellData } from '../services/selection/convert-rect-range';
function hasQuickListType(text: string): text is keyof typeof QuickListTypeMap {
return Object.prototype.hasOwnProperty.call(QuickListTypeMap, text);
}
function hasQuickHeading(text: string): text is keyof typeof QUICK_HEADING_MAP {
return Object.prototype.hasOwnProperty.call(QUICK_HEADING_MAP, text);
}
export class DocAutoFormatController extends Disposable {
constructor(
@Inject(DocAutoFormatService) private readonly _docAutoFormatService: DocAutoFormatService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
this._initListTabAutoFormat();
this._initSpaceAutoFormat();
this._initDefaultEnterFormat();
this._initExitListAutoFormat();
}
private _initListTabAutoFormat() {
this.disposeWithMe(
this._docAutoFormatService.registerAutoFormat({
id: TabCommand.id,
match: (context) => {
const { selection, paragraphs, unit } = context;
// 1. match 1 bullet paragraph, range.start == paragraph.start
if (paragraphs.length === 1 && selection.startOffset === paragraphs[0].paragraphStart && paragraphs[0].bullet) {
// 2. cross paragraphs, some paragraph is bullet
const allParagraphs = unit.getBody()?.paragraphs;
// 3. disable first bullet indent
const bulletParagraphs = allParagraphs?.filter((p) => p.bullet?.listId === paragraphs[0].bullet!.listId);
if (bulletParagraphs?.findIndex((p) => p.startIndex === paragraphs[0].startIndex) === 0) {
return false;
}
return true;
} else if (paragraphs.length > 1 && paragraphs.some((p) => p.bullet)) {
return true;
}
return false;
},
// traverse all paragraphs, set paragraph
getMutations(context) {
const params = context.commandParams as Nullable<ITabCommandParams>;
return [{
id: ChangeListNestingLevelCommand.id,
params: {
type: params?.shift ? ChangeListNestingLevelType.decrease : ChangeListNestingLevelType.increase,
},
}];
},
priority: 100,
})
);
this.disposeWithMe(
this._docAutoFormatService.registerAutoFormat({
id: TabCommand.id,
match: (context) => {
const { selection, unit } = context;
const { startNodePosition, endNodePosition } = selection;
const renderObject = this._renderManagerService.getRenderUnitById(unit.getUnitId());
const skeleton = renderObject?.with(DocSkeletonManagerService).getSkeleton();
if (skeleton == null) {
return false;
}
if (startNodePosition && endNodePosition && isInSameTableCellData(skeleton, startNodePosition, endNodePosition)) {
return true;
}
if (startNodePosition && !endNodePosition && startNodePosition.path.indexOf('cells') > -1) {
return true;
}
return false;
},
getMutations(context) {
const params = context.commandParams as Nullable<ITabCommandParams>;
return [{
id: DocTableTabCommand.id,
params: {
shift: !!params?.shift,
},
}];
},
priority: 99,
})
);
}
private _initSpaceAutoFormat() {
this.disposeWithMe(
this._docAutoFormatService.registerAutoFormat({
id: AfterSpaceCommand.id,
match: (context) => {
const { selection, paragraphs, unit } = context;
if (!selection.collapsed) {
return false;
}
if (paragraphs.length !== 1) {
return false;
}
if (!selection.collapsed) {
return false;
}
const text = unit.getBody()?.dataStream.slice(paragraphs[0].paragraphStart, selection.startOffset - 1);
if (text && (hasQuickListType(text) || hasQuickHeading(text))) {
return true;
}
return false;
},
getMutations(context) {
const { paragraphs, unit, selection } = context;
const text = unit.getBody()?.dataStream.slice(paragraphs[0].paragraphStart, selection.startOffset - 1);
if (text && hasQuickListType(text)) {
const type = QuickListTypeMap[text];
return [{
id: QuickListCommand.id,
params: {
listType: type,
paragraph: paragraphs[0],
},
}];
}
if (text && hasQuickHeading(text)) {
const type = QUICK_HEADING_MAP[text];
return [{
id: QuickHeadingCommand.id,
params: {
value: type,
},
}];
}
return [];
},
})
);
}
private _initExitListAutoFormat() {
this.disposeWithMe(
this._docAutoFormatService.registerAutoFormat({
id: EnterCommand.id,
match: (context) => {
const { paragraphs } = context;
// selection at empty bullet paragraph
if (paragraphs.length === 1 && paragraphs[0].bullet && paragraphs[0].paragraphStart === paragraphs[0].paragraphEnd) {
return true;
}
return false;
},
getMutations: (context) => {
const bullet = context.paragraphs[0].bullet;
if (!bullet) {
return [];
}
if (bullet.nestingLevel > 0) {
return [{
id: ChangeListNestingLevelCommand.id,
params: {
type: ChangeListNestingLevelType.decrease,
},
}];
}
return [{
id: ListOperationCommand.id,
params: {
listType: context.paragraphs[0].bullet!.listType,
},
}];
},
})
);
}
private _initDefaultEnterFormat() {
// enter default
this.disposeWithMe(
this._docAutoFormatService.registerAutoFormat({
id: EnterCommand.id,
match: () => {
return true;
},
getMutations() {
return [{
id: BreakLineCommand.id,
}];
},
priority: -9999,
})
);
}
}

View File

@@ -0,0 +1,386 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommandInfo } from '@univerjs/core';
import type {
Documents,
IMouseEvent,
IPageRenderConfig,
IPathProps,
IPointerEvent,
IRenderContext,
IRenderModule,
RenderComponentType,
} from '@univerjs/engine-render';
import type { LocaleKey } from '../locale/types';
import {
ColorKit,
Disposable,
DocumentFlavor,
generateRandomId,
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
ThemeService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DocSkeletonManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DocumentEditArea, IRenderManagerService, PageLayoutType, Path, Rect, Vector2 } from '@univerjs/engine-render';
import { neoGetDocObject } from '../basics/component-tools';
import { CloseHeaderFooterCommand, CoreHeaderFooterCommand } from '../commands/commands/doc-header-footer.command';
import { IEditorService } from '../services/editor/editor-manager.service';
import { DocSelectionRenderService } from '../services/selection/doc-selection-render.service';
import { getHeaderFooterTarget } from '../utils/section-header-footer';
import { TextBubbleShape } from '../views/header-footer/text-bubble';
const HEADER_FOOTER_COVER_ALPHA = 0.5;
const HEADER_FOOTER_LABEL_ALPHA = 0.08;
export class DocHeaderFooterController extends Disposable implements IRenderModule {
private _loadedMap = new WeakSet<RenderComponentType>();
private _headerFooterColors = {
primary: '',
cover: '',
label: '',
};
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@ICommandService private readonly _commandService: ICommandService,
@IEditorService private readonly _editorService: IEditorService,
@IUniverInstanceService private readonly _instanceSrv: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(DocSkeletonManagerService) private readonly _docSkeletonManagerService: DocSkeletonManagerService,
@Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super();
this._initialize();
}
private _initialize() {
this._initThemeColors();
this._init();
this._drawHeaderFooterLabel();
this._listenSwitchMode();
}
private _initThemeColors(): void {
this.disposeWithMe(this._themeService.currentTheme$.subscribe(() => {
const primary = this._themeService.getColorFromTheme('primary.600');
this._headerFooterColors = {
primary,
cover: new ColorKit(this._themeService.getColorFromTheme('gray.0'))
.setAlpha(HEADER_FOOTER_COVER_ALPHA)
.toRgbString(),
label: new ColorKit(primary)
.setAlpha(HEADER_FOOTER_LABEL_ALPHA)
.toRgbString(),
};
}));
}
override dispose(): void {
super.dispose();
// this._loadedMap = new WeakSet<RenderComponentType>();
}
// Close header footer panel when switch mode.
private _listenSwitchMode() {
// this.disposeWithMe(
// this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
// if (SwitchDocModeCommand.id === command.id) {
// const viewModel = this._docSkeletonManagerService.getViewModel();
// const editArea = viewModel.getEditArea();
// if (editArea !== DocumentEditArea.BODY) {
// this._commandService.executeCommand(CloseHeaderFooterCommand.id, {
// unitId: this._context.unitId,
// });
// }
// }
// })
// );
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (RichTextEditingMutation.id === command.id) {
const docDataModel = this._context.unit;
const viewModel = this._docSkeletonManagerService.getViewModel();
const editArea = viewModel.getEditArea();
const documentFlavor = docDataModel.getSnapshot().documentStyle.documentFlavor;
if (editArea !== DocumentEditArea.BODY && documentFlavor === DocumentFlavor.MODERN) {
this._commandService.executeCommand(CloseHeaderFooterCommand.id, {
unitId: this._context.unitId,
});
}
}
})
);
}
private _init() {
const { unitId } = this._context;
const docObject = neoGetDocObject(this._context);
if (docObject == null || docObject.document == null) {
return;
}
if (!this._loadedMap.has(docObject.document)) {
this._initialMain(unitId);
this._loadedMap.add(docObject.document);
}
}
private _initialMain(unitId: string) {
const docObject = neoGetDocObject(this._context);
const { document } = docObject;
this.disposeWithMe(document.onDblclick$.subscribeEvent(async (evt: IPointerEvent | IMouseEvent) => {
if (this._isEditorReadOnly(unitId)) {
return;
}
if (!this._isTraditionalMode()) {
return;
}
const { offsetX, offsetY } = evt;
const {
pageLayoutType = PageLayoutType.VERTICAL,
pageMarginLeft,
pageMarginTop,
} = document.getOffsetConfig();
const coord = this._getTransformCoordForDocumentOffset(offsetX, offsetY);
if (coord == null) {
return;
}
const viewModel = this._docSkeletonManagerService.getViewModel();
const skeleton = this._docSkeletonManagerService.getSkeleton();
const preEditArea = viewModel.getEditArea();
const { editArea, pageNumber } = skeleton.findEditAreaByCoord(
coord,
pageLayoutType,
pageMarginLeft,
pageMarginTop
);
if (
preEditArea === editArea &&
(editArea === DocumentEditArea.BODY || this._docSelectionRenderService.getSegmentPage() === pageNumber)
) {
return;
}
viewModel.setEditArea(editArea);
const page = skeleton.getSkeletonData()?.pages[pageNumber];
const { createType, headerFooterId, sectionId } = getHeaderFooterTarget(viewModel, editArea, pageNumber, page);
if (editArea === DocumentEditArea.BODY) {
this._docSelectionRenderService.setSegment('');
this._docSelectionRenderService.setSegmentPage(-1);
this._docSelectionRenderService.setCursorManually(offsetX, offsetY);
} else {
if (createType != null) {
const SEGMENT_ID_LEN = 6;
const segmentId = generateRandomId(SEGMENT_ID_LEN);
this._docSelectionRenderService.setSegment(segmentId);
this._docSelectionRenderService.setSegmentPage(pageNumber);
await this._commandService.executeCommand(CoreHeaderFooterCommand.id, {
unitId,
createType,
segmentId,
sectionId,
});
} else if (headerFooterId != null) {
this._docSelectionRenderService.setSegment(headerFooterId);
this._docSelectionRenderService.setSegmentPage(pageNumber);
this._docSelectionRenderService.setCursorManually(offsetX, offsetY);
}
}
}));
}
private _getTransformCoordForDocumentOffset(evtOffsetX: number, evtOffsetY: number) {
const docObject = neoGetDocObject(this._context);
const { document, scene } = docObject;
const { documentTransform } = document.getOffsetConfig();
const activeViewport = scene.getViewports()[0];
if (activeViewport == null) {
return;
}
const originCoord = activeViewport.transformVector2SceneCoord(Vector2.FromArray([evtOffsetX, evtOffsetY]));
return documentTransform.clone().invert().applyPoint(originCoord);
}
private _drawHeaderFooterLabel() {
this.disposeWithMe(this._instanceSrv.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_DOC).subscribe((unit) => {
if (unit == null) {
return;
}
const unitId = unit.getUnitId();
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
if (this._editorService.isEditor(unitId) || this._instanceSrv.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC) == null) {
return;
}
if (currentRender == null) {
return;
}
const { mainComponent } = currentRender;
const docsComponent = mainComponent as Documents;
this.disposeWithMe(
toDisposable(
docsComponent.pageRender$.subscribe((config: IPageRenderConfig) => this._drawHeaderFooterPage(config, unitId))
)
);
}));
}
private _drawHeaderFooterPage(config: IPageRenderConfig, unitId: string): void {
if (this._editorService.isEditor(unitId) || !this._isTraditionalMode()) {
return;
}
const editArea = this._docSkeletonManagerService.getViewModel().getEditArea();
const isEditBody = editArea === DocumentEditArea.BODY;
const { pageLeft, pageTop, ctx } = config;
ctx.save();
ctx.translate(pageLeft - 0.5, pageTop - 0.5);
this._drawHeaderFooterCover(config, isEditBody);
if (!isEditBody) {
this._drawHeaderFooterGuides(config);
}
ctx.restore();
}
private _drawHeaderFooterCover({ page, ctx }: IPageRenderConfig, isEditBody: boolean): void {
const { pageWidth, pageHeight, marginTop, marginBottom } = page;
if (isEditBody) {
Rect.drawWith(ctx, {
left: 0,
top: 0,
width: pageWidth,
height: marginTop,
fill: this._headerFooterColors.cover,
});
ctx.save();
ctx.translate(0, pageHeight - marginBottom);
Rect.drawWith(ctx, {
left: 0,
top: 0,
width: pageWidth,
height: marginBottom,
fill: this._headerFooterColors.cover,
});
ctx.restore();
return;
}
ctx.save();
ctx.translate(0, marginTop);
Rect.drawWith(ctx, {
left: 0,
top: marginTop,
width: pageWidth,
height: pageHeight - marginTop - marginBottom,
fill: this._headerFooterColors.cover,
});
ctx.restore();
}
private _drawHeaderFooterGuides({ page, ctx }: IPageRenderConfig): void {
const { pageWidth, pageHeight, marginTop, marginBottom } = page;
const headerPathConfig = {
dataArray: [{
command: 'M',
points: [0, marginTop],
}, {
command: 'L',
points: [pageWidth, marginTop],
}] as unknown as IPathProps['dataArray'],
strokeWidth: 1,
stroke: this._headerFooterColors.primary,
};
const footerPathConfig = {
dataArray: [{
command: 'M',
points: [0, pageHeight - marginBottom],
}, {
command: 'L',
points: [pageWidth, pageHeight - marginBottom],
}] as unknown as IPathProps['dataArray'],
strokeWidth: 1,
stroke: this._headerFooterColors.primary,
};
Path.drawWith(ctx, headerPathConfig);
Path.drawWith(ctx, footerPathConfig);
ctx.translate(0, marginTop + 1);
TextBubbleShape.drawWith(ctx, {
text: this._localeService.t<LocaleKey>('docs-ui.headerFooter.header'),
color: this._headerFooterColors.label,
});
ctx.translate(0, pageHeight - marginTop - marginBottom);
TextBubbleShape.drawWith(ctx, {
text: this._localeService.t<LocaleKey>('docs-ui.headerFooter.footer'),
color: this._headerFooterColors.label,
});
}
private _isEditorReadOnly(unitId: string) {
const editor = this._editorService.getEditor(unitId);
if (!editor) {
return false;
}
return editor.isReadOnly();
}
private _isTraditionalMode() {
const docDataModel = this._context.unit;
const documentFlavor = docDataModel.getSnapshot().documentStyle.documentFlavor;
return documentFlavor === DocumentFlavor.TRADITIONAL;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService, Inject } from '@univerjs/core';
import { ISidebarService } from '@univerjs/ui';
const paragraphSettingIndexKey = 'doc_ui_paragraph-setting-panel';
export class DocParagraphSettingController extends Disposable {
private _id: 'DocParagraphSetting';
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(ISidebarService) private _sidebarService: ISidebarService
) {
super();
}
public openPanel() {
const props = {
header: { title: 'docs-ui.doc.slider.paragraphSetting' },
id: this._id,
children: {
label: paragraphSettingIndexKey,
},
width: 300,
};
this._sidebarService.open(props);
}
public closePanel() {
this._sidebarService.close(this._id);
}
}

View File

@@ -0,0 +1,55 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { ISidebarService } from '@univerjs/ui';
import { DOC_SECTION_SETTING_COMPONENT } from '../views/section-setting/component-name';
export class DocSectionSettingController extends Disposable {
private readonly _panelId = 'DocSectionSetting';
constructor(
@Inject(ISidebarService) private readonly _sidebarService: ISidebarService,
@Inject(DocSelectionManagerService) private readonly _selectionManager: DocSelectionManagerService
) {
super();
}
openPanel(): void {
this._sidebarService.open({
header: { title: 'docs-ui.doc.slider.sectionSetting' },
id: this._panelId,
children: { label: DOC_SECTION_SETTING_COMPONENT },
width: 300,
});
}
closePanel(): void {
this._sidebarService.close(this._panelId);
}
navigateToSectionEnd(unitId: string, sectionStart: number, sectionEnd: number): void {
const params = { unitId, subUnitId: unitId };
const cursorOffset = Math.max(sectionStart, sectionEnd - 1);
this._selectionManager.replaceDocRanges(
[{ startOffset: cursorOffset, endOffset: cursorOffset }],
params,
true,
{ shouldFocus: false }
);
}
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService } from '@univerjs/core';
import { DocCreateTableOperation } from '../commands/operations/doc-create-table.operation';
export class DocTableController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initialize();
}
private _initialize() {
this._init();
this._registerCommands();
}
private _registerCommands() {
[
DocCreateTableOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _init() {}
}

View File

@@ -0,0 +1,140 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { AddImageIcon, AlignTextBothIcon, BoldIcon, ColumnIcon, CopyDoubleIcon, CutIcon, DeleteColumnDoubleIcon, DeleteIcon, DeleteRowDoubleIcon, DeleteTableDoubleIcon, DocSettingIcon, FontColorDoubleIcon, GridIcon, H1Icon, H2Icon, H3Icon, H4Icon, H5Icon, HeaderFooterIcon, HorizontallyIcon, InsertDoubleIcon, InsertRowAboveDoubleIcon, InsertRowBelowDoubleIcon, ItalicIcon, KeyboardIcon, LeftInsertColumnDoubleIcon, LeftJustifyingIcon, LineIndentDecreaseIcon, LineIndentIncreaseIcon, MenuIcon, MoreLeftIcon, MoreRightIcon, NoColorDoubleIcon, OrderIcon, PaintBucketDoubleIcon, PasteSpecialDoubleIcon, ReduceDoubleIcon, ReduceIcon, RightInsertColumnDoubleIcon, RightJustifyingIcon, ShapeIcon, SmileIcon, StrikethroughIcon, SubscriptIcon, SuperscriptIcon, SymbolsIcon, TextTypeIcon, TodoListDoubleIcon, UnderlineIcon, UnorderIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DOC_LAYOUT_RECOVERY_COMPONENT, DocLayoutRecovery } from '../../views/DocLayoutRecovery';
import { COMPONENT_DOC_HEADER_FOOTER_PANEL } from '../../views/header-footer/panel/component-name';
import { DefaultTextColorIcon, DocParagraphBackgroundColorSwatchIcon0, DocParagraphBackgroundColorSwatchIcon1, DocParagraphBackgroundColorSwatchIcon10, DocParagraphBackgroundColorSwatchIcon11, DocParagraphBackgroundColorSwatchIcon12, DocParagraphBackgroundColorSwatchIcon13, DocParagraphBackgroundColorSwatchIcon14, DocParagraphBackgroundColorSwatchIcon15, DocParagraphBackgroundColorSwatchIcon2, DocParagraphBackgroundColorSwatchIcon3, DocParagraphBackgroundColorSwatchIcon4, DocParagraphBackgroundColorSwatchIcon5, DocParagraphBackgroundColorSwatchIcon6, DocParagraphBackgroundColorSwatchIcon7, DocParagraphBackgroundColorSwatchIcon8, DocParagraphBackgroundColorSwatchIcon9, DocParagraphTextColorSwatchIcon0, DocParagraphTextColorSwatchIcon1, DocParagraphTextColorSwatchIcon2, DocParagraphTextColorSwatchIcon3, DocParagraphTextColorSwatchIcon4, DocParagraphTextColorSwatchIcon5, DocParagraphTextColorSwatchIcon6, HeaderTextColorIcon, SubtitleTypeIcon, TitleTypeIcon } from '../../views/Icon';
import { BULLET_LIST_TYPE_COMPONENT, BulletListTypePicker, ORDER_LIST_TYPE_COMPONENT, OrderListTypePicker } from '../../views/list-type-picker/index';
import { MOBILE_DOC_ELEMENT_MENU, MobileDocElementMenuPopup } from '../../views/mobile-element-menu/MobileDocElementMenu';
import { MobileHeaderFooterPanel, MobilePageSettings, MobileParagraphSettings, MobileSectionSettings } from '../../views/mobile/MobileDocSettings';
import { PAGE_SETTING_COMPONENT_ID } from '../../views/PageSettings';
import { DOC_SECTION_SETTING_COMPONENT } from '../../views/section-setting/component-name';
import { COMPONENT_DOC_CREATE_TABLE_CONFIRM } from '../../views/table/create/component-name';
import { DocCreateTableConfirm } from '../../views/table/create/TableCreate';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) protected readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerParts();
this._registerIcons();
}
private _registerParts(): void {
const componentManager = this._componentManager;
this.disposeWithMe(componentManager.register(COMPONENT_DOC_CREATE_TABLE_CONFIRM, DocCreateTableConfirm));
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
AlignTextBothIcon,
AddImageIcon,
BoldIcon,
CopyDoubleIcon,
ColumnIcon,
CutIcon,
DeleteColumnDoubleIcon,
DeleteIcon,
DeleteRowDoubleIcon,
DeleteTableDoubleIcon,
DocSettingIcon,
FontColorDoubleIcon,
GridIcon,
H1Icon,
H2Icon,
H3Icon,
H4Icon,
H5Icon,
HeaderFooterIcon,
HorizontallyIcon,
InsertDoubleIcon,
InsertRowAboveDoubleIcon,
InsertRowBelowDoubleIcon,
ItalicIcon,
KeyboardIcon,
LeftInsertColumnDoubleIcon,
LeftJustifyingIcon,
LineIndentDecreaseIcon,
LineIndentIncreaseIcon,
MenuIcon,
TextTypeIcon,
NoColorDoubleIcon,
OrderIcon,
PaintBucketDoubleIcon,
PasteSpecialDoubleIcon,
ReduceDoubleIcon,
ReduceIcon,
RightInsertColumnDoubleIcon,
RightJustifyingIcon,
ShapeIcon,
SmileIcon,
StrikethroughIcon,
SubscriptIcon,
SuperscriptIcon,
SymbolsIcon,
TitleTypeIcon,
SubtitleTypeIcon,
TodoListDoubleIcon,
UnderlineIcon,
UnorderIcon,
DefaultTextColorIcon,
HeaderTextColorIcon,
MoreRightIcon,
MoreLeftIcon,
'DocParagraphTextColorSwatchIcon.0': DocParagraphTextColorSwatchIcon0,
'DocParagraphTextColorSwatchIcon.1': DocParagraphTextColorSwatchIcon1,
'DocParagraphTextColorSwatchIcon.2': DocParagraphTextColorSwatchIcon2,
'DocParagraphTextColorSwatchIcon.3': DocParagraphTextColorSwatchIcon3,
'DocParagraphTextColorSwatchIcon.4': DocParagraphTextColorSwatchIcon4,
'DocParagraphTextColorSwatchIcon.5': DocParagraphTextColorSwatchIcon5,
'DocParagraphTextColorSwatchIcon.6': DocParagraphTextColorSwatchIcon6,
'DocParagraphBackgroundColorSwatchIcon.0': DocParagraphBackgroundColorSwatchIcon0,
'DocParagraphBackgroundColorSwatchIcon.1': DocParagraphBackgroundColorSwatchIcon1,
'DocParagraphBackgroundColorSwatchIcon.2': DocParagraphBackgroundColorSwatchIcon2,
'DocParagraphBackgroundColorSwatchIcon.3': DocParagraphBackgroundColorSwatchIcon3,
'DocParagraphBackgroundColorSwatchIcon.4': DocParagraphBackgroundColorSwatchIcon4,
'DocParagraphBackgroundColorSwatchIcon.5': DocParagraphBackgroundColorSwatchIcon5,
'DocParagraphBackgroundColorSwatchIcon.6': DocParagraphBackgroundColorSwatchIcon6,
'DocParagraphBackgroundColorSwatchIcon.7': DocParagraphBackgroundColorSwatchIcon7,
'DocParagraphBackgroundColorSwatchIcon.8': DocParagraphBackgroundColorSwatchIcon8,
'DocParagraphBackgroundColorSwatchIcon.9': DocParagraphBackgroundColorSwatchIcon9,
'DocParagraphBackgroundColorSwatchIcon.10': DocParagraphBackgroundColorSwatchIcon10,
'DocParagraphBackgroundColorSwatchIcon.11': DocParagraphBackgroundColorSwatchIcon11,
'DocParagraphBackgroundColorSwatchIcon.12': DocParagraphBackgroundColorSwatchIcon12,
'DocParagraphBackgroundColorSwatchIcon.13': DocParagraphBackgroundColorSwatchIcon13,
'DocParagraphBackgroundColorSwatchIcon.14': DocParagraphBackgroundColorSwatchIcon14,
'DocParagraphBackgroundColorSwatchIcon.15': DocParagraphBackgroundColorSwatchIcon15,
}));
}
protected _registerComponents(): void {
this.disposeWithMe(this._componentManager.register(COMPONENT_DOC_HEADER_FOOTER_PANEL, MobileHeaderFooterPanel));
this.disposeWithMe(this._componentManager.register('doc_ui_paragraph-setting-panel', MobileParagraphSettings));
this.disposeWithMe(this._componentManager.register(DOC_SECTION_SETTING_COMPONENT, MobileSectionSettings));
this.disposeWithMe(this._componentManager.register(PAGE_SETTING_COMPONENT_ID, MobilePageSettings));
this.disposeWithMe(this._componentManager.register(MOBILE_DOC_ELEMENT_MENU, MobileDocElementMenuPopup));
this.disposeWithMe(this._componentManager.register(BULLET_LIST_TYPE_COMPONENT, BulletListTypePicker));
this.disposeWithMe(this._componentManager.register(ORDER_LIST_TYPE_COMPONENT, OrderListTypePicker));
this.disposeWithMe(this._componentManager.register(DOC_LAYOUT_RECOVERY_COMPONENT, DocLayoutRecovery));
}
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService, Inject, Injector, UniverInstanceType } from '@univerjs/core';
import { BuiltInUIPart, connectInjector, ILayoutService, IMenuManagerService, IUIPartsService } from '@univerjs/ui';
import { CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../../commands/commands/doc-header-footer.command';
import { SidebarDocHeaderFooterPanelOperation } from '../../commands/operations/doc-header-footer-panel.operation';
import { OpenDocParagraphPermissionOperation } from '../../commands/operations/paragraph-permission.operation';
import { OpenDocPermissionPanelOperation } from '../../commands/operations/permission-panel.operation';
import { mobileMenuSchema } from '../../menu/mobile-schema';
import { menuSchema } from '../../menu/schema';
import { MobileDocEditDoneButton, MobileDocToolbar } from '../../views/mobile-doc-toolbar/MobileDocToolbar';
import { MobileDocCanvasViewport } from '../../views/mobile/MobileDocCanvasViewport';
export class DocMobileUIController extends Disposable {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@ICommandService private readonly _commandService: ICommandService,
@ILayoutService private readonly _layoutService: ILayoutService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IUIPartsService private readonly _uiPartsService: IUIPartsService
) {
super();
this._initMenus();
this._initFocusHandler();
this._initCommands();
this._initUiParts();
}
private _initUiParts(): void {
this.disposeWithMe(this._uiPartsService.registerComponent(
BuiltInUIPart.CONTENT,
() => connectInjector(MobileDocCanvasViewport, this._injector)
));
this.disposeWithMe(this._uiPartsService.registerComponent(
BuiltInUIPart.FOOTER,
() => connectInjector(MobileDocToolbar, this._injector)
));
this.disposeWithMe(this._uiPartsService.registerComponent(
BuiltInUIPart.HEADER_MENU,
() => connectInjector(MobileDocEditDoneButton, this._injector)
));
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
this._menuManagerService.appendRootMenu(mobileMenuSchema);
}
private _initFocusHandler(): void {
// Only a direct canvas gesture may reopen the software keyboard.
this.disposeWithMe(this._layoutService.registerFocusHandler(UniverInstanceType.UNIVER_DOC, () => {}));
}
private _initCommands(): void {
[
OpenDocPermissionPanelOperation,
OpenDocParagraphPermissionOperation,
CoreHeaderFooterCommand,
OpenHeaderFooterPanelCommand,
SidebarDocHeaderFooterPanelOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
}

View File

@@ -0,0 +1,472 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @vitest-environment jsdom
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import type { IPointerEvent } from '@univerjs/engine-render';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { DocumentFlavor, getDocsEmptySnapshot, ICommandService, IUniverInstanceService, LocaleType, RichTextBuilder, Univer, UniverInstanceType } from '@univerjs/core';
import { DocLayoutExecutorService, DocSelectionManagerService, DocSkeletonManagerService, SetTextSelectionsOperation } from '@univerjs/docs';
import {
CanvasColorService,
DocBackground,
Documents,
Engine,
ICanvasColorService,
IRenderManagerService,
RenderManagerService,
RenderUnit,
Scene,
ScrollBar,
Viewport,
} from '@univerjs/engine-render';
import { DesktopLayoutService, ILayoutService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DOCS_VIEW_KEY, VIEWPORT_KEY } from '../../../basics/docs-view-key';
import { DocPageLayoutService } from '../../../services/doc-page-layout.service';
import { DocViewScaleService } from '../../../services/doc-view-scale';
import { EditorService, IEditorService } from '../../../services/editor/editor-manager.service';
import { MobileDocSelectionRenderService } from '../../../services/mobile/doc-selection-render.service';
import { MobileDocViewScaleService } from '../../../services/mobile/doc-view-scale';
import { NodePositionConvertToCursor } from '../../../services/selection/convert-text-range';
import { DocSelectionRenderService } from '../../../services/selection/doc-selection-render.service';
import { getAnchorBounding } from '../../../services/selection/text-range';
import { DocBackScrollRenderController } from '../back-scroll.render-controller';
import { MobileDocBackScrollRenderController } from '../mobile/back-scroll.render-controller';
function createEditor() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
injector.add([IEditorService, { useClass: EditorService }]);
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
injector.get(ICommandService).registerCommand(SetTextSelectionsOperation);
const dataStream = `${Array.from({ length: 100 }, (_, i) => `Paragraph ${i} has enough text to wrap onto several lines.\r`).join('')}\n`;
const model = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, {
id: 'back-scroll-test',
body: {
dataStream,
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({
startIndex: match.index!,
paragraphId: `paragraph-${index}`,
})),
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'body' }],
},
documentStyle: {
documentFlavor: DocumentFlavor.TRADITIONAL,
pageSize: { width: 300, height: 400 },
marginLeft: 20,
marginRight: 20,
marginTop: 20,
marginBottom: 20,
},
});
const unitId = model.getUnitId();
injector.get(IUniverInstanceService).setCurrentUnitForType(unitId);
const render = injector.get(IRenderManagerService).createRender(unitId) as RenderUnit;
render.deactivate();
render.engine.resizeBySize(400, 250);
render.addRenderDependencies([[DocSkeletonManagerService]]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const pages = skeleton.getSkeletonData()!.pages;
expect(pages.length).toBeGreaterThan(4);
const height = pages.reduce((sum, page) => sum + page.pageHeight + 20, 0);
render.scene.transformByState({ width: 400, height });
const documents = new Documents('doc-main', skeleton, { pageMarginTop: 20, pageMarginLeft: 0 });
documents.resize(300, height);
render.mainComponent = documents;
render.scene.addObject(documents);
const viewport = new Viewport(VIEWPORT_KEY.VIEW_MAIN, render.scene, {
left: 0,
top: 0,
width: 400,
height: 250,
active: true,
});
new ScrollBar(viewport, { mainScene: render.scene });
viewport.resetCanvasSizeAndUpdateScroll();
expect(viewport.isActive).toBe(true);
render.addRenderDependencies([[DocViewScaleService], [DocPageLayoutService], [DocSelectionRenderService], [DocBackScrollRenderController]]);
const controller = render.with(DocBackScrollRenderController);
const selections = injector.get(DocSelectionManagerService);
return {
controller,
render,
skeleton,
viewport,
select(offset: number, isEditing = true): void {
selections.__TEST_ONLY_add([{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true }], isEditing);
selections.__replaceTextRangesWithNoRefresh({
...selections.getSelectionInfo()!,
textRanges: [{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true }],
rectRanges: [],
isEditing,
}, { unitId, subUnitId: unitId });
expect(selections.getActiveTextRange()?.startOffset).toBe(offset);
},
dispose(): void {
univer.dispose();
},
};
}
beforeEach(() => {
const context = new Proxy({
font: '',
webkitBackingStorePixelRatio: 1,
measureText: (text: string) => ({
width: text.length * 8,
actualBoundingBoxAscent: 8,
actualBoundingBoxDescent: 2,
fontBoundingBoxAscent: 8,
fontBoundingBoxDescent: 2,
}),
}, { get: (target, key) => key in target ? Reflect.get(target, key) : () => {} });
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as never);
});
describe('DocBackScrollRenderController', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('keeps a new manual scroll instead of replaying an earlier input scroll', async () => {
const editor = createEditor();
try {
editor.select(3);
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
const userScroll = editor.viewport.viewportScrollY;
expect(userScroll).toBeGreaterThan(1000);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(userScroll);
} finally {
editor.dispose();
}
});
it('does not reuse an input scroll for a newer non-editing selection', async () => {
const editor = createEditor();
try {
editor.select(3);
editor.select(editor.skeleton.getSkeletonData()!.pages[4].st, false);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
} finally {
editor.dispose();
}
});
it('still follows typing when no newer interaction takes ownership', async () => {
const editor = createEditor();
try {
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
expect(editor.viewport.viewportScrollY).toBeGreaterThan(1000);
editor.select(3);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
} finally {
editor.dispose();
}
});
it('does not let a later layout refresh reclaim a manually scrolled viewport', async () => {
const editor = createEditor();
try {
editor.select(3);
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
await vi.advanceTimersByTimeAsync(20);
editor.select(3);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
editor.select(4);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
} finally {
editor.dispose();
}
});
it('cancels an earlier input scroll as soon as a pointer selection starts', async () => {
const editor = createEditor();
try {
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
editor.select(3);
editor.render.scene.onPointerDown$.emitEvent({ offsetX: 40, offsetY: 40, button: 0 } as IPointerEvent);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
} finally {
editor.dispose();
}
});
it('cancels a materialization retry when the user scrolls elsewhere', async () => {
const editor = createEditor();
try {
const pages = editor.skeleton.getSkeletonData()!.pages;
const original = pages[4];
pages[4] = { ...original, isMaterializationPlaceholder: true, sections: [], skeTables: new Map() };
editor.controller.scrollToRange({ startOffset: original.st, endOffset: original.st, collapsed: true });
expect(editor.viewport.viewportScrollY).toBeGreaterThan(1200);
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
pages[4] = original;
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
} finally {
editor.dispose();
}
});
it('waits for a usable document viewport before revealing an SDK range', async () => {
const editor = createEditor();
try {
const documentComponent = editor.render.mainComponent as Documents;
const target = editor.skeleton.getSkeletonData()!.pages[4].st;
editor.select(3);
await vi.advanceTimersByTimeAsync(20);
documentComponent.translate(-10000, -10000);
editor.controller.scrollToRange({ startOffset: target, endOffset: target, collapsed: true });
// A resize/layout refresh can replay the pre-request selection while
// the real viewport is becoming ready. It must not steal ownership.
editor.select(3, false);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(0);
documentComponent.translate(0, 20);
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBeGreaterThan(1000);
} finally {
editor.dispose();
}
});
it('releases an outstanding input scroll on disposal', async () => {
const editor = createEditor();
try {
editor.viewport.scrollToViewportPos({ viewportScrollY: 1200 });
editor.select(3);
editor.controller.dispose();
await vi.advanceTimersByTimeAsync(20);
expect(editor.viewport.viewportScrollY).toBe(1200);
} finally {
editor.dispose();
}
});
});
describe('back scroll controller platform boundaries', () => {
it('keeps mobile-only state and services out of the desktop controller', () => {
const desktopSource = readFileSync(resolve(process.cwd(), 'src/controllers/render-controllers/back-scroll.render-controller.ts'), 'utf8');
const mobileSource = readFileSync(resolve(process.cwd(), 'src/controllers/render-controllers/mobile/back-scroll.render-controller.ts'), 'utf8');
expect(desktopSource).not.toContain('mobileKeyboard');
expect(desktopSource).not.toContain('_initViewportOcclusion');
expect(desktopSource).not.toContain('DocSelectionRenderService');
expect(desktopSource).not.toContain('DocPageLayoutService');
expect(desktopSource).not.toContain('setBottomReserve');
expect(mobileSource).toContain('extends DocBackScrollRenderController');
});
});
const cleanup: Array<() => void> = [];
function createRender(mobile = true, documentFlavor = DocumentFlavor.MODERN, zoomRatio = 1) {
const originalViewport = window.visualViewport;
const visualViewport = Object.assign(new EventTarget(), {
height: 800,
width: 430,
offsetTop: 0,
offsetLeft: 0,
});
Object.defineProperty(window, 'visualViewport', { configurable: true, value: visualViewport });
cleanup.push(() => Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: originalViewport,
}));
const univer = new Univer({ locale: LocaleType.EN_US });
cleanup.push(() => univer.dispose());
const injector = univer.__getInjector();
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([IEditorService, { useClass: EditorService }]);
injector.add([DocLayoutExecutorService]);
injector.add([DocSelectionManagerService]);
const data = getDocsEmptySnapshot('keyboard-doc', LocaleType.EN_US, 'Keyboard', documentFlavor);
data.settings = { ...data.settings, zoomRatio };
const builder = RichTextBuilder.create();
for (let index = 0; index < 80; index++) {
builder.paragraph({ spaceAfter: 8 }).span(`Mobile paragraph ${index}`, { fontSize: 18 });
}
const body = builder.getData().body;
if (!body) {
throw new Error('The keyboard regression document must have a body');
}
data.body = {
...body,
sectionBreaks: [{ sectionId: 'keyboard-section', startIndex: body.dataStream.length - 1 }],
};
data.documentStyle = {
...data.documentStyle,
pageSize: { width: 400, height: Infinity },
marginTop: 20,
marginBottom: 20,
marginLeft: 20,
marginRight: 20,
};
const doc = univer.createUnit(UniverInstanceType.UNIVER_DOC, data);
const engine = injector.createInstance(Engine, doc.getUnitId(), { elementWidth: 430, elementHeight: 800 });
cleanup.push(() => engine.dispose());
const scene = new Scene('document', engine);
cleanup.push(() => scene.dispose());
const render = injector.createInstance(RenderUnit, { unit: doc, engine, scene, isMainScene: true });
cleanup.push(() => render.dispose());
render.addRenderDependencies([
[DocSkeletonManagerService],
[DocViewScaleService, { useClass: mobile ? MobileDocViewScaleService : DocViewScaleService }],
[DocPageLayoutService],
[DocSelectionRenderService, { useClass: mobile ? MobileDocSelectionRenderService : DocSelectionRenderService }],
]);
const skeleton = render.with(DocSkeletonManagerService).getSkeleton();
const documents = new Documents(DOCS_VIEW_KEY.MAIN, skeleton);
const background = new DocBackground(DOCS_VIEW_KEY.BACKGROUND, skeleton);
documents.resize(400, skeleton.getActualSize().actualHeight + 40);
render.mainComponent = documents;
render.components.set(DOCS_VIEW_KEY.BACKGROUND, background);
scene.addObjects([documents, background]);
const viewport = new Viewport(VIEWPORT_KEY.VIEW_MAIN, scene, { left: 0, top: 0, right: 0, bottom: 0 });
const scrollbar = new ScrollBar(viewport);
cleanup.push(() => scrollbar.dispose());
render.with(DocPageLayoutService).calculatePagePosition();
render.addRenderDependencies([[DocBackScrollRenderController, { useClass: mobile ? MobileDocBackScrollRenderController : DocBackScrollRenderController }]]);
render.with(DocBackScrollRenderController);
const selection = render.with(DocSelectionRenderService);
if (mobile) {
(selection as MobileDocSelectionRenderService).enterMobileEditMode();
}
const manager = injector.get(DocSelectionManagerService);
const getCaretBounds = (paragraph: number) => {
const offset = data.body?.paragraphs?.[paragraph].startIndex ?? 0;
const position = skeleton.findNodePositionByCharIndex(offset, true);
const converter = new NodePositionConvertToCursor(documents.getOffsetConfig(), skeleton);
const { contentBoxPointGroup } = converter.getRangePointData(position, position);
const bounds = getAnchorBounding(contentBoxPointGroup);
return { top: bounds.top + documents.top, bottom: bounds.top + documents.top + bounds.height, position };
};
const setCaret = (paragraph: number) => {
const offset = data.body?.paragraphs?.[paragraph].startIndex ?? 0;
manager.__TEST_ONLY_add([{ startOffset: offset, endOffset: offset, collapsed: true, isActive: true }]);
return getCaretBounds(paragraph);
};
const setKeyboardHeight = (height: number) => {
visualViewport.height = 800 - height;
visualViewport.dispatchEvent(new Event('resize'));
};
return { engine, scene, render, viewport, getCaretBounds, setCaret, setKeyboardHeight };
}
describe('DocBackScrollRenderController mobile keyboard visibility', () => {
afterEach(() => {
for (const dispose of cleanup.reverse()) {
dispose();
}
cleanup.length = 0;
vi.restoreAllMocks();
});
it.each([DocumentFlavor.MODERN, DocumentFlavor.TRADITIONAL])(
'reveals an obscured caret synchronously before the next frame for document flavor %s',
(flavor) => {
const { viewport, engine, getCaretBounds, setCaret, setKeyboardHeight } = createRender(true, flavor);
const before = viewport.calcViewportInfo().viewBound;
let paragraph = 0;
while (paragraph < 79 && getCaretBounds(paragraph).bottom <= before.bottom - 380 - 48) {
paragraph++;
}
const caret = setCaret(paragraph);
expect(caret.bottom).toBeLessThan(before.bottom);
expect(caret.bottom).toBeGreaterThan(before.bottom - 380 - 48);
setKeyboardHeight(380);
const after = viewport.calcViewportInfo().viewBound;
expect(after.top).toBeGreaterThan(before.top);
expect(caret.bottom).toBeLessThanOrEqual(after.bottom - 380 - 48);
expect(caret.top - after.top).toBeCloseTo(
(after.bottom - after.top - 380 - 48 - (caret.bottom - caret.top)) * 0.4,
0
);
expect(engine.height).toBe(800);
const scrollTop = viewport.viewportScrollY;
setKeyboardHeight(380);
expect(viewport.viewportScrollY).toBe(scrollTop);
}
);
it.each([0.5, 1, 2])('places an offscreen caret above center at scale %s without moving it again', (scale) => {
const { scene, render, viewport, setCaret, setKeyboardHeight } = createRender(true, DocumentFlavor.TRADITIONAL, scale);
expect(scene.getAncestorScale().scaleY).toBe(scale);
const caret = setCaret(40);
setKeyboardHeight(380);
const assertPosition = () => {
const bound = viewport.calcViewportInfo().viewBound;
const expectedTop = (bound.bottom - bound.top - 428 / scale - (caret.bottom - caret.top)) * 0.4;
// Viewport scrolling rounds to physical pixels.
expect(Math.abs(caret.top - bound.top - expectedTop) * scale).toBeLessThan(1);
};
assertPosition();
const controller = render.with(DocBackScrollRenderController);
const scroll = viewport.viewportScrollY;
controller.scrollToNode(caret.position);
expect(viewport.viewportScrollY).toBe(scroll);
viewport.scrollToViewportPos({ viewportScrollY: viewport.viewportScrollY + 400 / scale });
controller.scrollToNode(caret.position);
assertPosition();
});
it('does not move a visible caret or pull the document back as the keyboard closes', () => {
const { viewport, setCaret, setKeyboardHeight } = createRender();
setCaret(0);
const initialScroll = viewport.viewportScrollY;
setKeyboardHeight(380);
expect(viewport.viewportScrollY).toBe(initialScroll);
viewport.scrollToViewportPos({ viewportScrollY: 100 });
setKeyboardHeight(180);
expect(viewport.viewportScrollY).toBeCloseTo(100);
setKeyboardHeight(0);
expect(viewport.viewportScrollY).toBeCloseTo(100);
});
it('does not apply mobile keyboard compensation to desktop documents', () => {
const { viewport, setCaret, setKeyboardHeight } = createRender(false);
setCaret(15);
const initialScroll = viewport.viewportScrollY;
setKeyboardHeight(380);
expect(viewport.viewportScrollY).toBe(initialScroll);
});
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BehaviorSubject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { DocLayoutInteractionService } from '../../../services/doc-layout-interaction.service';
import { DocCanvasPopupLayoutInteractionController } from '../doc-canvas-popup-layout-interaction.controller';
describe('DocCanvasPopupLayoutInteractionController', () => {
it('holds the layout interaction while its render unit owns a popup', () => {
const popupUnits$ = new BehaviorSubject<ReadonlySet<string>>(new Set());
const interactionService = new DocLayoutInteractionService();
const controller = new DocCanvasPopupLayoutInteractionController(
{ unitId: 'doc-1' },
{ popupUnits$ },
interactionService
);
popupUnits$.next(new Set(['doc-2']));
expect(interactionService.isActive).toBe(false);
popupUnits$.next(new Set(['doc-1', 'doc-2']));
expect(interactionService.isActive).toBe(true);
popupUnits$.next(new Set(['doc-1']));
expect(interactionService.isActive).toBe(true);
popupUnits$.next(new Set());
expect(interactionService.isActive).toBe(false);
controller.dispose();
interactionService.dispose();
popupUnits$.complete();
});
it('releases the layout interaction when its render controller is disposed', () => {
const popupUnits$ = new BehaviorSubject<ReadonlySet<string>>(new Set(['doc-1']));
const interactionService = new DocLayoutInteractionService();
const controller = new DocCanvasPopupLayoutInteractionController(
{ unitId: 'doc-1' },
{ popupUnits$ },
interactionService
);
expect(interactionService.isActive).toBe(true);
controller.dispose();
expect(interactionService.isActive).toBe(false);
interactionService.dispose();
popupUnits$.complete();
});
});

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CURSOR_TYPE } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { ToggleCheckListCommand } from '../../../commands/commands/list.command';
import { DocChecklistRenderController } from '../doc-checklist.render-controller';
describe('DocChecklistRenderController', () => {
it('toggles the clicked checklist paragraph with current text ranges', () => {
const clickBullets$ = new Subject<any>();
const controller = new DocChecklistRenderController(
{ mainComponent: { setCursor: vi.fn() } } as never,
{} as never,
{ executeCommand: vi.fn() } as never,
{ clickBullets$, hoverBullet$: new Subject() } as never,
{ getTextRanges: vi.fn(() => [{ startOffset: 3, endOffset: 5 }]) } as never
);
clickBullets$.next({
paragraph: { startIndex: 12 },
segmentId: 'header-1',
});
expect((controller as any)._commandService.executeCommand).toHaveBeenCalledWith(ToggleCheckListCommand.id, {
index: 12,
segmentId: 'header-1',
textRanges: [{ startOffset: 3, endOffset: 5 }],
});
controller.dispose();
});
it('switches the document cursor between pointer and text when hovering checklist bullets', () => {
const hoverBullet$ = new Subject<any>();
const setCursor = vi.fn();
const controller = new DocChecklistRenderController(
{ mainComponent: { setCursor } } as never,
{} as never,
{ executeCommand: vi.fn() } as never,
{ clickBullets$: new Subject(), hoverBullet$ } as never,
{ getTextRanges: vi.fn(() => []) } as never
);
hoverBullet$.next({ paragraph: { startIndex: 1 } });
hoverBullet$.next(null);
expect(setCursor).toHaveBeenNthCalledWith(1, CURSOR_TYPE.POINTER);
expect(setCursor).toHaveBeenNthCalledWith(2, CURSOR_TYPE.TEXT);
controller.dispose();
});
});

View File

@@ -0,0 +1,115 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EDITOR_ACTIVATED, FOCUSING_DOC } from '@univerjs/core';
import { HTML_CLIPBOARD_MIME_TYPE, PLAIN_TEXT_CLIPBOARD_MIME_TYPE } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DOC_INTERNAL_FRAGMENT_MIME } from '../../../services/clipboard/internal-fragment';
import { DocClipboardController } from '../doc-clipboard.controller';
function createClipboardEvent(options: {
html?: string;
internalJson?: string;
text?: string;
items?: Array<{ type: string; getAsFile: () => File | null }>;
}) {
const data = new Map([
[HTML_CLIPBOARD_MIME_TYPE, options.html ?? ''],
[DOC_INTERNAL_FRAGMENT_MIME, options.internalJson ?? ''],
[PLAIN_TEXT_CLIPBOARD_MIME_TYPE, options.text ?? ''],
]);
return {
preventDefault: vi.fn(),
clipboardData: {
getData: vi.fn((type: string) => data.get(type) ?? ''),
items: options.items ?? [],
},
};
}
function createController(options?: { focusingDoc?: boolean; editorActivated?: boolean; editorExists?: boolean }) {
const onPaste$ = new Subject<{ event: ReturnType<typeof createClipboardEvent> }>();
const legacyPaste = vi.fn(() => Promise.resolve(true));
const controller = new DocClipboardController(
{ unitId: 'doc-1' } as never,
{} as never,
{ legacyPaste } as never,
{ onPaste$ } as never,
{
getContextValue: vi.fn((key: string) => {
if (key === FOCUSING_DOC) return options?.focusingDoc ?? true;
if (key === EDITOR_ACTIVATED) return options?.editorActivated ?? false;
return false;
}),
} as never,
{ getEditor: vi.fn(() => options?.editorExists ? {} : null) } as never
);
return { controller, onPaste$, legacyPaste };
}
describe('DocClipboardController', () => {
it('prevents the native paste and forwards html, internal json, text and image files to legacy paste', () => {
const imageFile = new File(['image'], 'image.png', { type: 'image/png' });
const { controller, onPaste$, legacyPaste } = createController();
const event = createClipboardEvent({
html: '<p>Hello</p>',
internalJson: '{"body":true}',
text: 'Hello',
items: [
{ type: 'image/png', getAsFile: () => imageFile },
{ type: 'text/plain', getAsFile: () => null },
],
});
onPaste$.next({ event });
expect(event.preventDefault).toHaveBeenCalled();
expect(legacyPaste).toHaveBeenCalledWith({
html: '<p>Hello</p>',
internalJson: '{"body":true}',
text: 'Hello',
files: [imageFile],
unitId: 'doc-1',
});
controller.dispose();
});
it('drops pasted table html while editing and ignores paste events outside docs or editors', () => {
const editing = createController({ editorExists: true });
const tableEvent = createClipboardEvent({ html: '<table><tr><td>A</td></tr></table>', text: 'A' });
editing.onPaste$.next({ event: tableEvent });
expect(editing.legacyPaste).toHaveBeenCalledWith(expect.objectContaining({
html: '',
text: 'A',
}));
editing.controller.dispose();
const inactive = createController({ focusingDoc: false, editorActivated: false });
const inactiveEvent = createClipboardEvent({ text: 'Ignored' });
inactive.onPaste$.next({ event: inactiveEvent });
expect(inactiveEvent.preventDefault).not.toHaveBeenCalled();
expect(inactive.legacyPaste).not.toHaveBeenCalled();
inactive.controller.dispose();
});
});

View File

@@ -0,0 +1,122 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DocumentBlockRangeType, FOCUSING_COMMON_DRAWINGS, UniverInstanceType } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
import { DocContextMenuRenderController } from '../doc-contextmenu.render-controller';
function createEventSubject() {
const subscribers = new Set<(evt: any) => void>();
return {
subscribeEvent: vi.fn((handler: (evt: any) => void) => {
subscribers.add(handler);
const unsubscribe = vi.fn(() => subscribers.delete(handler));
return { dispose: unsubscribe, unsubscribe };
}),
emit: (evt: any) => subscribers.forEach((handler) => handler(evt)),
};
}
function createController(options?: {
pointerOnBullet?: boolean;
textRanges?: Array<{ startOffset: number; endOffset: number }>;
blockRanges?: Array<{ startIndex: number; endIndex: number; blockType: DocumentBlockRangeType }>;
menuVisible?: boolean;
focusingDrawing?: boolean;
}) {
const onPointerDown$ = createEventSubject();
let commandHandler: ((command: { id: string }) => void) | undefined;
const contextMenuService = {
visible: options?.menuVisible ?? false,
triggerContextMenu: vi.fn(),
hideContextMenu: vi.fn(),
};
const controller = new DocContextMenuRenderController(
{
unitId: 'doc-1',
mainComponent: { onPointerDown$ },
} as never,
contextMenuService as never,
{
onCommandExecuted: vi.fn((handler) => {
commandHandler = handler;
return { dispose: vi.fn() };
}),
} as never,
{ getParagraphByOffset: () => null, isPointerOnNonChecklistBullet: vi.fn(() => options?.pointerOnBullet ?? false) } as never,
{ getTextRanges: vi.fn(() => options?.textRanges ?? []) } as never,
{
getContextValue: vi.fn((key) => key === FOCUSING_COMMON_DRAWINGS && (options?.focusingDrawing ?? false)),
} as never,
{
getUnit: () => null,
getCurrentUnitOfType: vi.fn((type) => type === UniverInstanceType.UNIVER_DOC
? { getBody: () => ({ blockRanges: options?.blockRanges ?? [] }) }
: null),
} as never
);
return { controller, onPointerDown$, contextMenuService, commandHandler: () => commandHandler };
}
describe('DocContextMenuRenderController', () => {
it('opens the main-area context menu on right click when the selection is normal document text', () => {
const { controller, onPointerDown$, contextMenuService } = createController();
const event = { button: 2, offsetX: 10, offsetY: 20 };
onPointerDown$.emit(event);
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.MAIN_AREA);
controller.dispose();
});
it('opens the drawing context menu when a drawing is focused', () => {
const { controller, onPointerDown$, contextMenuService } = createController({ focusingDrawing: true });
const event = { button: 2, offsetX: 10, offsetY: 20 };
onPointerDown$.emit(event);
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING);
controller.dispose();
});
it('does not open context menu for checklist bullets or selections inside code blocks', () => {
const bullet = createController({ pointerOnBullet: true });
bullet.onPointerDown$.emit({ button: 2, offsetX: 10, offsetY: 20 });
expect(bullet.contextMenuService.triggerContextMenu).not.toHaveBeenCalled();
bullet.controller.dispose();
const codeBlock = createController({
textRanges: [{ startOffset: 5, endOffset: 8 }],
blockRanges: [{ startIndex: 1, endIndex: 10, blockType: DocumentBlockRangeType.CODE }],
});
codeBlock.onPointerDown$.emit({ button: 2, offsetX: 10, offsetY: 20 });
expect(codeBlock.contextMenuService.triggerContextMenu).not.toHaveBeenCalled();
codeBlock.controller.dispose();
});
it('hides an open context menu after document text is edited', () => {
const { controller, contextMenuService, commandHandler } = createController({ menuVisible: true });
commandHandler()?.({ id: RichTextEditingMutation.id });
expect(contextMenuService.hideContextMenu).toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,162 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { RichTextEditingMutation } from '@univerjs/docs';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DocEditorBridgeController } from '../doc-editor-bridge.controller';
function createController(options: { cancelResize?: boolean; sheetEditor?: boolean; focusEditorId?: string | null } = {}) {
const blur$ = new Subject<void>();
const onBlur$ = new Subject<void>();
const commandHandlers: Array<(command: { id: string; params?: unknown }) => void> = [];
const scene = { transformByState: vi.fn() };
const mainComponent = { resize: vi.fn() };
const editor = {
getEditorId: vi.fn(() => 'editor-1'),
cancelDefaultResizeListener: options.cancelResize ?? false,
render: { scene, mainComponent },
getBoundingClientRect: vi.fn(() => ({ width: 120, height: 80 })),
isSheetEditor: vi.fn(() => options.sheetEditor ?? false),
};
const editorService = {
getAllEditor: vi.fn(() => [editor]),
getEditor: vi.fn(() => editor),
getEditorRenderConfig: vi.fn(() => ({ canvasStyle: {}, scrollBar: false })),
isSheetEditor: vi.fn((unitId: string) => unitId === 'sheet-editor'),
blur$,
blur: vi.fn(),
getFocusEditor: vi.fn(() => options.focusEditorId ? { getEditorId: () => options.focusEditorId } : null),
};
const skeleton = {
calculate: vi.fn(),
getActualSize: vi.fn(() => ({ actualWidth: 150, actualHeight: 90 })),
};
const canvasEle = document.createElement('canvas');
const controller = new DocEditorBridgeController(
{ unitId: 'editor-1' } as never,
{
getUnit: vi.fn(() => ({
getSnapshot: () => ({
documentStyle: {
marginTop: 10,
marginBottom: 20,
marginLeft: 5,
marginRight: 15,
},
}),
})),
getCurrentTypeOfUnit$: vi.fn(() => new BehaviorSubject({ getUnitId: () => 'sheet-1' })),
} as never,
editorService as never,
{
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
} as never,
{
blur: vi.fn(),
onBlur$,
} as never,
{
getSkeleton: vi.fn(() => skeleton),
} as never,
{
getRenderUnitById: vi.fn(() => ({
engine: {
getCanvas: () => ({
getCanvasEle: () => canvasEle,
}),
},
})),
} as never
);
return {
controller,
editor,
scene,
mainComponent,
skeleton,
editorService,
blur$,
onBlur$,
commandHandlers,
canvasEle,
};
}
describe('DocEditorBridgeController', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('resizes editor scene and main component from skeleton content plus document margins', () => {
const { controller, scene, mainComponent, skeleton } = createController();
expect(skeleton.calculate).toHaveBeenCalled();
expect(scene.transformByState).toHaveBeenCalledWith({ width: 170, height: 120 });
expect(mainComponent.resize).toHaveBeenCalledWith(170, 120);
controller.dispose();
});
it('refreshes editor size after rich text edits for the same text editor', () => {
const { controller, scene, commandHandlers } = createController();
scene.transformByState.mockClear();
commandHandlers[0]({ id: RichTextEditingMutation.id, params: { unitId: 'other-editor' } });
commandHandlers[0]({ id: RichTextEditingMutation.id, params: { unitId: 'sheet-editor' } });
commandHandlers[0]({ id: RichTextEditingMutation.id, params: { unitId: 'editor-1' } });
expect(scene.transformByState).toHaveBeenCalledTimes(1);
controller.dispose();
});
it('bridges blur state and keeps formula helper mouse events from stealing focus', () => {
const { controller, editorService, blur$, onBlur$, canvasEle } = createController();
const windowStop = vi.fn();
const canvasStop = vi.fn();
const formulaHelp = document.createElement('div');
formulaHelp.className = 'univer-formula-help';
document.body.appendChild(formulaHelp);
blur$.next();
onBlur$.next();
formulaHelp.dispatchEvent(Object.assign(new MouseEvent('mousedown', { bubbles: true }), { stopPropagation: windowStop }));
canvasEle.dispatchEvent(Object.assign(new MouseEvent('mousedown', { bubbles: true }), {
stopPropagation: canvasStop,
}));
expect(editorService.blur).toHaveBeenCalled();
expect(windowStop).toHaveBeenCalled();
expect(canvasStop).toHaveBeenCalled();
document.body.removeChild(formulaHelp);
controller.dispose();
});
it('skips default resize when editor opts out', () => {
const { controller, scene, mainComponent } = createController({ cancelResize: true });
expect(scene.transformByState).not.toHaveBeenCalled();
expect(mainComponent.resize).not.toHaveBeenCalled();
controller.dispose();
});
});

View File

@@ -0,0 +1,329 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @vitest-environment jsdom
*/
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, EmbedInteractionBoundaryService, EmbedRuntimeFocusCoordinator } from '../../../services/doc-embed-integration.service';
import { DocInputController } from '../doc-input.controller';
describe('DocInputController', () => {
it('does not insert host document text while an embedded child runtime owns focus', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn();
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
const lease = focusCoordinator.acquireLease({
embedId: 'embed-sheet',
role: 'child-session',
owner: 'stage2-runtime',
});
new DocInputController(
{
unitId: 'host-doc',
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
})),
},
} as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never,
undefined,
focusCoordinator
);
onInput$.next({
event: { defaultPrevented: false, data: '=' },
content: '=',
activeRange: {
segmentId: undefined,
startOffset: 0,
endOffset: 0,
},
});
await Promise.resolve();
expect(executeCommand).not.toHaveBeenCalled();
lease.dispose();
});
it('keeps sheet cell editor input available while an embedded child runtime owns focus', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn();
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
const lease = focusCoordinator.acquireLease({
embedId: 'embed-sheet',
role: 'child-session',
owner: 'stage2-runtime',
});
new DocInputController(
{
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
})),
},
} as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never,
undefined,
focusCoordinator
);
onInput$.next({
event: { defaultPrevented: false, data: '=' },
content: '=',
activeRange: {
segmentId: undefined,
startOffset: 0,
endOffset: 0,
},
});
await Promise.resolve();
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
unitId: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
}));
lease.dispose();
});
it('does not let a host-scoped child session suppress unrelated host document input', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn();
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
const lease = focusCoordinator.acquireLease({
embedId: 'embed-sheet',
role: 'child-session',
owner: 'stage2-runtime',
hostUnitId: 'host-doc',
childUnitId: 'child-sheet',
});
new DocInputController(
{
unitId: 'other-host-doc',
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
})),
},
} as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never,
undefined,
focusCoordinator
);
onInput$.next({
event: { defaultPrevented: false, data: 'o' },
content: 'o',
activeRange: {
segmentId: undefined,
startOffset: 0,
endOffset: 0,
},
});
await Promise.resolve();
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
unitId: 'other-host-doc',
}));
lease.dispose();
});
it('keeps embedded child document input available while its host owns the embed session', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn();
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
const interactionBoundaryService = new EmbedInteractionBoundaryService();
const childEditor = document.createElement('div');
childEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'embed-doc');
const childInput = document.createElement('input');
childEditor.appendChild(childInput);
document.body.appendChild(childEditor);
const lease = focusCoordinator.acquireLease({
embedId: 'embed-doc',
role: 'child-session',
owner: 'stage2-runtime',
hostUnitId: 'host-sheet',
childUnitId: 'child-doc',
});
new DocInputController(
{
unitId: 'child-doc',
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
})),
},
} as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never,
interactionBoundaryService,
focusCoordinator
);
onInput$.next({
event: { defaultPrevented: false, data: 'a', target: childInput },
content: 'a',
activeRange: {
segmentId: undefined,
startOffset: 0,
endOffset: 0,
},
});
await Promise.resolve();
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
unitId: 'child-doc',
}));
lease.dispose();
childEditor.remove();
});
it('keeps tab embedded child document input available through runtime ownership without a stage2 lease', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn();
const focusCoordinator = new EmbedRuntimeFocusCoordinator();
const interactionBoundaryService = new EmbedInteractionBoundaryService();
const childEditor = document.createElement('div');
childEditor.setAttribute(EMBED_INTERACTION_BOUNDARY_OWNER_ATTRIBUTE, 'sheets-tab-doc');
const childInput = document.createElement('input');
childEditor.appendChild(childInput);
document.body.appendChild(childEditor);
const runtimeScope = focusCoordinator.registerRuntimeScope({
embedId: 'sheets-tab-doc',
hostUnitId: 'host-sheet',
childUnitId: 'child-doc',
});
new DocInputController(
{
unitId: 'child-doc',
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: '\r\n' })),
})),
},
} as never,
{ onInput$, getAllRectRanges: vi.fn(() => []) } as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never,
interactionBoundaryService,
focusCoordinator
);
onInput$.next({
event: { defaultPrevented: false, data: 'x', target: childInput },
content: 'x',
activeRange: {
segmentId: undefined,
startOffset: 0,
endOffset: 0,
},
});
await Promise.resolve();
expect(executeCommand).toHaveBeenCalledWith('doc.command.insert-text', expect.objectContaining({
unitId: 'child-doc',
}));
runtimeScope.dispose();
childEditor.remove();
});
it('routes input over mixed text and table ranges through structural replacement', async () => {
const onInput$ = new Subject<unknown>();
const executeCommand = vi.fn(() => Promise.resolve(true));
const activeRange = {
segmentId: '',
startOffset: 0,
endOffset: 4,
collapsed: false,
isActive: true,
};
new DocInputController(
{
unitId: 'test-doc',
unit: {
getSelfOrHeaderFooterModel: vi.fn(() => ({
getBody: vi.fn(() => ({ dataStream: 'text\r\n' })),
})),
},
} as never,
{
onInput$,
getAllRectRanges: vi.fn(() => [{
tableId: 'table-1',
startOffset: 5,
endOffset: 10,
}]),
} as never,
{ getSkeleton: vi.fn(() => ({})) } as never,
{ executeCommand } as never,
{
getDefaultStyle: vi.fn(() => ({})),
getStyleCache: vi.fn(() => ({})),
} as never
);
onInput$.next({
event: { defaultPrevented: false, data: 'x' },
content: 'x',
activeRange,
rangeList: [activeRange],
});
await Promise.resolve();
expect(executeCommand).toHaveBeenCalledWith('doc.command.replace-selection', expect.objectContaining({
unitId: 'test-doc',
segmentId: '',
body: expect.objectContaining({ dataStream: 'x' }),
}));
});
});

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