snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
41
packages/docs-ui/README.md
Normal file
41
packages/docs-ui/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @univerjs/docs-ui
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/docs-ui)
|
||||
[](https://npmjs.com/package/@univerjs/docs-ui)
|
||||
[](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)
|
||||
19
packages/docs-ui/jest.setup.ts
Normal file
19
packages/docs-ui/jest.setup.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.stubGlobal('jest', vi);
|
||||
108
packages/docs-ui/package.json
Normal file
108
packages/docs-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/docs-ui/postcss.config.mjs
Normal file
1
packages/docs-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@univerjs-infra/shared/postcss';
|
||||
104
packages/docs-ui/src/__tests__/doc-custom-range-chrome.spec.ts
Normal file
104
packages/docs-ui/src/__tests__/doc-custom-range-chrome.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
279
packages/docs-ui/src/__tests__/embed-host-anchor.spec.ts
Normal file
279
packages/docs-ui/src/__tests__/embed-host-anchor.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
44
packages/docs-ui/src/__tests__/mobile-plugin.spec.ts
Normal file
44
packages/docs-ui/src/__tests__/mobile-plugin.spec.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
46
packages/docs-ui/src/basics/__tests__/paragraph.spec.ts
Normal file
46
packages/docs-ui/src/basics/__tests__/paragraph.spec.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
60
packages/docs-ui/src/basics/component-tools.ts
Normal file
60
packages/docs-ui/src/basics/component-tools.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
128
packages/docs-ui/src/basics/custom-decoration-factory.ts
Normal file
128
packages/docs-ui/src/basics/custom-decoration-factory.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { 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;
|
||||
}
|
||||
80
packages/docs-ui/src/basics/doc-custom-range-chrome.ts
Normal file
80
packages/docs-ui/src/basics/doc-custom-range-chrome.ts
Normal 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();
|
||||
}
|
||||
37
packages/docs-ui/src/basics/docs-view-key.ts
Normal file
37
packages/docs-ui/src/basics/docs-view-key.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export 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';
|
||||
39
packages/docs-ui/src/basics/editor.ts
Normal file
39
packages/docs-ui/src/basics/editor.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
102
packages/docs-ui/src/basics/paragraph.ts
Normal file
102
packages/docs-ui/src/basics/paragraph.ts
Normal 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);
|
||||
}
|
||||
46
packages/docs-ui/src/basics/selection.ts
Normal file
46
packages/docs-ui/src/basics/selection.ts
Normal 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;
|
||||
}
|
||||
213
packages/docs-ui/src/basics/table.ts
Normal file
213
packages/docs-ui/src/basics/table.ts
Normal 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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
247
packages/docs-ui/src/commands/commands/break-line.command.ts
Normal file
247
packages/docs-ui/src/commands/commands/break-line.command.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
120
packages/docs-ui/src/commands/commands/clipboard.command.ts
Normal file
120
packages/docs-ui/src/commands/commands/clipboard.command.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
514
packages/docs-ui/src/commands/commands/doc-block-move.command.ts
Normal file
514
packages/docs-ui/src/commands/commands/doc-block-move.command.ts
Normal 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);
|
||||
}
|
||||
950
packages/docs-ui/src/commands/commands/doc-delete.command.ts
Normal file
950
packages/docs-ui/src/commands/commands/doc-delete.command.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
270
packages/docs-ui/src/commands/commands/doc-page-setup.command.ts
Normal file
270
packages/docs-ui/src/commands/commands/doc-page-setup.command.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
381
packages/docs-ui/src/commands/commands/doc-select-all.command.ts
Normal file
381
packages/docs-ui/src/commands/commands/doc-select-all.command.ts
Normal 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;
|
||||
}
|
||||
260
packages/docs-ui/src/commands/commands/ime-input.command.ts
Normal file
260
packages/docs-ui/src/commands/commands/ime-input.command.ts
Normal 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;
|
||||
}
|
||||
612
packages/docs-ui/src/commands/commands/inline-format.command.ts
Normal file
612
packages/docs-ui/src/commands/commands/inline-format.command.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
614
packages/docs-ui/src/commands/commands/list.command.ts
Normal file
614
packages/docs-ui/src/commands/commands/list.command.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
337
packages/docs-ui/src/commands/commands/set-heading.command.ts
Normal file
337
packages/docs-ui/src/commands/commands/set-heading.command.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
794
packages/docs-ui/src/commands/commands/table/table.ts
Normal file
794
packages/docs-ui/src/commands/commands/table/table.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
packages/docs-ui/src/commands/commands/util.ts
Normal file
32
packages/docs-ui/src/commands/commands/util.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
29
packages/docs-ui/src/commands/util.ts
Normal file
29
packages/docs-ui/src/commands/util.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
76
packages/docs-ui/src/config/config.ts
Normal file
76
packages/docs-ui/src/config/config.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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,
|
||||
},
|
||||
};
|
||||
18
packages/docs-ui/src/consts/mobile-context.ts
Normal file
18
packages/docs-ui/src/consts/mobile-context.ts
Normal 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';
|
||||
@@ -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,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
244
packages/docs-ui/src/controllers/components.controller.ts
Normal file
244
packages/docs-ui/src/controllers/components.controller.ts
Normal 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
237
packages/docs-ui/src/controllers/doc-auto-format.controller.ts
Normal file
237
packages/docs-ui/src/controllers/doc-auto-format.controller.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
386
packages/docs-ui/src/controllers/doc-header-footer.controller.ts
Normal file
386
packages/docs-ui/src/controllers/doc-header-footer.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1494
packages/docs-ui/src/controllers/doc-move-cursor.controller.ts
Normal file
1494
packages/docs-ui/src/controllers/doc-move-cursor.controller.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
packages/docs-ui/src/controllers/doc-table.controller.ts
Normal file
41
packages/docs-ui/src/controllers/doc-table.controller.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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() {}
|
||||
}
|
||||
140
packages/docs-ui/src/controllers/mobile/components.controller.ts
Normal file
140
packages/docs-ui/src/controllers/mobile/components.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
78
packages/docs-ui/src/controllers/mobile/ui.controller.ts
Normal file
78
packages/docs-ui/src/controllers/mobile/ui.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user