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:
1
packages/engine-render/.gitignore
vendored
Normal file
1
packages/engine-render/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
src/components/docs/layout/hyphenation/pattern-loaders.gen.ts
|
||||
8
packages/engine-render/.vscode/settings.json
vendored
Normal file
8
packages/engine-render/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"adjustability",
|
||||
"Justifiables",
|
||||
"shrinkability",
|
||||
"univerjs"
|
||||
]
|
||||
}
|
||||
37
packages/engine-render/README.md
Normal file
37
packages/engine-render/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @univerjs/engine-render
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/engine-render)
|
||||
[](https://npmjs.com/package/@univerjs/engine-render)
|
||||
[](https://npmjs.com/package/@univerjs/engine-render)
|
||||
|
||||
`@univerjs/engine-render` is Univer's canvas rendering engine. It handles document layout, rendering primitives, interaction layers, scrolling, and zooming.
|
||||
|
||||
## Package Overview
|
||||
|
||||
| Package | UMD global | CSS | Locales | Facade entry |
|
||||
| --- | --- | :---: | :---: | :---: |
|
||||
| `@univerjs/engine-render` | `UniverEngineRender` | No | No | No |
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @univerjs/engine-render
|
||||
# or
|
||||
npm install @univerjs/engine-render
|
||||
```
|
||||
|
||||
Keep all `@univerjs/*` packages on the same version.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
|
||||
|
||||
univer.registerPlugin(UniverRenderEnginePlugin);
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.univer.ai)
|
||||
- [NPM package](https://npmjs.com/package/@univerjs/engine-render)
|
||||
- [GitHub repository](https://github.com/dream-num/univer)
|
||||
1813
packages/engine-render/docs/render-unit.excalidraw
Normal file
1813
packages/engine-render/docs/render-unit.excalidraw
Normal file
File diff suppressed because it is too large
Load Diff
19
packages/engine-render/jest.setup.ts
Normal file
19
packages/engine-render/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);
|
||||
85
packages/engine-render/package.json
Normal file
85
packages/engine-render/package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "@univerjs/engine-render",
|
||||
"version": "1.0.0-rc.0",
|
||||
"private": false,
|
||||
"description": "Canvas rendering engine for Univer documents, sheets, and slides.",
|
||||
"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",
|
||||
"render-engine",
|
||||
"canvas",
|
||||
"layout",
|
||||
"graphics"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"./lib/*": "./lib/*"
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "node --experimental-strip-types ./scripts/generate-hyphen-pattern-loaders.mts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:bundle": "univer-cli build --config tsdown.override.mts",
|
||||
"build:types": "tsc -p tsconfig.node.json",
|
||||
"build": "pnpm run build:bundle && pnpm run build:types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": ">=7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"@univerjs/core": "workspace:*",
|
||||
"franc-min": "^6.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@univerjs-infra/shared": "workspace:*",
|
||||
"jest-canvas-mock": "^2.5.8",
|
||||
"rollup-plugin-polyfill-node": "^0.13.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tsdown": "^0.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
|
||||
function snackToPascal(snack: string) {
|
||||
return snack
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0].toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
const GENERATED_FILE_PATH = path.join(
|
||||
process.cwd(),
|
||||
'src/components/docs/layout/hyphenation/pattern-loaders.gen.ts'
|
||||
);
|
||||
|
||||
const PATTERNS_DIR = path.join(
|
||||
process.cwd(),
|
||||
'src/components/docs/layout/hyphenation/patterns'
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(PATTERNS_DIR)
|
||||
.filter((file) => file.endsWith('.ts'))
|
||||
.map((file) => file.replace(/\.ts$/, ''))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const entries = files
|
||||
.map((stem) => {
|
||||
const key = snackToPascal(stem);
|
||||
return ` [Lang.${key}]: () => import('./patterns/${stem}'),`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const content = [
|
||||
'/* eslint-disable */',
|
||||
"import { Lang } from './lang';",
|
||||
'',
|
||||
'// Generated by scripts/generate-hyphen-pattern-loaders.mts',
|
||||
'export const PATTERN_LOADERS: Partial<Record<Lang, () => Promise<unknown>>> = {',
|
||||
entries,
|
||||
'};',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
fs.mkdirSync(path.dirname(GENERATED_FILE_PATH), { recursive: true });
|
||||
fs.writeFileSync(GENERATED_FILE_PATH, content);
|
||||
console.log(`Updated ${GENERATED_FILE_PATH}`);
|
||||
49
packages/engine-render/src/__tests__/alignment-snap.spec.ts
Normal file
49
packages/engine-render/src/__tests__/alignment-snap.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AlignmentSnapSession,
|
||||
getAlignmentRectXAnchors,
|
||||
getAlignmentRectYAnchors,
|
||||
getClosestAlignmentOffset,
|
||||
} from '../alignment-snap';
|
||||
|
||||
describe('alignment snap', () => {
|
||||
it('resolves normalized edge and center anchors', () => {
|
||||
const rect = { left: 30, top: 50, width: -20, height: -40 };
|
||||
|
||||
expect(getAlignmentRectXAnchors(rect)).toEqual([10, 20, 30]);
|
||||
expect(getAlignmentRectYAnchors(rect)).toEqual([10, 30, 50]);
|
||||
expect(getClosestAlignmentOffset([10, 20, 30], [32], 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('uses hysteresis and breakaway without accumulating correction', () => {
|
||||
const session = new AlignmentSnapSession({
|
||||
enterThreshold: 6,
|
||||
exitThreshold: 14,
|
||||
breakawayThreshold: 4,
|
||||
cooldownMs: 250,
|
||||
});
|
||||
const guide = { id: 'x:100', axis: 'x' as const, position: 100 };
|
||||
|
||||
expect(session.resolveAxisSnap({ axis: 'x', value: 97, guide, now: 0 })).toMatchObject({ snapped: true, value: 100 });
|
||||
expect(session.resolveAxisSnap({ axis: 'x', value: 99, guide, now: 1 })).toMatchObject({ snapped: true, value: 100 });
|
||||
expect(session.resolveAxisSnap({ axis: 'x', value: 103, guide, now: 2 })).toMatchObject({ snapped: false, value: 103 });
|
||||
expect(session.resolveAxisSnap({ axis: 'x', value: 99, guide, now: 100 })).toMatchObject({ snapped: false, value: 99 });
|
||||
expect(session.resolveAxisSnap({ axis: 'x', value: 99, guide, now: 300 })).toMatchObject({ snapped: true, value: 100 });
|
||||
});
|
||||
});
|
||||
260
packages/engine-render/src/__tests__/base-object.spec.ts
Normal file
260
packages/engine-render/src/__tests__/base-object.spec.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 { IViewportInfo } from '../basics/vector2';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BaseObject } from '../base-object';
|
||||
import { CURSOR_TYPE, RENDER_CLASS_TYPE } from '../basics/const';
|
||||
import { Vector2 } from '../basics/vector2';
|
||||
|
||||
class TestObject extends BaseObject {
|
||||
setBox(left: number | string, top: number | string, width: number | string, height: number | string) {
|
||||
this.left = left;
|
||||
this.top = top;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
setTransformState(input: {
|
||||
strokeWidth?: number;
|
||||
angle?: number;
|
||||
scaleX?: number;
|
||||
scaleY?: number;
|
||||
skewX?: number;
|
||||
skewY?: number;
|
||||
flipX?: boolean;
|
||||
flipY?: boolean;
|
||||
}) {
|
||||
if (input.strokeWidth !== undefined) this.strokeWidth = input.strokeWidth;
|
||||
if (input.angle !== undefined) this.angle = input.angle;
|
||||
if (input.scaleX !== undefined) this.scaleX = input.scaleX;
|
||||
if (input.scaleY !== undefined) this.scaleY = input.scaleY;
|
||||
if (input.skewX !== undefined) this.skewX = input.skewX;
|
||||
if (input.skewY !== undefined) this.skewY = input.skewY;
|
||||
if (input.flipX !== undefined) this.flipX = input.flipX;
|
||||
if (input.flipY !== undefined) this.flipY = input.flipY;
|
||||
(this as any)._setTransForm();
|
||||
}
|
||||
|
||||
override render(_ctx: any, _bounds?: IViewportInfo) {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
function createParentChain() {
|
||||
const scene = {
|
||||
classType: RENDER_CLASS_TYPE.SCENE,
|
||||
getParent: vi.fn(() => ({
|
||||
classType: RENDER_CLASS_TYPE.ENGINE,
|
||||
getParent: vi.fn(() => null),
|
||||
})),
|
||||
setCursor: vi.fn(),
|
||||
resetCursor: vi.fn(),
|
||||
attachTransformerTo: vi.fn(),
|
||||
detachTransformerFrom: vi.fn(),
|
||||
} as any;
|
||||
return scene;
|
||||
}
|
||||
|
||||
describe('base object', () => {
|
||||
it('covers transform/getter/state and cursor/parent chain methods', () => {
|
||||
const obj = new TestObject('obj-1');
|
||||
const scene = createParentChain();
|
||||
obj.parent = scene;
|
||||
obj.layer = { zIndex: 9, makeDirty: vi.fn(), makeDirtyWithDebounce: vi.fn() } as any;
|
||||
obj.setBox(10, 20, 30, 40);
|
||||
obj.setTransformState({
|
||||
strokeWidth: 2,
|
||||
angle: 30,
|
||||
scaleX: 1.5,
|
||||
scaleY: 2,
|
||||
skewX: 5,
|
||||
skewY: 6,
|
||||
flipX: true,
|
||||
flipY: false,
|
||||
});
|
||||
|
||||
expect(obj.oKey).toBe('obj-1');
|
||||
expect(obj.top).toBe(20);
|
||||
expect(obj.left).toBe(10);
|
||||
expect(obj.width).toBe(30);
|
||||
expect(obj.height).toBe(40);
|
||||
expect(obj.strokeWidth).toBe(2);
|
||||
expect(obj.transform).toBeTruthy();
|
||||
expect(obj.transformForAngle((obj as any)._transform.clone())).toBeTruthy();
|
||||
expect(obj.getState()).toEqual(expect.objectContaining({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
}));
|
||||
|
||||
obj.cursor = CURSOR_TYPE.POINTER;
|
||||
obj.resetCursor();
|
||||
expect(scene.setCursor).toHaveBeenCalledWith(CURSOR_TYPE.POINTER);
|
||||
expect(scene.resetCursor).toHaveBeenCalled();
|
||||
expect(obj.getScene()).toBe(scene);
|
||||
expect(obj.getEngine()).toBeTruthy();
|
||||
expect(obj.getLayerIndex()).toBe(9);
|
||||
|
||||
obj.applyTransform();
|
||||
obj.removeTransform();
|
||||
expect(scene.attachTransformerTo).toHaveBeenCalledWith(obj);
|
||||
expect(scene.detachTransformerFrom).toHaveBeenCalledWith(obj);
|
||||
expect(obj.getObjects()).toEqual([]);
|
||||
});
|
||||
|
||||
it('covers dirty control, visibility and render/hit branches', () => {
|
||||
const obj = new TestObject();
|
||||
obj.setBox(0, 0, 20, 10);
|
||||
obj.setTransformState({ strokeWidth: 2 });
|
||||
|
||||
obj.layer = null as any;
|
||||
obj.makeDirty(true);
|
||||
expect(obj.isDirty()).toBe(false);
|
||||
|
||||
const layer = { zIndex: 3, makeDirty: vi.fn(), makeDirtyWithDebounce: vi.fn() };
|
||||
obj.layer = layer as any;
|
||||
obj.makeDirty(true);
|
||||
expect(layer.makeDirtyWithDebounce).toHaveBeenCalled();
|
||||
|
||||
obj.makeDirtyNoDebounce(true);
|
||||
expect(layer.makeDirty).toHaveBeenCalled();
|
||||
|
||||
obj.makeForceDirty(true);
|
||||
obj.hide();
|
||||
expect(obj.visible).toBe(false);
|
||||
obj.show();
|
||||
expect(obj.visible).toBe(true);
|
||||
|
||||
expect(obj.isRender({} as any)).toBe(true);
|
||||
(obj as any)._forceRender = true;
|
||||
expect(obj.isRender({} as any)).toBe(false);
|
||||
|
||||
expect(obj.isHit(Vector2.FromArray([5, 5]))).toBe(true);
|
||||
expect(obj.isHit(Vector2.FromArray([100, 100]))).toBe(false);
|
||||
expect(obj.getInverseCoord(Vector2.FromArray([2, 3]))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('covers event trigger propagation and stopPropagation branches', () => {
|
||||
const obj = new TestObject();
|
||||
const parent = {
|
||||
triggerPointerMove: vi.fn(),
|
||||
triggerPointerDown: vi.fn(),
|
||||
triggerPointerUp: vi.fn(),
|
||||
triggerSingleClick: vi.fn(),
|
||||
triggerClick: vi.fn(),
|
||||
triggerDblclick: vi.fn(),
|
||||
triggerTripleClick: vi.fn(),
|
||||
triggerMouseWheel: vi.fn(),
|
||||
triggerPointerOut: vi.fn(),
|
||||
triggerPointerLeave: vi.fn(),
|
||||
triggerPointerOver: vi.fn(),
|
||||
triggerPointerEnter: vi.fn(),
|
||||
triggerPointerCancel: vi.fn(),
|
||||
triggerDragLeave: vi.fn(),
|
||||
triggerDragOver: vi.fn(),
|
||||
triggerDragEnter: vi.fn(),
|
||||
triggerDrop: vi.fn(),
|
||||
};
|
||||
obj.parent = parent as any;
|
||||
|
||||
const event = { stopPropagation: false } as any;
|
||||
expect(obj.triggerPointerMove(event)).toBe(false);
|
||||
expect(obj.triggerPointerDown(event)).toBe(false);
|
||||
expect(obj.triggerPointerUp(event)).toBe(false);
|
||||
expect(obj.triggerSingleClick(event)).toBe(false);
|
||||
expect(obj.triggerClick(event)).toBe(false);
|
||||
expect(obj.triggerDblclick(event)).toBe(false);
|
||||
expect(obj.triggerTripleClick(event)).toBe(false);
|
||||
expect(obj.triggerMouseWheel(event)).toBe(false);
|
||||
expect(obj.triggerPointerOut(event)).toBe(false);
|
||||
expect(obj.triggerPointerLeave(event)).toBe(false);
|
||||
expect(obj.triggerPointerOver(event)).toBe(true);
|
||||
expect(obj.triggerPointerEnter(event)).toBe(false);
|
||||
expect(obj.triggerPointerCancel(event)).toBe(false);
|
||||
expect(obj.triggerDragLeave(event)).toBe(false);
|
||||
expect(obj.triggerDragOver(event)).toBe(false);
|
||||
expect(obj.triggerDragEnter(event)).toBe(false);
|
||||
expect(obj.triggerDrop(event)).toBe(false);
|
||||
|
||||
const stopEvt = { stopPropagation: true } as any;
|
||||
obj.onPointerMove$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onPointerDown$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onPointerUp$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onSingleClick$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onClick$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onDblclick$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onTripleClick$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onMouseWheel$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onPointerOut$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onPointerLeave$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onPointerOver$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onPointerEnter$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onDragLeave$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onDragOver$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onDragEnter$.emitEvent = vi.fn(() => stopEvt);
|
||||
obj.onDrop$.emitEvent = vi.fn(() => stopEvt);
|
||||
|
||||
expect(obj.triggerPointerMove({} as any)).toBe(true);
|
||||
expect(obj.triggerPointerDown({} as any)).toBe(true);
|
||||
expect(obj.triggerPointerUp({} as any)).toBe(true);
|
||||
expect(obj.triggerSingleClick({} as any)).toBe(true);
|
||||
expect(obj.triggerClick({} as any)).toBe(true);
|
||||
expect(obj.triggerDblclick({} as any)).toBe(true);
|
||||
expect(obj.triggerTripleClick({} as any)).toBe(true);
|
||||
expect(obj.triggerMouseWheel({} as any)).toBe(true);
|
||||
expect(obj.triggerPointerOut({} as any)).toBe(true);
|
||||
expect(obj.triggerPointerLeave({} as any)).toBe(true);
|
||||
expect(obj.triggerPointerOver({} as any)).toBe(true);
|
||||
expect(obj.triggerPointerEnter({} as any)).toBe(true);
|
||||
expect(obj.triggerDragLeave({} as any)).toBe(true);
|
||||
expect(obj.triggerDragOver({} as any)).toBe(true);
|
||||
expect(obj.triggerDragEnter({} as any)).toBe(true);
|
||||
expect(obj.triggerDrop({} as any)).toBe(true);
|
||||
});
|
||||
|
||||
it('covers transform APIs, serialization and dispose flow', () => {
|
||||
const obj = new TestObject();
|
||||
obj.setBox('10', '20', '30', '40');
|
||||
obj.transformByState({});
|
||||
obj.translate(11, 12).resize(13, 14).scale(1.2, 1.3).skew(2, 3).flip(true, false);
|
||||
obj.transformByState({
|
||||
left: 15,
|
||||
top: 16,
|
||||
width: 17,
|
||||
height: 18,
|
||||
angle: 10,
|
||||
scaleX: 1.1,
|
||||
scaleY: 1.2,
|
||||
skewX: 3,
|
||||
skewY: 4,
|
||||
flipX: false,
|
||||
flipY: true,
|
||||
strokeWidth: 1,
|
||||
});
|
||||
expect(obj.toJson()).toEqual(expect.objectContaining({
|
||||
top: expect.any(Number),
|
||||
left: expect.any(Number),
|
||||
}));
|
||||
|
||||
const parent = { removeObject: vi.fn() };
|
||||
obj.parent = parent as any;
|
||||
obj.dispose();
|
||||
expect(parent.removeObject).toHaveBeenCalledWith(obj);
|
||||
expect(obj.parent).toBeNull();
|
||||
});
|
||||
});
|
||||
119
packages/engine-render/src/__tests__/canvas.spec.ts
Normal file
119
packages/engine-render/src/__tests__/canvas.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICanvasColorService } from '../services/canvas-color.service';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Canvas, CanvasRenderMode, HitCanvas, SceneCanvas } from '../canvas';
|
||||
|
||||
describe('canvas wrapper', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('initializes rendering and printing contexts with sizing and ids', () => {
|
||||
const colorService: ICanvasColorService = { getRenderColor: (color: string) => color };
|
||||
const canvas = new Canvas({
|
||||
id: 'ignored-by-constructor',
|
||||
width: 120,
|
||||
height: 80,
|
||||
pixelRatio: 2,
|
||||
colorService,
|
||||
});
|
||||
|
||||
canvas.setId('render-canvas');
|
||||
expect(canvas.getCanvasEle().id).toBe('render-canvas');
|
||||
expect(canvas.getCanvasEle().dataset.uComp).toBe('render-canvas');
|
||||
expect(canvas.getCanvasEle().tabIndex).toBe(1);
|
||||
expect(canvas.getPixelRatio()).toBe(2);
|
||||
expect(canvas.getWidth()).toBe(120);
|
||||
expect(canvas.getHeight()).toBe(80);
|
||||
expect(canvas.getCanvasEle().width).toBe(240);
|
||||
expect(canvas.getCanvasEle().style.width).toBe('120px');
|
||||
|
||||
canvas.setPixelRatio(0.5);
|
||||
expect(canvas.getPixelRatio()).toBe(1);
|
||||
|
||||
canvas.clear();
|
||||
canvas.dispose();
|
||||
expect(canvas.getCanvasEle()).toBeNull();
|
||||
|
||||
const printing = new Canvas({ mode: CanvasRenderMode.Printing, width: 20, height: 10 });
|
||||
expect(printing.getContext().__mode).toBe('printing');
|
||||
printing.dispose();
|
||||
});
|
||||
|
||||
it('resolves theme colors in printing contexts', () => {
|
||||
const getRenderColor = vi.fn((color: string) => color === 'primary.500' ? '#dfe5ff' : color);
|
||||
const colorService: ICanvasColorService = { getRenderColor };
|
||||
const printing = new Canvas({
|
||||
mode: CanvasRenderMode.Printing,
|
||||
width: 20,
|
||||
height: 10,
|
||||
colorService,
|
||||
});
|
||||
|
||||
printing.getContext().fillStyle = 'primary.500';
|
||||
|
||||
expect(getRenderColor).toHaveBeenCalledWith('primary.500');
|
||||
expect(printing.getContext().fillStyle).toBe('#dfe5ff');
|
||||
printing.dispose();
|
||||
});
|
||||
|
||||
it('keeps zero-sized canvases from changing pixel ratio and supports scene/hit subclasses', () => {
|
||||
const canvas = new Canvas();
|
||||
canvas.setPixelRatio(3);
|
||||
expect(canvas.getPixelRatio()).toBeGreaterThan(0);
|
||||
|
||||
const sceneCanvas = new SceneCanvas({ width: 30, height: 40, pixelRatio: 2 });
|
||||
expect(sceneCanvas.getWidth()).toBe(30);
|
||||
expect(sceneCanvas.getHeight()).toBe(40);
|
||||
|
||||
const hitCanvas = new HitCanvas({ width: 10, height: 20, pixelRatio: 2 });
|
||||
expect(hitCanvas.hitCanvas).toBe(true);
|
||||
expect(hitCanvas.getWidth()).toBe(10);
|
||||
|
||||
canvas.dispose();
|
||||
sceneCanvas.dispose();
|
||||
hitCanvas.dispose();
|
||||
});
|
||||
|
||||
it('falls back when exporting data URLs fails', () => {
|
||||
const canvas = new Canvas({ width: 10, height: 10 });
|
||||
const nativeCanvas = canvas.getCanvasEle();
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const toDataURL = vi.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('typed export failed');
|
||||
})
|
||||
.mockReturnValueOnce('data:image/png;base64,fallback');
|
||||
nativeCanvas.toDataURL = toDataURL;
|
||||
|
||||
expect(canvas.toDataURL('image/jpeg', 0.8)).toBe('data:image/png;base64,fallback');
|
||||
|
||||
toDataURL
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('typed export failed');
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('fallback failed');
|
||||
});
|
||||
expect(canvas.toDataURL('image/jpeg', 0.8)).toBe('');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Unable to get data URL. fallback failed'));
|
||||
|
||||
canvas.dispose();
|
||||
});
|
||||
});
|
||||
142
packages/engine-render/src/__tests__/context-proxy.spec.ts
Normal file
142
packages/engine-render/src/__tests__/context-proxy.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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, vi } from 'vitest';
|
||||
import { UniverRenderingContext2D } from '../context';
|
||||
|
||||
function createNativeContext() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = {
|
||||
canvas,
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
filter: 'none',
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'low',
|
||||
lineCap: 'butt',
|
||||
lineDashOffset: 0,
|
||||
lineJoin: 'miter',
|
||||
lineWidth: 1,
|
||||
miterLimit: 10,
|
||||
shadowBlur: 0,
|
||||
shadowColor: '#000000',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
direction: 'inherit',
|
||||
font: '10px serif',
|
||||
fontKerning: 'auto',
|
||||
fontStretch: 'condensed',
|
||||
fontVariantCaps: 'small-caps',
|
||||
letterSpacing: '1px',
|
||||
textRendering: 'optimizeLegibility',
|
||||
wordSpacing: '2px',
|
||||
textAlign: 'start',
|
||||
textBaseline: 'alphabetic',
|
||||
getTransform: vi.fn(() => ({ a: 2, b: 0, c: 0, d: 4, e: 0, f: 0 })),
|
||||
resetTransform: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
createConicGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
createPattern: vi.fn(() => ({})),
|
||||
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 42 })),
|
||||
stroke: vi.fn(),
|
||||
setLineDash: undefined,
|
||||
getLineDash: vi.fn(() => []),
|
||||
setTransform: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return ctx as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
describe('context proxy branches', () => {
|
||||
it('reads proxied state and helper factories from the native context', () => {
|
||||
const nativeCtx = createNativeContext() as any;
|
||||
const renderCtx = new UniverRenderingContext2D(nativeCtx);
|
||||
|
||||
expect(renderCtx.globalAlpha).toBe(1);
|
||||
expect(renderCtx.globalCompositeOperation).toBe('source-over');
|
||||
expect(renderCtx.fillStyle).toBe('#000000');
|
||||
expect(renderCtx.strokeStyle).toBe('#000000');
|
||||
expect(renderCtx.filter).toBe('none');
|
||||
expect(renderCtx.imageSmoothingEnabled).toBe(true);
|
||||
expect(renderCtx.imageSmoothingQuality).toBe('low');
|
||||
expect(renderCtx.lineCap).toBe('butt');
|
||||
expect(renderCtx.lineDashOffset).toBe(0);
|
||||
expect(renderCtx.lineJoin).toBe('miter');
|
||||
expect(renderCtx.lineWidth).toBe(1);
|
||||
expect(renderCtx.miterLimit).toBe(10);
|
||||
expect(renderCtx.shadowBlur).toBe(0);
|
||||
expect(renderCtx.shadowColor).toBe('#000000');
|
||||
expect(renderCtx.shadowOffsetX).toBe(0);
|
||||
expect(renderCtx.shadowOffsetY).toBe(0);
|
||||
expect(renderCtx.direction).toBe('inherit');
|
||||
expect(renderCtx.font).toBe('10px serif');
|
||||
nativeCtx.font = '12px serif';
|
||||
expect(renderCtx.font).toBe('10px serif');
|
||||
renderCtx.font = '14px serif';
|
||||
expect(renderCtx.font).toBe('14px serif');
|
||||
expect(renderCtx.fontKerning).toBe('auto');
|
||||
expect(renderCtx.fontStretch).toBe('condensed');
|
||||
expect(renderCtx.fontVariantCaps).toBe('small-caps');
|
||||
expect(renderCtx.letterSpacing).toBe('1px');
|
||||
expect(renderCtx.textRendering).toBe('optimizeLegibility');
|
||||
expect(renderCtx.wordSpacing).toBe('2px');
|
||||
expect(renderCtx.textAlign).toBe('start');
|
||||
expect(renderCtx.textBaseline).toBe('alphabetic');
|
||||
|
||||
renderCtx.fontStretch = 'expanded' as any;
|
||||
renderCtx.fontVariantCaps = 'all-small-caps' as any;
|
||||
renderCtx.letterSpacing = '3px';
|
||||
renderCtx.textRendering = 'geometricPrecision' as any;
|
||||
renderCtx.wordSpacing = '4px';
|
||||
expect(nativeCtx.fontStretch).toBe('expanded');
|
||||
expect(nativeCtx.fontVariantCaps).toBe('all-small-caps');
|
||||
expect(nativeCtx.letterSpacing).toBe('3px');
|
||||
expect(nativeCtx.textRendering).toBe('geometricPrecision');
|
||||
expect(nativeCtx.wordSpacing).toBe('4px');
|
||||
|
||||
expect(renderCtx.getScale()).toEqual({ scaleX: 2, scaleY: 4 });
|
||||
expect(renderCtx.createConicGradient(0, 1, 2)).toBeTruthy();
|
||||
expect(renderCtx.createLinearGradient(0, 0, 1, 1)).toBeTruthy();
|
||||
expect(renderCtx.createPattern(document.createElement('canvas'), 'repeat')).toBeTruthy();
|
||||
expect(renderCtx.createRadialGradient(0, 0, 1, 2, 2, 3)).toBeTruthy();
|
||||
expect(renderCtx.measureText('abc')).toEqual({ width: 42 });
|
||||
|
||||
renderCtx.resetTransform();
|
||||
renderCtx.reset();
|
||||
renderCtx.fillText('with width', 1, 2, 3);
|
||||
renderCtx.fillTextPrecision('without width', 1, 2);
|
||||
renderCtx.stroke(new Path2D());
|
||||
expect(nativeCtx.resetTransform).toHaveBeenCalled();
|
||||
expect(nativeCtx.reset).toHaveBeenCalled();
|
||||
expect(nativeCtx.fillText).toHaveBeenCalledWith('with width', 1, 2, 3);
|
||||
expect(nativeCtx.stroke).toHaveBeenCalledWith(expect.any(Path2D));
|
||||
});
|
||||
|
||||
it('uses webkit line dash fallback when standard dash APIs are missing', () => {
|
||||
const nativeCtx = createNativeContext() as any;
|
||||
nativeCtx.webkitLineDash = [];
|
||||
const renderCtx = new UniverRenderingContext2D(nativeCtx as CanvasRenderingContext2D);
|
||||
|
||||
renderCtx.setLineDash([3, 2]);
|
||||
|
||||
expect(nativeCtx.webkitLineDash).toEqual([3, 2]);
|
||||
});
|
||||
});
|
||||
254
packages/engine-render/src/__tests__/context.spec.ts
Normal file
254
packages/engine-render/src/__tests__/context.spec.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Tools } from '@univerjs/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { UniverPrintingContext, UniverRenderingContext2D } from '../context';
|
||||
|
||||
function createNativeContext() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 200;
|
||||
canvas.height = 100;
|
||||
|
||||
const ctx = {
|
||||
canvas,
|
||||
globalAlpha: 1,
|
||||
globalCompositeOperation: 'source-over',
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
filter: 'none',
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'low',
|
||||
lineCap: 'butt',
|
||||
lineDashOffset: 0,
|
||||
lineJoin: 'miter',
|
||||
lineWidth: 1,
|
||||
miterLimit: 10,
|
||||
shadowBlur: 0,
|
||||
shadowColor: '#000000',
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
direction: 'inherit',
|
||||
font: '10px serif',
|
||||
fontKerning: 'auto',
|
||||
textAlign: 'start',
|
||||
textBaseline: 'alphabetic',
|
||||
getTransform: vi.fn(() => ({ a: 2, b: 0, c: 0, d: 4, e: 1, f: 1 })),
|
||||
getContextAttributes: vi.fn(() => ({})),
|
||||
isContextLost: vi.fn(() => false),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
arcTo: vi.fn(),
|
||||
bezierCurveTo: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
createConicGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
createImageData: vi.fn((a: any, b?: any) => {
|
||||
if (typeof a === 'number') {
|
||||
return { data: new Uint8ClampedArray(4), width: a, height: b ?? a };
|
||||
}
|
||||
return { data: new Uint8ClampedArray(4), width: a.width, height: a.height };
|
||||
}),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
createPattern: vi.fn(() => ({})),
|
||||
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
drawFocusIfNeeded: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
ellipse: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(4), width: 1, height: 1 })),
|
||||
isPointInPath: vi.fn(() => false),
|
||||
isPointInStroke: vi.fn(() => false),
|
||||
lineTo: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
putImageData: vi.fn(),
|
||||
quadraticCurveTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
resetTransform: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
getLineDash: vi.fn(() => [1, 2]),
|
||||
setTransform: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
strokeText: vi.fn(),
|
||||
transform: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
};
|
||||
|
||||
return ctx as any as CanvasRenderingContext2D;
|
||||
}
|
||||
|
||||
describe('context extra', () => {
|
||||
it('forwards style and basic drawing calls', () => {
|
||||
const nativeCtx = createNativeContext();
|
||||
const renderCtx = new UniverRenderingContext2D(nativeCtx, {
|
||||
canvasColorService: {
|
||||
getRenderColor: (color: string) => `render-${color}`,
|
||||
} as any,
|
||||
});
|
||||
|
||||
renderCtx.setId('ctx-id');
|
||||
expect(renderCtx.getId()).toBe('ctx-id');
|
||||
expect(renderCtx.isContextLost()).toBe(false);
|
||||
|
||||
renderCtx.fillStyle = '#111111';
|
||||
renderCtx.strokeStyle = '#222222';
|
||||
expect(nativeCtx.fillStyle).toBe('render-#111111');
|
||||
expect(nativeCtx.strokeStyle).toBe('render-#222222');
|
||||
|
||||
renderCtx.globalAlpha = 0.7;
|
||||
renderCtx.globalCompositeOperation = 'multiply';
|
||||
renderCtx.lineCap = 'round';
|
||||
renderCtx.lineJoin = 'bevel';
|
||||
renderCtx.lineDashOffset = 3;
|
||||
renderCtx.lineWidth = 2;
|
||||
renderCtx.miterLimit = 5;
|
||||
renderCtx.shadowBlur = 4;
|
||||
renderCtx.shadowColor = '#333';
|
||||
renderCtx.shadowOffsetX = 6;
|
||||
renderCtx.shadowOffsetY = 8;
|
||||
renderCtx.direction = 'ltr';
|
||||
renderCtx.font = '12px Arial';
|
||||
renderCtx.fontKerning = 'none';
|
||||
renderCtx.textAlign = 'center';
|
||||
renderCtx.textBaseline = 'middle';
|
||||
renderCtx.filter = 'blur(1px)';
|
||||
renderCtx.imageSmoothingEnabled = false;
|
||||
renderCtx.imageSmoothingQuality = 'high';
|
||||
renderCtx.setLineWidthByPrecision(12);
|
||||
|
||||
expect(nativeCtx.globalAlpha).toBe(0.7);
|
||||
expect(nativeCtx.globalCompositeOperation).toBe('multiply');
|
||||
expect(nativeCtx.lineWidth).toBe(3);
|
||||
|
||||
renderCtx.beginPath();
|
||||
renderCtx.closePath();
|
||||
renderCtx.arc(1, 2, -10, 0, Math.PI);
|
||||
renderCtx.arcTo(1, 2, 3, 4, 5);
|
||||
renderCtx.bezierCurveTo(1, 2, 3, 4, 5, 6);
|
||||
renderCtx.clearRect(1, 2, 3, 4);
|
||||
renderCtx.clip();
|
||||
renderCtx.ellipse(1, 2, 3, 4, 0, 0, Math.PI);
|
||||
renderCtx.fill();
|
||||
renderCtx.fillRect(1, 2, 3, 4);
|
||||
renderCtx.fillText('hello', 3, 4);
|
||||
renderCtx.getImageData(0, 0, 1, 1);
|
||||
renderCtx.isPointInPath(1, 1);
|
||||
renderCtx.isPointInStroke(1, 1);
|
||||
renderCtx.lineTo(1, 2);
|
||||
renderCtx.moveTo(1, 2);
|
||||
renderCtx.putImageData({} as ImageData, 1, 2);
|
||||
renderCtx.quadraticCurveTo(1, 2, 3, 4);
|
||||
renderCtx.rect(1, 2, 3, 4);
|
||||
renderCtx.rotate(0.2);
|
||||
renderCtx.save();
|
||||
renderCtx.scale(2, 3);
|
||||
renderCtx.setLineDash([2, 2]);
|
||||
renderCtx.stroke();
|
||||
renderCtx.strokeRect(1, 2, 3, 4);
|
||||
renderCtx.strokeText('text', 1, 2);
|
||||
renderCtx.transform(1, 0, 0, 1, 1, 2);
|
||||
renderCtx.translate(3, 4);
|
||||
renderCtx.restore();
|
||||
renderCtx.drawFocusIfNeeded(document.body);
|
||||
|
||||
expect(nativeCtx.beginPath).toHaveBeenCalled();
|
||||
expect(nativeCtx.strokeText).toHaveBeenCalled();
|
||||
expect(renderCtx.getLineDash()).toEqual([1, 2]);
|
||||
expect(renderCtx.getContextAttributes()).toEqual({});
|
||||
});
|
||||
|
||||
it('covers precision, drawImage, createImageData and env branches', () => {
|
||||
const nativeCtx = createNativeContext();
|
||||
const renderCtx = new UniverRenderingContext2D(nativeCtx);
|
||||
|
||||
renderCtx.roundRectByPrecision(1, 2, 3, 4, 2);
|
||||
renderCtx.arcByPrecision(1, 2, 5, 0, Math.PI);
|
||||
renderCtx.arcToByPrecision(1, 2, 3, 4, 5);
|
||||
renderCtx.bezierCurveToByPrecision(1, 2, 3, 4, 5, 6);
|
||||
renderCtx.clearRectByPrecision(1, 2, 3, 4);
|
||||
renderCtx.fillRectByPrecision(1, 2, 3, 4);
|
||||
renderCtx.strokeRectPrecision(1, 2, 3, 4);
|
||||
renderCtx.fillTextPrecision('abc', 1, 2, 40);
|
||||
renderCtx.lineToByPrecision(1, 2);
|
||||
renderCtx.moveToByPrecision(1, 2);
|
||||
renderCtx.moveToByPrecisionLog(1, 2);
|
||||
renderCtx.rectByPrecision(1, 2, 3, 4);
|
||||
renderCtx.strokeTextByPrecision('abc', 1, 2, 30);
|
||||
renderCtx.translateWithPrecision(1, 2);
|
||||
renderCtx.translateWithPrecisionRatio(10, 20);
|
||||
renderCtx.clearRectForTexture(1, 2, 3, 4);
|
||||
renderCtx.setGlobalCompositeOperation('source-over');
|
||||
|
||||
const image = document.createElement('canvas');
|
||||
renderCtx.drawImage(image, 1, 2);
|
||||
renderCtx.drawImage(image, 1, 2, 3, 4);
|
||||
renderCtx.drawImage(image, 1, 2, 3, 4, 5, 6, 7, 8);
|
||||
|
||||
expect(() => (renderCtx as any).createImageData()).toThrow('arguments is zero');
|
||||
expect((renderCtx as any).createImageData(3, 4)).toBeTruthy();
|
||||
expect((renderCtx as any).createImageData({ width: 2, height: 2 })).toBeTruthy();
|
||||
expect((renderCtx as any).createImageData(3, 4, { colorSpace: 'srgb' })).toBeTruthy();
|
||||
|
||||
const browserSpy = vi.spyOn(Tools, 'getBrowserType').mockReturnValue('Chrome' as any);
|
||||
const systemSpy = vi.spyOn(Tools, 'getSystemType').mockReturnValue('Mac' as any);
|
||||
renderCtx.closePathByEnv();
|
||||
expect(nativeCtx.closePath).not.toHaveBeenCalled();
|
||||
browserSpy.mockReturnValue('Firefox' as any);
|
||||
(renderCtx as any)._browserType = '';
|
||||
renderCtx.closePathByEnv();
|
||||
expect(nativeCtx.closePath).toHaveBeenCalledTimes(1);
|
||||
systemSpy.mockRestore();
|
||||
browserSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back when the native context does not support roundRect', () => {
|
||||
const nativeCtx = createNativeContext();
|
||||
delete (nativeCtx as Partial<CanvasRenderingContext2D>).roundRect;
|
||||
const renderCtx = new UniverRenderingContext2D(nativeCtx);
|
||||
|
||||
renderCtx.roundRect(10, 20, 40, 20, 4);
|
||||
|
||||
expect(nativeCtx.moveTo).toHaveBeenCalledWith(14, 20);
|
||||
expect(nativeCtx.quadraticCurveTo).toHaveBeenNthCalledWith(1, 50, 20, 50, 24);
|
||||
expect(nativeCtx.closePath).toHaveBeenCalledTimes(1);
|
||||
expect(nativeCtx.rect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('covers fallback line dash branches and printing context', () => {
|
||||
const nativeCtx = createNativeContext() as any;
|
||||
delete nativeCtx.setLineDash;
|
||||
nativeCtx.mozDash = [];
|
||||
const renderCtx = new UniverRenderingContext2D(nativeCtx as CanvasRenderingContext2D);
|
||||
renderCtx.setLineDash([4, 1]);
|
||||
expect(nativeCtx.mozDash).toEqual([4, 1]);
|
||||
|
||||
const printingCtx = new UniverPrintingContext(createNativeContext());
|
||||
printingCtx.clearRect(1, 2, 3, 4);
|
||||
printingCtx.clearRectForTexture(1, 2, 3, 4);
|
||||
printingCtx.setGlobalCompositeOperation('lighter');
|
||||
expect(printingCtx.__mode).toBe('printing');
|
||||
});
|
||||
});
|
||||
378
packages/engine-render/src/__tests__/drawing-group.spec.ts
Normal file
378
packages/engine-render/src/__tests__/drawing-group.spec.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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, vi } from 'vitest';
|
||||
import { CURSOR_TYPE, RENDER_CLASS_TYPE } from '../basics/const';
|
||||
import { Vector2 } from '../basics/vector2';
|
||||
import { DrawingGroupObject } from '../drawing-group';
|
||||
import { Group } from '../group';
|
||||
import { Rect } from '../shape/rect';
|
||||
|
||||
function createContext() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
transform: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
lineCap: 'round',
|
||||
lineDashOffset: 0,
|
||||
lineJoin: 'round',
|
||||
miterLimit: 0,
|
||||
shadowColor: '',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
filter: 'none',
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createSceneStub(objectMap: Record<string, Rect | undefined>) {
|
||||
return {
|
||||
classType: RENDER_CLASS_TYPE.SCENE,
|
||||
getParent: () => null,
|
||||
getObject: (key: string) => objectMap[key],
|
||||
removeObject: vi.fn(),
|
||||
setCursor: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('drawing group', () => {
|
||||
it('handles base bound/ancestor transform and child hit detection', () => {
|
||||
const parentGroup = new Group('parent');
|
||||
parentGroup.transformByState({
|
||||
left: 5,
|
||||
top: 8,
|
||||
width: 200,
|
||||
height: 120,
|
||||
});
|
||||
|
||||
const drawingGroup = new DrawingGroupObject('drawing-group');
|
||||
drawingGroup.transformByState({
|
||||
left: 20,
|
||||
top: 30,
|
||||
width: 120,
|
||||
height: 80,
|
||||
});
|
||||
drawingGroup.setBaseBound({
|
||||
left: 10,
|
||||
top: 12,
|
||||
width: 100,
|
||||
height: 60,
|
||||
});
|
||||
expect(drawingGroup.getBaseBound()).toEqual({
|
||||
left: 10,
|
||||
top: 12,
|
||||
width: 100,
|
||||
height: 60,
|
||||
});
|
||||
|
||||
const child = new Rect('child', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 20,
|
||||
height: 20,
|
||||
fill: '#333333',
|
||||
});
|
||||
vi.spyOn(child, 'isHit').mockReturnValue(true);
|
||||
vi.spyOn(child, 'render').mockImplementation(() => child);
|
||||
|
||||
drawingGroup.addObjects(child);
|
||||
parentGroup.addObject(drawingGroup);
|
||||
|
||||
const ancestor = drawingGroup.ancestorTransform.getMatrix();
|
||||
expect(ancestor).toHaveLength(6);
|
||||
expect(ancestor[4]).toBeTypeOf('number');
|
||||
expect(ancestor[5]).toBeTypeOf('number');
|
||||
|
||||
expect(drawingGroup.isHit(Vector2.FromArray([40, 50]))).toBe(true);
|
||||
expect(drawingGroup.isHit(Vector2.FromArray([1000, 1000]))).toBe(false);
|
||||
});
|
||||
|
||||
it('renders children with group-centered transform', () => {
|
||||
const drawingGroup = new DrawingGroupObject('drawing-group-render');
|
||||
drawingGroup.transformByState({
|
||||
left: 10,
|
||||
top: 10,
|
||||
width: 100,
|
||||
height: 60,
|
||||
});
|
||||
const child = new Rect('render-child', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: '#666666',
|
||||
});
|
||||
const childRenderSpy = vi.spyOn(child, 'render').mockImplementation(() => child);
|
||||
drawingGroup.addObject(child);
|
||||
drawingGroup.setOuterShadow({
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
shadowBlur: 12,
|
||||
shadowOffsetX: 4,
|
||||
shadowOffsetY: 0,
|
||||
});
|
||||
drawingGroup.setGlow({
|
||||
color: '#5b9bd5',
|
||||
radius: 4,
|
||||
});
|
||||
|
||||
const ctx = createContext();
|
||||
drawingGroup.render(ctx, {
|
||||
cacheBound: { left: 0, top: 0, right: 500, bottom: 500 },
|
||||
} as any);
|
||||
|
||||
expect(ctx.transform).toHaveBeenCalled();
|
||||
expect(childRenderSpy).toHaveBeenCalled();
|
||||
expect(ctx.filter).toContain('drop-shadow(0px 0px 2px #5b9bd5)');
|
||||
expect(ctx.shadowColor).toBe('rgba(0, 0, 0, 0.5)');
|
||||
expect(ctx.shadowBlur).toBe(12);
|
||||
expect(ctx.shadowOffsetX).toBe(4);
|
||||
});
|
||||
|
||||
it('maps children of nested drawing groups through the parent rendered bound', () => {
|
||||
const outerGroup = new DrawingGroupObject('outer-group');
|
||||
outerGroup.transformByState({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 500,
|
||||
height: 500,
|
||||
});
|
||||
outerGroup.setBaseBound({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
const innerGroup = new DrawingGroupObject('inner-group');
|
||||
innerGroup.transformByState({
|
||||
left: 1,
|
||||
top: 1,
|
||||
width: 2,
|
||||
height: 2,
|
||||
});
|
||||
innerGroup.setBaseBound({
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
});
|
||||
|
||||
const child = new Rect('nested-child', {
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
fill: '#333333',
|
||||
});
|
||||
|
||||
innerGroup.addObject(child);
|
||||
outerGroup.addObject(innerGroup);
|
||||
|
||||
expect(innerGroup.getRealBound().width).toBe(100);
|
||||
expect(child.getRealBound().width).toBe(100);
|
||||
});
|
||||
|
||||
it('maps a rotated child through an anisotropically resized drawing group', () => {
|
||||
const drawingGroup = new DrawingGroupObject('rotated-child-group');
|
||||
drawingGroup.transformByState({
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
drawingGroup.setBaseBound({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 200,
|
||||
});
|
||||
|
||||
const child = new Rect('rotated-child', {
|
||||
left: -50,
|
||||
top: 50,
|
||||
width: 200,
|
||||
height: 100,
|
||||
angle: 90,
|
||||
fill: '#333333',
|
||||
});
|
||||
drawingGroup.addObject(child);
|
||||
|
||||
const bound = child.getRealBound();
|
||||
expect(bound.left).toBeCloseTo(-50, 6);
|
||||
expect(bound.top).toBeCloseTo(-100, 6);
|
||||
expect(bound.width).toBeCloseTo(100, 6);
|
||||
expect(bound.height).toBeCloseTo(200, 6);
|
||||
});
|
||||
|
||||
it('covers group object management, transform recalculation and dispose flow', () => {
|
||||
const sceneChild = new Rect('scene-child', {
|
||||
left: 5,
|
||||
top: 8,
|
||||
width: 20,
|
||||
height: 16,
|
||||
fill: '#224466',
|
||||
});
|
||||
sceneChild.zIndex = 1;
|
||||
|
||||
const directChild = new Rect('direct-child', {
|
||||
left: 40,
|
||||
top: 25,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: '#446622',
|
||||
});
|
||||
directChild.zIndex = 5;
|
||||
|
||||
const hiddenChild = new Rect('hidden-child', {
|
||||
left: 2,
|
||||
top: 3,
|
||||
width: 8,
|
||||
height: 8,
|
||||
fill: '#663344',
|
||||
});
|
||||
hiddenChild.hide();
|
||||
|
||||
const nestedGroup = new Group('nested-group', new Rect('nested-rect', {
|
||||
left: 1,
|
||||
top: 1,
|
||||
width: 4,
|
||||
height: 4,
|
||||
fill: '#111111',
|
||||
}));
|
||||
nestedGroup.transformByState({
|
||||
left: 30,
|
||||
top: 12,
|
||||
width: 18,
|
||||
height: 10,
|
||||
});
|
||||
|
||||
const scene = createSceneStub({
|
||||
'scene-child': sceneChild,
|
||||
});
|
||||
const group = new Group('group-ops');
|
||||
group.parent = scene;
|
||||
group.transformByState({
|
||||
left: 10,
|
||||
top: 6,
|
||||
});
|
||||
|
||||
group.addObject('scene-child');
|
||||
group.addObject(directChild);
|
||||
group.addObject(hiddenChild);
|
||||
group.addObject(nestedGroup);
|
||||
group.addObject('missing-child');
|
||||
|
||||
expect(group.getObjects().length).toBe(4);
|
||||
expect(sceneChild.parent).toBe(group);
|
||||
expect(sceneChild.isInGroup).toBe(true);
|
||||
expect(sceneChild.groupKey).toBe(group.oKey);
|
||||
expect(group.maxZIndex).toBe(5);
|
||||
|
||||
const visibleObjects = group.getObjectsByOrder();
|
||||
expect(visibleObjects).not.toContain(hiddenChild);
|
||||
expect(visibleObjects).toContain(directChild);
|
||||
|
||||
group.cursor = CURSOR_TYPE.POINTER;
|
||||
expect(scene.setCursor).toHaveBeenCalledWith(CURSOR_TYPE.POINTER);
|
||||
|
||||
const preWidth = directChild.width;
|
||||
const preHeight = directChild.height;
|
||||
group.width = group.width + 6;
|
||||
group.height = group.height + 4;
|
||||
expect(directChild.width).toBeCloseTo(preWidth + 6, 6);
|
||||
expect(directChild.height).toBeCloseTo(preHeight + 4, 6);
|
||||
|
||||
const directTransformSpy = vi.spyOn(directChild, 'transformByState');
|
||||
group.openSelfSizeMode();
|
||||
group.width = 33;
|
||||
group.height = 21;
|
||||
expect(group.width).toBe(33);
|
||||
expect(group.height).toBe(21);
|
||||
group.reCalculateObjects();
|
||||
expect(directTransformSpy).not.toHaveBeenCalled();
|
||||
group.closeSelfSizeMode();
|
||||
|
||||
group.reCalculateObjects();
|
||||
expect(directTransformSpy).toHaveBeenCalled();
|
||||
|
||||
const detachedRect = new Rect('detached-rect', {
|
||||
left: 4,
|
||||
top: 4,
|
||||
width: 6,
|
||||
height: 6,
|
||||
fill: '#333333',
|
||||
});
|
||||
const detachedGroup = new Group('detached-group', new Rect('nested-2', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 2,
|
||||
height: 2,
|
||||
fill: '#999999',
|
||||
}));
|
||||
group.addObject(detachedRect);
|
||||
group.addObject(detachedGroup);
|
||||
|
||||
const detachedRectTransformSpy = vi.spyOn(detachedRect, 'transformByState');
|
||||
const detachedGroupTransformSpy = vi.spyOn(detachedGroup, 'transformByState');
|
||||
group.removeSelfObjectAndTransform(detachedRect.oKey, undefined, undefined, true);
|
||||
group.removeSelfObjectAndTransform(detachedGroup.oKey, undefined, undefined, true);
|
||||
expect(detachedRectTransformSpy).toHaveBeenCalled();
|
||||
expect(detachedGroupTransformSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
left: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
}));
|
||||
expect(detachedRect.parent).toBe(scene);
|
||||
expect(detachedRect.isInGroup).toBe(false);
|
||||
|
||||
group.removeObject('scene-child');
|
||||
group.removeObject(hiddenChild);
|
||||
expect(scene.removeObject).toHaveBeenCalled();
|
||||
|
||||
const renderTarget = new Rect('render-target', {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: '#555555',
|
||||
});
|
||||
const renderSpy = vi.spyOn(renderTarget, 'render').mockImplementation(() => renderTarget);
|
||||
group.addObject(renderTarget);
|
||||
|
||||
const ctx = createContext();
|
||||
group.render(ctx, {
|
||||
cacheBound: { left: -50, top: -50, right: 200, bottom: 200 },
|
||||
} as any);
|
||||
expect(ctx.transform).toHaveBeenCalled();
|
||||
expect(renderSpy).toHaveBeenCalled();
|
||||
|
||||
const disposeSpy = vi.spyOn(renderTarget, 'dispose');
|
||||
group.dispose();
|
||||
expect(disposeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
1408
packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts
Normal file
1408
packages/engine-render/src/__tests__/engine-scene-viewport.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
3
packages/engine-render/src/__tests__/jest-canvas-mock-lib.d.ts
vendored
Normal file
3
packages/engine-render/src/__tests__/jest-canvas-mock-lib.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module 'jest-canvas-mock/lib/index.js' {
|
||||
export function setupJestCanvasMock(window?: Window): void;
|
||||
}
|
||||
208
packages/engine-render/src/__tests__/layer.spec.ts
Normal file
208
packages/engine-render/src/__tests__/layer.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IViewportInfo } from '../basics/vector2';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BaseObject } from '../base-object';
|
||||
import { RENDER_CLASS_TYPE } from '../basics/const';
|
||||
import { Layer } from '../layer';
|
||||
|
||||
class TestObject extends BaseObject {
|
||||
constructor(id: string, zIndex = 1) {
|
||||
super(id);
|
||||
this.zIndex = zIndex;
|
||||
}
|
||||
|
||||
override render(_ctx: any, _bounds?: IViewportInfo) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class TestGroup extends TestObject {
|
||||
constructor(id: string, private readonly _children: BaseObject[]) {
|
||||
super(id, 100);
|
||||
}
|
||||
|
||||
override get classType() {
|
||||
return RENDER_CLASS_TYPE.GROUP;
|
||||
}
|
||||
|
||||
override getObjects() {
|
||||
return this._children;
|
||||
}
|
||||
}
|
||||
|
||||
function createScene() {
|
||||
let transformCallback: (() => void) | undefined;
|
||||
const sceneViewer = {
|
||||
classType: RENDER_CLASS_TYPE.SCENE_VIEWER,
|
||||
makeDirty: vi.fn(),
|
||||
};
|
||||
const viewport = {
|
||||
viewportKey: 'viewport',
|
||||
shouldIntoRender: vi.fn(() => true),
|
||||
render: vi.fn(),
|
||||
};
|
||||
const engine = {
|
||||
width: 160,
|
||||
height: 90,
|
||||
canvasColorService: undefined,
|
||||
onTransformChange$: {
|
||||
subscribeEvent: vi.fn((callback: () => void) => {
|
||||
transformCallback = callback;
|
||||
return { dispose: vi.fn() };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
getParent: vi.fn(() => sceneViewer),
|
||||
getEngine: vi.fn(() => engine),
|
||||
getObject: vi.fn(() => null),
|
||||
setObjectBehavior: vi.fn(),
|
||||
getViewports: vi.fn(() => [viewport]),
|
||||
__viewport: viewport,
|
||||
__sceneViewer: sceneViewer,
|
||||
__resizeCache: () => transformCallback?.(),
|
||||
};
|
||||
|
||||
return scene as any;
|
||||
}
|
||||
|
||||
function createCtx() {
|
||||
const canvas = document.createElement('canvas');
|
||||
const transform = document.createElement('canvas').getContext('2d')!.getTransform();
|
||||
return {
|
||||
canvas,
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
getTransform: vi.fn(() => transform),
|
||||
drawImage: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('layer', () => {
|
||||
it('adds, orders, filters and removes regular objects', () => {
|
||||
const scene = createScene();
|
||||
const visible = new TestObject('visible', 2);
|
||||
const hidden = new TestObject('hidden', 1);
|
||||
const eventless = new TestObject('eventless', 3);
|
||||
hidden.hide();
|
||||
eventless.evented = false;
|
||||
|
||||
const layer = new Layer(scene, [visible, hidden, eventless], 4);
|
||||
|
||||
expect(layer.scene).toBe(scene);
|
||||
expect(layer.zIndex).toBe(4);
|
||||
expect(layer.getObjectsByOrder().map((object) => object.oKey)).toEqual(['visible', 'eventless']);
|
||||
expect(layer.getObjectsByOrderForPick().map((object) => object.oKey)).toEqual(['visible']);
|
||||
expect(scene.setObjectBehavior).toHaveBeenCalledTimes(3);
|
||||
expect(scene.__sceneViewer.makeDirty).toHaveBeenCalledWith(true);
|
||||
|
||||
layer.removeObject(visible);
|
||||
expect(layer.getObjects().map((object) => object.oKey)).not.toContain('visible');
|
||||
layer.removeObject('hidden');
|
||||
expect(layer.getObjects().map((object) => object.oKey)).not.toContain('hidden');
|
||||
layer.removeObjects(['eventless']);
|
||||
expect(layer.getObjects()).toEqual([]);
|
||||
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('expands groups, renders through viewports and updates dirty state', async () => {
|
||||
const scene = createScene();
|
||||
const child = new TestObject('child', 1);
|
||||
const duplicateChild = new TestObject('duplicate-child', 2);
|
||||
scene.getObject = vi.fn((key: string) => key === 'duplicate-child' ? duplicateChild : null);
|
||||
const group = new TestGroup('group', [child, duplicateChild]);
|
||||
const layer = new Layer(scene, [], 1);
|
||||
|
||||
layer.addObject(group);
|
||||
expect(layer.getObjects().map((object) => object.oKey)).toEqual(['child', 'group']);
|
||||
expect(child.layer).toBe(layer);
|
||||
expect(group.layer).toBe(layer);
|
||||
|
||||
layer.makeDirtyWithDebounce(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(layer.isDirty()).toBe(true);
|
||||
|
||||
const ctx = createCtx();
|
||||
layer.render(ctx, true);
|
||||
expect(ctx.save).toHaveBeenCalled();
|
||||
expect(scene.__viewport.render).toHaveBeenCalledWith(ctx, [child, group], true, undefined);
|
||||
expect(layer.isDirty()).toBe(false);
|
||||
|
||||
child.onTransformChange$.emitEvent({} as any);
|
||||
expect(layer.isDirty()).toBe(true);
|
||||
|
||||
layer.clear();
|
||||
expect(layer.getObjects()).toEqual([]);
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('clips scroll repaint and refreshes the stale layer cache before reuse', () => {
|
||||
const scene = createScene();
|
||||
const object = new TestObject('cached', 1);
|
||||
const layer = new Layer(scene, [object], 1, true);
|
||||
const ctx = createCtx();
|
||||
const viewportInfo = { viewportKey: 'viewport' } as IViewportInfo;
|
||||
const dirtyBounds = [{ left: 140, top: 0, right: 160, bottom: 90 }];
|
||||
|
||||
scene.__resizeCache();
|
||||
layer.render(ctx, false, {
|
||||
dirtyBounds,
|
||||
preserveCache: true,
|
||||
viewportInfos: new Map([['viewport', viewportInfo]]),
|
||||
});
|
||||
expect(scene.__viewport.render).toHaveBeenLastCalledWith(expect.anything(), [object], false, viewportInfo);
|
||||
expect(ctx.rect).toHaveBeenCalledWith(140, 0, 20, 90);
|
||||
expect(ctx.drawImage).not.toHaveBeenCalled();
|
||||
|
||||
scene.__viewport.render.mockClear();
|
||||
layer.render(ctx);
|
||||
expect(scene.__viewport.render).toHaveBeenCalledWith(expect.anything(), [object], false, undefined);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), 0, 0, 160, 90);
|
||||
|
||||
layer.dispose();
|
||||
});
|
||||
|
||||
it('uses cache canvas rendering, resizing and disposal paths', () => {
|
||||
const scene = createScene();
|
||||
const object = new TestObject('cached', 1);
|
||||
const layer = new Layer(scene, [object], 1, true);
|
||||
const ctx = createCtx();
|
||||
|
||||
expect(layer.isAllowCache()).toBe(true);
|
||||
layer.render(ctx);
|
||||
expect(ctx.drawImage).not.toHaveBeenCalled();
|
||||
|
||||
scene.__resizeCache();
|
||||
layer.render(ctx);
|
||||
expect(ctx.drawImage).toHaveBeenCalledWith(expect.any(HTMLCanvasElement), 0, 0, 160, 90);
|
||||
|
||||
layer.disableCache();
|
||||
expect(layer.isAllowCache()).toBe(false);
|
||||
layer.enableCache();
|
||||
expect(layer.isAllowCache()).toBe(true);
|
||||
|
||||
layer.dispose();
|
||||
expect(layer.getObjects()).toEqual([]);
|
||||
});
|
||||
});
|
||||
146
packages/engine-render/src/__tests__/render-test-utils.ts
Normal file
146
packages/engine-render/src/__tests__/render-test-utils.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
function patchCanvasContextProto() {
|
||||
const proto = (globalThis as any).CanvasRenderingContext2D?.prototype as Record<string, unknown> | undefined;
|
||||
if (!proto) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!proto.getTransform) {
|
||||
proto.getTransform = vi.fn(() => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }));
|
||||
}
|
||||
if (!proto.getContextAttributes) {
|
||||
proto.getContextAttributes = vi.fn(() => ({}));
|
||||
}
|
||||
if (!proto.reset) {
|
||||
proto.reset = vi.fn();
|
||||
}
|
||||
if (!proto.roundRect) {
|
||||
proto.roundRect = vi.fn();
|
||||
}
|
||||
if (!proto.drawFocusIfNeeded) {
|
||||
proto.drawFocusIfNeeded = vi.fn();
|
||||
}
|
||||
if (!proto.createConicGradient) {
|
||||
proto.createConicGradient = vi.fn(() => ({ addColorStop: vi.fn() }));
|
||||
}
|
||||
if (!proto.isContextLost) {
|
||||
proto.isContextLost = vi.fn(() => false);
|
||||
}
|
||||
|
||||
if (proto.webkitBackingStorePixelRatio == null) {
|
||||
proto.webkitBackingStorePixelRatio = 1;
|
||||
}
|
||||
if (proto.mozBackingStorePixelRatio == null) {
|
||||
proto.mozBackingStorePixelRatio = 1;
|
||||
}
|
||||
if (proto.msBackingStorePixelRatio == null) {
|
||||
proto.msBackingStorePixelRatio = 1;
|
||||
}
|
||||
if (proto.oBackingStorePixelRatio == null) {
|
||||
proto.oBackingStorePixelRatio = 1;
|
||||
}
|
||||
if (proto.backingStorePixelRatio == null) {
|
||||
proto.backingStorePixelRatio = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function patchPointerCapture() {
|
||||
const proto = HTMLCanvasElement.prototype as any;
|
||||
if (!proto.setPointerCapture) {
|
||||
proto.setPointerCapture = () => {};
|
||||
}
|
||||
if (!proto.releasePointerCapture) {
|
||||
proto.releasePointerCapture = () => {};
|
||||
}
|
||||
if (!proto.hasPointerCapture) {
|
||||
proto.hasPointerCapture = () => false;
|
||||
}
|
||||
|
||||
const captureMap = new WeakMap<HTMLCanvasElement, Set<number>>();
|
||||
const getCaptureSet = (canvas: HTMLCanvasElement) => {
|
||||
let set = captureMap.get(canvas);
|
||||
if (!set) {
|
||||
set = new Set<number>();
|
||||
captureMap.set(canvas, set);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
|
||||
const setPointerCaptureSpy = vi.spyOn(proto, 'setPointerCapture').mockImplementation(function (
|
||||
this: HTMLCanvasElement,
|
||||
...args: unknown[]
|
||||
) {
|
||||
getCaptureSet(this).add(Number(args[0]));
|
||||
});
|
||||
const releasePointerCaptureSpy = vi.spyOn(proto, 'releasePointerCapture').mockImplementation(function (
|
||||
this: HTMLCanvasElement,
|
||||
...args: unknown[]
|
||||
) {
|
||||
getCaptureSet(this).delete(Number(args[0]));
|
||||
});
|
||||
const hasPointerCaptureSpy = vi.spyOn(proto, 'hasPointerCapture').mockImplementation(function (
|
||||
this: HTMLCanvasElement,
|
||||
...args: unknown[]
|
||||
) {
|
||||
return getCaptureSet(this).has(Number(args[0]));
|
||||
});
|
||||
|
||||
return {
|
||||
setPointerCaptureSpy,
|
||||
releasePointerCaptureSpy,
|
||||
hasPointerCaptureSpy,
|
||||
};
|
||||
}
|
||||
|
||||
export function setupRenderTestEnv() {
|
||||
const originalJest = (globalThis as any).jest;
|
||||
const originalResizeObserver = (globalThis as any).ResizeObserver;
|
||||
const originalRequestIdleCallback = (window as any).requestIdleCallback;
|
||||
const originalCancelIdleCallback = (window as any).cancelIdleCallback;
|
||||
|
||||
(globalThis as any).jest = vi;
|
||||
patchCanvasContextProto();
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
(globalThis as any).ResizeObserver = ResizeObserverMock;
|
||||
(window as any).requestIdleCallback = (cb: IdleRequestCallback) => setTimeout(() => cb({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => 16,
|
||||
} as IdleDeadline), 0);
|
||||
(window as any).cancelIdleCallback = (id: number) => clearTimeout(id);
|
||||
|
||||
const pointerPatches = patchPointerCapture();
|
||||
|
||||
return {
|
||||
restore() {
|
||||
pointerPatches.setPointerCaptureSpy.mockRestore();
|
||||
pointerPatches.releasePointerCaptureSpy.mockRestore();
|
||||
pointerPatches.hasPointerCaptureSpy.mockRestore();
|
||||
(globalThis as any).jest = originalJest;
|
||||
(globalThis as any).ResizeObserver = originalResizeObserver;
|
||||
(window as any).requestIdleCallback = originalRequestIdleCallback;
|
||||
(window as any).cancelIdleCallback = originalCancelIdleCallback;
|
||||
},
|
||||
};
|
||||
}
|
||||
127
packages/engine-render/src/__tests__/scene-viewer.spec.ts
Normal file
127
packages/engine-render/src/__tests__/scene-viewer.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { Vector2 } from '../basics/vector2';
|
||||
import { Canvas } from '../canvas';
|
||||
import { Engine } from '../engine';
|
||||
import { MAIN_VIEW_PORT_KEY, Scene } from '../scene';
|
||||
import { SceneViewer } from '../scene-viewer';
|
||||
import { Rect } from '../shape/rect';
|
||||
import { Viewport } from '../viewport';
|
||||
import { setupRenderTestEnv } from './render-test-utils';
|
||||
|
||||
function createSubScene(engine: Engine, key: string) {
|
||||
const scene = new Scene(key, engine);
|
||||
scene.transformByState({
|
||||
width: 600,
|
||||
height: 320,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
new Viewport(MAIN_VIEW_PORT_KEY, scene, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 420,
|
||||
height: 220,
|
||||
active: true,
|
||||
allowCache: true,
|
||||
bufferEdgeX: 8,
|
||||
bufferEdgeY: 6,
|
||||
});
|
||||
scene.addObject(new Rect(`${key}-rect`, {
|
||||
left: 12,
|
||||
top: 16,
|
||||
width: 120,
|
||||
height: 60,
|
||||
fill: '#66aa88',
|
||||
stroke: '#222222',
|
||||
}), 1);
|
||||
return scene;
|
||||
}
|
||||
|
||||
describe('scene viewer', () => {
|
||||
let restoreEnv: () => void;
|
||||
let container: HTMLDivElement;
|
||||
let engine: Engine;
|
||||
let mainCanvas: Canvas;
|
||||
|
||||
beforeEach(() => {
|
||||
restoreEnv = setupRenderTestEnv().restore;
|
||||
container = document.createElement('div');
|
||||
container.style.width = '720px';
|
||||
container.style.height = '420px';
|
||||
document.body.appendChild(container);
|
||||
engine = new Engine('scene-viewer-engine', { elementWidth: 700, elementHeight: 400, dpr: 1 });
|
||||
engine.mount(container, false);
|
||||
mainCanvas = new Canvas({ width: 720, height: 420, pixelRatio: 1 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mainCanvas.dispose();
|
||||
engine.dispose();
|
||||
restoreEnv();
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('manages sub-scenes, render bounds and pick lifecycle', () => {
|
||||
const sceneA = createSubScene(engine, 'sub-a');
|
||||
const sceneB = createSubScene(engine, 'sub-b');
|
||||
|
||||
const viewer = new SceneViewer('viewer', {
|
||||
left: 20,
|
||||
top: 10,
|
||||
width: 420,
|
||||
height: 220,
|
||||
});
|
||||
viewer.addSubScene(sceneA);
|
||||
viewer.addSubScene(sceneB);
|
||||
expect(viewer.getSubScenes().size).toBe(2);
|
||||
|
||||
viewer.activeSubScene('sub-a');
|
||||
expect(viewer.getActiveSubScene()?.sceneKey).toBe('sub-a');
|
||||
expect(viewer.getSubScene('sub-b')?.sceneKey).toBe('sub-b');
|
||||
|
||||
viewer.enableSelectedClipElement();
|
||||
expect(viewer.allowSelectedClipElement()).toBe(true);
|
||||
viewer.disableSelectedClipElement();
|
||||
expect(viewer.allowSelectedClipElement()).toBe(false);
|
||||
|
||||
const ctx = mainCanvas.getContext();
|
||||
const outsideBounds = {
|
||||
viewBound: { left: 1000, top: 1000, right: 1200, bottom: 1200 },
|
||||
cacheBound: { left: 1000, top: 1000, right: 1200, bottom: 1200 },
|
||||
} as any;
|
||||
const insideBounds = {
|
||||
viewBound: { left: 0, top: 0, right: 500, bottom: 300 },
|
||||
cacheBound: { left: 0, top: 0, right: 500, bottom: 300 },
|
||||
} as any;
|
||||
|
||||
expect(viewer.render(ctx, outsideBounds)).toBe(viewer);
|
||||
expect(viewer.render(ctx, insideBounds)).toBe(viewer);
|
||||
|
||||
const picked = viewer.pick(Vector2.FromArray([60, 50]));
|
||||
expect(picked).toBeTruthy();
|
||||
|
||||
viewer.removeSubScene('sub-a');
|
||||
expect(viewer.getSubScenes().size).toBe(1);
|
||||
expect(viewer.getActiveSubScene()?.sceneKey).toBe('sub-b');
|
||||
|
||||
viewer.activeSubScene(null);
|
||||
viewer.dispose();
|
||||
});
|
||||
});
|
||||
172
packages/engine-render/src/__tests__/scene.input-manager.spec.ts
Normal file
172
packages/engine-render/src/__tests__/scene.input-manager.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DeviceType, PointerInput } from '../basics/i-events';
|
||||
import { Engine } from '../engine';
|
||||
import { MAIN_VIEW_PORT_KEY, Scene } from '../scene';
|
||||
import { InputManager } from '../scene.input-manager';
|
||||
import { Rect } from '../shape/rect';
|
||||
import { Viewport } from '../viewport';
|
||||
import { setupRenderTestEnv } from './render-test-utils';
|
||||
|
||||
describe('InputManager click gestures', () => {
|
||||
let env: ReturnType<typeof setupRenderTestEnv>;
|
||||
let engine: Engine;
|
||||
let scene: Scene;
|
||||
let doubleClick: ReturnType<typeof vi.fn<() => void>>;
|
||||
let tripleClick: ReturnType<typeof vi.fn<() => void>>;
|
||||
|
||||
function pointer(type: string, x = 20, y = 20, button = 0, deviceType = DeviceType.Mouse) {
|
||||
engine.onInputChanged$.emitEvent(Object.assign(new MouseEvent(type, { clientX: x, clientY: y, button }), {
|
||||
deviceType,
|
||||
inputIndex: PointerInput.LeftClick + button,
|
||||
previousState: null,
|
||||
currentState: null,
|
||||
pointerId: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function click(x = 20, y = 20, button = 0) {
|
||||
pointer('pointerdown', x, y, button);
|
||||
pointer('pointerup', x, y, button);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
env = setupRenderTestEnv();
|
||||
vi.useFakeTimers();
|
||||
engine = new Engine('click-unit', { elementWidth: 300, elementHeight: 200, dpr: 1 });
|
||||
scene = new Scene('click-scene', engine);
|
||||
new Viewport(MAIN_VIEW_PORT_KEY, scene, { left: 0, top: 0, width: 300, height: 200 });
|
||||
const rect = new Rect('target', { left: 0, top: 0, width: 200, height: 150 });
|
||||
scene.addObject(rect);
|
||||
doubleClick = vi.fn();
|
||||
tripleClick = vi.fn();
|
||||
rect.onDblclick$.subscribeEvent(doubleClick);
|
||||
rect.onTripleClick$.subscribeEvent(tripleClick);
|
||||
scene.attachControl();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
env.restore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('recognizes primary-button double and triple clicks', () => {
|
||||
click();
|
||||
click();
|
||||
expect(doubleClick).toHaveBeenCalledTimes(1);
|
||||
click();
|
||||
expect(tripleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not turn a drag ending at the last clicked cell into a double click', () => {
|
||||
click();
|
||||
pointer('pointerdown', 100, 80);
|
||||
pointer('pointermove', 60, 50);
|
||||
pointer('pointerup');
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
expect(doubleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not treat a drag returning to its origin as a click', () => {
|
||||
click();
|
||||
pointer('pointerdown');
|
||||
pointer('pointermove', 100, 80);
|
||||
pointer('pointermove');
|
||||
pointer('pointerup');
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([1, 2])('does not include button %s in a primary click sequence', (button) => {
|
||||
click();
|
||||
click(20, 20, button);
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
expect(doubleClick).toHaveBeenCalledTimes(1);
|
||||
click(20, 20, button);
|
||||
expect(tripleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not recognize a triple click at a different position', () => {
|
||||
click();
|
||||
click();
|
||||
click(100, 80);
|
||||
expect(tripleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts a fresh sequence after pointer cancellation', () => {
|
||||
click();
|
||||
pointer('pointerdown');
|
||||
pointer('pointercancel');
|
||||
pointer('pointerup');
|
||||
click();
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
expect(doubleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([1, 2])('starts a fresh sequence after leaving the canvas following %s clicks', (count) => {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
click();
|
||||
}
|
||||
const previousDoubleClicks = doubleClick.mock.calls.length;
|
||||
pointer('pointerleave');
|
||||
pointer('pointerenter');
|
||||
click();
|
||||
expect(doubleClick).toHaveBeenCalledTimes(previousDoubleClicks);
|
||||
expect(tripleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
expect(doubleClick).toHaveBeenCalledTimes(previousDoubleClicks + 1);
|
||||
});
|
||||
|
||||
it('expires double and triple click sequences', () => {
|
||||
click();
|
||||
vi.advanceTimersByTime(InputManager.DoubleClickDelay + 1);
|
||||
click();
|
||||
expect(doubleClick).not.toHaveBeenCalled();
|
||||
click();
|
||||
vi.advanceTimersByTime(InputManager.TripleClickDelay + 1);
|
||||
click();
|
||||
expect(tripleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a small movement within the click tolerance', () => {
|
||||
click();
|
||||
pointer('pointerdown');
|
||||
pointer('pointermove', 21, 21);
|
||||
pointer('pointerup', 21, 21);
|
||||
expect(doubleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([DeviceType.Touch, DeviceType.Mouse])('uses the device-specific double-click threshold for %s', (deviceType) => {
|
||||
pointer('pointerdown', 20, 20, 0, deviceType);
|
||||
pointer('pointerup', 20, 20, 0, deviceType);
|
||||
pointer('pointerdown', 26, 20, 0, deviceType);
|
||||
pointer('pointerup', 26, 20, 0, deviceType);
|
||||
|
||||
expect(doubleClick).toHaveBeenCalledTimes(deviceType === DeviceType.Touch ? 1 : 0);
|
||||
});
|
||||
});
|
||||
393
packages/engine-render/src/__tests__/scene.transformer.spec.ts
Normal file
393
packages/engine-render/src/__tests__/scene.transformer.spec.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DeviceType, PointerInput } from '../basics/i-events';
|
||||
import { Vector2 } from '../basics/vector2';
|
||||
import { Engine } from '../engine';
|
||||
import { Scene } from '../scene';
|
||||
import { Transformer } from '../scene.transformer';
|
||||
import { Control } from '../shape/control';
|
||||
import { Rect } from '../shape/rect';
|
||||
import { Viewport } from '../viewport';
|
||||
import { setupRenderTestEnv } from './render-test-utils';
|
||||
|
||||
function createPointerEvent(type: string, offsetX: number, offsetY: number) {
|
||||
const event = Object.assign(new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: offsetX,
|
||||
clientY: offsetY,
|
||||
pointerId: 1,
|
||||
pointerType: 'touch',
|
||||
}), {
|
||||
currentState: null,
|
||||
deviceType: DeviceType.Touch,
|
||||
inputIndex: PointerInput.LeftClick,
|
||||
previousState: null,
|
||||
});
|
||||
Object.defineProperties(event, {
|
||||
offsetX: { value: offsetX },
|
||||
offsetY: { value: offsetY },
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('Transformer', () => {
|
||||
const env = setupRenderTestEnv();
|
||||
|
||||
afterEach(() => {
|
||||
env.restore();
|
||||
});
|
||||
|
||||
it.each([0.35, 1, 2])('keeps resize and rotation touch targets in screen pixels at scale %s', (scale) => {
|
||||
const engine = new Engine('touch-controls-engine', { elementWidth: 1600, elementHeight: 1600, dpr: 1 });
|
||||
const scene = new Scene('touch-controls-scene', engine);
|
||||
const viewport = new Viewport('touch-controls-viewport', scene, { left: 0, top: 0, width: 1600, height: 1600 });
|
||||
scene.scale(scale, scale);
|
||||
const rect = new Rect('touch-object', { left: 200, top: 200, width: 400, height: 300 });
|
||||
scene.addObject(rect);
|
||||
const transformer = new Transformer(scene, { rotateEnabled: true });
|
||||
try {
|
||||
transformer.setSelectedControl(rect);
|
||||
for (const key of ['ResizeRB', 'Rotate__']) {
|
||||
const anchor = scene.getAllObjectsByOrderForPick().find((object) => object.oKey.includes(key));
|
||||
if (!(anchor instanceof Control)) {
|
||||
throw new TypeError(`Missing ${key} control`);
|
||||
}
|
||||
const artwork = anchor.getState();
|
||||
const center = anchor.ancestorTransform.applyPoint(new Vector2(anchor.width / 2, anchor.height / 2));
|
||||
const point = new Vector2(center.x * scale + 20, center.y * scale);
|
||||
expect(scene.pick(point, DeviceType.Touch)).toBe(anchor);
|
||||
expect(scene.pick(point, DeviceType.Mouse)).not.toBe(anchor);
|
||||
expect(anchor.getState()).toEqual(artwork);
|
||||
}
|
||||
} finally {
|
||||
transformer.dispose();
|
||||
viewport.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('chooses the nearest touch control and leaves mouse, hidden controls and overlays precise', () => {
|
||||
const engine = new Engine('adjust-hit-engine', { elementWidth: 800, elementHeight: 800, dpr: 1 });
|
||||
const scene = new Scene('adjust-hit-scene', engine);
|
||||
const viewport = new Viewport('adjust-hit-viewport', scene, { left: 0, top: 0, width: 800, height: 800 });
|
||||
const object = new Rect('object', { left: 0, top: 0, width: 400, height: 300 });
|
||||
const a = new Control('adjust-a', { left: 100, top: 100, width: 8, height: 8, angle: 45 });
|
||||
const b = new Control('adjust-b', { left: 120, top: 100, width: 8, height: 8 });
|
||||
scene.addObject(object, 1);
|
||||
scene.addObjects([a, b], 2);
|
||||
scene.attachControl();
|
||||
const down = vi.fn();
|
||||
const subscription = a.onPointerDown$.subscribeEvent(down);
|
||||
try {
|
||||
const point = new Vector2(112, 110);
|
||||
expect(scene.pick(point, DeviceType.Touch)).toBe(a);
|
||||
expect(scene.pick(point, DeviceType.Mouse)).toBe(object);
|
||||
engine.getCanvasElement().dispatchEvent(createPointerEvent('pointerdown', point.x, point.y));
|
||||
expect(down).toHaveBeenCalledTimes(1);
|
||||
engine.getCanvasElement().dispatchEvent(createPointerEvent('pointerup', point.x, point.y));
|
||||
a.hide();
|
||||
expect(scene.pick(point, DeviceType.Touch)).toBe(b);
|
||||
b.evented = false;
|
||||
expect(scene.pick(point, DeviceType.Touch)).toBe(object);
|
||||
b.evented = true;
|
||||
const overlay = new Rect('overlay', { left: 100, top: 100, width: 40, height: 40 });
|
||||
scene.addObject(overlay, 3);
|
||||
expect(scene.pick(point, DeviceType.Touch)).toBe(overlay);
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
viewport.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([0.5, 1, 2])('keeps all eight crop hit targets usable at scale %s without enlarging their artwork', (scale) => {
|
||||
const engine = new Engine('crop-hit-engine', { elementWidth: 800, elementHeight: 800, dpr: 1 });
|
||||
const scene = new Scene('crop-hit-scene', engine);
|
||||
const viewport = new Viewport('crop-hit-viewport', scene, { left: 0, top: 0, width: 800, height: 800 });
|
||||
scene.scale(scale, scale);
|
||||
const crop = new Rect('crop-frame', { left: 100, top: 100, width: 400, height: 300, angle: 30 });
|
||||
crop.transformerConfig = { isCropper: true, anchorSize: 24, cropAnchorHitSize: 44 };
|
||||
const image = new Rect('underlying-image', crop.getState());
|
||||
scene.addObject(image, 1);
|
||||
scene.addObject(crop, 2);
|
||||
const transformer = new Transformer(scene);
|
||||
try {
|
||||
transformer.createControlForCopper(crop);
|
||||
transformer.setSelectedControl(image);
|
||||
const anchors = scene.getAllObjectsByOrderForPick().filter((object) =>
|
||||
object.getLayerIndex() === crop.getLayerIndex() && object.oKey.includes('TransformerResize'));
|
||||
expect(anchors).toHaveLength(8);
|
||||
for (const anchor of anchors) {
|
||||
expect(Math.max(anchor.width, anchor.height)).toBe(24);
|
||||
const halfWidth = Math.max(anchor.width, 44 / scale) / 2;
|
||||
const halfHeight = Math.max(anchor.height, 44 / scale) / 2;
|
||||
const hit = (x: number, y: number) => anchor.isHit(anchor.ancestorTransform.applyPoint(new Vector2(
|
||||
anchor.width / 2 + x,
|
||||
anchor.height / 2 + y
|
||||
)));
|
||||
expect(hit(halfWidth - 1 / scale, halfHeight - 1 / scale)).toBe(true);
|
||||
expect(hit(-halfWidth + 1 / scale, -halfHeight + 1 / scale)).toBe(true);
|
||||
expect(hit(halfWidth + 1 / scale, 0)).toBe(false);
|
||||
const point = anchor.ancestorTransform.applyPoint(new Vector2(anchor.width / 2, anchor.height / 2));
|
||||
expect(scene.pick(new Vector2(point.x * scale, point.y * scale))).toBe(anchor);
|
||||
expect(scene.pick(new Vector2(point.x * scale, point.y * scale), DeviceType.Touch)).toBe(anchor);
|
||||
}
|
||||
} finally {
|
||||
viewport.dispose();
|
||||
transformer.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps desktop crop handles using their precise painted hit areas', () => {
|
||||
const engine = new Engine('desktop-crop-engine', { elementWidth: 800, elementHeight: 800, dpr: 1 });
|
||||
const scene = new Scene('desktop-crop-scene', engine);
|
||||
const crop = new Rect('desktop-crop-frame', { width: 400, height: 300 });
|
||||
crop.transformerConfig = { isCropper: true, anchorSize: 24 };
|
||||
scene.addObject(crop);
|
||||
const transformer = new Transformer(scene);
|
||||
try {
|
||||
transformer.createControlForCopper(crop);
|
||||
const anchors = scene.getAllObjectsByOrderForPick().filter((object) => object.oKey.includes('TransformerResize'));
|
||||
expect(anchors).toHaveLength(8);
|
||||
for (const anchor of anchors) {
|
||||
expect(anchor.isHit(anchor.ancestorTransform.applyPoint(new Vector2(anchor.width + 4, anchor.height / 2)))).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
transformer.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['takeover', 'pointercancel'])('finishes a move once on %s and releases the scene for viewport gestures', (reason) => {
|
||||
const engine = new Engine('gesture-engine', { elementWidth: 800, elementHeight: 800, dpr: 1 });
|
||||
const scene = new Scene('gesture-scene', engine, { width: 800, height: 800 });
|
||||
const viewport = new Viewport('gesture-viewport', scene, { left: 0, top: 0, width: 800, height: 800 });
|
||||
const rect = new Rect('gesture-object', { left: 100, top: 100, width: 100, height: 100 });
|
||||
scene.addObject(rect);
|
||||
const transformer = new Transformer(scene);
|
||||
const ended = vi.fn();
|
||||
const subscription = transformer.changeEnd$.subscribe(ended);
|
||||
try {
|
||||
transformer.attachTo(rect);
|
||||
transformer.setSelectedControl(rect);
|
||||
rect.onPointerDown$.emitEvent(createPointerEvent('pointerdown', 120, 120));
|
||||
scene.onPointerMove$.emitEvent(createPointerEvent('pointermove', 160, 150));
|
||||
if (reason === 'takeover') {
|
||||
transformer.finishTransform();
|
||||
} else {
|
||||
scene.onPointerCancel$.emitEvent(createPointerEvent('pointercancel', 0, 0));
|
||||
}
|
||||
expect(ended).toHaveBeenCalledTimes(1);
|
||||
expect(ended.mock.calls[0][0]).toMatchObject({ offsetX: 160, offsetY: 150 });
|
||||
expect(rect.left).toBe(140);
|
||||
expect(rect.top).toBe(130);
|
||||
scene.onPointerMove$.emitEvent(createPointerEvent('pointermove', 300, 300));
|
||||
scene.onPointerUp$.emitEvent(createPointerEvent('pointerup', 300, 300));
|
||||
transformer.finishTransform();
|
||||
expect(ended).toHaveBeenCalledTimes(1);
|
||||
expect(rect.left).toBe(140);
|
||||
expect(scene.pick(new Vector2(170, 160))).toBe(rect);
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
transformer.dispose();
|
||||
viewport.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('releases hover subscriptions with its object subscription', () => {
|
||||
const engine = new Engine('transformer-engine', { elementWidth: 100, elementHeight: 100, dpr: 1 });
|
||||
const scene = new Scene('transformer-scene', engine);
|
||||
const rect = new Rect('transformer-rect', { width: 10, height: 10 });
|
||||
const transformer = new Transformer(scene, {
|
||||
hoverEnabled: true,
|
||||
hoverEnterFunc: vi.fn(),
|
||||
hoverLeaveFunc: vi.fn(),
|
||||
});
|
||||
|
||||
transformer.attachTo(rect);
|
||||
|
||||
expect((rect.onPointerEnter$ as any).observers).toHaveLength(1);
|
||||
expect((rect.onPointerLeave$ as any).observers).toHaveLength(1);
|
||||
|
||||
transformer.dispose();
|
||||
|
||||
expect((rect.onPointerEnter$ as any).observers).toHaveLength(0);
|
||||
expect((rect.onPointerLeave$ as any).observers).toHaveLength(0);
|
||||
|
||||
rect.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it.each(['ResizeRB', 'Rotate__'])('releases %s controls when a pinch takes ownership', (controlKey) => {
|
||||
const engine = new Engine('control-gesture-engine', { elementWidth: 800, elementHeight: 800, dpr: 1 });
|
||||
const scene = new Scene('control-gesture-scene', engine, { width: 800, height: 800 });
|
||||
const viewport = new Viewport('control-gesture-viewport', scene, { left: 0, top: 0, width: 800, height: 800 });
|
||||
const rect = new Rect('control-gesture-object', { left: 100, top: 100, width: 100, height: 100 });
|
||||
scene.addObject(rect);
|
||||
const transformer = new Transformer(scene, { rotateEnabled: true });
|
||||
const ended = vi.fn();
|
||||
const subscription = transformer.changeEnd$.subscribe(ended);
|
||||
try {
|
||||
transformer.setSelectedControl(rect);
|
||||
const anchor = scene.getAllObjectsByOrderForPick().find((object) => object.oKey.includes(controlKey));
|
||||
if (!anchor) {
|
||||
throw new Error(`Missing ${controlKey} control`);
|
||||
}
|
||||
const initial = rect.getState();
|
||||
const point = anchor.ancestorTransform.applyPoint(new Vector2(anchor.width / 2, anchor.height / 2));
|
||||
anchor.onPointerDown$.emitEvent(createPointerEvent('pointerdown', point.x, point.y));
|
||||
scene.onPointerMove$.emitEvent(createPointerEvent('pointermove', point.x + 60, point.y + 40));
|
||||
transformer.finishTransform();
|
||||
expect(ended).toHaveBeenCalledTimes(1);
|
||||
const transformed = rect.getState();
|
||||
expect(transformed).not.toEqual(initial);
|
||||
scene.onPointerMove$.emitEvent(createPointerEvent('pointermove', 500, 500));
|
||||
scene.onPointerUp$.emitEvent(createPointerEvent('pointerup', 500, 500));
|
||||
expect(ended).toHaveBeenCalledTimes(1);
|
||||
expect(rect.getState()).toEqual(transformed);
|
||||
} finally {
|
||||
subscription.unsubscribe();
|
||||
transformer.dispose();
|
||||
viewport.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders controls on the configured layer without moving the selected object', () => {
|
||||
const engine = new Engine('transformer-layer-engine', { elementWidth: 100, elementHeight: 100, dpr: 1 });
|
||||
const scene = new Scene('transformer-layer-scene', engine);
|
||||
const rect = new Rect('transformer-layer-rect', { width: 10, height: 10 });
|
||||
scene.addObject(rect, 2);
|
||||
const transformer = new Transformer(scene, {
|
||||
borderEnabled: true,
|
||||
controlLayerIndex: 4,
|
||||
});
|
||||
let controlLayerIndex: number | undefined;
|
||||
const controlSubscription = transformer.createControl$.subscribe((control) => {
|
||||
controlLayerIndex = control.getLayerIndex();
|
||||
});
|
||||
|
||||
transformer.setSelectedControl(rect);
|
||||
|
||||
expect(rect.getLayerIndex()).toBe(2);
|
||||
expect(controlLayerIndex).toBe(4);
|
||||
|
||||
controlSubscription.unsubscribe();
|
||||
transformer.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('restores a missing control when an already selected object is activated again', () => {
|
||||
const engine = new Engine('transformer-restore-engine', { elementWidth: 100, elementHeight: 100, dpr: 1 });
|
||||
const scene = new Scene('transformer-restore-scene', engine);
|
||||
const rect = new Rect('transformer-restore-rect', { width: 10, height: 10 });
|
||||
scene.addObject(rect);
|
||||
const transformer = new Transformer(scene);
|
||||
const createControl = vi.fn();
|
||||
const subscription = transformer.createControl$.subscribe(createControl);
|
||||
|
||||
transformer.setSelectedControl(rect);
|
||||
transformer.clearControls();
|
||||
transformer.activeAnObject(rect);
|
||||
|
||||
expect(transformer.getSelectedObjectMap().get(rect.oKey)).toBe(rect);
|
||||
expect(createControl).toHaveBeenCalledTimes(2);
|
||||
|
||||
subscription.unsubscribe();
|
||||
transformer.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('keeps read-only objects selectable without starting a move gesture', () => {
|
||||
const engine = new Engine('transformer-readonly-engine', { elementWidth: 100, elementHeight: 100, dpr: 1 });
|
||||
const scene = new Scene('transformer-readonly-scene', engine);
|
||||
const rect = new Rect('transformer-readonly-rect', { width: 10, height: 10 });
|
||||
rect.transformerConfig = {
|
||||
moveEnabled: false,
|
||||
resizeEnabled: false,
|
||||
rotateEnabled: false,
|
||||
};
|
||||
const transformer = new Transformer(scene);
|
||||
const changeStart = vi.fn();
|
||||
const subscription = transformer.changeStart$.subscribe(changeStart);
|
||||
|
||||
transformer.attachTo(rect);
|
||||
rect.onPointerDown$.emitEvent({ offsetX: 5, offsetY: 5 } as never);
|
||||
|
||||
expect(transformer.getSelectedObjectMap().get(rect.oKey)).toBe(rect);
|
||||
expect(changeStart).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
transformer.dispose();
|
||||
rect.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
});
|
||||
|
||||
it('moves an object only on a gesture that starts after selection', () => {
|
||||
const engine = new Engine('transformer-selection-engine', { elementWidth: 100, elementHeight: 100, dpr: 1 });
|
||||
const scene = new Scene('transformer-selection-scene', engine);
|
||||
const rect = new Rect('transformer-selection-rect', { width: 10, height: 10 });
|
||||
scene.addObject(rect);
|
||||
const transformer = new Transformer(scene, { moveOnlyWhenSelected: true });
|
||||
const changeStart = vi.fn();
|
||||
const changeStartSubscription = transformer.changeStart$.subscribe(changeStart);
|
||||
const reattachSubscription = transformer.createControl$.subscribe(() => transformer.attachTo(rect));
|
||||
|
||||
transformer.attachTo(rect);
|
||||
rect.onPointerDown$.emitEvent(createPointerEvent('pointerdown', 5, 5));
|
||||
scene.onPointerMove$.emitEvent(createPointerEvent('pointermove', 25, 25));
|
||||
scene.onPointerUp$.emitEvent(createPointerEvent('pointerup', 25, 25));
|
||||
|
||||
expect(transformer.getSelectedObjectMap().has(rect.oKey)).toBe(false);
|
||||
expect(changeStart).not.toHaveBeenCalled();
|
||||
expect(rect.left).toBe(0);
|
||||
expect(rect.top).toBe(0);
|
||||
|
||||
rect.onPointerDown$.emitEvent(createPointerEvent('pointerdown', 5, 5));
|
||||
scene.onPointerUp$.emitEvent(createPointerEvent('pointerup', 5, 5));
|
||||
|
||||
expect(transformer.getSelectedObjectMap().get(rect.oKey)).toBe(rect);
|
||||
|
||||
rect.onPointerDown$.emitEvent(createPointerEvent('pointerdown', 5, 5));
|
||||
scene.onPointerMove$.emitEvent(createPointerEvent('pointermove', 25, 25));
|
||||
scene.onPointerUp$.emitEvent(createPointerEvent('pointerup', 25, 25));
|
||||
|
||||
expect(changeStart).toHaveBeenCalledTimes(1);
|
||||
expect(rect.left).toBe(20);
|
||||
expect(rect.top).toBe(20);
|
||||
|
||||
reattachSubscription.unsubscribe();
|
||||
changeStartSubscription.unsubscribe();
|
||||
transformer.dispose();
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
});
|
||||
});
|
||||
101
packages/engine-render/src/__tests__/scroll-timer.spec.ts
Normal file
101
packages/engine-render/src/__tests__/scroll-timer.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ScrollTimer, ScrollTimerType } from '../scroll-timer';
|
||||
|
||||
function createViewport() {
|
||||
return {
|
||||
top: 10,
|
||||
left: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
scrollByViewportDeltaVal: vi.fn(() => true),
|
||||
transScroll2ViewportScrollValue: vi.fn((x: number, y: number) => ({ x: x * 2, y: y * 3 })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScrollTimer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('auto-scrolls the active viewport when the pointer moves near the right and bottom edges', () => {
|
||||
const viewport = createViewport();
|
||||
const scene = {
|
||||
findViewportByPosToScene: vi.fn(() => viewport),
|
||||
};
|
||||
const timer = ScrollTimer.create(scene as never, ScrollTimerType.ALL, { t: 5, b: 10, l: 5, r: 15 });
|
||||
const scrollFunction = vi.fn();
|
||||
|
||||
timer.scrolling(130, 95, scrollFunction);
|
||||
timer.startScroll(130, 95);
|
||||
timer.stopScroll();
|
||||
|
||||
expect(scene.findViewportByPosToScene).toHaveBeenCalled();
|
||||
expect(viewport.scrollByViewportDeltaVal).toHaveBeenCalledWith({
|
||||
viewportScrollX: 25,
|
||||
viewportScrollY: 15,
|
||||
});
|
||||
expect(viewport.transScroll2ViewportScrollValue).toHaveBeenCalledWith(25, 15);
|
||||
expect(scrollFunction).toHaveBeenCalledWith(50, 45);
|
||||
});
|
||||
|
||||
it('respects axis restrictions and explicit target viewport', () => {
|
||||
const viewport = createViewport();
|
||||
const scene = {
|
||||
findViewportByPosToScene: vi.fn(),
|
||||
};
|
||||
const timer = ScrollTimer.create(scene as never, ScrollTimerType.Y, { t: 5, b: 10, l: 5, r: 15 });
|
||||
const scrollFunction = vi.fn();
|
||||
|
||||
timer.scrolling(130, 95, scrollFunction);
|
||||
timer.startScroll(130, 95, viewport);
|
||||
timer.stopScroll();
|
||||
|
||||
expect(scene.findViewportByPosToScene).not.toHaveBeenCalled();
|
||||
expect(viewport.scrollByViewportDeltaVal).toHaveBeenCalledWith({
|
||||
viewportScrollX: 0,
|
||||
viewportScrollY: 15,
|
||||
});
|
||||
expect(scrollFunction).toHaveBeenCalledWith(0, 45);
|
||||
expect(timer.getActiveViewport()).toBe(viewport);
|
||||
expect(timer.offsetX).toBe(130);
|
||||
expect(timer.offsetY).toBe(95);
|
||||
});
|
||||
|
||||
it('does not invoke the scroll callback with movement when pointer stays inside the safe area', () => {
|
||||
const viewport = createViewport();
|
||||
const scene = {
|
||||
findViewportByPosToScene: vi.fn(() => viewport),
|
||||
};
|
||||
const timer = new ScrollTimer(scene as never, ScrollTimerType.ALL, { t: 5, b: 10, l: 5, r: 15 });
|
||||
const scrollFunction = vi.fn();
|
||||
|
||||
timer.scrolling(60, 50, scrollFunction);
|
||||
timer.startScroll(60, 50);
|
||||
timer.stopScroll();
|
||||
timer.dispose();
|
||||
|
||||
expect(viewport.scrollByViewportDeltaVal).not.toHaveBeenCalled();
|
||||
expect(scrollFunction).toHaveBeenCalledWith(0, 0);
|
||||
expect(timer.getScene()).toBe(scene);
|
||||
});
|
||||
});
|
||||
45
packages/engine-render/src/__tests__/viewport.spec.ts
Normal file
45
packages/engine-render/src/__tests__/viewport.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { Engine } from '../engine';
|
||||
import { Scene } from '../scene';
|
||||
import { Viewport } from '../viewport';
|
||||
import { setupRenderTestEnv } from './render-test-utils';
|
||||
|
||||
describe('Viewport', () => {
|
||||
const env = setupRenderTestEnv();
|
||||
|
||||
afterEach(() => {
|
||||
env.restore();
|
||||
});
|
||||
|
||||
it('releases its engine transform subscription on disposal', () => {
|
||||
const engine = new Engine('viewport-engine', { elementWidth: 100, elementHeight: 100, dpr: 1 });
|
||||
const scene = new Scene('viewport-scene', engine);
|
||||
const initialObserverCount = (engine.onTransformChange$ as any).observers.length;
|
||||
const viewport = new Viewport('viewport', scene, { width: 100, height: 100 });
|
||||
|
||||
expect((engine.onTransformChange$ as any).observers).toHaveLength(initialObserverCount + 1);
|
||||
|
||||
viewport.dispose();
|
||||
|
||||
expect((engine.onTransformChange$ as any).observers).toHaveLength(initialObserverCount);
|
||||
|
||||
scene.dispose();
|
||||
engine.dispose();
|
||||
});
|
||||
});
|
||||
1891
packages/engine-render/src/__tests__/worker-layout.spec.ts
Normal file
1891
packages/engine-render/src/__tests__/worker-layout.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
191
packages/engine-render/src/alignment-snap.ts
Normal file
191
packages/engine-render/src/alignment-snap.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface IAlignmentRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type AlignmentSnapAxis = 'x' | 'y';
|
||||
|
||||
export interface IAlignmentSnapGuide {
|
||||
id: string;
|
||||
axis: AlignmentSnapAxis;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface IAlignmentSnapSessionConfig {
|
||||
enterThreshold?: number;
|
||||
exitThreshold?: number;
|
||||
breakawayThreshold?: number;
|
||||
softThreshold?: number;
|
||||
hardThreshold?: number;
|
||||
softMaxStrength?: number;
|
||||
cooldownMs?: number;
|
||||
}
|
||||
|
||||
export interface IAlignmentSnapAxisOptions {
|
||||
axis: AlignmentSnapAxis;
|
||||
value: number;
|
||||
guide: IAlignmentSnapGuide | null;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export interface IAlignmentSnapAxisResult {
|
||||
snapped: boolean;
|
||||
value: number;
|
||||
guide: IAlignmentSnapGuide | null;
|
||||
}
|
||||
|
||||
const DEFAULT_ENTER_THRESHOLD = 6;
|
||||
const DEFAULT_EXIT_THRESHOLD = 11;
|
||||
const DEFAULT_COOLDOWN_MS = 250;
|
||||
|
||||
export function normalizeAlignmentRect(rect: IAlignmentRect): IAlignmentRect {
|
||||
return {
|
||||
left: rect.width >= 0 ? rect.left : rect.left + rect.width,
|
||||
top: rect.height >= 0 ? rect.top : rect.top + rect.height,
|
||||
width: Math.abs(rect.width),
|
||||
height: Math.abs(rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAlignmentRectXAnchors(rect: IAlignmentRect): [number, number, number] {
|
||||
const normalized = normalizeAlignmentRect(rect);
|
||||
return [normalized.left, normalized.left + normalized.width / 2, normalized.left + normalized.width];
|
||||
}
|
||||
|
||||
export function getAlignmentRectYAnchors(rect: IAlignmentRect): [number, number, number] {
|
||||
const normalized = normalizeAlignmentRect(rect);
|
||||
return [normalized.top, normalized.top + normalized.height / 2, normalized.top + normalized.height];
|
||||
}
|
||||
|
||||
export function getClosestAlignmentOffset(
|
||||
activeAnchors: readonly number[],
|
||||
targetAnchors: readonly number[],
|
||||
threshold: number
|
||||
): number {
|
||||
let closestOffset = 0;
|
||||
let closestDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
targetAnchors.forEach((target) => {
|
||||
activeAnchors.forEach((active) => {
|
||||
const offset = target - active;
|
||||
const distance = Math.abs(offset);
|
||||
if (distance <= threshold && distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestOffset = offset;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return closestDistance === Number.POSITIVE_INFINITY ? 0 : closestOffset;
|
||||
}
|
||||
|
||||
export class AlignmentSnapSession {
|
||||
private readonly _enterThreshold: number;
|
||||
private readonly _exitThreshold: number;
|
||||
private readonly _breakawayThreshold: number | undefined;
|
||||
private readonly _softThreshold: number | undefined;
|
||||
private readonly _hardThreshold: number;
|
||||
private readonly _softMaxStrength: number;
|
||||
private readonly _cooldownMs: number;
|
||||
private _activeGuideId: string | null = null;
|
||||
private _activeGuideStartValue: number | null = null;
|
||||
private _releasedUntil = 0;
|
||||
|
||||
constructor(config: IAlignmentSnapSessionConfig = {}) {
|
||||
this._enterThreshold = config.enterThreshold ?? DEFAULT_ENTER_THRESHOLD;
|
||||
this._exitThreshold = config.exitThreshold ?? DEFAULT_EXIT_THRESHOLD;
|
||||
this._breakawayThreshold = config.breakawayThreshold;
|
||||
this._softThreshold = config.softThreshold;
|
||||
this._hardThreshold = config.hardThreshold ?? this._enterThreshold;
|
||||
this._softMaxStrength = clamp(config.softMaxStrength ?? 0.65, 0, 1);
|
||||
this._cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._activeGuideId = null;
|
||||
this._activeGuideStartValue = null;
|
||||
this._releasedUntil = 0;
|
||||
}
|
||||
|
||||
resolveAxisSnap(options: IAlignmentSnapAxisOptions): IAlignmentSnapAxisResult {
|
||||
const { value, guide } = options;
|
||||
const now = options.now ?? Date.now();
|
||||
if (!guide) {
|
||||
this._activeGuideId = null;
|
||||
this._activeGuideStartValue = null;
|
||||
return { snapped: false, value, guide: null };
|
||||
}
|
||||
|
||||
const distance = Math.abs(value - guide.position);
|
||||
if (this._activeGuideId === guide.id) {
|
||||
const rawDragDistance = this._activeGuideStartValue === null ? 0 : Math.abs(value - this._activeGuideStartValue);
|
||||
const isPastBreakaway = this._breakawayThreshold !== undefined && rawDragDistance > this._breakawayThreshold;
|
||||
const exitThreshold = this._softThreshold ?? this._exitThreshold;
|
||||
if (isPastBreakaway || distance > exitThreshold) {
|
||||
this._activeGuideId = null;
|
||||
this._activeGuideStartValue = null;
|
||||
this._releasedUntil = now + this._cooldownMs;
|
||||
return { snapped: false, value, guide: null };
|
||||
}
|
||||
|
||||
return { snapped: true, value: this._resolveSnappedValue(value, guide.position), guide };
|
||||
}
|
||||
|
||||
if (this._releasedUntil > now) {
|
||||
return { snapped: false, value, guide: null };
|
||||
}
|
||||
|
||||
const enterThreshold = this._softThreshold ?? this._enterThreshold;
|
||||
if (distance <= enterThreshold) {
|
||||
this._activeGuideId = guide.id;
|
||||
this._activeGuideStartValue = value;
|
||||
return { snapped: true, value: this._resolveSnappedValue(value, guide.position), guide };
|
||||
}
|
||||
|
||||
return { snapped: false, value, guide: null };
|
||||
}
|
||||
|
||||
private _resolveSnappedValue(value: number, guidePosition: number): number {
|
||||
if (this._softThreshold === undefined) {
|
||||
return guidePosition;
|
||||
}
|
||||
|
||||
const distance = Math.abs(value - guidePosition);
|
||||
if (distance <= this._hardThreshold) {
|
||||
return guidePosition;
|
||||
}
|
||||
|
||||
if (distance >= this._softThreshold) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const span = Math.max(this._softThreshold - this._hardThreshold, 0.001);
|
||||
const progress = clamp((this._softThreshold - distance) / span, 0, 1);
|
||||
const eased = progress * progress * (3 - 2 * progress);
|
||||
const strength = eased * this._softMaxStrength;
|
||||
|
||||
return value + (guidePosition - value) * strength;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
970
packages/engine-render/src/base-object.ts
Normal file
970
packages/engine-render/src/base-object.ts
Normal file
@@ -0,0 +1,970 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IGroupBaseBound, ITransformState, Nullable } from '@univerjs/core';
|
||||
import type { IDragEvent, IMouseEvent, IPointerEvent, IWheelEvent } from './basics/i-events';
|
||||
import type { IObjectFullState, ITransformChangeState } from './basics/interfaces';
|
||||
import type { ITransformerConfig } from './basics/transformer-config';
|
||||
import type { IViewportInfo, Vector2 } from './basics/vector2';
|
||||
import type { UniverRenderingContext } from './context';
|
||||
import type { Engine } from './engine';
|
||||
import type { Layer } from './layer';
|
||||
import type { Scene } from './scene';
|
||||
import { Disposable, EventSubject } from '@univerjs/core';
|
||||
import { getRenderTransformBaseOnParentBound, getRotatedBoundInGroup } from './basics';
|
||||
import { CURSOR_TYPE, RENDER_CLASS_TYPE } from './basics/const';
|
||||
import { TRANSFORM_CHANGE_OBSERVABLE_TYPE } from './basics/interfaces';
|
||||
import { generateRandomKey, toPx } from './basics/tools';
|
||||
import { Transform } from './basics/transform';
|
||||
|
||||
export const BASE_OBJECT_ARRAY = [
|
||||
'top',
|
||||
'left',
|
||||
'width',
|
||||
'height',
|
||||
'angle',
|
||||
'scaleX',
|
||||
'scaleY',
|
||||
'skewX',
|
||||
'skewY',
|
||||
'flipX',
|
||||
'flipY',
|
||||
'strokeWidth',
|
||||
];
|
||||
|
||||
export enum ObjectType {
|
||||
UNKNOWN,
|
||||
RICH_TEXT,
|
||||
SHAPE,
|
||||
IMAGE,
|
||||
RECT,
|
||||
CIRCLE,
|
||||
CHART,
|
||||
DRAWING_DOM,
|
||||
}
|
||||
|
||||
export abstract class BaseObject extends Disposable {
|
||||
groupKey?: string;
|
||||
isInGroup: boolean = false;
|
||||
isDrawingObject: boolean = false;
|
||||
|
||||
objectType: ObjectType = ObjectType.UNKNOWN;
|
||||
|
||||
onTransformChange$ = new EventSubject<ITransformChangeState>();
|
||||
|
||||
onPointerDown$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onPointerMove$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onPointerUp$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onPointerOut$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onPointerOver$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onPointerLeave$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onPointerEnter$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
|
||||
onSingleClick$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onClick$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onDblclick$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onTripleClick$ = new EventSubject<IPointerEvent | IMouseEvent>();
|
||||
onMouseWheel$ = new EventSubject<IWheelEvent>();
|
||||
|
||||
onDragLeave$ = new EventSubject<IDragEvent | IMouseEvent>();
|
||||
onDragOver$ = new EventSubject<IDragEvent | IMouseEvent>();
|
||||
onDragEnter$ = new EventSubject<IDragEvent | IMouseEvent>();
|
||||
onDrop$ = new EventSubject<IDragEvent | IMouseEvent>();
|
||||
|
||||
onIsAddedToParent$ = new EventSubject<any>();
|
||||
onDispose$ = new EventSubject<BaseObject>();
|
||||
|
||||
protected _oKey: string;
|
||||
|
||||
protected _dirty: boolean = true;
|
||||
protected _forceDirty: boolean = true;
|
||||
|
||||
private _printable: boolean = false;
|
||||
|
||||
private _top: number = 0;
|
||||
private _topOrigin: number | string = 0;
|
||||
|
||||
private _left: number = 0;
|
||||
private _leftOrigin: number | string = 0;
|
||||
|
||||
private _width: number = 0;
|
||||
private _widthOrigin: number | string = 0;
|
||||
|
||||
private _height: number = 0;
|
||||
private _heightOrigin: number | string = 0;
|
||||
|
||||
private _angle: number = 0;
|
||||
|
||||
private _scaleX: number = 1;
|
||||
private _scaleY: number = 1;
|
||||
|
||||
private _skewX: number = 0;
|
||||
private _skewY: number = 0;
|
||||
|
||||
private _flipX: boolean = false;
|
||||
private _flipY: boolean = false;
|
||||
|
||||
private _strokeWidth: number = 0;
|
||||
|
||||
private _parent: any; // TODO: @DR-Univer. The object must be mounted to a scene or group. 'Any' is used here to avoid circular dependencies. This will be resolved later through dependency injection.
|
||||
|
||||
private _zIndex: number = 0;
|
||||
|
||||
private _evented: boolean = true;
|
||||
|
||||
private _visible: boolean = true;
|
||||
|
||||
private _debounceParentDirty: boolean = true;
|
||||
|
||||
protected _transform = new Transform();
|
||||
|
||||
private _cursor: CURSOR_TYPE = CURSOR_TYPE.DEFAULT;
|
||||
|
||||
private _transformerConfig: ITransformerConfig;
|
||||
|
||||
private _forceRender = false;
|
||||
|
||||
private _layer: Nullable<Layer>; // TODO: @DR-Univer. Belong to layer
|
||||
|
||||
constructor(key?: string) {
|
||||
super();
|
||||
|
||||
if (key) {
|
||||
this._oKey = key;
|
||||
} else {
|
||||
this._oKey = generateRandomKey();
|
||||
}
|
||||
}
|
||||
|
||||
get transform() {
|
||||
const transform = this._transform.clone();
|
||||
return this.transformForAngle(transform);
|
||||
}
|
||||
|
||||
transformForAngle(transform: Transform) {
|
||||
/**
|
||||
* If the object is center rotated, the coordinate needs to be rotated back to the original position.
|
||||
*/
|
||||
if (this._angle !== 0) {
|
||||
const cx = (this.width + this.strokeWidth) / 2;
|
||||
const cy = (this.height + this.strokeWidth) / 2;
|
||||
transform.rotate(-this._angle);
|
||||
transform.translate(cx, cy);
|
||||
transform.rotate(this.angle);
|
||||
transform.translate(-cx, -cy);
|
||||
}
|
||||
|
||||
return transform;
|
||||
}
|
||||
|
||||
get printable() {
|
||||
return this._printable;
|
||||
}
|
||||
|
||||
get topOrigin() {
|
||||
return this._topOrigin;
|
||||
}
|
||||
|
||||
get leftOrigin() {
|
||||
return this._leftOrigin;
|
||||
}
|
||||
|
||||
get widthOrigin() {
|
||||
return this._widthOrigin;
|
||||
}
|
||||
|
||||
get heightOrigin() {
|
||||
return this._heightOrigin;
|
||||
}
|
||||
|
||||
get classType() {
|
||||
return RENDER_CLASS_TYPE.BASE_OBJECT;
|
||||
}
|
||||
|
||||
get top(): number {
|
||||
return this._top;
|
||||
}
|
||||
|
||||
get left(): number {
|
||||
return this._left;
|
||||
}
|
||||
|
||||
get width(): number {
|
||||
return this._width;
|
||||
}
|
||||
|
||||
get height(): number {
|
||||
return this._height;
|
||||
}
|
||||
|
||||
get strokeWidth() {
|
||||
return this._strokeWidth;
|
||||
}
|
||||
|
||||
get angle() {
|
||||
return this._angle;
|
||||
}
|
||||
|
||||
get scaleX() {
|
||||
return this._scaleX;
|
||||
}
|
||||
|
||||
get scaleY() {
|
||||
return this._scaleY;
|
||||
}
|
||||
|
||||
get ancestorScaleX() {
|
||||
const pScale: number = this.getParent()?.ancestorScaleX || 1;
|
||||
return this.scaleX * pScale;
|
||||
}
|
||||
|
||||
get ancestorScaleY() {
|
||||
const pScale: number = this.getParent()?.ancestorScaleY || 1;
|
||||
return this.scaleY * pScale;
|
||||
}
|
||||
|
||||
get ancestorLeft() {
|
||||
return this.left + (this.getParent()?.ancestorLeft || 0);
|
||||
}
|
||||
|
||||
get ancestorTop() {
|
||||
return this.top + (this.getParent()?.ancestorTop || 0);
|
||||
}
|
||||
|
||||
get ancestorTransform() {
|
||||
const parent = this.getParent();
|
||||
if (this.isInGroup && parent?.classType === RENDER_CLASS_TYPE.GROUP) {
|
||||
return parent?.ancestorTransform.multiply(this.transform);
|
||||
}
|
||||
return this.transform;
|
||||
}
|
||||
|
||||
get ancestorGroup() {
|
||||
let group: Nullable<BaseObject> = null;
|
||||
let parent = this.getParent();
|
||||
while (parent != null && parent) {
|
||||
if (parent.classType === RENDER_CLASS_TYPE.GROUP) {
|
||||
group = parent;
|
||||
}
|
||||
if (parent.classType === RENDER_CLASS_TYPE.GROUP) {
|
||||
parent = parent.getParent();
|
||||
} else {
|
||||
parent = null;
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
get skewX() {
|
||||
return this._skewX;
|
||||
}
|
||||
|
||||
get skewY() {
|
||||
return this._skewY;
|
||||
}
|
||||
|
||||
get flipX() {
|
||||
return this._flipX;
|
||||
}
|
||||
|
||||
get flipY() {
|
||||
return this._flipY;
|
||||
}
|
||||
|
||||
get parent() {
|
||||
return this._parent;
|
||||
}
|
||||
|
||||
get oKey() {
|
||||
return this._oKey;
|
||||
}
|
||||
|
||||
get zIndex() {
|
||||
return this._zIndex;
|
||||
}
|
||||
|
||||
get evented() {
|
||||
return this._evented;
|
||||
}
|
||||
|
||||
get visible() {
|
||||
return this._visible;
|
||||
}
|
||||
|
||||
get debounceParentDirty() {
|
||||
return this._debounceParentDirty;
|
||||
}
|
||||
|
||||
get cursor() {
|
||||
return this._cursor;
|
||||
}
|
||||
|
||||
get layer(): Nullable<Layer> {
|
||||
return this._layer;
|
||||
}
|
||||
|
||||
set transform(trans: Transform) {
|
||||
this._transform = trans;
|
||||
}
|
||||
|
||||
set zIndex(index: number) {
|
||||
this._zIndex = index;
|
||||
}
|
||||
|
||||
set parent(o: any) {
|
||||
this._parent = o;
|
||||
}
|
||||
|
||||
set evented(state: boolean) {
|
||||
this._evented = state;
|
||||
}
|
||||
|
||||
set debounceParentDirty(state: boolean) {
|
||||
this._debounceParentDirty = state;
|
||||
}
|
||||
|
||||
set cursor(val: CURSOR_TYPE) {
|
||||
this.setCursor(val);
|
||||
}
|
||||
|
||||
set layer(layer: Layer) {
|
||||
this._layer = layer;
|
||||
}
|
||||
|
||||
protected set top(num: number | string) {
|
||||
this._topOrigin = num;
|
||||
this._top = toPx(num, this._parent?.height);
|
||||
}
|
||||
|
||||
protected set left(num: number | string) {
|
||||
this._leftOrigin = num;
|
||||
this._left = toPx(num, this._parent?.width);
|
||||
}
|
||||
|
||||
protected set width(num: number | string) {
|
||||
this._widthOrigin = num;
|
||||
this._width = toPx(num, this._parent?.width);
|
||||
}
|
||||
|
||||
protected set height(num: number | string) {
|
||||
this._heightOrigin = num;
|
||||
this._height = toPx(num, this._parent?.height);
|
||||
}
|
||||
|
||||
protected set strokeWidth(width: number) {
|
||||
this._strokeWidth = width;
|
||||
}
|
||||
|
||||
protected set angle(angle: number) {
|
||||
this._angle = angle;
|
||||
}
|
||||
|
||||
protected set scaleX(scaleX: number) {
|
||||
this._scaleX = scaleX;
|
||||
}
|
||||
|
||||
protected set scaleY(scaleY: number) {
|
||||
this._scaleY = scaleY;
|
||||
}
|
||||
|
||||
protected set skewX(skewX: number) {
|
||||
this._skewX = skewX;
|
||||
}
|
||||
|
||||
protected set flipY(flipY: boolean) {
|
||||
this._flipY = flipY;
|
||||
}
|
||||
|
||||
protected set flipX(flipX: boolean) {
|
||||
this._flipX = flipX;
|
||||
}
|
||||
|
||||
protected set skewY(skewY: number) {
|
||||
this._skewY = skewY;
|
||||
}
|
||||
|
||||
get transformerConfig() {
|
||||
return this._transformerConfig;
|
||||
}
|
||||
|
||||
set transformerConfig(config: ITransformerConfig) {
|
||||
this._transformerConfig = config;
|
||||
}
|
||||
|
||||
get maxZIndex() {
|
||||
return this._zIndex;
|
||||
}
|
||||
|
||||
makeDirty(state: boolean = true) {
|
||||
this._dirty = state;
|
||||
|
||||
if (state) {
|
||||
if (this._layer == null) {
|
||||
this._dirty = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._layer.makeDirtyWithDebounce(state);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
makeForceDirty(state: boolean = true) {
|
||||
this._forceDirty = state;
|
||||
}
|
||||
|
||||
makeDirtyNoDebounce(state: boolean = true) {
|
||||
this._dirty = state;
|
||||
if (state) {
|
||||
this._layer?.makeDirty(state);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
isDirty(): boolean {
|
||||
return this._dirty;
|
||||
}
|
||||
|
||||
translate(x?: number | string, y?: number | string) {
|
||||
const preTop = this.top;
|
||||
if (y !== undefined) {
|
||||
this.top = y;
|
||||
}
|
||||
|
||||
const preLeft = this.left;
|
||||
if (x !== undefined) {
|
||||
this.left = x;
|
||||
}
|
||||
|
||||
this._setTransForm();
|
||||
|
||||
this.onTransformChange$.emitEvent({
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE.translate,
|
||||
value: { top: this._top, left: this._left },
|
||||
preValue: { top: preTop, left: preLeft },
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
resize(width?: number | string, height?: number | string) {
|
||||
const preWidth = this.width;
|
||||
if (width !== undefined) {
|
||||
this.width = width;
|
||||
}
|
||||
const preHeight = this.height;
|
||||
if (height !== undefined) {
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
this._setTransForm();
|
||||
|
||||
this.onTransformChange$.emitEvent({
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE.resize,
|
||||
value: { width: this._width, height: this._height },
|
||||
preValue: { width: preWidth, height: preHeight },
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
scale(scaleX?: number, scaleY?: number) {
|
||||
const preScaleX = this.scaleX;
|
||||
if (scaleX !== undefined) {
|
||||
this.scaleX = scaleX;
|
||||
}
|
||||
|
||||
const preScaleY = this.scaleY;
|
||||
if (scaleY !== undefined) {
|
||||
this.scaleY = scaleY;
|
||||
}
|
||||
|
||||
this._setTransForm();
|
||||
|
||||
this.onTransformChange$.emitEvent({
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE.scale,
|
||||
value: { scaleX: this._scaleX, scaleY: this._scaleY },
|
||||
preValue: { scaleX: preScaleX, scaleY: preScaleY },
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
skew(skewX?: number, skewY?: number) {
|
||||
const preSkewX = skewX;
|
||||
if (skewX !== undefined) {
|
||||
this.skewX = skewX;
|
||||
}
|
||||
|
||||
const preSkewY = skewY;
|
||||
if (skewY !== undefined) {
|
||||
this.skewY = skewY;
|
||||
}
|
||||
|
||||
this._setTransForm();
|
||||
|
||||
this.onTransformChange$.emitEvent({
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE.skew,
|
||||
value: { skewX: this._skewX, skewY: this._skewY },
|
||||
preValue: { skewX: preSkewX, skewY: preSkewY },
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
flip(flipX?: boolean, flipY?: boolean) {
|
||||
const preFlipX = flipX;
|
||||
if (flipX !== undefined) {
|
||||
this.flipX = flipX;
|
||||
}
|
||||
const preFlipY = flipY;
|
||||
if (flipY !== undefined) {
|
||||
this.flipY = flipY;
|
||||
}
|
||||
|
||||
this._setTransForm();
|
||||
|
||||
this.onTransformChange$.emitEvent({
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE.flip,
|
||||
value: { flipX: this._flipX, flipY: this._flipY },
|
||||
preValue: { flipX: preFlipX, flipY: preFlipY },
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* this[pKey] = option[pKey]
|
||||
* @param option
|
||||
*/
|
||||
transformByState(option: IObjectFullState) {
|
||||
const optionKeys = Object.keys(option);
|
||||
const preKeys: IObjectFullState = {};
|
||||
|
||||
if (optionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
optionKeys.forEach((pKey) => {
|
||||
if (option[pKey as keyof IObjectFullState] !== undefined) {
|
||||
preKeys[pKey as keyof IObjectFullState] = this[pKey as keyof BaseObject];
|
||||
(this as Record<string, any>)[pKey] = option[pKey as keyof IObjectFullState];
|
||||
}
|
||||
});
|
||||
|
||||
this._setTransForm();
|
||||
|
||||
this.onTransformChange$.emitEvent({
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE.all,
|
||||
value: option,
|
||||
preValue: preKeys,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
isRender(bounds?: IViewportInfo) {
|
||||
if (this._forceRender) {
|
||||
return false;
|
||||
}
|
||||
return bounds && !this.isInGroup;
|
||||
}
|
||||
|
||||
getParent() {
|
||||
return this._parent;
|
||||
}
|
||||
|
||||
getState(): ITransformState {
|
||||
return {
|
||||
left: this.left,
|
||||
top: this.top,
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
scaleX: this.scaleX,
|
||||
scaleY: this.scaleY,
|
||||
angle: this.angle,
|
||||
skewX: this.skewX,
|
||||
skewY: this.skewY,
|
||||
flipX: this.flipX,
|
||||
flipY: this.flipY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the flip state after composing this object with every ancestor
|
||||
* drawing group whose transform participates in rendering.
|
||||
*/
|
||||
getEffectiveFlipState(): { flipX: boolean; flipY: boolean } {
|
||||
let flipX = this.flipX;
|
||||
let flipY = this.flipY;
|
||||
let isInGroup = this.isInGroup;
|
||||
let parent = this.getParent();
|
||||
|
||||
while (isInGroup && parent?.classType === RENDER_CLASS_TYPE.GROUP) {
|
||||
flipX = flipX !== Boolean(parent.flipX);
|
||||
flipY = flipY !== Boolean(parent.flipY);
|
||||
isInGroup = Boolean(parent.isInGroup);
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return { flipX, flipY };
|
||||
}
|
||||
|
||||
getRealBound(): IGroupBaseBound {
|
||||
let { width: realWidth, height: realHeight, left: realLeft, top: realTop } = this;
|
||||
let baseBound;
|
||||
if (this.isInGroup && this.parent?.classType === RENDER_CLASS_TYPE.GROUP && this.parent?.getBaseBound) {
|
||||
baseBound = this.parent.getBaseBound();
|
||||
}
|
||||
if (baseBound) {
|
||||
const parentState = this.getParent();
|
||||
const parentRealBound = typeof parentState.getRealBound === 'function'
|
||||
? parentState.getRealBound()
|
||||
: parentState;
|
||||
const parentBound = {
|
||||
top: parentRealBound.top || 0,
|
||||
left: parentRealBound.left,
|
||||
width: parentRealBound.width || 0,
|
||||
height: parentRealBound.height || 0,
|
||||
};
|
||||
const rotatedBound = getRotatedBoundInGroup(
|
||||
{ width: realWidth, height: realHeight, left: realLeft, top: realTop },
|
||||
this.angle
|
||||
);
|
||||
const mappedRotatedBound = getRenderTransformBaseOnParentBound(baseBound, parentBound, rotatedBound);
|
||||
const normalizedAngle = ((this.angle % 360) + 360) % 360;
|
||||
const swapsAxes =
|
||||
(normalizedAngle >= 45 && normalizedAngle < 135) ||
|
||||
(normalizedAngle >= 225 && normalizedAngle < 315);
|
||||
const mappedCenterX = mappedRotatedBound.left + mappedRotatedBound.width / 2;
|
||||
const mappedCenterY = mappedRotatedBound.top + mappedRotatedBound.height / 2;
|
||||
|
||||
realWidth = swapsAxes ? mappedRotatedBound.height : mappedRotatedBound.width;
|
||||
realHeight = swapsAxes ? mappedRotatedBound.width : mappedRotatedBound.height;
|
||||
realLeft = mappedCenterX - realWidth / 2 - parentBound.left - parentBound.width / 2;
|
||||
realTop = mappedCenterY - realHeight / 2 - parentBound.top - parentBound.height / 2;
|
||||
|
||||
// const isParentFlipX = this.parent?.flipX;
|
||||
// const isParentFlipY = this.parent?.flipY;
|
||||
// const parentCenterX = parentBound.left + parentBound.width / 2;
|
||||
// const parentCenterY = parentBound.top + parentBound.height / 2;
|
||||
// if (isParentFlipX) {
|
||||
// realLeft = 2 * parentCenterX - realLeft;
|
||||
// }
|
||||
// if (isParentFlipY) {
|
||||
// realTop = 2 * parentCenterY - realTop;
|
||||
// }
|
||||
}
|
||||
return {
|
||||
left: realLeft,
|
||||
top: realTop,
|
||||
width: realWidth,
|
||||
height: realHeight,
|
||||
};
|
||||
}
|
||||
|
||||
hide() {
|
||||
this._visible = false;
|
||||
this._makeDirtyMix();
|
||||
}
|
||||
|
||||
show() {
|
||||
this._visible = true;
|
||||
this._makeDirtyMix();
|
||||
}
|
||||
|
||||
render(ctx: UniverRenderingContext, bounds: IViewportInfo) {
|
||||
/* abstract */
|
||||
}
|
||||
|
||||
isHit(coord: Vector2) {
|
||||
const oCoord = this.getInverseCoord(coord);
|
||||
if (
|
||||
oCoord.x >= -this.strokeWidth / 2 &&
|
||||
oCoord.x <= this.width + this.strokeWidth / 2 &&
|
||||
oCoord.y >= -this.strokeWidth / 2 &&
|
||||
oCoord.y <= this.height + this.strokeWidth / 2
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
triggerPointerMove(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerMove$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerMove(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerDown(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerDown$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerDown(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerUp(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerUp$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerUp(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerSingleClick(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onSingleClick$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerSingleClick(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerClick(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onClick$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerClick(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerDblclick(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onDblclick$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerDblclick(evt);
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerTripleClick(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onTripleClick$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerTripleClick(evt);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerMouseWheel(evt: IWheelEvent) {
|
||||
if (!this.onMouseWheel$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerMouseWheel(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerOut(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerOut$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerOut(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerLeave(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerLeave$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerLeave(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerOver(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerOver$.emitEvent(evt)) {
|
||||
this._parent?.triggerPointerOver(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerEnter(evt: IPointerEvent | IMouseEvent) {
|
||||
if (!this.onPointerEnter$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerEnter(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerPointerCancel(evt: IPointerEvent) {
|
||||
if (!this.onPointerEnter$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerPointerCancel(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerDragLeave(evt: IDragEvent | IMouseEvent) {
|
||||
if (!this.onDragLeave$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerDragLeave(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerDragOver(evt: IDragEvent | IMouseEvent) {
|
||||
if (!this.onDragOver$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerDragOver(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerDragEnter(evt: IDragEvent | IMouseEvent) {
|
||||
if (!this.onDragEnter$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerDragEnter(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
triggerDrop(evt: IDragEvent | IMouseEvent) {
|
||||
if (!this.onDrop$.emitEvent(evt)?.stopPropagation) {
|
||||
this._parent?.triggerDrop(evt);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
super.dispose();
|
||||
this.onTransformChange$.complete();
|
||||
this.onPointerDown$.complete();
|
||||
this.onPointerMove$.complete();
|
||||
this.onPointerUp$.complete();
|
||||
this.onMouseWheel$.complete();
|
||||
this.onPointerOut$.complete();
|
||||
this.onPointerLeave$.complete();
|
||||
this.onPointerOver$.complete();
|
||||
this.onPointerEnter$.complete();
|
||||
this.onDragLeave$.complete();
|
||||
this.onDragOver$.complete();
|
||||
this.onDragEnter$.complete();
|
||||
this.onDrop$.complete();
|
||||
this.onSingleClick$.complete();
|
||||
this.onClick$.complete();
|
||||
this.onDblclick$.complete();
|
||||
this.onTripleClick$.complete();
|
||||
this.onIsAddedToParent$.complete();
|
||||
|
||||
this.parent?.removeObject(this);
|
||||
|
||||
this.onDispose$.emitEvent(this);
|
||||
|
||||
this._makeDirtyMix();
|
||||
|
||||
this.onDispose$.complete();
|
||||
|
||||
this._parent = null;
|
||||
this._layer = null;
|
||||
this.transform = null as unknown as Transform;
|
||||
}
|
||||
|
||||
toJson() {
|
||||
const props: Record<string, any> = {};
|
||||
BASE_OBJECT_ARRAY.forEach((key) => {
|
||||
if (this[key as keyof BaseObject]) {
|
||||
props[key] = this[key as keyof BaseObject];
|
||||
}
|
||||
});
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
getScene(): Nullable<Scene> {
|
||||
let parent: any = this.parent;
|
||||
while (parent) {
|
||||
if (parent.classType === RENDER_CLASS_TYPE.SCENE) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.getParent();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
resetCursor() {
|
||||
this.getScene()?.resetCursor();
|
||||
}
|
||||
|
||||
setCursor(val: CURSOR_TYPE) {
|
||||
this._cursor = val;
|
||||
this.getScene()?.setCursor(val);
|
||||
}
|
||||
|
||||
getEngine(): Nullable<Engine> {
|
||||
let parent: any = this.getParent();
|
||||
while (parent != null) {
|
||||
if (parent.classType === RENDER_CLASS_TYPE.ENGINE) {
|
||||
return parent;
|
||||
}
|
||||
parent = parent.getParent();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getObjects(): BaseObject[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
getLayerIndex() {
|
||||
if (this._layer == null) {
|
||||
return 1;
|
||||
}
|
||||
return this._layer.zIndex;
|
||||
}
|
||||
|
||||
applyTransform() {
|
||||
this.getScene()?.attachTransformerTo(this);
|
||||
}
|
||||
|
||||
removeTransform() {
|
||||
this.getScene()?.detachTransformerFrom(this);
|
||||
}
|
||||
|
||||
getInverseCoord(coord: Vector2): Vector2 {
|
||||
return this.ancestorTransform.invert().applyPoint(coord);
|
||||
}
|
||||
|
||||
protected _setTransForm() {
|
||||
const composeResult = Transform.create().composeMatrix({
|
||||
left: this.left + this.strokeWidth / 2,
|
||||
top: this.top + this.strokeWidth / 2,
|
||||
scaleX: this.scaleX,
|
||||
scaleY: this.scaleY,
|
||||
angle: this.angle,
|
||||
skewX: this.skewX,
|
||||
skewY: this.skewY,
|
||||
flipX: this.flipX,
|
||||
flipY: this.flipY,
|
||||
});
|
||||
this.transform = composeResult;
|
||||
this._makeDirtyMix();
|
||||
}
|
||||
|
||||
private _makeDirtyMix() {
|
||||
if (this.debounceParentDirty) {
|
||||
this.makeDirty(true);
|
||||
} else {
|
||||
this.makeDirtyNoDebounce(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
132
packages/engine-render/src/basics/__tests__/cjk-regexp.spec.ts
Normal file
132
packages/engine-render/src/basics/__tests__/cjk-regexp.spec.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cjk } from '../cjk-regexp';
|
||||
|
||||
type CharsetDataUnit = [number, number];
|
||||
type TestType = 'non-cjk' | 'cjk-letter' | 'cjk-punctuation';
|
||||
type TestCases = Record<string, TestType>;
|
||||
|
||||
const CJK_LETTER_SOURCE = String.raw`[\u1100-\u11ff\u2e80-\u2e99\u2e9b-\u2ef3\u2f00-\u2fd5\u3005\u3007\u3021-\u3029\u3038-\u303b\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fd-\u30ff\u3105-\u312f\u3131-\u318e\u31a0-\u31bf\u31f0-\u321e\u3260-\u327e\u32d0-\u32fe\u3300-\u3357\u3400-\u4dbf\u4e00-\u9fff\ua960-\ua97c\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufa6d\ufa70-\ufad9\uff66-\uff6f\uff71-\uff9d\uffa0-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]|[\ud840-\ud868\ud86a-\ud86d\ud86f-\ud872\ud874-\ud879\ud880-\ud883\ud885-\ud88c][\udc00-\udfff]|\ud81b[\udfe3\udff2-\udff6]|\ud82b[\udff0-\udff3\udff5-\udffb\udffd-\udffe]|\ud82c[\udc00-\udd22\udd32\udd50-\udd52\udd55\udd64-\udd67]|\ud83c[\ude00]|\ud869[\udc00-\udedf\udf00-\udfff]|\ud86e[\udc00-\udc1d\udc20-\udfff]|\ud873[\udc00-\udead\udeb0-\udfff]|\ud87a[\udc00-\udfe0\udff0-\udfff]|\ud87b[\udc00-\ude5d]|\ud87e[\udc00-\ude1d]|\ud884[\udc00-\udf4a\udf50-\udfff]|\ud88d[\udc00-\udc79]`;
|
||||
const CJK_PUNCTUATION_SOURCE = String.raw`[\u2ff0-\u3004\u3006\u3008-\u3020\u302a-\u3037\u303c-\u303f\u30a0\u30fb-\u30fc\u3190-\u319f\u31c0-\u31ef\u321f-\u325f\u327f-\u32cf\u32ff\u3358-\u33ff\ufe10-\ufe1f\ufe30-\ufe6f\uff00-\uff65\uff70\uff9e-\uff9f\uffbf-\uffc1\uffc8-\uffc9\uffd0-\uffd1\uffd8-\uffd9\uffdd-\uffef]`;
|
||||
const CJK_ALL_SOURCE = String.raw`[\u1100-\u11ff\u2e80-\u2e99\u2e9b-\u2ef3\u2f00-\u2fd5\u2ff0-\u303f\u3041-\u3096\u309d-\u30ff\u3105-\u312f\u3131-\u318e\u3190-\u4dbf\u4e00-\u9fff\ua960-\ua97c\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufa6d\ufa70-\ufad9\ufe10-\ufe1f\ufe30-\ufe6f\uff00-\uffef]|[\ud840-\ud868\ud86a-\ud86d\ud86f-\ud872\ud874-\ud879\ud880-\ud883\ud885-\ud88c][\udc00-\udfff]|\ud81b[\udfe3\udff2-\udff6]|\ud82b[\udff0-\udff3\udff5-\udffb\udffd-\udffe]|\ud82c[\udc00-\udd22\udd32\udd50-\udd52\udd55\udd64-\udd67]|\ud83c[\ude00]|\ud869[\udc00-\udedf\udf00-\udfff]|\ud86e[\udc00-\udc1d\udc20-\udfff]|\ud873[\udc00-\udead\udeb0-\udfff]|\ud87a[\udc00-\udfe0\udff0-\udfff]|\ud87b[\udc00-\ude5d]|\ud87e[\udc00-\ude1d]|\ud884[\udc00-\udf4a\udf50-\udfff]|\ud88d[\udc00-\udc79]`;
|
||||
|
||||
const EXPECTED_CJK_LETTER_DATA: CharsetDataUnit[] = [[4352, 4607], [11904, 11929], [11931, 12019], [12032, 12245], [12293, 12293], [12295, 12295], [12321, 12329], [12344, 12347], [12353, 12438], [12445, 12447], [12449, 12538], [12541, 12543], [12549, 12591], [12593, 12686], [12704, 12735], [12784, 12830], [12896, 12926], [13008, 13054], [13056, 13143], [13312, 19903], [19968, 40959], [43360, 43388], [44032, 55203], [55216, 55238], [55243, 55291], [63744, 64109], [64112, 64217], [65382, 65391], [65393, 65437], [65440, 65470], [65474, 65479], [65482, 65487], [65490, 65495], [65498, 65500], [94179, 94179], [94194, 94198], [110576, 110579], [110581, 110587], [110589, 110590], [110592, 110882], [110898, 110898], [110928, 110930], [110933, 110933], [110948, 110951], [127488, 127488], [131072, 173791], [173824, 178205], [178208, 183981], [183984, 191456], [191472, 192093], [194560, 195101], [196608, 201546], [201552, 210041]];
|
||||
const EXPECTED_CJK_PUNCTUATION_DATA: CharsetDataUnit[] = [[12272, 12292], [12294, 12294], [12296, 12320], [12330, 12343], [12348, 12351], [12448, 12448], [12539, 12540], [12688, 12703], [12736, 12783], [12831, 12895], [12927, 13007], [13055, 13055], [13144, 13311], [65040, 65055], [65072, 65135], [65280, 65381], [65392, 65392], [65438, 65439], [65471, 65473], [65480, 65481], [65488, 65489], [65496, 65497], [65501, 65519]];
|
||||
const EXPECTED_CJK_ALL_DATA: CharsetDataUnit[] = [[4352, 4607], [11904, 11929], [11931, 12019], [12032, 12245], [12272, 12351], [12353, 12438], [12445, 12543], [12549, 12591], [12593, 12686], [12688, 19903], [19968, 40959], [43360, 43388], [44032, 55203], [55216, 55238], [55243, 55291], [63744, 64109], [64112, 64217], [65040, 65055], [65072, 65135], [65280, 65519], [94179, 94179], [94194, 94198], [110576, 110579], [110581, 110587], [110589, 110590], [110592, 110882], [110898, 110898], [110928, 110930], [110933, 110933], [110948, 110951], [127488, 127488], [131072, 173791], [173824, 178205], [178208, 183981], [183984, 191456], [191472, 192093], [194560, 195101], [196608, 201546], [201552, 210041]];
|
||||
|
||||
function casify(chars: string, type: TestType): TestCases {
|
||||
return Object.fromEntries(Array.from(chars).map((character) => [character, type]));
|
||||
}
|
||||
|
||||
// Character samples copied from cjk-regex 3.4.0 upstream tests.
|
||||
const cjkTestCases: TestCases = {
|
||||
...casify('⻁⺪⻏', 'cjk-letter'),
|
||||
...casify('。「」〒〓〱', 'cjk-punctuation'),
|
||||
...casify('〻〧〨〤々', 'cjk-letter'),
|
||||
...casify('⽒⽓⽔⽕', 'cjk-letter'),
|
||||
...casify('⿰⿱⿲⿳', 'cjk-punctuation'),
|
||||
...casify('あどなにぬハヒヘホ', 'cjk-letter'),
|
||||
...casify('゠・', 'cjk-punctuation'),
|
||||
...casify('ㇼㇽㇾㇿ', 'cjk-letter'),
|
||||
...casify('㈱㋋㋌㋿', 'cjk-punctuation'),
|
||||
...casify('㇀㇁㇂㇃㇄㇅㇆㇇㇈㇉㇊㇢㇣', 'cjk-punctuation'),
|
||||
...casify('ㄅㄬㄭㄮㄯㆠㆡㆾㆿ', 'cjk-letter'),
|
||||
...casify('ㅂㅵㅶㅷㅸꥠꥹꥺꥻퟔퟸퟹퟺퟻ', 'cjk-letter'),
|
||||
...casify('가힠힡힢힣', 'cjk-letter'),
|
||||
...casify('㆐㆑㆒㆓', 'cjk-punctuation'),
|
||||
...casify('㌕㍕㍖㍗', 'cjk-letter'),
|
||||
...casify('㍧㍻㍿㏄㏾', 'cjk-punctuation'),
|
||||
...casify('䶼䶽䶾䶿中', 'cjk-letter'),
|
||||
...casify('豈兀﨔隷侮恵並﨎', 'cjk-letter'),
|
||||
...casify('﹌﹍﹎﹏﹨﹩﹪﹫!D「¥○', 'cjk-punctuation'),
|
||||
...casify('ヲユᅦ', 'cjk-letter'),
|
||||
...casify('𬉼𬷰𬷱𬺠𬺡', 'cjk-letter'),
|
||||
...casify('𛋸𛋹𛋺𛋻', 'non-cjk'),
|
||||
...casify('𘳒𘳓𘳔𘳕', 'non-cjk'),
|
||||
...casify('.a', 'non-cjk'),
|
||||
...casify('𪛜𪛝𪛞𪛟', 'cjk-letter'),
|
||||
...casify('𫚄𫚅𫜸𫜹', 'cjk-letter'),
|
||||
...casify('𫝀𫝁𫝂𫝃', 'cjk-letter'),
|
||||
...casify('𮨥𮨦𮯠', 'cjk-letter'),
|
||||
...casify('𰀀𰀁𱍀𱍊', 'cjk-letter'),
|
||||
...casify('𱍐𱍑𲎬𲎭', 'cjk-letter'),
|
||||
...casify('乁你鼻𪘀', 'cjk-letter'),
|
||||
...casify('𛄀𛄁𛄡𛄢𚿵𚿶𚿷𚿸𛀂𛀀𛀁𛃾𛃿𛅕𛄲𛅤𛅥𛅦𛅧', 'cjk-letter'),
|
||||
};
|
||||
|
||||
describe('cjk', () => {
|
||||
it('keeps cjk-regex 3.4.0 generated sources', () => {
|
||||
expect(cjk.CJK_LETTER_REG.source).toBe(CJK_LETTER_SOURCE);
|
||||
expect(cjk.CJK_PUNCTUATION_REG.source).toBe(CJK_PUNCTUATION_SOURCE);
|
||||
expect(cjk.CJK_ALL_REG.source).toBe(CJK_ALL_SOURCE);
|
||||
});
|
||||
|
||||
it('builds charset-compatible cjk-regex snapshots', () => {
|
||||
expect(cjk.letters().data).toEqual(EXPECTED_CJK_LETTER_DATA);
|
||||
expect(cjk.punctuations().data).toEqual(EXPECTED_CJK_PUNCTUATION_DATA);
|
||||
expect(cjk.all().data).toEqual(EXPECTED_CJK_ALL_DATA);
|
||||
expect(cjk.CJK_LETTER_DATA).toEqual(EXPECTED_CJK_LETTER_DATA);
|
||||
expect(cjk.CJK_PUNCTUATION_DATA).toEqual(EXPECTED_CJK_PUNCTUATION_DATA);
|
||||
expect(cjk.CJK_ALL_DATA).toEqual(EXPECTED_CJK_ALL_DATA);
|
||||
});
|
||||
|
||||
describe('matches cjk-regex 3.4.0 character categories', () => {
|
||||
const allReg = cjk.all().toRegExp('u');
|
||||
const lettersReg = cjk.letters().toRegExp('u');
|
||||
const punctuationsReg = cjk.punctuations().toRegExp('u');
|
||||
|
||||
for (const [character, category] of Object.entries(cjkTestCases)) {
|
||||
const title = `"${character}" (0x${character.codePointAt(0)!.toString(16)}) is ${category}`;
|
||||
|
||||
it(title, () => {
|
||||
switch (category) {
|
||||
case 'non-cjk': {
|
||||
expect(character).not.toMatch(allReg);
|
||||
expect(character).not.toMatch(lettersReg);
|
||||
expect(character).not.toMatch(punctuationsReg);
|
||||
expect(cjk.hasCJK(character)).toBe(false);
|
||||
expect(cjk.hasCJKText(character)).toBe(false);
|
||||
expect(cjk.hasCJKPunctuation(character)).toBe(false);
|
||||
break;
|
||||
}
|
||||
case 'cjk-letter': {
|
||||
expect(character).toMatch(allReg);
|
||||
expect(character).toMatch(lettersReg);
|
||||
expect(character).not.toMatch(punctuationsReg);
|
||||
expect(cjk.hasCJK(character)).toBe(true);
|
||||
expect(cjk.hasCJKText(character)).toBe(true);
|
||||
expect(cjk.hasCJKPunctuation(character)).toBe(false);
|
||||
break;
|
||||
}
|
||||
case 'cjk-punctuation': {
|
||||
expect(character).toMatch(allReg);
|
||||
expect(character).not.toMatch(lettersReg);
|
||||
expect(character).toMatch(punctuationsReg);
|
||||
expect(cjk.hasCJK(character)).toBe(true);
|
||||
expect(cjk.hasCJKText(character)).toBe(false);
|
||||
expect(cjk.hasCJKPunctuation(character)).toBe(true);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unexpected category "${category}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getParagraphByGlyph,
|
||||
hasListGlyph,
|
||||
isFirstGlyph,
|
||||
isIndentByGlyph,
|
||||
isLastGlyph,
|
||||
isPlaceholderOrSpace,
|
||||
isSameLine,
|
||||
} from '../document-node-tools';
|
||||
import { GlyphType } from '../i-document-skeleton-cached';
|
||||
|
||||
function createGlyphLine() {
|
||||
const list = { glyphType: GlyphType.LIST, content: '•' } as any;
|
||||
const g1 = { glyphType: GlyphType.WORD, content: 'A' } as any;
|
||||
const g2 = { glyphType: GlyphType.WORD, content: 'B' } as any;
|
||||
|
||||
const divide = {
|
||||
glyphGroup: [list, g1, g2],
|
||||
} as any;
|
||||
const line = {
|
||||
divides: [divide],
|
||||
paragraphIndex: 5,
|
||||
} as any;
|
||||
|
||||
list.parent = divide;
|
||||
g1.parent = divide;
|
||||
g2.parent = divide;
|
||||
divide.parent = line;
|
||||
|
||||
return { list, g1, g2, divide, line };
|
||||
}
|
||||
|
||||
describe('document node tools', () => {
|
||||
it('detects list/first/last glyphs and same-line relations', () => {
|
||||
const { list, g1, g2, divide, line } = createGlyphLine();
|
||||
expect(hasListGlyph(g1)).toBe(true);
|
||||
expect(hasListGlyph(null)).toBe(false);
|
||||
|
||||
expect(isFirstGlyph(list)).toBe(true);
|
||||
expect(isFirstGlyph(g1)).toBe(true);
|
||||
expect(isFirstGlyph(g2)).toBe(false);
|
||||
|
||||
expect(isLastGlyph(g2)).toBe(true);
|
||||
expect(isLastGlyph(g1)).toBe(false);
|
||||
expect(isLastGlyph(null)).toBe(false);
|
||||
|
||||
const line2 = { divides: [divide], paragraphIndex: 7 } as any;
|
||||
const g3 = { glyphType: GlyphType.WORD, content: 'C', parent: { parent: line2 } } as any;
|
||||
|
||||
expect(isSameLine(g1, g2)).toBe(true);
|
||||
expect(isSameLine(g1, g3)).toBe(false);
|
||||
expect(isSameLine(null, g3)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves paragraph context and placeholder/space states', () => {
|
||||
const { g1, line } = createGlyphLine();
|
||||
const body = {
|
||||
paragraphs: [
|
||||
{ startIndex: 2, paragraphStyle: {} },
|
||||
{ startIndex: 5, paragraphStyle: { indentStart: { v: 2 } } },
|
||||
],
|
||||
} as any;
|
||||
|
||||
const paragraph = getParagraphByGlyph(g1, body);
|
||||
expect(paragraph).toEqual(expect.objectContaining({
|
||||
startIndex: 5,
|
||||
paragraphStart: 3,
|
||||
paragraphEnd: 5,
|
||||
}));
|
||||
expect(isIndentByGlyph(g1, body)).toBe(true);
|
||||
|
||||
line.paragraphIndex = 99;
|
||||
expect(getParagraphByGlyph(g1, body)).toBeUndefined();
|
||||
expect(isIndentByGlyph(g1, body)).toBe(false);
|
||||
|
||||
const placeholder = { streamType: DataStreamTreeTokenType.PARAGRAPH, content: '' } as any;
|
||||
const tab = { streamType: DataStreamTreeTokenType.TAB, content: '' } as any;
|
||||
const sectionBreak = { streamType: DataStreamTreeTokenType.SECTION_BREAK, content: '' } as any;
|
||||
const space = { streamType: '', content: DataStreamTreeTokenType.SPACE } as any;
|
||||
const text = { streamType: '', content: 'x' } as any;
|
||||
|
||||
expect(isPlaceholderOrSpace(placeholder)).toBe(true);
|
||||
expect(isPlaceholderOrSpace(tab)).toBe(true);
|
||||
expect(isPlaceholderOrSpace(sectionBreak)).toBe(true);
|
||||
expect(isPlaceholderOrSpace(space)).toBe(true);
|
||||
expect(isPlaceholderOrSpace(text)).toBe(false);
|
||||
expect(isPlaceholderOrSpace(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
154
packages/engine-render/src/basics/__tests__/draw.spec.ts
Normal file
154
packages/engine-render/src/basics/__tests__/draw.spec.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BorderStyleTypes } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BORDER_TYPE, ORIENTATION_TYPE } from '../const';
|
||||
import {
|
||||
calculateRectRotate,
|
||||
clearLineByBorderType,
|
||||
drawDiagonalLineByBorderType,
|
||||
drawLineByBorderType,
|
||||
getDevicePixelRatio,
|
||||
getLineOffset,
|
||||
getLineWidth,
|
||||
getLineWith,
|
||||
getRotateOffsetAndFarthestHypotenuse,
|
||||
getRotateOrientation,
|
||||
getTranslateInSpreadContextWithPixelRatio,
|
||||
setLineType,
|
||||
} from '../draw';
|
||||
import { Vector2 } from '../vector2';
|
||||
|
||||
function createCtxMock() {
|
||||
return {
|
||||
beginPath: vi.fn(),
|
||||
moveToByPrecision: vi.fn(),
|
||||
lineToByPrecision: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
closePathByEnv: vi.fn(),
|
||||
clearRectForTexture: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('draw extra', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
webkitBackingStorePixelRatio: 1,
|
||||
mozBackingStorePixelRatio: 1,
|
||||
msBackingStorePixelRatio: 1,
|
||||
oBackingStorePixelRatio: 1,
|
||||
backingStorePixelRatio: 1,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('gets pixel ratio and line helpers', () => {
|
||||
const ratio = getDevicePixelRatio();
|
||||
expect(ratio).toBeGreaterThanOrEqual(1);
|
||||
|
||||
expect(getLineWith(2)).toBeGreaterThan(0);
|
||||
expect(getLineOffset()).toBeGreaterThanOrEqual(0);
|
||||
expect(getLineOffset()).toBeLessThan(1);
|
||||
expect(getLineWidth(BorderStyleTypes.THICK)).toBe(3);
|
||||
expect(getLineWidth(BorderStyleTypes.MEDIUM)).toBe(2);
|
||||
expect(getLineWidth(BorderStyleTypes.DASHED)).toBe(1);
|
||||
});
|
||||
|
||||
it('draws and clears lines by border type', () => {
|
||||
const ctx = createCtxMock();
|
||||
const position = { startX: 10, startY: 20, endX: 30, endY: 40 };
|
||||
|
||||
drawLineByBorderType(ctx, BORDER_TYPE.TOP, 1, position);
|
||||
drawLineByBorderType(ctx, BORDER_TYPE.BOTTOM, 1, position);
|
||||
drawLineByBorderType(ctx, BORDER_TYPE.LEFT, 1, position);
|
||||
drawLineByBorderType(ctx, BORDER_TYPE.RIGHT, 1, position);
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalledTimes(4);
|
||||
expect(ctx.moveToByPrecision).toHaveBeenCalledTimes(4);
|
||||
expect(ctx.lineToByPrecision).toHaveBeenCalledTimes(4);
|
||||
expect(ctx.stroke).toHaveBeenCalledTimes(4);
|
||||
|
||||
clearLineByBorderType(ctx, BORDER_TYPE.TOP, position);
|
||||
clearLineByBorderType(ctx, BORDER_TYPE.BOTTOM, position);
|
||||
clearLineByBorderType(ctx, BORDER_TYPE.LEFT, position);
|
||||
clearLineByBorderType(ctx, BORDER_TYPE.RIGHT, position);
|
||||
expect(ctx.clearRectForTexture).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('draws diagonal lines and sets line styles', () => {
|
||||
const ctx = createCtxMock();
|
||||
const position = { startX: 0, startY: 0, endX: 20, endY: 20 };
|
||||
|
||||
drawDiagonalLineByBorderType(ctx, BorderStyleTypes.DOUBLE, BORDER_TYPE.TL_BR, position);
|
||||
drawDiagonalLineByBorderType(ctx, BorderStyleTypes.DOUBLE, BORDER_TYPE.BL_TR, position);
|
||||
drawDiagonalLineByBorderType(ctx, BorderStyleTypes.THIN, BORDER_TYPE.TL_BC, position);
|
||||
|
||||
expect(ctx.moveToByPrecision).toHaveBeenCalled();
|
||||
expect(ctx.lineToByPrecision).toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
|
||||
setLineType(ctx, BorderStyleTypes.HAIR);
|
||||
setLineType(ctx, BorderStyleTypes.DASH_DOT_DOT);
|
||||
setLineType(ctx, BorderStyleTypes.DASH_DOT);
|
||||
setLineType(ctx, BorderStyleTypes.DOTTED);
|
||||
setLineType(ctx, BorderStyleTypes.DASHED);
|
||||
setLineType(ctx, BorderStyleTypes.THICK);
|
||||
|
||||
expect(ctx.setLineDash).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('computes rotate offsets and translation helpers', () => {
|
||||
const rectResult = calculateRectRotate(
|
||||
new Vector2(10, 0),
|
||||
new Vector2(5, 5),
|
||||
Math.PI / 6,
|
||||
Math.PI / 4,
|
||||
new Vector2(2, 3)
|
||||
);
|
||||
expect(Number.isFinite(rectResult.x)).toBe(true);
|
||||
expect(Number.isFinite(rectResult.y)).toBe(true);
|
||||
|
||||
expect(getRotateOrientation(0.2)).toBe(ORIENTATION_TYPE.DOWN);
|
||||
expect(getRotateOrientation(-0.2)).toBe(ORIENTATION_TYPE.UP);
|
||||
|
||||
const resultUp = getRotateOffsetAndFarthestHypotenuse(
|
||||
[{ lineHeight: 10 }, { lineHeight: 20 }] as any,
|
||||
100,
|
||||
-Math.PI / 6
|
||||
);
|
||||
expect(resultUp.rotateTranslateXList.length).toBe(2);
|
||||
expect(resultUp.rotatedHeight).toBeGreaterThan(0);
|
||||
|
||||
const resultDown = getRotateOffsetAndFarthestHypotenuse(
|
||||
[{ lineHeight: 10 }, { lineHeight: 15 }, { lineHeight: 20 }] as any,
|
||||
80,
|
||||
Math.PI / 6
|
||||
);
|
||||
expect(resultDown.rotateTranslateXList.length).toBe(3);
|
||||
expect(resultDown.rotatedWidth).toBeGreaterThan(0);
|
||||
|
||||
const translate = getTranslateInSpreadContextWithPixelRatio();
|
||||
expect(translate).toEqual({
|
||||
left: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 {
|
||||
combineDrawingEffectFilter,
|
||||
createDrawingEffectFilter,
|
||||
expandDrawingEffectBounds,
|
||||
resolveDrawingEffectMasks,
|
||||
resolveGlowEffect,
|
||||
resolveOuterShadowEffect,
|
||||
} from '../drawing-effect';
|
||||
|
||||
describe('drawing effect', () => {
|
||||
it('resolves an outer shadow using opacity, direction, distance, and scale', () => {
|
||||
const resolved = resolveOuterShadowEffect({
|
||||
color: '#336699',
|
||||
opacity: 0.5,
|
||||
blurRadius: 8,
|
||||
direction: 90,
|
||||
distance: 4,
|
||||
sx: 2,
|
||||
sy: 2,
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
color: 'rgba(51,102,153,0.5)',
|
||||
blurRadius: 16,
|
||||
offsetY: 8,
|
||||
});
|
||||
expect(resolved?.offsetX).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it('resolves a centered glow with the DrawingML calibration', () => {
|
||||
expect(resolveGlowEffect({
|
||||
color: '#5b9bd5',
|
||||
radius: 12,
|
||||
})).toEqual({
|
||||
color: '#5b9bd5',
|
||||
blurRadius: 6,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips effects that cannot paint visible pixels', () => {
|
||||
expect(resolveGlowEffect({ color: '#5b9bd5', radius: 0 })).toBeUndefined();
|
||||
expect(resolveGlowEffect({ color: 'rgba(91, 155, 213, 0)', radius: 4 })).toBeUndefined();
|
||||
expect(resolveOuterShadowEffect({
|
||||
color: '#000000',
|
||||
opacity: 0,
|
||||
blurRadius: 4,
|
||||
distance: 2,
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('creates one filter chain for glow and shadow without duplicating the source draw', () => {
|
||||
expect(createDrawingEffectFilter(
|
||||
{ color: '#5b9bd5', radius: 4 },
|
||||
{ color: '#000000', opacity: 0.4, blurRadius: 3, distance: 2, direction: 0 }
|
||||
)).toBe(
|
||||
'drop-shadow(0px 0px 2px #5b9bd5) '
|
||||
+ 'drop-shadow(2px 0px 3px rgba(0,0,0,0.4))'
|
||||
);
|
||||
expect(combineDrawingEffectFilter('blur(1px)', 'drop-shadow(0 0 1px red)'))
|
||||
.toBe('blur(1px) drop-shadow(0 0 1px red)');
|
||||
});
|
||||
|
||||
it('resolves alpha-mask passes for glow and outer shadow', () => {
|
||||
expect(resolveDrawingEffectMasks(
|
||||
{ color: '#5b9bd5', radius: 4 },
|
||||
{ color: '#000000', blurRadius: 3 }
|
||||
)).toEqual([
|
||||
{ color: '#5b9bd5', blurRadius: 2, offsetX: 0, offsetY: 0 },
|
||||
{ color: '#5b9bd5', blurRadius: 2, offsetX: 0, offsetY: 0 },
|
||||
{ color: '#000000', blurRadius: 3, offsetX: 0, offsetY: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands cache bounds for the sequential glow and shadow filter chain', () => {
|
||||
expect(expandDrawingEffectBounds(
|
||||
{ left: 0, top: 0, right: 100, bottom: 50 },
|
||||
{ color: '#5b9bd5', radius: 4 },
|
||||
{ color: '#000000', blurRadius: 3, distance: 2, direction: 0 }
|
||||
)).toEqual({
|
||||
left: -13,
|
||||
top: -15,
|
||||
right: 117,
|
||||
bottom: 65,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 {
|
||||
getDrawingGroupState,
|
||||
getGroupState,
|
||||
getRenderTransformBaseOnParentBound,
|
||||
getRotatedBoundInGroup,
|
||||
transformObjectOutOfGroup,
|
||||
} from '../group-transform';
|
||||
|
||||
describe('group transform', () => {
|
||||
it('calculates group bounds from child states', () => {
|
||||
const states = [
|
||||
{ left: 10, top: 20, width: 30, height: 40 },
|
||||
{ left: -5, top: 15, width: 12, height: 8 },
|
||||
{ left: 40, top: 3, width: 5, height: 10 },
|
||||
] as any[];
|
||||
|
||||
const normal = getGroupState(100, 50, states);
|
||||
expect(normal).toEqual({
|
||||
left: 95,
|
||||
top: 53,
|
||||
width: 50,
|
||||
height: 57,
|
||||
angle: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
|
||||
const drawing = getDrawingGroupState(100, 50, states);
|
||||
expect(drawing).toEqual({
|
||||
left: -5,
|
||||
top: 3,
|
||||
width: 50,
|
||||
height: 57,
|
||||
angle: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps child bounds by parent/base bounds and rotates/flips out of group', () => {
|
||||
const mapped = getRenderTransformBaseOnParentBound(
|
||||
{ left: 10, top: 20, width: 200, height: 100 },
|
||||
{ left: 100, top: 60, width: 400, height: 200 },
|
||||
{ left: 60, top: 45, width: 40, height: 20 }
|
||||
);
|
||||
expect(mapped).toEqual({
|
||||
left: 200,
|
||||
top: 110,
|
||||
width: 80,
|
||||
height: 40,
|
||||
});
|
||||
|
||||
const passThrough = getRenderTransformBaseOnParentBound(
|
||||
null as any,
|
||||
{ left: 100, top: 60, width: 400, height: 200 },
|
||||
{ left: 60, top: 45, width: 40, height: 20 }
|
||||
);
|
||||
expect(passThrough).toEqual({
|
||||
left: 60,
|
||||
top: 45,
|
||||
width: 40,
|
||||
height: 20,
|
||||
});
|
||||
|
||||
const transformed = transformObjectOutOfGroup(
|
||||
{ left: 60, top: 45, width: 40, height: 20, angle: 30, flipX: false, flipY: true } as any,
|
||||
{ left: 100, top: 60, angle: 90, flipX: true, flipY: false } as any,
|
||||
400,
|
||||
200,
|
||||
{ left: 10, top: 20, width: 200, height: 100 }
|
||||
);
|
||||
expect(transformed.width).toBeGreaterThan(0);
|
||||
expect(transformed.height).toBeGreaterThan(0);
|
||||
expect(transformed.angle).toBe(120);
|
||||
expect(transformed.flipX).toBe(true);
|
||||
expect(transformed.flipY).toBe(true);
|
||||
expect(Number.isFinite(transformed.left)).toBe(true);
|
||||
expect(Number.isFinite(transformed.top)).toBe(true);
|
||||
});
|
||||
|
||||
it('switches major axes for rotated bounds in group space', () => {
|
||||
const bound = { left: 10, top: 20, width: 60, height: 30 };
|
||||
|
||||
const horizontal = getRotatedBoundInGroup(bound, 10);
|
||||
expect(horizontal).toEqual(bound);
|
||||
|
||||
const vertical = getRotatedBoundInGroup(bound, 90);
|
||||
expect(vertical.width).toBe(30);
|
||||
expect(vertical.height).toBe(60);
|
||||
expect(vertical.left).toBe(25);
|
||||
expect(vertical.top).toBe(5);
|
||||
|
||||
const around360 = getRotatedBoundInGroup(bound, 450);
|
||||
expect(around360.width).toBe(30);
|
||||
expect(around360.height).toBe(60);
|
||||
});
|
||||
});
|
||||
53
packages/engine-render/src/basics/__tests__/path2.spec.ts
Normal file
53
packages/engine-render/src/basics/__tests__/path2.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Path2 } from '../path2';
|
||||
import { Vector2 } from '../vector2';
|
||||
|
||||
describe('Path2', () => {
|
||||
it('returns undefined when either path has no drawable segment', () => {
|
||||
expect(new Path2([]).intersection([new Vector2(0, 0), new Vector2(1, 1)])).toBeUndefined();
|
||||
expect(new Path2([new Vector2(0, 0), new Vector2(1, 1)]).intersection([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds intersections between the source path and the provided contrast path', () => {
|
||||
const path = new Path2([
|
||||
new Vector2(0, 0),
|
||||
new Vector2(10, 10),
|
||||
new Vector2(20, 0),
|
||||
]);
|
||||
|
||||
const intersections = path.intersection([
|
||||
new Vector2(0, 10),
|
||||
new Vector2(10, 0),
|
||||
new Vector2(20, 10),
|
||||
]);
|
||||
|
||||
expect(intersections).toHaveLength(2);
|
||||
expect(intersections?.[0].x).toBeCloseTo(5);
|
||||
expect(intersections?.[0].y).toBeCloseTo(5);
|
||||
expect(intersections?.[1].x).toBeCloseTo(15);
|
||||
expect(intersections?.[1].y).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
it('does not report parallel or touching-only segments as intersections', () => {
|
||||
const path = new Path2([new Vector2(0, 0), new Vector2(10, 0)]);
|
||||
|
||||
expect(path.intersection([new Vector2(0, 2), new Vector2(10, 2)])).toEqual([]);
|
||||
expect(path.intersection([new Vector2(10, 0), new Vector2(10, 10)])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PerformanceMonitor, RollingAverage } from '../performance-monitor';
|
||||
|
||||
describe('performance monitor', () => {
|
||||
it('samples frame timing, fps and reset/disable lifecycle', () => {
|
||||
const monitor = new PerformanceMonitor(3);
|
||||
|
||||
monitor.endFrame(0);
|
||||
monitor.endFrame(16);
|
||||
monitor.endFrame(33);
|
||||
monitor.endFrame(50);
|
||||
monitor.sampleFrame(1050);
|
||||
|
||||
expect(monitor.instantaneousFrameTime).toBeGreaterThan(0);
|
||||
expect(monitor.instantaneousFPS).toBeGreaterThan(0);
|
||||
expect(monitor.averageFrameTime).toBeGreaterThan(0);
|
||||
expect(monitor.averageFrameTimeVariance).not.toBeNaN();
|
||||
expect(monitor.averageFPS).toBeGreaterThan(0);
|
||||
expect(monitor.isSaturated).toBe(true);
|
||||
expect(monitor.isEnabled).toBe(true);
|
||||
expect(monitor.estimatedFrameInterval).toBeGreaterThan(0);
|
||||
|
||||
monitor.disable();
|
||||
monitor.sampleFrame(1100);
|
||||
expect(monitor.isEnabled).toBe(false);
|
||||
|
||||
monitor.enable();
|
||||
monitor.endFrame(1200);
|
||||
monitor.reset();
|
||||
expect(monitor.instantaneousFrameTime).toBe(0);
|
||||
expect(monitor.isSaturated).toBe(false);
|
||||
|
||||
monitor.dispose();
|
||||
});
|
||||
|
||||
it('estimates animation-frame cadence without following isolated long frames', () => {
|
||||
const monitor = new PerformanceMonitor();
|
||||
|
||||
for (const timestamp of [0, 8, 16, 24, 40]) {
|
||||
monitor.endFrame(timestamp);
|
||||
}
|
||||
|
||||
expect(monitor.estimatedFrameInterval).toBe(8);
|
||||
monitor.reset();
|
||||
expect(monitor.estimatedFrameInterval).toBeCloseTo(16.67, 2);
|
||||
});
|
||||
|
||||
it('uses performance.now when available and Date.now as fallback', () => {
|
||||
const monitor = new PerformanceMonitor();
|
||||
const performanceNow = vi.spyOn(performance, 'now').mockReturnValue(123);
|
||||
|
||||
expect(monitor.now()).toBe(123);
|
||||
performanceNow.mockRestore();
|
||||
|
||||
const originalPerformance = globalThis.performance;
|
||||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(456);
|
||||
try {
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(monitor.now()).toBe(456);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'performance', {
|
||||
configurable: true,
|
||||
value: originalPerformance,
|
||||
});
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('computes rolling history, saturation and wraparound variance', () => {
|
||||
const average = new RollingAverage(3);
|
||||
|
||||
expect(average.history(0)).toBe(0);
|
||||
average.addFrameTime(10);
|
||||
average.calcAverageFrameTime();
|
||||
average.addFrameTime(20);
|
||||
average.calcAverageFrameTime();
|
||||
average.addFrameTime(30);
|
||||
average.calcAverageFrameTime();
|
||||
average.addFrameTime(40);
|
||||
average.calcAverageFrameTime();
|
||||
|
||||
expect(average.isSaturated()).toBe(true);
|
||||
expect(average.history(0)).toBe(40);
|
||||
expect(average.history(1)).toBe(30);
|
||||
expect(average.history(3)).toBe(0);
|
||||
expect(average.averageFrameTime).not.toBeNaN();
|
||||
expect(average.variance).not.toBeNaN();
|
||||
|
||||
average.reset();
|
||||
expect(average.history(0)).toBe(0);
|
||||
expect(average.averageFrameTime).toBeCloseTo(16.67, 2);
|
||||
expect(average.variance).toBe(0);
|
||||
});
|
||||
|
||||
it('removes only one minimum and maximum from duplicate samples', () => {
|
||||
const average = new RollingAverage(3);
|
||||
|
||||
for (const frameTime of [10, 10, 20]) {
|
||||
average.addFrameTime(frameTime);
|
||||
average.calcAverageFrameTime();
|
||||
}
|
||||
|
||||
expect(average.averageFrameTime).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { attachObjectHover } from '../quick-event';
|
||||
|
||||
function createEventSubject() {
|
||||
let listener: ((evt: unknown) => void) | undefined;
|
||||
return {
|
||||
subscribeEvent: vi.fn((callback: (evt: unknown) => void) => {
|
||||
listener = callback;
|
||||
}),
|
||||
emit: (evt: unknown) => listener?.(evt),
|
||||
};
|
||||
}
|
||||
|
||||
describe('quick event helpers', () => {
|
||||
it('binds pointer enter and leave events to hover callbacks with the target object', () => {
|
||||
const enter = createEventSubject();
|
||||
const leave = createEventSubject();
|
||||
const object = {
|
||||
onPointerEnter$: enter,
|
||||
onPointerLeave$: leave,
|
||||
};
|
||||
const hoverIn = vi.fn();
|
||||
const hoverOut = vi.fn();
|
||||
|
||||
attachObjectHover(object as never, hoverIn, hoverOut);
|
||||
|
||||
const enterEvent = { offsetX: 10, offsetY: 20 };
|
||||
const leaveEvent = { offsetX: 30, offsetY: 40 };
|
||||
enter.emit(enterEvent);
|
||||
leave.emit(leaveEvent);
|
||||
|
||||
expect(enter.subscribeEvent).toHaveBeenCalledTimes(1);
|
||||
expect(leave.subscribeEvent).toHaveBeenCalledTimes(1);
|
||||
expect(hoverIn).toHaveBeenCalledWith(object, enterEvent);
|
||||
expect(hoverOut).toHaveBeenCalledWith(object, leaveEvent);
|
||||
});
|
||||
});
|
||||
357
packages/engine-render/src/basics/__tests__/tools.spec.ts
Normal file
357
packages/engine-render/src/basics/__tests__/tools.spec.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BaselineOffset, Rectangle, Tools } from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FontCache } from '../../components/docs/layout/shaping-engine/font-cache';
|
||||
import {
|
||||
cancelRequestFrame,
|
||||
checkStyle,
|
||||
clampRange,
|
||||
createCanvasElement,
|
||||
createImageElement,
|
||||
degToRad,
|
||||
expandRangeIfIntersects,
|
||||
fixLineWidthByScale,
|
||||
generateRandomKey,
|
||||
getCellPositionByIndex,
|
||||
getColor,
|
||||
getDPI,
|
||||
getFirstGrapheme,
|
||||
getFontStyleString,
|
||||
getPointerPrefix,
|
||||
getScale,
|
||||
getSizeForDom,
|
||||
getSystemHighlightColor,
|
||||
getValueType,
|
||||
hasAllLatin,
|
||||
hasArabic,
|
||||
hasBasicLatin,
|
||||
hasLatinExtendedA,
|
||||
hasLatinExtendedB,
|
||||
hasLatinOneSupplement,
|
||||
hasSpace,
|
||||
hasThai,
|
||||
hasTibetan,
|
||||
injectStyle,
|
||||
inViewRanges,
|
||||
isArray,
|
||||
isCjkCenterAlignedPunctuation,
|
||||
isCjkLeftAlignedPunctuation,
|
||||
isCjkRightAlignedPunctuation,
|
||||
isDate,
|
||||
isFunction,
|
||||
isNumber,
|
||||
isObject,
|
||||
isRectIntersect,
|
||||
isRegExp,
|
||||
IsSafari,
|
||||
isString,
|
||||
mergeInfoOffset,
|
||||
pixelToPt,
|
||||
precisionTo,
|
||||
ptToMM,
|
||||
ptToPixel,
|
||||
ptToPx,
|
||||
pxToInch,
|
||||
pxToNum,
|
||||
pxToPt,
|
||||
radToDeg,
|
||||
requestNewFrame,
|
||||
startWithEmoji,
|
||||
toPx,
|
||||
} from '../tools';
|
||||
|
||||
describe('tools extra', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('avoids Unicode segmentation for individual code units while preserving complex graphemes', () => {
|
||||
const segment = vi.spyOn(Intl.Segmenter.prototype, 'segment');
|
||||
for (const text of ['', 'A', '中', ' ', '\r', '\u0301', '\uD83D']) {
|
||||
expect(getFirstGrapheme(text)).toBe(text || null);
|
||||
}
|
||||
expect(segment).not.toHaveBeenCalled();
|
||||
for (const [text, first] of [
|
||||
['e\u0301x', 'e\u0301'],
|
||||
['\r\nx', '\r\n'],
|
||||
['🇨🇳abc', '🇨🇳'],
|
||||
['👨👩👧👦abc', '👨👩👧👦'],
|
||||
['👍🏽abc', '👍🏽'],
|
||||
['1️⃣abc', '1️⃣'],
|
||||
['中文', '中'],
|
||||
]) {
|
||||
expect(getFirstGrapheme(text)).toBe(first);
|
||||
}
|
||||
expect(segment).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('handles colors, unit conversion and precision helpers', () => {
|
||||
expect(getColor([1, 2, 3])).toBe('rgb(1,2,3)');
|
||||
expect(getColor([1, 2, 3], 0.5)).toBe('rgba(1,2,3,0.5)');
|
||||
expect(getColor(undefined as any)).toBe('rgb(0,0,0)');
|
||||
|
||||
expect(toPx(10, 100)).toBe(10);
|
||||
expect(toPx('50%', 200)).toBe(100);
|
||||
expect(toPx('invalid', 100)).toBe(0);
|
||||
|
||||
expect(radToDeg(Math.PI)).toBeCloseTo(180);
|
||||
expect(degToRad(180)).toBeCloseTo(Math.PI);
|
||||
expect(precisionTo(1.2345, 2)).toBe(1.23);
|
||||
expect(fixLineWidthByScale(7, 2)).toBe(7);
|
||||
});
|
||||
|
||||
it('uses request/cancel frame fallbacks and DOM factories', () => {
|
||||
const requester = {
|
||||
requestPostAnimationFrame: vi.fn(() => 11),
|
||||
cancelPostAnimationFrame: vi.fn(),
|
||||
};
|
||||
const frameId = requestNewFrame(() => null, requester);
|
||||
expect(frameId).toBe(11);
|
||||
cancelRequestFrame(frameId, requester);
|
||||
expect(requester.cancelPostAnimationFrame).toHaveBeenCalledWith(11);
|
||||
|
||||
const requester2 = {
|
||||
requestAnimationFrame: vi.fn(() => 12),
|
||||
cancelAnimationFrame: vi.fn(),
|
||||
};
|
||||
expect(requestNewFrame(() => null, requester2)).toBe(12);
|
||||
cancelRequestFrame(12, requester2);
|
||||
expect(requester2.cancelAnimationFrame).toHaveBeenCalledWith(12);
|
||||
|
||||
expect(createCanvasElement().tagName).toBe('CANVAS');
|
||||
expect(createCanvasElement().dir).toBe('ltr');
|
||||
expect(createImageElement().tagName).toBe('IMG');
|
||||
});
|
||||
|
||||
it('detects pointer prefix and browser helpers', () => {
|
||||
const browserSpy = vi.spyOn(Tools, 'getBrowserType');
|
||||
browserSpy.mockReturnValue('safari' as any);
|
||||
expect(IsSafari()).toBe(true);
|
||||
browserSpy.mockReturnValue('chrome' as any);
|
||||
expect(IsSafari()).toBe(false);
|
||||
|
||||
const tabletSpy = vi.spyOn(Tools, 'isTablet').mockReturnValue(false);
|
||||
const iphoneSpy = vi.spyOn(Tools, 'isIPhone').mockReturnValue(false);
|
||||
const originalPointer = (window as any).PointerEvent;
|
||||
(window as any).PointerEvent = undefined;
|
||||
|
||||
expect(getPointerPrefix()).toBe('mouse');
|
||||
|
||||
tabletSpy.mockReturnValue(true);
|
||||
expect(getPointerPrefix()).toBe('mouse');
|
||||
|
||||
(window as any).PointerEvent = originalPointer;
|
||||
tabletSpy.mockRestore();
|
||||
iphoneSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles random key and type predicates', () => {
|
||||
const key = generateRandomKey('shape', 4);
|
||||
expect(key.startsWith('shape_')).toBe(true);
|
||||
|
||||
expect(getValueType('a')).toBe('[object String]');
|
||||
expect(isFunction(() => null)).toBe(true);
|
||||
expect(isDate(new Date())).toBe(true);
|
||||
expect(isRegExp(/a/)).toBe(true);
|
||||
expect(isArray([1, 2])).toBe(true);
|
||||
expect(isString('abc')).toBe(true);
|
||||
expect(isNumber(123)).toBe(true);
|
||||
expect(isObject({ a: 1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('builds font styles and language detectors', () => {
|
||||
const baselineSpy = vi.spyOn(FontCache, 'getBaselineOffsetInfo').mockReturnValue({ sbr: 0.5, spr: 0.6 } as any);
|
||||
|
||||
const defaultStyle = getFontStyleString();
|
||||
expect(defaultStyle.fontString).toContain('pt');
|
||||
|
||||
const subscript = getFontStyleString({
|
||||
it: 1,
|
||||
bl: 1,
|
||||
fs: 12,
|
||||
ff: 'Open Sans',
|
||||
va: BaselineOffset.SUBSCRIPT,
|
||||
} as any);
|
||||
expect(subscript.fontFamily).toBe('"Open Sans"');
|
||||
expect(subscript.fontSize).toBeCloseTo(6);
|
||||
|
||||
const fontStack = getFontStyleString({
|
||||
fs: 12,
|
||||
ff: '"SF Mono", "Cascadia Code", Consolas, monospace',
|
||||
} as any);
|
||||
expect(fontStack.fontFamily).toBe('"SF Mono", "Cascadia Code", Consolas, monospace');
|
||||
expect(fontStack.fontString).toContain('"SF Mono", "Cascadia Code", Consolas, monospace');
|
||||
|
||||
const themeFont = getFontStyleString({
|
||||
bl: 1,
|
||||
fs: 15,
|
||||
ff: '+mj-lt',
|
||||
} as any);
|
||||
expect(themeFont.fontFamily).toBe('"+mj-lt"');
|
||||
expect(themeFont.fontCache).toBe('normal bold 15pt "+mj-lt"');
|
||||
|
||||
const fractionalSize = getFontStyleString({
|
||||
fs: 10.0125,
|
||||
ff: 'Microsoft YaHei',
|
||||
} as any);
|
||||
expect(fractionalSize.originFontSize).toBe(10.0125);
|
||||
expect(fractionalSize.fontSize).toBe(10.0125);
|
||||
expect(fractionalSize.fontString).toContain('10.0125pt');
|
||||
expect(fractionalSize.fontCache).toContain('10.0125pt');
|
||||
|
||||
const superscript = getFontStyleString({
|
||||
fs: 12,
|
||||
ff: 'Arial',
|
||||
va: BaselineOffset.SUPERSCRIPT,
|
||||
} as any);
|
||||
expect(superscript.fontSize).toBeCloseTo(7.2);
|
||||
expect(baselineSpy).toHaveBeenCalled();
|
||||
|
||||
expect(hasAllLatin('abc')).toBe(true);
|
||||
expect(hasBasicLatin('A')).toBe(true);
|
||||
expect(hasLatinOneSupplement('é')).toBe(true);
|
||||
expect(hasLatinExtendedA('Ā')).toBe(true);
|
||||
expect(hasLatinExtendedB('Ȁ')).toBe(true);
|
||||
expect(startWithEmoji('😀abc')).toBe(true);
|
||||
expect(hasArabic('مرحبا')).toBe(true);
|
||||
expect(hasTibetan('Ȁ')).toBe(true);
|
||||
expect(hasSpace('a b')).toBe(true);
|
||||
expect(isCjkLeftAlignedPunctuation('。')).toBe(true);
|
||||
expect(isCjkRightAlignedPunctuation('(')).toBe(true);
|
||||
expect(isCjkCenterAlignedPunctuation('·')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles dpi and geometric conversion helpers', () => {
|
||||
const matchMediaMock = vi.fn((query: string) => ({
|
||||
matches: query.includes('96dpi'),
|
||||
}));
|
||||
vi.stubGlobal('matchMedia', matchMediaMock);
|
||||
|
||||
const dpi = getDPI();
|
||||
expect(dpi).toBeGreaterThan(0);
|
||||
expect(ptToPx(72)).toBeGreaterThan(0);
|
||||
expect(pxToPt(96)).toBeGreaterThan(0);
|
||||
expect(ptToMM(10)).toBeGreaterThan(0);
|
||||
expect(pxToInch(10)).toBeGreaterThan(0);
|
||||
|
||||
expect(ptToPixel(8)).toBeCloseTo(10.6666666667);
|
||||
expect(pixelToPt(8)).toBe(6);
|
||||
expect(getScale({ scaleX: 2, scaleY: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('handles range and cell geometry helpers', () => {
|
||||
expect(getCellPositionByIndex(1, 1, [10, 20], [5, 10])).toEqual({
|
||||
startY: 10,
|
||||
endY: 20,
|
||||
startX: 5,
|
||||
endX: 10,
|
||||
});
|
||||
|
||||
expect(mergeInfoOffset({
|
||||
startY: 1,
|
||||
endY: 2,
|
||||
startX: 3,
|
||||
endX: 4,
|
||||
} as any, 10, 20)).toEqual(expect.objectContaining({
|
||||
startY: 21,
|
||||
endY: 22,
|
||||
startX: 13,
|
||||
endX: 14,
|
||||
}));
|
||||
|
||||
expect(isRectIntersect(
|
||||
{ left: 0, top: 0, right: 10, bottom: 10 },
|
||||
{ left: 5, top: 5, right: 20, bottom: 20 }
|
||||
)).toBe(true);
|
||||
expect(isRectIntersect(
|
||||
{ left: 0, top: 0, right: 4, bottom: 4 },
|
||||
{ left: 5, top: 5, right: 20, bottom: 20 }
|
||||
)).toBe(false);
|
||||
|
||||
expect(inViewRanges([{ startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 }], 2, 2)).toBe(true);
|
||||
expect(inViewRanges([{ startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 }], 3, 2)).toBe(false);
|
||||
|
||||
const intersects = expandRangeIfIntersects(
|
||||
[{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }],
|
||||
[{ startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 }, { startRow: 5, endRow: 6, startColumn: 5, endColumn: 6 }]
|
||||
);
|
||||
expect(intersects).toHaveLength(2);
|
||||
expect(Rectangle.simpleRangesIntersect(intersects[0], intersects[1])).toBe(true);
|
||||
|
||||
expect(clampRange({ startRow: -1, endRow: 9, startColumn: -2, endColumn: 10 }, 5, 6)).toEqual({
|
||||
startRow: 0,
|
||||
endRow: 5,
|
||||
startColumn: 0,
|
||||
endColumn: 6,
|
||||
});
|
||||
|
||||
expect(hasThai('สร้างงานใหม่')).toBe(true);
|
||||
expect(hasThai('hello')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles style insertion and DOM size helpers', () => {
|
||||
injectStyle(['.tool-extra-test{color:red;}']);
|
||||
expect(checkStyle('tool-extra-test')).toBe(false);
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '.tool-extra-test-manual{color:blue;}';
|
||||
document.head.append(style);
|
||||
expect(checkStyle('tool-extra-test-manual')).toBe(true);
|
||||
|
||||
expect(pxToNum('12px')).toBe(12);
|
||||
const el = document.createElement('div');
|
||||
document.body.append(el);
|
||||
const styleSpy = vi.spyOn(window, 'getComputedStyle').mockReturnValue({
|
||||
borderTopWidth: '1px',
|
||||
borderLeftWidth: '2px',
|
||||
borderRightWidth: '3px',
|
||||
borderBottomWidth: '4px',
|
||||
width: '20px',
|
||||
height: '30px',
|
||||
} as any);
|
||||
expect(getSizeForDom(el)).toEqual({
|
||||
top: 1,
|
||||
left: 2,
|
||||
right: 3,
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
height: 30,
|
||||
});
|
||||
styleSpy.mockRestore();
|
||||
|
||||
const highlight = getSystemHighlightColor();
|
||||
expect(highlight).toEqual(expect.objectContaining({
|
||||
r: expect.any(Number),
|
||||
g: expect.any(Number),
|
||||
b: expect.any(Number),
|
||||
}));
|
||||
});
|
||||
|
||||
it('Emoji test', () => {
|
||||
expect(startWithEmoji('🐱🏍One Team, One Dream!!! 🐱🏍')).toBe(true);
|
||||
expect(startWithEmoji('ordinary text')).toBe(false);
|
||||
expect(startWithEmoji('1abc')).toBe(false);
|
||||
expect(startWithEmoji('#tag')).toBe(false);
|
||||
expect(startWithEmoji('1️⃣')).toBe(true);
|
||||
expect(startWithEmoji('*️⃣')).toBe(true);
|
||||
expect(startWithEmoji('31️⃣2')).toBe(false);
|
||||
expect(startWithEmoji('👨👩👧👦abc')).toBe(true);
|
||||
expect(startWithEmoji('1👨👩👧👦abc')).toBe(false);
|
||||
expect(getFirstGrapheme('🐱🏍One Team, One Dream!!! 🐱🏍')).toBe('🐱🏍');
|
||||
expect(getFirstGrapheme('👨👩👧👦abc')).toBe('👨👩👧👦');
|
||||
});
|
||||
});
|
||||
105
packages/engine-render/src/basics/__tests__/transform.spec.ts
Normal file
105
packages/engine-render/src/basics/__tests__/transform.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 { Transform } from '../transform';
|
||||
import { Vector2 } from '../vector2';
|
||||
|
||||
describe('Transform extra', () => {
|
||||
it('applies translate/scale/rotate/skew/multiply and inverse transforms', () => {
|
||||
const transform = Transform.create();
|
||||
transform.translate(10, 20).scale(2, 3).rotate(90).skew(0.1, -0.2);
|
||||
|
||||
const matrix = transform.getMatrix();
|
||||
expect(matrix.length).toBe(6);
|
||||
|
||||
const point = new Vector2(2, 5);
|
||||
const world = transform.applyPoint(point);
|
||||
const local = transform.clone().invert().applyPoint(world);
|
||||
|
||||
expect(local.x).toBeCloseTo(point.x, 4);
|
||||
expect(local.y).toBeCloseTo(point.y, 4);
|
||||
});
|
||||
|
||||
it('copies and resets matrix values', () => {
|
||||
const transform = Transform.create([1, 2, 3, 4, 5, 6]);
|
||||
const copy = transform.copy();
|
||||
expect(copy.getMatrix()).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
|
||||
const target = Transform.create();
|
||||
transform.copyInto(target);
|
||||
expect(target.getMatrix()).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
|
||||
transform.reset();
|
||||
expect(transform.getMatrix()).toEqual([1, 0, 0, 1, 0, 0]);
|
||||
});
|
||||
|
||||
it('supports matrix decomposition and composition', () => {
|
||||
const transform = Transform.create().composeMatrix({
|
||||
left: 30,
|
||||
top: 40,
|
||||
angle: 15,
|
||||
scaleX: 2,
|
||||
scaleY: 3,
|
||||
skewX: 10,
|
||||
skewY: -5,
|
||||
flipX: false,
|
||||
flipY: true,
|
||||
});
|
||||
|
||||
const dom = transform.convert2DOMMatrix2D();
|
||||
expect(dom).toEqual(expect.objectContaining({
|
||||
a: expect.any(Number),
|
||||
b: expect.any(Number),
|
||||
c: expect.any(Number),
|
||||
d: expect.any(Number),
|
||||
e: expect.any(Number),
|
||||
f: expect.any(Number),
|
||||
}));
|
||||
|
||||
const decomposed = transform.decompose();
|
||||
expect(decomposed.x).toBeCloseTo(30, 3);
|
||||
expect(decomposed.y).toBeCloseTo(40, 3);
|
||||
expect(decomposed.scaleX).toBeCloseTo(2, 1);
|
||||
});
|
||||
|
||||
it('supports absolute positioning and matrix rounding helpers', () => {
|
||||
const transform = Transform.create().translate(10, 20);
|
||||
transform.setAbsolutePosition(new Vector2(30, 40));
|
||||
const translated = transform.getTranslation();
|
||||
expect(translated.x).toBeCloseTo(30, 5);
|
||||
expect(translated.y).toBeCloseTo(40, 5);
|
||||
|
||||
const rounded = Transform.create([1 / 3, 2 / 3, 0, 1, 0, 0]).getMatrixByAccurate(2);
|
||||
expect(rounded).toEqual([0.33, 0.67, 0, 1, 0, 0]);
|
||||
});
|
||||
|
||||
it('computes bounding boxes from transformed points', () => {
|
||||
const transform = Transform.create([1, 0, 0, 1, 5, -2]);
|
||||
const bounds = transform.makeBoundingBoxFromPoints([
|
||||
new Vector2(0, 0),
|
||||
new Vector2(4, 0),
|
||||
new Vector2(4, 3),
|
||||
new Vector2(0, 3),
|
||||
]);
|
||||
|
||||
expect(bounds.left).toBe(5);
|
||||
expect(bounds.top).toBe(-2);
|
||||
expect(bounds.width).toBe(4);
|
||||
expect(bounds.height).toBe(3);
|
||||
expect(bounds.points[0].asArray()).toEqual([5, -2]);
|
||||
});
|
||||
});
|
||||
175
packages/engine-render/src/basics/__tests__/vector2.spec.ts
Normal file
175
packages/engine-render/src/basics/__tests__/vector2.spec.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 { Vector2 } from '../vector2';
|
||||
|
||||
describe('Vector2 extra', () => {
|
||||
it('supports static constructors and interpolation helpers', () => {
|
||||
expect(Vector2.Zero().asArray()).toEqual([0, 0]);
|
||||
expect(Vector2.One().asArray()).toEqual([1, 1]);
|
||||
expect(Vector2.FromArray([9, 8, 7], 1).asArray()).toEqual([8, 7]);
|
||||
|
||||
const target = new Vector2();
|
||||
Vector2.FromArrayToRef([3, 4], 0, target);
|
||||
expect(target.asArray()).toEqual([3, 4]);
|
||||
|
||||
const catmull = Vector2.CatmullRom(
|
||||
new Vector2(0, 0),
|
||||
new Vector2(10, 0),
|
||||
new Vector2(10, 10),
|
||||
new Vector2(20, 10),
|
||||
0.5
|
||||
);
|
||||
expect(catmull.x).toBeCloseTo(10);
|
||||
expect(catmull.y).toBeCloseTo(5);
|
||||
|
||||
const hermite = Vector2.Hermite(
|
||||
new Vector2(0, 0),
|
||||
new Vector2(2, 0),
|
||||
new Vector2(10, 10),
|
||||
new Vector2(0, 2),
|
||||
0.5
|
||||
);
|
||||
expect(hermite.x).toBeCloseTo(5.25);
|
||||
expect(hermite.y).toBeCloseTo(4.75);
|
||||
|
||||
const derivative = Vector2.Hermite1stDerivative(
|
||||
new Vector2(0, 0),
|
||||
new Vector2(2, 0),
|
||||
new Vector2(10, 10),
|
||||
new Vector2(0, 2),
|
||||
0.5
|
||||
);
|
||||
expect(derivative.x).toBeCloseTo(14.5);
|
||||
expect(derivative.y).toBeCloseTo(14.5);
|
||||
});
|
||||
|
||||
it('supports static geometry helpers', () => {
|
||||
const value = new Vector2(5, -3);
|
||||
const clamped = Vector2.Clamp(value, new Vector2(0, -2), new Vector2(4, 2));
|
||||
expect(clamped.asArray()).toEqual([4, -2]);
|
||||
|
||||
expect(Vector2.Lerp(new Vector2(0, 0), new Vector2(10, 20), 0.25).asArray()).toEqual([2.5, 5]);
|
||||
expect(Vector2.Dot(new Vector2(2, 3), new Vector2(4, -1))).toBe(5);
|
||||
|
||||
expect(Vector2.Normalize(new Vector2(3, 4)).asArray()).toEqual([0.6, 0.8]);
|
||||
|
||||
const zeroResult = new Vector2(10, 10);
|
||||
Vector2.NormalizeToRef(new Vector2(0, 0), zeroResult);
|
||||
expect(zeroResult.asArray()).toEqual([10, 10]);
|
||||
|
||||
expect(Vector2.Minimize(new Vector2(1, 5), new Vector2(2, 4)).asArray()).toEqual([1, 4]);
|
||||
expect(Vector2.Maximize(new Vector2(1, 5), new Vector2(2, 4)).asArray()).toEqual([2, 5]);
|
||||
|
||||
expect(
|
||||
Vector2.PointInTriangle(
|
||||
new Vector2(1, 1),
|
||||
new Vector2(0, 0),
|
||||
new Vector2(4, 0),
|
||||
new Vector2(0, 4)
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
Vector2.PointInTriangle(
|
||||
new Vector2(4, 4),
|
||||
new Vector2(0, 0),
|
||||
new Vector2(4, 0),
|
||||
new Vector2(0, 4)
|
||||
)
|
||||
).toBe(false);
|
||||
|
||||
expect(Vector2.Distance(new Vector2(0, 0), new Vector2(3, 4))).toBe(5);
|
||||
expect(Vector2.DistanceSquared(new Vector2(0, 0), new Vector2(3, 4))).toBe(25);
|
||||
expect(Vector2.Center(new Vector2(0, 0), new Vector2(4, 2)).asArray()).toEqual([2, 1]);
|
||||
expect(Vector2.DistanceOfPointFromSegment(new Vector2(5, 2), new Vector2(0, 0), new Vector2(10, 0))).toBe(2);
|
||||
});
|
||||
|
||||
it('supports instance operators and transforms', () => {
|
||||
const vec = new Vector2(3, 4);
|
||||
expect(vec.getClassName()).toBe('Vector2');
|
||||
expect(vec.getHashCode()).toBe((3 * 397) ^ 4);
|
||||
expect(vec.toString()).toBe('{X: 3 Y: 4}');
|
||||
|
||||
const arr: number[] = [];
|
||||
vec.toArray(arr);
|
||||
expect(arr).toEqual([3, 4]);
|
||||
expect(new Vector2().fromArray(arr).asArray()).toEqual([3, 4]);
|
||||
expect(vec.asArray()).toEqual([3, 4]);
|
||||
|
||||
const ref = new Vector2();
|
||||
expect(vec.add(new Vector2(1, 2)).asArray()).toEqual([4, 6]);
|
||||
vec.addToRef(new Vector2(1, 2), ref);
|
||||
expect(ref.asArray()).toEqual([4, 6]);
|
||||
expect(vec.addByPoint(2, -1).asArray()).toEqual([5, 3]);
|
||||
|
||||
expect(vec.clone().addInPlace(new Vector2(1, 2)).asArray()).toEqual([4, 6]);
|
||||
expect(vec.subtract(new Vector2(1, 2)).asArray()).toEqual([2, 2]);
|
||||
expect(vec.subtractByPoint(2, -1).asArray()).toEqual([1, 5]);
|
||||
vec.subtractToRef(new Vector2(1, 2), ref);
|
||||
expect(ref.asArray()).toEqual([2, 2]);
|
||||
expect(vec.clone().subtractInPlace(new Vector2(1, 2)).asArray()).toEqual([2, 2]);
|
||||
|
||||
expect(vec.clone().multiplyInPlace(new Vector2(2, 3)).asArray()).toEqual([6, 12]);
|
||||
expect(vec.multiply(new Vector2(2, 3)).asArray()).toEqual([6, 12]);
|
||||
vec.multiplyToRef(new Vector2(2, 3), ref);
|
||||
expect(ref.asArray()).toEqual([6, 12]);
|
||||
expect(vec.multiplyByFloats(2, 3).asArray()).toEqual([6, 12]);
|
||||
|
||||
expect(vec.divide(new Vector2(3, 2)).asArray()).toEqual([1, 2]);
|
||||
vec.divideToRef(new Vector2(3, 2), ref);
|
||||
expect(ref.asArray()).toEqual([1, 2]);
|
||||
expect(vec.clone().divideInPlace(new Vector2(3, 2)).asArray()).toEqual([1, 2]);
|
||||
|
||||
expect(vec.negate().asArray()).toEqual([-3, -4]);
|
||||
expect(vec.clone().negateInPlace().asArray()).toEqual([-3, -4]);
|
||||
vec.negateToRef(ref);
|
||||
expect(ref.asArray()).toEqual([-3, -4]);
|
||||
|
||||
expect(vec.clone().scaleInPlace(2).asArray()).toEqual([6, 8]);
|
||||
expect(vec.scale(2).asArray()).toEqual([6, 8]);
|
||||
vec.scaleToRef(2, ref);
|
||||
expect(ref.asArray()).toEqual([6, 8]);
|
||||
vec.scaleAndAddToRef(2, ref);
|
||||
expect(ref.asArray()).toEqual([12, 16]);
|
||||
|
||||
expect(vec.equals(new Vector2(3, 4))).toBe(true);
|
||||
expect(vec.floor().asArray()).toEqual([3, 4]);
|
||||
expect(new Vector2(3.5, -2.25).fract().asArray()).toEqual([0.5, 0.75]);
|
||||
|
||||
const rotated = new Vector2(1, 0).rotate(Math.PI / 2);
|
||||
expect(rotated.x).toBeCloseTo(0);
|
||||
expect(rotated.y).toBeCloseTo(1);
|
||||
|
||||
const rotateRef = new Vector2();
|
||||
new Vector2(1, 0).rotateToRef(Math.PI / 2, rotateRef);
|
||||
expect(rotateRef.x).toBeCloseTo(0);
|
||||
expect(rotateRef.y).toBeCloseTo(1);
|
||||
|
||||
const byPoint = new Vector2(2, 1).rotateByPoint(Math.PI / 2, new Vector2(1, 1));
|
||||
expect(byPoint.x).toBeCloseTo(1);
|
||||
expect(byPoint.y).toBeCloseTo(2);
|
||||
|
||||
const coordOnRotate = new Vector2(3, 4).transformCoordinateOnRotate(Math.PI / 4);
|
||||
expect(coordOnRotate.length()).toBeCloseTo(5);
|
||||
expect(coordOnRotate.lengthSquared()).toBeCloseTo(25);
|
||||
expect(new Vector2(3, 4).normalize().asArray()).toEqual([0.6, 0.8]);
|
||||
|
||||
expect(new Vector2().copyFrom(vec).asArray()).toEqual([3, 4]);
|
||||
expect(new Vector2().copyFromFloats(9, 8).asArray()).toEqual([9, 8]);
|
||||
expect(new Vector2().set(7, 6).clone().asArray()).toEqual([7, 6]);
|
||||
});
|
||||
});
|
||||
@@ -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 { describe, expect, it } from 'vitest';
|
||||
import { subtractViewportRange } from '../viewport-subtract';
|
||||
|
||||
describe('subtractViewportRange', () => {
|
||||
it('returns the original viewport when the ranges do not overlap', () => {
|
||||
const range = { startRow: 0, endRow: 10, startColumn: 0, endColumn: 8 };
|
||||
|
||||
expect(subtractViewportRange(range, { startRow: 10, endRow: 12, startColumn: 0, endColumn: 8 })).toEqual([range]);
|
||||
expect(subtractViewportRange(range, { startRow: 0, endRow: 10, startColumn: 8, endColumn: 12 })).toEqual([range]);
|
||||
});
|
||||
|
||||
it('splits the visible viewport around a frozen pane overlap', () => {
|
||||
const ranges = subtractViewportRange(
|
||||
{ startRow: 0, endRow: 10, startColumn: 0, endColumn: 10 },
|
||||
{ startRow: 2, endRow: 8, startColumn: 3, endColumn: 7 }
|
||||
);
|
||||
|
||||
expect(ranges).toEqual([
|
||||
{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 10 },
|
||||
{ startRow: 8, startColumn: 0, endRow: 10, endColumn: 10 },
|
||||
{ startRow: 2, startColumn: 0, endRow: 8, endColumn: 3 },
|
||||
{ startRow: 2, startColumn: 7, endRow: 8, endColumn: 10 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when the second range fully covers the first range', () => {
|
||||
expect(subtractViewportRange(
|
||||
{ startRow: 2, endRow: 8, startColumn: 3, endColumn: 7 },
|
||||
{ startRow: 0, endRow: 10, startColumn: 0, endColumn: 10 }
|
||||
)).toEqual([]);
|
||||
});
|
||||
});
|
||||
51
packages/engine-render/src/basics/__tests__/zoom.spec.ts
Normal file
51
packages/engine-render/src/basics/__tests__/zoom.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EventConstants } from '../i-events';
|
||||
import { getNextWheelZoomRatio } from '../zoom';
|
||||
|
||||
describe('wheel zoom helpers', () => {
|
||||
it('uses small trackpad deltas proportionally instead of a fixed large zoom step', () => {
|
||||
expect(getNextWheelZoomRatio(1, {
|
||||
deltaMode: EventConstants.DOM_DELTA_PIXEL,
|
||||
deltaX: 0,
|
||||
deltaY: -4,
|
||||
})).toBe(1.03);
|
||||
});
|
||||
|
||||
it('caps large wheel deltas and clamps zoom boundaries', () => {
|
||||
expect(getNextWheelZoomRatio(1, {
|
||||
deltaMode: EventConstants.DOM_DELTA_PIXEL,
|
||||
deltaX: 0,
|
||||
deltaY: -1000,
|
||||
})).toBe(1.14);
|
||||
|
||||
expect(getNextWheelZoomRatio(4, {
|
||||
deltaMode: EventConstants.DOM_DELTA_PIXEL,
|
||||
deltaX: 0,
|
||||
deltaY: -1000,
|
||||
})).toBe(4);
|
||||
});
|
||||
|
||||
it('normalizes line-mode wheel events', () => {
|
||||
expect(getNextWheelZoomRatio(1, {
|
||||
deltaMode: EventConstants.DOM_DELTA_LINE,
|
||||
deltaX: 0,
|
||||
deltaY: 3,
|
||||
})).toBe(0.86);
|
||||
});
|
||||
});
|
||||
22
packages/engine-render/src/basics/cell-data.ts
Normal file
22
packages/engine-render/src/basics/cell-data.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICellData, IStyleData } from '@univerjs/core';
|
||||
|
||||
export interface ITempCellData extends ICellData {
|
||||
style: IStyleData;
|
||||
prefix: string[];
|
||||
}
|
||||
108
packages/engine-render/src/basics/cjk-regexp.ts
Normal file
108
packages/engine-render/src/basics/cjk-regexp.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { regexp } from '@univerjs/core';
|
||||
|
||||
type CharsetDataUnit = [number, number];
|
||||
|
||||
/**
|
||||
* Golden regex sources generated from `cjk-regex@3.4.0`.
|
||||
*
|
||||
* Keep these strings stable unless intentionally upgrading cjk-regex semantics. The tests
|
||||
* assert exact source equality so document CJK layout behavior does not drift through
|
||||
* manual range edits.
|
||||
*/
|
||||
const CJK_LETTER_SOURCE = String.raw`[\u1100-\u11ff\u2e80-\u2e99\u2e9b-\u2ef3\u2f00-\u2fd5\u3005\u3007\u3021-\u3029\u3038-\u303b\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fd-\u30ff\u3105-\u312f\u3131-\u318e\u31a0-\u31bf\u31f0-\u321e\u3260-\u327e\u32d0-\u32fe\u3300-\u3357\u3400-\u4dbf\u4e00-\u9fff\ua960-\ua97c\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufa6d\ufa70-\ufad9\uff66-\uff6f\uff71-\uff9d\uffa0-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]|[\ud840-\ud868\ud86a-\ud86d\ud86f-\ud872\ud874-\ud879\ud880-\ud883\ud885-\ud88c][\udc00-\udfff]|\ud81b[\udfe3\udff2-\udff6]|\ud82b[\udff0-\udff3\udff5-\udffb\udffd-\udffe]|\ud82c[\udc00-\udd22\udd32\udd50-\udd52\udd55\udd64-\udd67]|\ud83c[\ude00]|\ud869[\udc00-\udedf\udf00-\udfff]|\ud86e[\udc00-\udc1d\udc20-\udfff]|\ud873[\udc00-\udead\udeb0-\udfff]|\ud87a[\udc00-\udfe0\udff0-\udfff]|\ud87b[\udc00-\ude5d]|\ud87e[\udc00-\ude1d]|\ud884[\udc00-\udf4a\udf50-\udfff]|\ud88d[\udc00-\udc79]`;
|
||||
const CJK_PUNCTUATION_SOURCE = String.raw`[\u2ff0-\u3004\u3006\u3008-\u3020\u302a-\u3037\u303c-\u303f\u30a0\u30fb-\u30fc\u3190-\u319f\u31c0-\u31ef\u321f-\u325f\u327f-\u32cf\u32ff\u3358-\u33ff\ufe10-\ufe1f\ufe30-\ufe6f\uff00-\uff65\uff70\uff9e-\uff9f\uffbf-\uffc1\uffc8-\uffc9\uffd0-\uffd1\uffd8-\uffd9\uffdd-\uffef]`;
|
||||
const CJK_ALL_SOURCE = String.raw`[\u1100-\u11ff\u2e80-\u2e99\u2e9b-\u2ef3\u2f00-\u2fd5\u2ff0-\u303f\u3041-\u3096\u309d-\u30ff\u3105-\u312f\u3131-\u318e\u3190-\u4dbf\u4e00-\u9fff\ua960-\ua97c\uac00-\ud7a3\ud7b0-\ud7c6\ud7cb-\ud7fb\uf900-\ufa6d\ufa70-\ufad9\ufe10-\ufe1f\ufe30-\ufe6f\uff00-\uffef]|[\ud840-\ud868\ud86a-\ud86d\ud86f-\ud872\ud874-\ud879\ud880-\ud883\ud885-\ud88c][\udc00-\udfff]|\ud81b[\udfe3\udff2-\udff6]|\ud82b[\udff0-\udff3\udff5-\udffb\udffd-\udffe]|\ud82c[\udc00-\udd22\udd32\udd50-\udd52\udd55\udd64-\udd67]|\ud83c[\ude00]|\ud869[\udc00-\udedf\udf00-\udfff]|\ud86e[\udc00-\udc1d\udc20-\udfff]|\ud873[\udc00-\udead\udeb0-\udfff]|\ud87a[\udc00-\udfe0\udff0-\udfff]|\ud87b[\udc00-\ude5d]|\ud87e[\udc00-\ude1d]|\ud884[\udc00-\udf4a\udf50-\udfff]|\ud88d[\udc00-\udc79]`;
|
||||
|
||||
const CJK_LETTER_REG = new RegExp(CJK_LETTER_SOURCE);
|
||||
// eslint-disable-next-line no-misleading-character-class -- Generated cjk-regex punctuation class intentionally matches this exact source.
|
||||
const CJK_PUNCTUATION_REG = new RegExp(CJK_PUNCTUATION_SOURCE);
|
||||
const CJK_ALL_REG = new RegExp(CJK_ALL_SOURCE);
|
||||
|
||||
/**
|
||||
* Golden code-point ranges generated from `cjk-regex@3.4.0` snapshots.
|
||||
*
|
||||
* These are kept alongside the regex sources so compatibility can be checked against
|
||||
* both matching behavior and `Charset.data`-style range snapshots.
|
||||
*/
|
||||
const CJK_LETTER_DATA: CharsetDataUnit[] = [[4352, 4607], [11904, 11929], [11931, 12019], [12032, 12245], [12293, 12293], [12295, 12295], [12321, 12329], [12344, 12347], [12353, 12438], [12445, 12447], [12449, 12538], [12541, 12543], [12549, 12591], [12593, 12686], [12704, 12735], [12784, 12830], [12896, 12926], [13008, 13054], [13056, 13143], [13312, 19903], [19968, 40959], [43360, 43388], [44032, 55203], [55216, 55238], [55243, 55291], [63744, 64109], [64112, 64217], [65382, 65391], [65393, 65437], [65440, 65470], [65474, 65479], [65482, 65487], [65490, 65495], [65498, 65500], [94179, 94179], [94194, 94198], [110576, 110579], [110581, 110587], [110589, 110590], [110592, 110882], [110898, 110898], [110928, 110930], [110933, 110933], [110948, 110951], [127488, 127488], [131072, 173791], [173824, 178205], [178208, 183981], [183984, 191456], [191472, 192093], [194560, 195101], [196608, 201546], [201552, 210041]];
|
||||
const CJK_PUNCTUATION_DATA: CharsetDataUnit[] = [[12272, 12292], [12294, 12294], [12296, 12320], [12330, 12343], [12348, 12351], [12448, 12448], [12539, 12540], [12688, 12703], [12736, 12783], [12831, 12895], [12927, 13007], [13055, 13055], [13144, 13311], [65040, 65055], [65072, 65135], [65280, 65381], [65392, 65392], [65438, 65439], [65471, 65473], [65480, 65481], [65488, 65489], [65496, 65497], [65501, 65519]];
|
||||
const CJK_ALL_DATA: CharsetDataUnit[] = [[4352, 4607], [11904, 11929], [11931, 12019], [12032, 12245], [12272, 12351], [12353, 12438], [12445, 12543], [12549, 12591], [12593, 12686], [12688, 19903], [19968, 40959], [43360, 43388], [44032, 55203], [55216, 55238], [55243, 55291], [63744, 64109], [64112, 64217], [65040, 65055], [65072, 65135], [65280, 65519], [94179, 94179], [94194, 94198], [110576, 110579], [110581, 110587], [110589, 110590], [110592, 110882], [110898, 110898], [110928, 110930], [110933, 110933], [110948, 110951], [127488, 127488], [131072, 173791], [173824, 178205], [178208, 183981], [183984, 191456], [191472, 192093], [194560, 195101], [196608, 201546], [201552, 210041]];
|
||||
|
||||
/**
|
||||
* Returns the CJK letter charset generated from `cjk-regex@3.4.0`.
|
||||
*/
|
||||
function letters() {
|
||||
return regexp.charset(...CJK_LETTER_DATA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CJK punctuation charset generated from `cjk-regex@3.4.0`.
|
||||
*/
|
||||
function punctuations() {
|
||||
return regexp.charset(...CJK_PUNCTUATION_DATA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the combined CJK letter and punctuation charset generated from `cjk-regex@3.4.0`.
|
||||
*/
|
||||
function all() {
|
||||
return regexp.charset(...CJK_ALL_DATA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when text contains CJK letters, excluding punctuation and symbols.
|
||||
*/
|
||||
function hasCJKText(text: string): boolean {
|
||||
return CJK_LETTER_REG.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when text contains CJK letters or CJK punctuation.
|
||||
*/
|
||||
function hasCJK(text: string): boolean {
|
||||
return CJK_ALL_REG.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when text contains CJK punctuation, excluding CJK letters.
|
||||
*/
|
||||
function hasCJKPunctuation(text: string): boolean {
|
||||
return CJK_PUNCTUATION_REG.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace-style CJK regex API used by document rendering and layout.
|
||||
*/
|
||||
export const cjk = {
|
||||
CJK_LETTER_SOURCE,
|
||||
CJK_PUNCTUATION_SOURCE,
|
||||
CJK_ALL_SOURCE,
|
||||
CJK_LETTER_REG,
|
||||
CJK_PUNCTUATION_REG,
|
||||
CJK_ALL_REG,
|
||||
CJK_LETTER_DATA,
|
||||
CJK_PUNCTUATION_DATA,
|
||||
CJK_ALL_DATA,
|
||||
letters,
|
||||
punctuations,
|
||||
all,
|
||||
hasCJKText,
|
||||
hasCJK,
|
||||
hasCJKPunctuation,
|
||||
};
|
||||
147
packages/engine-render/src/basics/const.ts
Normal file
147
packages/engine-render/src/basics/const.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT 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 SHAPE_TYPE {
|
||||
RECT = 'rect',
|
||||
CIRCLE = 'circle',
|
||||
PATH = 'path',
|
||||
}
|
||||
|
||||
export enum LINK_VIEW_PORT_TYPE {
|
||||
XY = 0,
|
||||
X = 1,
|
||||
Y = 2,
|
||||
}
|
||||
|
||||
export const MIDDLE_CELL_POS_MAGIC_NUMBER = 1;
|
||||
|
||||
export const DEFAULT_FONTFACE_PLANE =
|
||||
'"Helvetica Neue", Helvetica, Arial, "PingFang SC", "Hiragino Sans GB", "Heiti SC", "Microsoft YaHei", "WenQuanYi Micro Hei", sans-serif';
|
||||
|
||||
const DEFAULT_SKELETON_HEADER_FOOTER = {
|
||||
lines: [],
|
||||
drawings: new Map(),
|
||||
height: 0,
|
||||
st: 0,
|
||||
ed: 0,
|
||||
marginLeft: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_SKELETON_HEADER = {
|
||||
...DEFAULT_SKELETON_HEADER_FOOTER,
|
||||
marginTop: 20,
|
||||
};
|
||||
|
||||
export const DEFAULT_SKELETON_FOOTER = {
|
||||
...DEFAULT_SKELETON_HEADER_FOOTER,
|
||||
marginBottom: 20,
|
||||
};
|
||||
|
||||
export const DEFAULT_OFFSET_SPACING = 1;
|
||||
|
||||
export const DEFAULT_DOCUMENT_FONTSIZE = 14;
|
||||
|
||||
/**
|
||||
* https://developer.mozilla.org/en-US/docs/Web/CSS/cursor
|
||||
*/
|
||||
export enum CURSOR_TYPE {
|
||||
DEFAULT = 'default',
|
||||
AUTO = 'auto',
|
||||
CROSSHAIR = 'crosshair',
|
||||
POINTER = 'pointer',
|
||||
MOVE = 'move',
|
||||
EAST_RESIZE = 'e-resize',
|
||||
NORTH_EAST_RESIZE = 'ne-resize',
|
||||
NORTH_WEST_RESIZE = 'nw-resize',
|
||||
NORTH_RESIZE = 'n-resize',
|
||||
SOUTH_EAST_RESIZE = 'se-resize',
|
||||
SOUTH_WEST_RESIZE = 'sw-resize',
|
||||
SOUTH_RESIZE = 's-resize',
|
||||
WEST_RESIZE = 'w-resize',
|
||||
EAST_WEST_RESIZE = 'ew-resize',
|
||||
NORTH_SOUTH_RESIZE = 'ns-resize',
|
||||
NORTH_EAST_SOUTH_WEST_RESIZE = 'nesw-resize',
|
||||
NORTH_WEST_SOUTH_EAST_RESIZE = 'nwse-resize',
|
||||
TEXT = 'text',
|
||||
WAIT = 'wait',
|
||||
HELP = 'help',
|
||||
CELL = 'cell',
|
||||
NONE = 'none',
|
||||
VERTICAL_TEXT = 'vertical-text',
|
||||
NO_DROP = 'no-drop',
|
||||
GRAB = 'grab',
|
||||
GRABBING = 'grabbing',
|
||||
ALL_SCROLL = 'all-scroll',
|
||||
COLUMN_RESIZE = 'col-resize',
|
||||
ROW_RESIZE = 'row-resize',
|
||||
PROGRESS = 'progress',
|
||||
NOT_ALLOWED = 'not-allowed',
|
||||
ZOOM_IN = 'zoom-in',
|
||||
ZOOM_OUT = 'zoom-out',
|
||||
}
|
||||
|
||||
export enum DOCUMENT_CONTEXT_CLIP_TYPE {
|
||||
noClip,
|
||||
DOC_SIZE,
|
||||
CONTENT_SIZE_LEFT,
|
||||
CONTENT_SIZE_CENTER,
|
||||
CONTENT_SIZE_Right,
|
||||
}
|
||||
|
||||
export const COLOR_BLACK_RGB = 'rgb(0,0,0)';
|
||||
|
||||
export enum BORDER_TYPE {
|
||||
TOP = 't',
|
||||
BOTTOM = 'b',
|
||||
LEFT = 'l',
|
||||
RIGHT = 'r',
|
||||
|
||||
TL_BR = 'tl_br',
|
||||
TL_BC = 'tl_bc',
|
||||
TL_MR = 'tl_mr',
|
||||
BL_TR = 'bl_tr',
|
||||
ML_TR = 'ml_tr',
|
||||
BC_TR = 'bc_tr',
|
||||
}
|
||||
|
||||
export enum ORIENTATION_TYPE {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
export enum RENDER_CLASS_TYPE {
|
||||
ENGINE = 'Engine',
|
||||
SCENE = 'Scene',
|
||||
BASE_OBJECT = 'BaseObject',
|
||||
SCENE_VIEWER = 'SceneViewer',
|
||||
CANVAS = 'Canvas',
|
||||
VIEWPORT = 'Viewport',
|
||||
LAYER = 'Layer',
|
||||
GROUP = 'Group',
|
||||
IMAGE = 'Image',
|
||||
SHAPE = 'Shape',
|
||||
TEXT = 'Text',
|
||||
}
|
||||
|
||||
export const MAXIMUM_ROW_HEIGHT = 2000;
|
||||
export const MAXIMUM_COL_WIDTH = 2000;
|
||||
export const MIN_COL_WIDTH = 2;
|
||||
|
||||
export const FIX_ONE_PIXEL_BLUR_OFFSET = 0.5;
|
||||
|
||||
export const DRAWING_OBJECT_LOWER_LAYER_INDEX = 3;
|
||||
export const DRAWING_OBJECT_LAYER_INDEX = 4;
|
||||
export const DRAWING_OBJECT_UPPER_LAYER_INDEX = 5;
|
||||
150
packages/engine-render/src/basics/document-node-tools.ts
Normal file
150
packages/engine-render/src/basics/document-node-tools.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody, Nullable } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from './i-document-skeleton-cached';
|
||||
import { checkParagraphHasIndentByStyle, DataStreamTreeTokenType } from '@univerjs/core';
|
||||
import { GlyphType } from './i-document-skeleton-cached';
|
||||
|
||||
export function hasListGlyph(glyph: Nullable<IDocumentSkeletonGlyph>) {
|
||||
const divide = glyph?.parent;
|
||||
|
||||
if (divide == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const glyphGroup = divide.glyphGroup;
|
||||
|
||||
return glyphGroup[0]?.glyphType === GlyphType.LIST;
|
||||
}
|
||||
|
||||
export function isIndentByGlyph(glyph: Nullable<IDocumentSkeletonGlyph>, body?: IDocumentBody) {
|
||||
const paragraph = getParagraphByGlyph(glyph, body);
|
||||
if (paragraph == null) {
|
||||
return false;
|
||||
}
|
||||
const { paragraphStyle } = paragraph;
|
||||
|
||||
if (paragraphStyle == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return checkParagraphHasIndentByStyle(paragraphStyle);
|
||||
}
|
||||
|
||||
export function isLastGlyph(glyph: Nullable<IDocumentSkeletonGlyph>) {
|
||||
const divide = glyph?.parent;
|
||||
|
||||
const line = divide?.parent;
|
||||
|
||||
const glyphGroup = divide?.glyphGroup;
|
||||
|
||||
const divides = line?.divides;
|
||||
|
||||
if (glyphGroup && glyph && divides && divide) {
|
||||
const glyphIndex = glyphGroup.indexOf(glyph);
|
||||
const divideIndex = divides.indexOf(divide);
|
||||
|
||||
if (divideIndex === divides.length - 1 && glyphIndex === glyphGroup.length - 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isFirstGlyph(glyph: Nullable<IDocumentSkeletonGlyph>) {
|
||||
const divide = glyph?.parent;
|
||||
const line = divide?.parent;
|
||||
const glyphGroup = divide?.glyphGroup;
|
||||
const divides = line?.divides;
|
||||
|
||||
if (glyphGroup && glyph && divides && divide) {
|
||||
const glyphIndex = glyphGroup.indexOf(glyph);
|
||||
const divideIndex = divides.indexOf(divide);
|
||||
|
||||
if (divideIndex === 0 && glyphIndex === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (divideIndex === 0 && glyphIndex === 1 && glyphGroup[0].glyphType === GlyphType.LIST) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getParagraphByGlyph(glyph: Nullable<IDocumentSkeletonGlyph>, body?: IDocumentBody) {
|
||||
const line = glyph?.parent?.parent;
|
||||
if (line == null || body == null) {
|
||||
return;
|
||||
}
|
||||
const paragraphs = body.paragraphs;
|
||||
|
||||
if (paragraphs == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
const paragraph = paragraphs[i];
|
||||
const prevParagraph = paragraphs[i - 1];
|
||||
if (paragraph.startIndex === line.paragraphIndex) {
|
||||
return {
|
||||
...paragraph,
|
||||
paragraphStart: (prevParagraph?.startIndex ?? -1) + 1,
|
||||
paragraphEnd: paragraph.startIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlaceholderOrSpace(glyph: Nullable<IDocumentSkeletonGlyph>) {
|
||||
if (glyph == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
[DataStreamTreeTokenType.PARAGRAPH, DataStreamTreeTokenType.TAB, DataStreamTreeTokenType.SECTION_BREAK].indexOf(
|
||||
glyph.streamType
|
||||
) !== -1 ||
|
||||
glyph.content === DataStreamTreeTokenType.SPACE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isSameLine(glyph1: Nullable<IDocumentSkeletonGlyph>, glyph2: Nullable<IDocumentSkeletonGlyph>) {
|
||||
if (glyph1 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (glyph2 == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (glyph1.parent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (glyph2.parent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return glyph1.parent.parent === glyph2.parent.parent;
|
||||
}
|
||||
465
packages/engine-render/src/basics/draw.ts
Normal file
465
packages/engine-render/src/basics/draw.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IPosition } from '@univerjs/core';
|
||||
import type { UniverRenderingContext } from '../context';
|
||||
import type { IDocumentSkeletonLine } from './i-document-skeleton-cached';
|
||||
import { BorderStyleTypes } from '@univerjs/core';
|
||||
import { BORDER_TYPE, ORIENTATION_TYPE } from './const';
|
||||
import { createCanvasElement } from './tools';
|
||||
import { Vector2 } from './vector2';
|
||||
|
||||
export interface IContext2D extends CanvasRenderingContext2D {
|
||||
webkitBackingStorePixelRatio: number;
|
||||
mozBackingStorePixelRatio: number;
|
||||
msBackingStorePixelRatio: number;
|
||||
oBackingStorePixelRatio: number;
|
||||
backingStorePixelRatio: number;
|
||||
}
|
||||
|
||||
// calculate pixel ratio
|
||||
export function getDevicePixelRatio(): number {
|
||||
let _pixelRatio: number = 1;
|
||||
const canvas = createCanvasElement();
|
||||
const context = canvas.getContext('2d') as IContext2D;
|
||||
_pixelRatio = (() => {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const backingStoreRatio =
|
||||
context.webkitBackingStorePixelRatio ||
|
||||
context.mozBackingStorePixelRatio ||
|
||||
context.msBackingStorePixelRatio ||
|
||||
context.oBackingStorePixelRatio ||
|
||||
context.backingStorePixelRatio ||
|
||||
1;
|
||||
return devicePixelRatio / backingStoreRatio;
|
||||
})();
|
||||
|
||||
if (_pixelRatio < 1) {
|
||||
return 1;
|
||||
}
|
||||
return _pixelRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param ctx canvas context
|
||||
* @param type top bottom left right
|
||||
* @param lineWidthBuffer Solving the problem of mitered corners in the drawing of borders thicker than 2 pixels, caused by the line segments being centered.
|
||||
* @param position border draw position
|
||||
*/
|
||||
export function drawLineByBorderType(ctx: UniverRenderingContext, type: BORDER_TYPE, lineWidthBuffer: number, position: IPosition) {
|
||||
let drawStartX = 0;
|
||||
let drawStartY = 0;
|
||||
let drawEndX = 0;
|
||||
let drawEndY = 0;
|
||||
const { startX, startY, endX, endY } = position;
|
||||
if (type === BORDER_TYPE.TOP) {
|
||||
drawStartX = startX - lineWidthBuffer;
|
||||
drawStartY = startY;
|
||||
drawEndX = endX + lineWidthBuffer;
|
||||
drawEndY = startY;
|
||||
} else if (type === BORDER_TYPE.BOTTOM) {
|
||||
drawStartX = startX - lineWidthBuffer;
|
||||
drawStartY = endY;
|
||||
drawEndX = endX - lineWidthBuffer;
|
||||
drawEndY = endY;
|
||||
} else if (type === BORDER_TYPE.LEFT) {
|
||||
drawStartX = startX;
|
||||
drawStartY = startY - lineWidthBuffer;
|
||||
drawEndX = startX;
|
||||
drawEndY = endY + lineWidthBuffer;
|
||||
} else if (type === BORDER_TYPE.RIGHT) {
|
||||
drawStartX = endX;
|
||||
drawStartY = startY - lineWidthBuffer;
|
||||
drawEndX = endX;
|
||||
drawEndY = endY + lineWidthBuffer;
|
||||
}
|
||||
|
||||
// ctx.clearRect(drawStartX - 1, drawStartY - 1, drawEndX - drawStartX + 2, drawEndY - drawStartY + 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveToByPrecision(drawStartX, drawStartY);
|
||||
ctx.lineToByPrecision(drawEndX, drawEndY);
|
||||
ctx.stroke();
|
||||
ctx.closePathByEnv();
|
||||
}
|
||||
|
||||
function drawDiagonalSingleLineByBorderType(ctx: UniverRenderingContext, type: BORDER_TYPE, position: IPosition) {
|
||||
let drawStartX = 0;
|
||||
let drawStartY = 0;
|
||||
let drawEndX = 0;
|
||||
let drawEndY = 0;
|
||||
const { startX, startY, endX, endY } = position;
|
||||
switch (type) {
|
||||
case BORDER_TYPE.TL_BR:
|
||||
drawStartX = startX;
|
||||
drawStartY = startY;
|
||||
drawEndX = endX;
|
||||
drawEndY = endY;
|
||||
break;
|
||||
case BORDER_TYPE.TL_BC:
|
||||
drawStartX = startX;
|
||||
drawStartY = startY;
|
||||
drawEndX = (startX + endX) / 2;
|
||||
drawEndY = endY;
|
||||
break;
|
||||
case BORDER_TYPE.TL_MR:
|
||||
drawStartX = startX;
|
||||
drawStartY = startY;
|
||||
drawEndX = endX;
|
||||
drawEndY = (startY + endY) / 2;
|
||||
break;
|
||||
case BORDER_TYPE.BL_TR:
|
||||
drawStartX = startX;
|
||||
drawStartY = endY;
|
||||
drawEndX = endX;
|
||||
drawEndY = startY;
|
||||
break;
|
||||
case BORDER_TYPE.ML_TR:
|
||||
drawStartX = startX;
|
||||
drawStartY = (startY + endY) / 2;
|
||||
drawEndX = endX;
|
||||
drawEndY = startY;
|
||||
break;
|
||||
case BORDER_TYPE.BC_TR:
|
||||
drawStartX = (startX + endX) / 2;
|
||||
drawStartY = endY;
|
||||
drawEndX = endX;
|
||||
drawEndY = startY;
|
||||
break;
|
||||
}
|
||||
|
||||
// ctx.clearRect(drawStartX - 1, drawStartY - 1, drawEndX - drawStartX + 2, drawEndY - drawStartY + 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveToByPrecision(drawStartX, drawStartY);
|
||||
ctx.lineToByPrecision(drawEndX, drawEndY);
|
||||
ctx.closePathByEnv();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawDiagonalDoubleLineByBorderType(ctx: UniverRenderingContext, type: BORDER_TYPE, position: IPosition) {
|
||||
let drawFirstStartX = 0;
|
||||
let drawFirstStartY = 0;
|
||||
let drawFirstEndX = 0;
|
||||
let drawFirstEndY = 0;
|
||||
let drawSecondStartX = 0;
|
||||
let drawSecondStartY = 0;
|
||||
let drawSecondEndX = 0;
|
||||
let drawSecondEndY = 0;
|
||||
const { startX, startY, endX, endY } = position;
|
||||
switch (type) {
|
||||
case BORDER_TYPE.TL_BR:
|
||||
drawFirstStartX = startX;
|
||||
drawFirstStartY = startY + 1.5;
|
||||
drawFirstEndX = endX - 1.5;
|
||||
drawFirstEndY = endY;
|
||||
drawSecondStartX = startX + 1.5;
|
||||
drawSecondStartY = startY;
|
||||
drawSecondEndX = endX;
|
||||
drawSecondEndY = endY - 1.5;
|
||||
break;
|
||||
case BORDER_TYPE.BL_TR:
|
||||
drawFirstStartX = startX;
|
||||
drawFirstStartY = endY - 1.5;
|
||||
drawFirstEndX = endX - 1.5;
|
||||
drawFirstEndY = startY;
|
||||
drawSecondStartX = startX + 1.5;
|
||||
drawSecondStartY = endY;
|
||||
drawSecondEndX = endX;
|
||||
drawSecondEndY = startY + 1.5;
|
||||
break;
|
||||
}
|
||||
|
||||
// ctx.clearRect(drawStartX - 1, drawStartY - 1, drawEndX - drawStartX + 2, drawEndY - drawStartY + 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveToByPrecision(drawFirstStartX, drawFirstStartY);
|
||||
ctx.lineToByPrecision(drawFirstEndX, drawFirstEndY);
|
||||
ctx.closePathByEnv();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveToByPrecision(drawSecondStartX, drawSecondStartY);
|
||||
ctx.lineToByPrecision(drawSecondEndX, drawSecondEndY);
|
||||
ctx.closePathByEnv();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
export function drawDiagonalLineByBorderType(ctx: UniverRenderingContext, style: BorderStyleTypes, type: BORDER_TYPE, position: IPosition) {
|
||||
if (style === BorderStyleTypes.DOUBLE && (type === BORDER_TYPE.TL_BR || type === BORDER_TYPE.BL_TR)) {
|
||||
drawDiagonalDoubleLineByBorderType(ctx, type, position);
|
||||
} else {
|
||||
drawDiagonalSingleLineByBorderType(ctx, type, position);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLineByBorderType(ctx: UniverRenderingContext, type: BORDER_TYPE, position: IPosition) {
|
||||
let drawStartX = 0;
|
||||
let drawStartY = 0;
|
||||
let drawEndX = 0;
|
||||
let drawEndY = 0;
|
||||
const { startX, startY, endX, endY } = position;
|
||||
switch (type) {
|
||||
case BORDER_TYPE.TOP:
|
||||
drawStartX = startX;
|
||||
drawStartY = startY;
|
||||
drawEndX = endX;
|
||||
drawEndY = startY;
|
||||
break;
|
||||
case BORDER_TYPE.BOTTOM:
|
||||
drawStartX = startX;
|
||||
drawStartY = endY;
|
||||
drawEndX = endX;
|
||||
drawEndY = endY;
|
||||
break;
|
||||
case BORDER_TYPE.LEFT:
|
||||
drawStartX = startX;
|
||||
drawStartY = startY;
|
||||
drawEndX = startX;
|
||||
drawEndY = endY;
|
||||
break;
|
||||
case BORDER_TYPE.RIGHT:
|
||||
drawStartX = endX;
|
||||
drawStartY = startY;
|
||||
drawEndX = endX;
|
||||
drawEndY = endY;
|
||||
break;
|
||||
}
|
||||
|
||||
// ctx.beginPath();
|
||||
// ctx.strokeStyle = 'rgb(255,255,255)';
|
||||
// ctx.lineWidth = 1.5 / Math.max(scaleX, scaleY);
|
||||
// ctx.moveTo(drawStartX, drawStartY);
|
||||
// ctx.lineTo(drawEndX, drawEndY);
|
||||
// ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.clearRectForTexture(drawStartX, drawStartY, drawEndX - drawStartX, drawEndY - drawStartY);
|
||||
}
|
||||
|
||||
export function setLineType(ctx: UniverRenderingContext, style: BorderStyleTypes) {
|
||||
if (style === BorderStyleTypes.HAIR) {
|
||||
ctx.setLineDash([1, 1]);
|
||||
} else if (style === BorderStyleTypes.DASH_DOT_DOT || style === BorderStyleTypes.MEDIUM_DASH_DOT_DOT) {
|
||||
ctx.setLineDash([2, 2, 5, 2, 2]);
|
||||
} else if (
|
||||
style === BorderStyleTypes.DASH_DOT ||
|
||||
style === BorderStyleTypes.MEDIUM_DASH_DOT ||
|
||||
style === BorderStyleTypes.SLANT_DASH_DOT
|
||||
) {
|
||||
ctx.setLineDash([2, 5, 2]);
|
||||
} else if (style === BorderStyleTypes.DOTTED) {
|
||||
ctx.setLineDash([2]);
|
||||
} else if (style === BorderStyleTypes.DASHED || style === BorderStyleTypes.MEDIUM_DASHED) {
|
||||
ctx.setLineDash([3]);
|
||||
} else {
|
||||
ctx.setLineDash([0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLineOffset() {
|
||||
const ratio = getLineWith(1);
|
||||
return ratio - Math.floor(ratio);
|
||||
}
|
||||
|
||||
export function getLineWith(width: number) {
|
||||
return Math.ceil((width / getDevicePixelRatio()) * 100) / 100;
|
||||
}
|
||||
|
||||
export function getLineWidth(style: BorderStyleTypes) {
|
||||
let lineWidth = 1;
|
||||
if (
|
||||
style === BorderStyleTypes.MEDIUM ||
|
||||
style === BorderStyleTypes.MEDIUM_DASH_DOT ||
|
||||
style === BorderStyleTypes.MEDIUM_DASHED ||
|
||||
style === BorderStyleTypes.MEDIUM_DASH_DOT_DOT
|
||||
) {
|
||||
lineWidth = 2;
|
||||
} else if (style === BorderStyleTypes.THICK) {
|
||||
lineWidth = 3;
|
||||
}
|
||||
|
||||
return lineWidth;
|
||||
}
|
||||
|
||||
export function calculateRectRotate(
|
||||
startPoint: Vector2,
|
||||
centerPoint: Vector2,
|
||||
radiusCenter: number,
|
||||
radiusVertex: number,
|
||||
offsetPoint: Vector2 = Vector2.create(0, 0)
|
||||
) {
|
||||
const rotationVector = startPoint.add(centerPoint).rotateByPoint(radiusVertex);
|
||||
|
||||
const newVector = rotationVector.subtract(centerPoint);
|
||||
|
||||
const finalAngle = radiusVertex - radiusCenter;
|
||||
|
||||
const finalXY = newVector.rotateByPoint(finalAngle, rotationVector);
|
||||
|
||||
const newXY = finalXY.add(offsetPoint).transformCoordinateOnRotate(finalAngle);
|
||||
|
||||
return newXY;
|
||||
}
|
||||
|
||||
export function getRotateOrientation(angle: number) {
|
||||
return angle > 0 ? ORIENTATION_TYPE.DOWN : ORIENTATION_TYPE.UP;
|
||||
}
|
||||
|
||||
// rotate calculate logic https://www.processon.com/view/link/630df928f346fb0714c9c4ec
|
||||
// eslint-disable-next-line max-lines-per-function, complexity
|
||||
export function getRotateOffsetAndFarthestHypotenuse(
|
||||
lines: IDocumentSkeletonLine[],
|
||||
rectWidth: number,
|
||||
vertexAngle: number
|
||||
) {
|
||||
const rotateTranslateXList: number[] = [];
|
||||
let rotateTranslateY = 0;
|
||||
let rotatedHeight = 0;
|
||||
let rotatedWidth = 0;
|
||||
let fixOffsetX = 0;
|
||||
let fixOffsetY = 0;
|
||||
|
||||
const orientation = getRotateOrientation(vertexAngle);
|
||||
const linesCount = lines.length;
|
||||
|
||||
vertexAngle = Math.abs(vertexAngle);
|
||||
const tanTheta = Math.tan(vertexAngle);
|
||||
const sinTheta = Math.sin(vertexAngle);
|
||||
const cosTheta = Math.cos(vertexAngle);
|
||||
|
||||
if (orientation === ORIENTATION_TYPE.UP) {
|
||||
let cumRectHeight = 0;
|
||||
|
||||
for (let i = 0; i < linesCount; i++) {
|
||||
const line = lines[i];
|
||||
const { lineHeight: rectHeight = 0 } = line;
|
||||
cumRectHeight += i === 0 ? 0 : rectHeight;
|
||||
|
||||
const currentRotateHeight = rectWidth * sinTheta + rectHeight * cosTheta;
|
||||
|
||||
rotateTranslateXList.push(cumRectHeight / tanTheta);
|
||||
|
||||
// rotatedHeight = Math.max(rotatedHeight, currentRotateHeight);
|
||||
|
||||
if (currentRotateHeight > rotatedHeight) {
|
||||
rotatedHeight = currentRotateHeight;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
rotatedWidth += rectHeight * sinTheta;
|
||||
fixOffsetY += rectHeight * cosTheta;
|
||||
}
|
||||
//else if (i === linesCount - 1) {
|
||||
// rotatedWidth += rectWidth * cosTheta;
|
||||
// } else {
|
||||
// rotatedWidth += cumRectHeight / sinTheta;
|
||||
// }
|
||||
}
|
||||
|
||||
rotatedWidth += cumRectHeight / sinTheta + rectWidth * cosTheta;
|
||||
fixOffsetY -= rotatedHeight;
|
||||
// fixOffsetY = -rectHeight * cosTheta;
|
||||
} else {
|
||||
let maxOffsetX = 0;
|
||||
let maxOffsetLineIndex = -1;
|
||||
const rotateOffsetXList = [];
|
||||
for (let i = linesCount - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
const { lineHeight: rectHeight = 0 } = line;
|
||||
|
||||
const offsetX = rectHeight / tanTheta;
|
||||
const currentRotateHeight = (rectWidth + offsetX) * sinTheta;
|
||||
|
||||
// if (i === linesCount - 1) {
|
||||
// rotateTranslateXList.unshift(rectHeight * tanTheta);
|
||||
// } else {
|
||||
// rotateTranslateXList.unshift(rectHeight / tanTheta);
|
||||
// }
|
||||
|
||||
rotateOffsetXList.unshift(rectHeight / tanTheta);
|
||||
|
||||
if (currentRotateHeight > rotatedHeight) {
|
||||
rotatedHeight = currentRotateHeight;
|
||||
maxOffsetX = offsetX;
|
||||
maxOffsetLineIndex = i;
|
||||
}
|
||||
|
||||
if (i === 0) {
|
||||
rotatedWidth += rectHeight * sinTheta;
|
||||
} else if (i === linesCount - 1) {
|
||||
rotatedWidth += rectWidth * cosTheta + rectHeight / sinTheta;
|
||||
} else {
|
||||
rotatedWidth += rectHeight / sinTheta;
|
||||
}
|
||||
}
|
||||
|
||||
let cumRotateHeightFix = lines[maxOffsetLineIndex]?.lineHeight || 0;
|
||||
|
||||
let cumBlowValue = 0;
|
||||
for (let i = maxOffsetLineIndex + 1; i <= linesCount - 1; i++) {
|
||||
const line = lines[i];
|
||||
const { lineHeight: rectHeight = 0 } = line;
|
||||
// const preRotateTranslateX = i === maxOffsetLineIndex ? 0 : rotateTranslateXList[i - 1];
|
||||
// rotateTranslateXList[i] -= preRotateTranslateX;
|
||||
|
||||
cumRotateHeightFix += rectHeight;
|
||||
|
||||
cumBlowValue += rotateOffsetXList[i] || 0;
|
||||
|
||||
rotateTranslateXList[i] = -cumBlowValue;
|
||||
}
|
||||
|
||||
cumBlowValue = 0;
|
||||
for (let i = maxOffsetLineIndex - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
const { lineHeight: rectHeight = 0 } = line;
|
||||
|
||||
cumBlowValue += rotateOffsetXList[i + 1] || 0;
|
||||
rotateTranslateXList[i] = cumBlowValue;
|
||||
|
||||
rotateTranslateY += rectHeight;
|
||||
}
|
||||
|
||||
rotateTranslateXList[maxOffsetLineIndex] = 0;
|
||||
|
||||
if (linesCount === 1) {
|
||||
rotatedWidth += rectWidth * cosTheta;
|
||||
// rotateTranslateXList[0] = 0;
|
||||
}
|
||||
|
||||
fixOffsetX = cumRotateHeightFix / sinTheta - maxOffsetX * cosTheta;
|
||||
}
|
||||
|
||||
return {
|
||||
rotateTranslateXList,
|
||||
rotatedHeight,
|
||||
rotatedWidth,
|
||||
fixOffsetX,
|
||||
fixOffsetY,
|
||||
rotateTranslateY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the resolution, an alignment needs to be done in special cases where the resolution is 1.5, 1.25, etc.
|
||||
* @returns {left: number, top: number} offset
|
||||
*/
|
||||
export function getTranslateInSpreadContextWithPixelRatio() {
|
||||
const offset = 0.5 - getLineOffset();
|
||||
return {
|
||||
left: offset,
|
||||
top: offset,
|
||||
};
|
||||
}
|
||||
177
packages/engine-render/src/basics/drawing-effect.ts
Normal file
177
packages/engine-render/src/basics/drawing-effect.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IGlowEffect, IShadowEffect } from '@univerjs/core';
|
||||
import type { IBoundRectNoAngle } from './vector2';
|
||||
import { ColorKit } from '@univerjs/core';
|
||||
|
||||
// Canvas blur visually spans about twice the DrawingML glow radius. This factor is verified against PowerPoint output.
|
||||
const DRAWINGML_GLOW_BLUR_SCALE = 0.5;
|
||||
// Canvas drop-shadow uses a Gaussian blur. Three standard deviations retain the visible effect without clipping.
|
||||
const GAUSSIAN_BLUR_BOUND_SCALE = 3;
|
||||
|
||||
export interface IResolvedDrawingShadow {
|
||||
color: string;
|
||||
blurRadius: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function colorWithOpacity(color: string, opacity: number | undefined): string {
|
||||
if (opacity === undefined) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const parsedColor = new ColorKit(color);
|
||||
if (!parsedColor.isValid) {
|
||||
return color;
|
||||
}
|
||||
|
||||
return parsedColor.setAlpha(parsedColor.getAlpha() * clamp(opacity, 0, 1)).toRgbString();
|
||||
}
|
||||
|
||||
function isTransparentColor(color: string): boolean {
|
||||
const parsedColor = new ColorKit(color);
|
||||
return parsedColor.isValid && parsedColor.getAlpha() <= 0;
|
||||
}
|
||||
|
||||
function cssPixels(value: number): string {
|
||||
return `${Math.abs(value) < Number.EPSILON ? 0 : value}px`;
|
||||
}
|
||||
|
||||
function toDropShadowFilter(effect: IResolvedDrawingShadow): string {
|
||||
return `drop-shadow(${cssPixels(effect.offsetX)} ${cssPixels(effect.offsetY)} ${cssPixels(effect.blurRadius)} ${effect.color})`;
|
||||
}
|
||||
|
||||
export function resolveOuterShadowEffect(effect: IShadowEffect | undefined): IResolvedDrawingShadow | undefined {
|
||||
if (!effect?.color || (effect.opacity !== undefined && effect.opacity <= 0)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const distance = effect.distance ?? 0;
|
||||
const direction = ((effect.direction ?? 0) * Math.PI) / 180;
|
||||
const sizeScale = ((effect.sx ?? 1) + (effect.sy ?? 1)) / 2;
|
||||
|
||||
const color = colorWithOpacity(effect.color, effect.opacity);
|
||||
if (isTransparentColor(color)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
color,
|
||||
blurRadius: Math.max(0, effect.blurRadius ?? 0) * sizeScale,
|
||||
offsetX: Math.cos(direction) * distance * sizeScale,
|
||||
offsetY: Math.sin(direction) * distance * sizeScale,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGlowEffect(effect: IGlowEffect | undefined): IResolvedDrawingShadow | undefined {
|
||||
if (!effect?.color || (effect.radius ?? 0) <= 0 || isTransparentColor(effect.color)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
color: effect.color,
|
||||
blurRadius: Math.max(0, effect.radius ?? 0) * DRAWINGML_GLOW_BLUR_SCALE,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves effects painted from an off-screen alpha mask.
|
||||
*
|
||||
* A glow uses two centered mask passes to match PowerPoint's DrawingML glow density without repainting source content.
|
||||
*/
|
||||
export function resolveDrawingEffectMasks(
|
||||
glow: IGlowEffect | undefined,
|
||||
outerShadow: IShadowEffect | undefined
|
||||
): IResolvedDrawingShadow[] {
|
||||
const effects: IResolvedDrawingShadow[] = [];
|
||||
const resolvedGlow = resolveGlowEffect(glow);
|
||||
const resolvedOuterShadow = resolveOuterShadowEffect(outerShadow);
|
||||
|
||||
if (resolvedGlow) {
|
||||
effects.push(resolvedGlow, resolvedGlow);
|
||||
}
|
||||
if (resolvedOuterShadow) {
|
||||
effects.push(resolvedOuterShadow);
|
||||
}
|
||||
|
||||
return effects;
|
||||
}
|
||||
|
||||
export function expandDrawingEffectBounds(
|
||||
bounds: IBoundRectNoAngle,
|
||||
glow: IGlowEffect | undefined,
|
||||
outerShadow: IShadowEffect | undefined
|
||||
): IBoundRectNoAngle {
|
||||
let expandedBounds = { ...bounds };
|
||||
const effects = [resolveGlowEffect(glow), resolveOuterShadowEffect(outerShadow)];
|
||||
|
||||
for (const effect of effects) {
|
||||
if (!effect) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const blurBound = effect.blurRadius * GAUSSIAN_BLUR_BOUND_SCALE;
|
||||
const shadowBounds = {
|
||||
left: expandedBounds.left + effect.offsetX - blurBound,
|
||||
top: expandedBounds.top + effect.offsetY - blurBound,
|
||||
right: expandedBounds.right + effect.offsetX + blurBound,
|
||||
bottom: expandedBounds.bottom + effect.offsetY + blurBound,
|
||||
};
|
||||
expandedBounds = {
|
||||
left: Math.min(expandedBounds.left, shadowBounds.left),
|
||||
top: Math.min(expandedBounds.top, shadowBounds.top),
|
||||
right: Math.max(expandedBounds.right, shadowBounds.right),
|
||||
bottom: Math.max(expandedBounds.bottom, shadowBounds.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
return expandedBounds;
|
||||
}
|
||||
|
||||
export function createDrawingEffectFilter(
|
||||
glow: IGlowEffect | undefined,
|
||||
outerShadow: IShadowEffect | undefined
|
||||
): string {
|
||||
const filters: string[] = [];
|
||||
const resolvedGlow = resolveGlowEffect(glow);
|
||||
const resolvedOuterShadow = resolveOuterShadowEffect(outerShadow);
|
||||
|
||||
if (resolvedGlow) {
|
||||
filters.push(toDropShadowFilter(resolvedGlow));
|
||||
}
|
||||
|
||||
if (resolvedOuterShadow) {
|
||||
filters.push(toDropShadowFilter(resolvedOuterShadow));
|
||||
}
|
||||
|
||||
return filters.join(' ');
|
||||
}
|
||||
|
||||
export function combineDrawingEffectFilter(currentFilter: string, effectFilter: string): string {
|
||||
if (!effectFilter) {
|
||||
return currentFilter;
|
||||
}
|
||||
|
||||
return currentFilter && currentFilter !== 'none' ? `${currentFilter} ${effectFilter}` : effectFilter;
|
||||
}
|
||||
248
packages/engine-render/src/basics/group-transform.ts
Normal file
248
packages/engine-render/src/basics/group-transform.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IGroupBaseBound, ITransformState, Nullable } from '@univerjs/core';
|
||||
import { offsetRotationAxis } from './offset-rotation-axis';
|
||||
import { Vector2 } from './vector2';
|
||||
|
||||
export function getGroupState(parentLeft: number, parentTop: number, objectStates: ITransformState[]) {
|
||||
let groupLeft = Number.MAX_SAFE_INTEGER;
|
||||
let groupTop = Number.MAX_SAFE_INTEGER;
|
||||
let groupRight = Number.MIN_SAFE_INTEGER;
|
||||
let groupBottom = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
objectStates.forEach((o) => {
|
||||
const { left = 0, top = 0, width = 0, height = 0 } = o;
|
||||
groupLeft = Math.min(groupLeft, left);
|
||||
groupTop = Math.min(groupTop, top);
|
||||
groupRight = Math.max(groupRight, left + width);
|
||||
groupBottom = Math.max(groupBottom, top + height);
|
||||
});
|
||||
|
||||
const groupWidth = groupRight - groupLeft;
|
||||
const groupHeight = groupBottom - groupTop;
|
||||
|
||||
return {
|
||||
left: groupLeft + parentLeft,
|
||||
top: groupTop + parentTop,
|
||||
width: groupWidth,
|
||||
height: groupHeight,
|
||||
angle: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDrawingGroupState(parentLeft: number, parentTop: number, objectStates: ITransformState[]) {
|
||||
let groupLeft = Number.MAX_SAFE_INTEGER;
|
||||
let groupTop = Number.MAX_SAFE_INTEGER;
|
||||
let groupRight = Number.MIN_SAFE_INTEGER;
|
||||
let groupBottom = Number.MIN_SAFE_INTEGER;
|
||||
|
||||
objectStates.forEach((o) => {
|
||||
const { left = 0, top = 0, width = 0, height = 0 } = o;
|
||||
groupLeft = Math.min(groupLeft, left);
|
||||
groupTop = Math.min(groupTop, top);
|
||||
groupRight = Math.max(groupRight, left + width);
|
||||
groupBottom = Math.max(groupBottom, top + height);
|
||||
});
|
||||
|
||||
const groupWidth = groupRight - groupLeft;
|
||||
const groupHeight = groupBottom - groupTop;
|
||||
|
||||
return {
|
||||
left: groupLeft,
|
||||
top: groupTop,
|
||||
width: groupWidth,
|
||||
height: groupHeight,
|
||||
angle: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a child object out of a group, computing its absolute position, angle, and flip state.
|
||||
*
|
||||
* When a DrawingGroup has a baseBound (chOff/chExt in OOXML), children store their positions
|
||||
* in the baseBound coordinate space. This method first maps the child's position from baseBound
|
||||
* space to the actual parent bound space, then applies group flip mirroring and rotation.
|
||||
*
|
||||
* @param child - The child's transform state (position in baseBound space if baseBound is provided)
|
||||
* @param parent - The parent group's transform state (absolute position, angle, flip)
|
||||
* @param groupOriginWidth - The original width of the group (used to compute group center)
|
||||
* @param groupOriginHeight - The original height of the group (used to compute group center)
|
||||
* @param baseBound - Optional. The group's baseBound (chOff/chExt). If provided, child coordinates
|
||||
* are mapped from this space to the parent's actual bound space before transforming.
|
||||
*
|
||||
* @example
|
||||
* // in excel, the group off & ext is the real position and size of the group, and the child position is relative to the group chOff/chExt. For example:
|
||||
* ```xml
|
||||
* <a:xfrm>
|
||||
* <a:off x="1212850" y="889000"/>
|
||||
* <a:ext cx="6813550" cy="4883150"/>
|
||||
* <a:chOff x="1212850" y="889000"/>
|
||||
* <a:chExt cx="6813550" cy="4883150"/>
|
||||
* </a:xfrm>
|
||||
* ```
|
||||
*/
|
||||
export function transformObjectOutOfGroup(
|
||||
child: ITransformState,
|
||||
parent: ITransformState,
|
||||
groupOriginWidth: number,
|
||||
groupOriginHeight: number,
|
||||
baseBound: Nullable<IGroupBaseBound>
|
||||
) {
|
||||
const { left = 0, top = 0, width = 0, height = 0, angle = 0 } = child;
|
||||
const { left: groupLeft = 0, top: groupTop = 0, angle: groupAngle = 0, flipX: groupFlipX = false, flipY: groupFlipY = false } = parent;
|
||||
|
||||
// Map child position from baseBound space to actual parent bound space
|
||||
let mappedLeft = left;
|
||||
let mappedTop = top;
|
||||
let mappedWidth = width;
|
||||
let mappedHeight = height;
|
||||
|
||||
if (baseBound && baseBound.width > 0 && baseBound.height > 0) {
|
||||
const parentBound: IGroupBaseBound = {
|
||||
left: groupLeft,
|
||||
top: groupTop,
|
||||
width: groupOriginWidth,
|
||||
height: groupOriginHeight,
|
||||
};
|
||||
const objectBound: IGroupBaseBound = { left, top, width, height };
|
||||
const mapped = getRenderTransformBaseOnParentBound(baseBound, parentBound, objectBound);
|
||||
mappedLeft = mapped.left;
|
||||
mappedTop = mapped.top;
|
||||
mappedWidth = mapped.width;
|
||||
mappedHeight = mapped.height;
|
||||
}
|
||||
|
||||
const groupCenterX = groupLeft + groupOriginWidth / 2;
|
||||
const groupCenterY = groupTop + groupOriginHeight / 2;
|
||||
|
||||
let flipX = child.flipX || false;
|
||||
let flipY = child.flipY || false;
|
||||
|
||||
let objectX = mappedLeft;
|
||||
let objectY = mappedTop;
|
||||
|
||||
if (groupFlipX) {
|
||||
const objectCenterX = objectX + mappedWidth / 2;
|
||||
const mirroredCenterX = 2 * groupCenterX - objectCenterX;
|
||||
objectX = mirroredCenterX - mappedWidth / 2;
|
||||
flipX = !flipX;
|
||||
}
|
||||
|
||||
if (groupFlipY) {
|
||||
const objectCenterY = objectY + mappedHeight / 2;
|
||||
const mirroredCenterY = 2 * groupCenterY - objectCenterY;
|
||||
objectY = mirroredCenterY - mappedHeight / 2;
|
||||
flipY = !flipY;
|
||||
}
|
||||
|
||||
const objectCenterX = objectX + mappedWidth / 2;
|
||||
const objectCenterY = objectY + mappedHeight / 2;
|
||||
|
||||
const finalPoint = offsetRotationAxis(
|
||||
new Vector2(groupCenterX, groupCenterY),
|
||||
groupAngle,
|
||||
new Vector2(objectX, objectY),
|
||||
new Vector2(objectCenterX, objectCenterY)
|
||||
);
|
||||
|
||||
return {
|
||||
left: finalPoint.x,
|
||||
top: finalPoint.y,
|
||||
width: mappedWidth,
|
||||
height: mappedHeight,
|
||||
angle: groupAngle + angle,
|
||||
flipX,
|
||||
flipY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rendered position and size of an object based on the group's baseBound and the parent's bound.
|
||||
* @param baseBound The group's baseBound defining the coordinate space for its children,In Excel, this corresponds to chOff (child offset) and chExt (child extent) in OOXML.
|
||||
* @param parentBound The bounding box of the parent context (e.g., the group or canvas) within which the object is rendered.
|
||||
* @param objectBound The original bounding box of the object in the group's coordinate space.
|
||||
* @returns {IGroupBaseBound} The transformed bound for rendering the object within the group context
|
||||
*/
|
||||
export function getRenderTransformBaseOnParentBound(baseBound: IGroupBaseBound, parentBound: IGroupBaseBound, objectBound: IGroupBaseBound): IGroupBaseBound {
|
||||
if (!baseBound) {
|
||||
return {
|
||||
left: objectBound.left,
|
||||
top: objectBound.top,
|
||||
width: objectBound.width,
|
||||
height: objectBound.height,
|
||||
};
|
||||
}
|
||||
const { left, top, width, height } = baseBound;
|
||||
const { left: parentLeft, top: parentTop, width: parentWidth, height: parentHeight } = parentBound;
|
||||
|
||||
const objectRelativeLeft = parentLeft + (objectBound.left - left) / width * parentWidth;
|
||||
const objectRelativeTop = parentTop + (objectBound.top - top) / height * parentHeight;
|
||||
const objectRelativeWidth = objectBound.width / width * parentWidth;
|
||||
const objectRelativeHeight = objectBound.height / height * parentHeight;
|
||||
return {
|
||||
left: objectRelativeLeft,
|
||||
top: objectRelativeTop,
|
||||
width: objectRelativeWidth,
|
||||
height: objectRelativeHeight,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In Excel, a rotated shape's bounding box for group calculations uses major axis switching.
|
||||
* The axis-aligned bound depends on which 90° increment the rotation angle is closest to:
|
||||
*
|
||||
* [-45°, 45°) → 0° horizontal (original width/height)
|
||||
* [45°, 135°) → 90° vertical (width/height swapped)
|
||||
* [135°, 225°) → 180° horizontal (original width/height)
|
||||
* [225°, 315°) → 270° vertical (width/height swapped)
|
||||
*
|
||||
* @param bound - The original axis-aligned bound { left, top, width, height }
|
||||
* @param angle - The rotation angle in degrees
|
||||
*/
|
||||
export function getRotatedBoundInGroup(bound: IGroupBaseBound, angle: number): IGroupBaseBound {
|
||||
const normalizedAngle = ((angle % 360) + 360) % 360;
|
||||
|
||||
const cx = bound.left + bound.width / 2;
|
||||
const cy = bound.top + bound.height / 2;
|
||||
|
||||
const isHorizontalLike =
|
||||
(normalizedAngle >= 315 || normalizedAngle < 45) ||
|
||||
(normalizedAngle >= 135 && normalizedAngle < 225);
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
|
||||
if (isHorizontalLike) {
|
||||
width = bound.width;
|
||||
height = bound.height;
|
||||
} else {
|
||||
// Major axis switch: swap width/height
|
||||
width = bound.height;
|
||||
height = bound.width;
|
||||
}
|
||||
|
||||
return {
|
||||
left: cx - width / 2,
|
||||
top: cy - height / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
447
packages/engine-render/src/basics/i-document-skeleton-cached.ts
Normal file
447
packages/engine-render/src/basics/i-document-skeleton-cached.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BulletAlignment,
|
||||
ColumnSeparatorType,
|
||||
DataStreamTreeTokenType,
|
||||
IColorStyle,
|
||||
IColumnGroup,
|
||||
IDocDrawingBase,
|
||||
IDocumentRenderConfig,
|
||||
INestingLevel,
|
||||
IParagraph,
|
||||
IParagraphBorder,
|
||||
IParagraphProperties,
|
||||
ITable,
|
||||
ITableRow,
|
||||
ITextStyle,
|
||||
PageOrientType,
|
||||
TabStopLeader,
|
||||
} from '@univerjs/core';
|
||||
import type { BreakPointType } from '../components/docs/layout/line-breaker/break';
|
||||
|
||||
export interface IDocumentSkeletonCached extends ISkeletonResourceReference {
|
||||
pages: IDocumentSkeletonPage[];
|
||||
left: number;
|
||||
top: number;
|
||||
st: number; // startIndex
|
||||
ed?: number; // endIndex
|
||||
parent?: unknown;
|
||||
}
|
||||
|
||||
export interface IParagraphList {
|
||||
bullet: IDocumentSkeletonBullet;
|
||||
paragraph: IParagraph;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonParagraphBorders {
|
||||
borderTop?: IParagraphBorder;
|
||||
borderBottom?: IParagraphBorder;
|
||||
borderLeft?: IParagraphBorder;
|
||||
borderRight?: IParagraphBorder;
|
||||
borderBetween?: IParagraphBorder;
|
||||
}
|
||||
|
||||
export interface ISkeletonResourceReference {
|
||||
skeHeaders: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>; // id:{ width: IDocumentSkeletonHeaderFooter }
|
||||
skeFooters: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>;
|
||||
/* Global cache, does not participate in rendering, only helps skeleton generation */
|
||||
skeListLevel?: Map<string, IParagraphList[][]>; // ordered list cache, id: { level: max(width) bullet }
|
||||
drawingAnchor?: Map<string, Map<number, IDocumentSkeletonDrawingAnchor>>; // Anchor point to assist floating element positioning
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonDrawingAnchor {
|
||||
elements: IDocumentSkeletonLine[]; // element: lines, tr
|
||||
paragraphIndex: number; // block id
|
||||
top: number; // relative height for previous block
|
||||
}
|
||||
|
||||
// export interface IDocumentSkeletonHeaderFooterBase {
|
||||
// lines: IDocumentSkeletonLine[];
|
||||
// skeDrawings: Map<string, IDocumentSkeletonDrawing>;
|
||||
// height: number; // total length of footer or header
|
||||
// st: number; // startIndex
|
||||
// ed: number; // endIndex
|
||||
// marginLeft: number;
|
||||
// }
|
||||
|
||||
export enum DocumentSkeletonPageType {
|
||||
BODY,
|
||||
HEADER,
|
||||
FOOTER,
|
||||
CELL,
|
||||
NOTE,
|
||||
};
|
||||
|
||||
export interface IDocumentSkeletonPage {
|
||||
/**
|
||||
* Preserves page geometry while incremental pagination rebuilds a later page.
|
||||
* A placeholder can retain cloned flow nodes as a non-interactive visual preview;
|
||||
* coordinate lookup must still treat the page as non-editable.
|
||||
*/
|
||||
isLayoutPlaceholder?: boolean;
|
||||
/** Canonical page geometry is available in the layout executor but not materialized on Main. */
|
||||
isMaterializationPlaceholder?: boolean;
|
||||
/** Stable id of the document section that owns this body page. */
|
||||
sectionId?: string;
|
||||
sections: IDocumentSkeletonSection[];
|
||||
headerId: string;
|
||||
footerId: string;
|
||||
// page
|
||||
pageWidth: number; // default Infinity, The current page width follows the snapshot configuration, determined jointly by documentStyle and sectionBreak, representing the static limit for each page, unrelated to the actual content's text width.
|
||||
pageHeight: number; // default Infinity, The current page's height follows the snapshot configuration, jointly determined by documentStyle and sectionBreak, representing the static limit for each page, unrelated to the total height of the actual content's text.
|
||||
pageOrient: PageOrientType; // Paper orientation, whether it's portrait (vertical) or landscape (horizontal)
|
||||
marginLeft: number; // The current page's padding, used to accommodate column title space, follows the snapshot configuration, jointly determined by documentStyle and sectionBreak. It represents the static limit for each page, which may vary per page.
|
||||
marginRight: number;
|
||||
originMarginTop: number; // The margin top in document style config, used to draw margin identifier.
|
||||
marginTop: number;
|
||||
originMarginBottom: number; // The margin bottom in document style config, used to draw margin identifier.
|
||||
marginBottom: number;
|
||||
|
||||
left: number; // Only use for cell.
|
||||
|
||||
pageNumber: number; // page number
|
||||
pageNumberStart: number; // start page number
|
||||
verticalAlign: boolean; // vertical align, only effective for one page
|
||||
angle: number; // rotation angle, only effective for one page
|
||||
width: number; // actual or content width,default 0
|
||||
height: number; // actual or content height, default 0
|
||||
|
||||
breakType: BreakType; // type of page break
|
||||
/** Internal layout provenance used to distinguish a forced boundary from natural overflow. */
|
||||
isExplicitPageBreak?: boolean;
|
||||
/** Internal layout provenance for a page opened because content exhausted the previous page. */
|
||||
isNaturalPageOverflow?: boolean;
|
||||
/** Note fragments use their own segment offsets, independent of body character indices. */
|
||||
notes?: IDocumentSkeletonNote[];
|
||||
/** Space reserved on this body page for footnotes, including separators and continuation notices. */
|
||||
footnoteHeight?: number;
|
||||
/** Positioned separators and continuation notices for both footnotes and endnotes. */
|
||||
noteDecorations?: IDocumentSkeletonNoteDecoration[];
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
/** Whether this cell page is only a layout placeholder covered by a merged cell. */
|
||||
isMergedCellCovered?: boolean;
|
||||
skeDrawings: Map<string, IDocumentSkeletonDrawing>;
|
||||
skeTables: Map<string, IDocumentSkeletonTable>; // table skeletons in the page
|
||||
skeColumnGroups: Map<string, IDocumentSkeletonColumnGroup>; // column group skeletons in the page
|
||||
segmentId: string; // header/footer id if header/footer, empty string if body page
|
||||
type: DocumentSkeletonPageType; // page type: header, footer, body, or cell
|
||||
renderConfig?: IDocumentRenderConfig;
|
||||
parent?: IDocumentSkeletonCached | IDocumentSkeletonRow | IDocumentSkeletonColumnGroupColumn | IDocumentSkeletonNote;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonNoteDecoration {
|
||||
noteType?: 'footnote' | 'endnote';
|
||||
kind: 'separator' | 'continuationSeparator' | 'continuationNotice';
|
||||
left: number;
|
||||
top: number;
|
||||
page: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonNote {
|
||||
noteType?: 'footnote' | 'endnote';
|
||||
noteId: string;
|
||||
referenceIndex: number;
|
||||
continued: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
page: IDocumentSkeletonPage;
|
||||
parent?: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonHeaderFooter extends IDocumentSkeletonPage {}
|
||||
|
||||
export interface IDocumentSkeletonSection {
|
||||
columns: IDocumentSkeletonColumn[];
|
||||
colCount: number; // column count
|
||||
// section coordinate system relative to page
|
||||
height: number; // set height, if not specified, same as pageContentHeight = pageHeight - marginTop - marginBottom
|
||||
top: number; // calculate next section start position based on previous height
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
parent?: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonTable {
|
||||
rows: IDocumentSkeletonRow[];
|
||||
width: number; // calculate table width based on table settings or column widths
|
||||
height: number; // calculate total table height based on row count and row height
|
||||
top: number; // calculate table top based on the paragraph it belongs to
|
||||
left: number; // calculate left value based on outer container width, align, and indent
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
tableId: string; // table id
|
||||
tableSource: ITable;
|
||||
hasPageBreak?: boolean;
|
||||
parent?: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonRow {
|
||||
cells: IDocumentSkeletonPage[];
|
||||
index: number; // row index
|
||||
height: number; // actual height
|
||||
top: number; // top relative to table top edge
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
rowSource: ITableRow;
|
||||
parent?: IDocumentSkeletonTable;
|
||||
isRepeatRow: boolean; // whether it is a repeated header row
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonColumnGroup {
|
||||
columns: IDocumentSkeletonColumnGroupColumn[];
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
st: number;
|
||||
ed: number;
|
||||
columnGroupId: string;
|
||||
columnGroupSource: IColumnGroup;
|
||||
parent?: IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonColumnGroupColumn {
|
||||
columnId: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
st: number;
|
||||
ed: number;
|
||||
page: IDocumentSkeletonPage;
|
||||
parent?: IDocumentSkeletonColumnGroup;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonColumn {
|
||||
lines: IDocumentSkeletonLine[];
|
||||
// column coordinate system relative to section
|
||||
left: number;
|
||||
width: number; // (columnWidth + spaceWidth) + ... = page.Width
|
||||
height?: number; // actual height
|
||||
spaceWidth: number;
|
||||
separator: ColumnSeparatorType; // type
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
drawingLRIds: []; // element ids affecting rows and columns, left and right
|
||||
isFull: boolean; // whether content is full
|
||||
parent?: IDocumentSkeletonSection;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonLine {
|
||||
paragraphIndex: number; // ID number associated with block
|
||||
type: LineType; // line type, can be paragraph or other block
|
||||
// line coordinate system relative to column
|
||||
divides: IDocumentSkeletonDivide[]; // divides affected by objects, splitting line into N parts
|
||||
divideLen: number; // number of parts divided by objects
|
||||
lineHeight: number; // total line height lineHeight = max(glyph.fontBoundingBoxAscent + glyph.fontBoundingBoxDescent, glyph2.....) + space
|
||||
contentHeight: number; // line content height, contentHeight = max(glyph.fontBoundingBoxAscent + glyph.fontBoundingBoxDescent, glyph2.....)
|
||||
top: number; // top paragraph(spaceAbove, spaceBelow, lineSpacing*PreLineHeight)
|
||||
asc: number; // =max(glyph.textMetrics.ba) alphabet alignment, needs calibration
|
||||
dsc: number; // =max(glyph.textMetrics.bd) alphabet alignment, needs calibration
|
||||
paddingTop: number; // paddingTop distance from content to top
|
||||
paddingBottom: number; // paddingBottom distance from content to bottom
|
||||
marginTop: number; // marginTop paragraph spaceAbove
|
||||
marginBottom: number; // marginBottom paragraph spaceBelow
|
||||
spaceBelowApply: number; // spaceBelowApply calculated bottom spacing, only for calculation
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
lineIndex: number; // lineIndex
|
||||
paragraphStart: boolean; // Paragraph start, default false
|
||||
isBehindTable: boolean; // In DataStream, if paragraph contains table, the first line isBehindTable is true and tableId is not empty, mainly used to calculate st\ed.
|
||||
tableId: string; // tableId if paragraph contains table, tableId is not empty, mainly used to calculate st\ed.
|
||||
|
||||
borderBetween?: IParagraphBorder; // borderBetween
|
||||
borderTop?: IParagraphBorder; // borderTop
|
||||
borderBottom?: IParagraphBorder; // borderBottom
|
||||
borderLeft?: IParagraphBorder; // borderLeft
|
||||
borderRight?: IParagraphBorder; // borderRight
|
||||
paragraphBorders?: IDocumentSkeletonParagraphBorders;
|
||||
paragraphPaddingLeft?: number;
|
||||
paragraphPaddingRight?: number;
|
||||
backgroundColor?: IColorStyle; // backgroundColor
|
||||
bullet?: IDocumentSkeletonBullet; // unordered and ordered list bullet
|
||||
width?: number; // the actual width of a line
|
||||
// dtId: string[]; // drawingTBIds element ids affecting rows, splitting divides, affecting top and bottom
|
||||
// bmt: number; // benchmarkTop, drawing position is relative to paragraph position, after paragraph crosses page, a calibration is needed
|
||||
parent?: IDocumentSkeletonColumn;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonDivide {
|
||||
// divide: Used to adapt to inserted objects, images, tables, etc., for mixed text and graphics layout
|
||||
glyphGroup: IDocumentSkeletonGlyph[]; // glyphGroup
|
||||
width: number; // width: Total width after division
|
||||
left: number; // left: Offset position after division by objects | d1 | | d2 |
|
||||
paddingLeft: number; // paddingLeft: Alignment offset calculated based on horizonAlign and width
|
||||
isFull: boolean; // isFull: Whether content is full
|
||||
st: number; // startIndex
|
||||
ed: number; // endIndex
|
||||
breakType?: BreakPointType;
|
||||
parent?: IDocumentSkeletonLine;
|
||||
glyphGroupWidth?: number; // glyphGroupWidth: Total width of glyphGroup, pre-calculated to avoid recalculation each time
|
||||
}
|
||||
|
||||
export interface IAdjustability {
|
||||
// The left and right strechability
|
||||
stretchability: [number, number];
|
||||
// The left and right shrinkability
|
||||
shrinkability: [number, number];
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonGlyph {
|
||||
// word or letter or image or custom
|
||||
glyphId?: string; // elementId, For custom cases
|
||||
glyphType: GlyphType; // GlyphType
|
||||
streamType: DataStreamTreeTokenType;
|
||||
width: number; // cum width
|
||||
bBox: IDocumentSkeletonBoundingBox; // bBox: size of glyph
|
||||
xOffset: number; // xOffset, adjust text align in glyph
|
||||
left: number; // left
|
||||
count: number; // count, content length,default 1
|
||||
content: string; // content
|
||||
raw: string;
|
||||
adjustability: IAdjustability; // The adjustability of the glyph.
|
||||
isJustifiable: boolean; // Whether this glyph is justifiable for CJK scripts.
|
||||
ts?: ITextStyle; // text style
|
||||
fontStyle?: IDocumentSkeletonFontStyle; // fontStyle : ITextStyle convert to canvas font
|
||||
parent?: IDocumentSkeletonDivide;
|
||||
url?: string; // image url
|
||||
featureId?: string; // support interaction for feature ,eg. hyperLine person
|
||||
drawingId?: string; // drawing.drawingId
|
||||
noteId?: string;
|
||||
noteSeparator?: boolean;
|
||||
fauxBoldStrokeWidth?: number;
|
||||
tabLeader?: TabStopLeader;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonBullet {
|
||||
listId: string; // listId
|
||||
symbol: string; // symbol list content
|
||||
ts: ITextStyle; // text style
|
||||
fontStyle?: IDocumentSkeletonFontStyle; // fontStyle converted from ITextStyle to canvas font
|
||||
startIndexItem: number; // startIndexItem, list start index
|
||||
startNumber?: number; // zero-based start number retained across a restarted sequence
|
||||
// bBox: IDocumentSkeletonBoundingBox; // bBox text position information
|
||||
nestingLevel?: INestingLevel;
|
||||
bulletAlign?: BulletAlignment;
|
||||
bulletType?: boolean; // bulletType false unordered, true ordered;
|
||||
compactSpacing?: boolean;
|
||||
preserveTextLineHeight?: boolean;
|
||||
imageSource?: string;
|
||||
paragraphProperties?: IParagraphProperties;
|
||||
// bp: number; // bulletPosition distance from list to page edge
|
||||
// ti: number; // textIndent distance from content to list, take Max(textIndent, followWith+)
|
||||
// fw: number; // followWidth spacing distance from content to list
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonDrawing {
|
||||
drawingId: string;
|
||||
aLeft: number; // relative to page left
|
||||
aTop: number; // relative to page top
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number; // rotation
|
||||
initialState: boolean; // whether initialized
|
||||
drawingOrigin: IDocDrawingBase;
|
||||
columnLeft: number;
|
||||
isPageBreak: boolean;
|
||||
lineTop: number;
|
||||
lineHeight: number;
|
||||
blockAnchorTop: number; // The paragraph top.
|
||||
customBlockRenderViewport?: {
|
||||
bleedLeft?: number;
|
||||
bleedWidth?: number;
|
||||
contentHeight?: number;
|
||||
contentWidth?: number;
|
||||
height?: number;
|
||||
pageContentWidth?: number;
|
||||
viewScale?: number;
|
||||
viewportHeight?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonFontStyle {
|
||||
fontString: string;
|
||||
fontSize: number;
|
||||
originFontSize: number;
|
||||
fontFamily: string;
|
||||
fontCache: string;
|
||||
}
|
||||
|
||||
export interface IDocumentSkeletonBoundingBox {
|
||||
width: number; // width
|
||||
ba: number; // boundingBoxAscent
|
||||
bd: number; // boundingBoxDescent
|
||||
normalLineHeight?: number; // Normal font spacing, including leading when available, for Word AUTO spacing.
|
||||
aba: number; // actualBoundingBoxAscent
|
||||
abd: number; // actualBoundingBoxDescent
|
||||
sp: number; // strikeoutPosition
|
||||
sbr: number; // subscriptSizeRatio
|
||||
sbo: number; // subscriptOffset
|
||||
spr: number; // superscriptSizeRatio
|
||||
spo: number; // superscriptOffset
|
||||
}
|
||||
|
||||
export enum SkeletonType {
|
||||
GLYPH,
|
||||
DIVIDE,
|
||||
LINE,
|
||||
COLUMN,
|
||||
SECTION,
|
||||
PAGE,
|
||||
DRAWING,
|
||||
BULLET,
|
||||
HEADER,
|
||||
FOOTER,
|
||||
ALL,
|
||||
}
|
||||
|
||||
export enum BreakType {
|
||||
SECTION,
|
||||
PAGE,
|
||||
COLUMN,
|
||||
}
|
||||
|
||||
export enum LineType {
|
||||
PARAGRAPH,
|
||||
BLOCK,
|
||||
}
|
||||
|
||||
export enum GlyphType {
|
||||
LETTER,
|
||||
WORD,
|
||||
LIST,
|
||||
PLACEHOLDER,
|
||||
TAB,
|
||||
IMAGE,
|
||||
AT,
|
||||
MATH,
|
||||
MULTI_LINE,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
/**
|
||||
* Determining the text layout, whether it's vertical or horizontal,
|
||||
* data storage is primarily horizontal,
|
||||
* and vertical layout is supported through rendering and drawing.
|
||||
*/
|
||||
export enum PageLayoutType {
|
||||
VERTICAL,
|
||||
HORIZONTAL,
|
||||
AUTO,
|
||||
}
|
||||
399
packages/engine-render/src/basics/i-events.ts
Normal file
399
packages/engine-render/src/basics/i-events.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR 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';
|
||||
|
||||
/**
|
||||
* Alias type for number that are floats
|
||||
*/
|
||||
export type float = number;
|
||||
|
||||
/**
|
||||
* Alias type for number that are doubles.
|
||||
*/
|
||||
export type double = number;
|
||||
|
||||
/* *
|
||||
* Alias type for number that are integer
|
||||
*/
|
||||
export type int = number;
|
||||
|
||||
/**
|
||||
* Alias type for number array or Float32Array
|
||||
*/
|
||||
export type FloatArray = number[] | Float32Array;
|
||||
|
||||
/**
|
||||
* Alias type for number array or Float32Array or Int32 Array or Uint32Array or Uint16Array
|
||||
*/
|
||||
export type IndicesArray = number[] | Int32Array | Uint32Array | Uint16Array;
|
||||
|
||||
/**
|
||||
* Alias for types that can be used by a Buffer or VertexBuffer.
|
||||
*/
|
||||
export type DataArray = number[] | ArrayBuffer | ArrayBufferView;
|
||||
|
||||
/**
|
||||
* Alias type for primitive types
|
||||
*/
|
||||
type Primitive = undefined | null | boolean | string | number | Function;
|
||||
|
||||
/**
|
||||
* Type modifier to make all the properties of an object Readonly
|
||||
*/
|
||||
export type Immutable<T> = T extends Primitive
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? readonly U[]
|
||||
: DeepImmutable<T>; /* T extends Map<infer K, infer V> ? ReadonlyMap<K, V> : // es2015+ only */
|
||||
|
||||
/**
|
||||
* Type modifier to make all the properties of an object Readonly recursively
|
||||
*/
|
||||
export type DeepImmutable<T> = T extends Primitive
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? IDeepImmutableArray<U>
|
||||
: /* T extends Map<infer K, infer V> ? DeepImmutableMap<K, V> : // es2015+ only */
|
||||
DeepImmutableObject<T>;
|
||||
|
||||
/**
|
||||
* Type modifier to make object properties readonly.
|
||||
*/
|
||||
export type DeepImmutableObject<T> = {
|
||||
readonly [K in keyof T]: DeepImmutable<T[K]>;
|
||||
};
|
||||
|
||||
/** @hidden */
|
||||
interface IDeepImmutableArray<T> extends ReadonlyArray<DeepImmutable<T>> {}
|
||||
/** @hidden */
|
||||
/* interface DeepImmutableMap<K, V> extends ReadonlyMap<DeepImmutable<K>, DeepImmutable<V>> {} // es2015+ only */
|
||||
|
||||
/**
|
||||
* Event Types
|
||||
*/
|
||||
export enum DeviceInputEventType {
|
||||
// Pointers
|
||||
/** PointerMove */
|
||||
PointerMove,
|
||||
/** PointerDown */
|
||||
PointerDown,
|
||||
/** PointerUp */
|
||||
PointerUp,
|
||||
|
||||
Dblclick,
|
||||
|
||||
Keyboard,
|
||||
}
|
||||
|
||||
/**
|
||||
* Native friendly interface for Event Obj ect
|
||||
*/
|
||||
export interface IEvent extends Event {
|
||||
// Methods
|
||||
/**
|
||||
* Device type
|
||||
*/
|
||||
deviceType: DeviceType;
|
||||
/**
|
||||
* Device slot
|
||||
*/
|
||||
inputIndex: number;
|
||||
/**
|
||||
* Previous state of given input, for what???, nobody read this value.
|
||||
*/
|
||||
previousState: Nullable<number>;
|
||||
/**
|
||||
* Current state of given input
|
||||
*/
|
||||
currentState: Nullable<number>;
|
||||
}
|
||||
|
||||
// TODO@wzhudev: this interface maybe not necessary
|
||||
|
||||
/**
|
||||
* Native friendly interface for UIEvent Object
|
||||
*/
|
||||
export interface IUIEvent extends IEvent, UIEvent {
|
||||
// TODO@wzhudev: should add a stack to mark RenderObjects that the event targets to
|
||||
}
|
||||
|
||||
/**
|
||||
* Native friendly interface for KeyboardEvent Object
|
||||
*/
|
||||
export interface IKeyboardEvent extends IUIEvent {
|
||||
// Properties
|
||||
/**
|
||||
* Status of Alt key being pressed
|
||||
*/
|
||||
altKey: boolean;
|
||||
|
||||
/**
|
||||
* Code for key based on layout
|
||||
*/
|
||||
code: string;
|
||||
|
||||
/**
|
||||
* Status of Ctrl key being pressed
|
||||
*/
|
||||
ctrlKey: boolean;
|
||||
|
||||
/**
|
||||
* String representation of key
|
||||
*/
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* Status of Meta key (eg. Windows key) being pressed
|
||||
*/
|
||||
metaKey: boolean;
|
||||
|
||||
/**
|
||||
* Status of Shift key being pressed
|
||||
*/
|
||||
shiftKey: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native friendly interface for MouseEvent Object
|
||||
*/
|
||||
export interface IMouseEvent extends IUIEvent, MouseEvent {
|
||||
// Properties
|
||||
/**
|
||||
* Status of Alt key being pressed
|
||||
*/
|
||||
altKey: boolean;
|
||||
|
||||
/**
|
||||
* Value of single mouse button pressed
|
||||
*/
|
||||
button: number;
|
||||
|
||||
/**
|
||||
* Value of all mouse buttons pressed
|
||||
*/
|
||||
buttons: number;
|
||||
|
||||
/**
|
||||
* Current X coordinate
|
||||
*/
|
||||
clientX: number;
|
||||
|
||||
/**
|
||||
* Current Y coordinate
|
||||
*/
|
||||
clientY: number;
|
||||
|
||||
/**
|
||||
* Status of Ctrl key being pressed
|
||||
*/
|
||||
ctrlKey: boolean;
|
||||
|
||||
/**
|
||||
* Status of Meta key (eg. Windows key) being pressed
|
||||
*/
|
||||
metaKey: boolean;
|
||||
|
||||
/**
|
||||
* Delta of movement on X axis
|
||||
*/
|
||||
movementX: number;
|
||||
|
||||
/**
|
||||
* Delta of movement on Y axis
|
||||
*/
|
||||
movementY: number;
|
||||
|
||||
/**
|
||||
* Delta of movement on X axis
|
||||
*/
|
||||
mozMovementX?: number;
|
||||
|
||||
/**
|
||||
* Delta of movement on Y axis
|
||||
*/
|
||||
mozMovementY?: number;
|
||||
|
||||
/**
|
||||
* Delta of movement on X axis
|
||||
*/
|
||||
msMovementX?: any;
|
||||
|
||||
/**
|
||||
* Delta of movement on Y axi s
|
||||
*/
|
||||
msMovementY?: any;
|
||||
|
||||
/**
|
||||
* Current coordinate of X within container
|
||||
*/
|
||||
offsetX: number;
|
||||
|
||||
/**
|
||||
* Current coordinate of Y within container
|
||||
*/
|
||||
offsetY: number;
|
||||
|
||||
/**
|
||||
* Status of Shift key being pressed
|
||||
*/
|
||||
shiftKey: boolean;
|
||||
|
||||
/**
|
||||
* Delta of movement on X axis
|
||||
*/
|
||||
webkitMovementX?: any;
|
||||
|
||||
/**
|
||||
* Delta of movement on Y axis
|
||||
*/
|
||||
webkitMovementY?: any;
|
||||
|
||||
/**
|
||||
* Alias of clientX
|
||||
*/
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* Alias of clientY
|
||||
*/
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native friendly interface for PointerEvent Object
|
||||
*/
|
||||
export interface IPointerEvent extends IMouseEvent {
|
||||
// Properties
|
||||
/**
|
||||
* Pointer Event ID
|
||||
*/
|
||||
pointerId: number;
|
||||
|
||||
/**
|
||||
* Type of pointer
|
||||
*/
|
||||
pointerType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native friendly interface for DragEvent Object
|
||||
*/
|
||||
export interface IDragEvent extends IMouseEvent {
|
||||
// Properties
|
||||
/**
|
||||
* Holds the drag operation's data
|
||||
*/
|
||||
dataTransfer: DataTransfer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native friendly interface for WheelEvent Object
|
||||
*/
|
||||
export interface IWheelEvent extends IMouseEvent {
|
||||
// Properties
|
||||
/**
|
||||
* Units for delta value
|
||||
*/
|
||||
deltaMode: number;
|
||||
|
||||
/**
|
||||
* Horizontal scroll delta
|
||||
*/
|
||||
deltaX: number;
|
||||
|
||||
/* *
|
||||
* Vertical scroll delta
|
||||
*/
|
||||
deltaY: number;
|
||||
|
||||
/**
|
||||
* Z-Axis scroll delta
|
||||
*/
|
||||
deltaZ: number;
|
||||
|
||||
/**
|
||||
* WheelDelta (From MouseWheel Event)
|
||||
*/
|
||||
wheelDelta?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants used for Events
|
||||
*/
|
||||
export class EventConstants {
|
||||
/**
|
||||
* Pixel delta for Wheel Events (Default)
|
||||
*/
|
||||
static DOM_DELTA_PIXEL = 0x00;
|
||||
|
||||
/**
|
||||
* Line delta for Wheel Events
|
||||
*/
|
||||
static DOM_DELTA_LINE = 0x01;
|
||||
|
||||
/**
|
||||
* Page delta for Wheel Events
|
||||
*/
|
||||
static DOM_DELTA_PAGE = 0x02;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum for Device Types
|
||||
*/
|
||||
export enum DeviceType {
|
||||
/** Generic */
|
||||
Generic = 0,
|
||||
/** Keyboard */
|
||||
Keyboard,
|
||||
/** Mouse */
|
||||
Mouse,
|
||||
/** Touch Pointers */
|
||||
Touch,
|
||||
}
|
||||
|
||||
// Device Enums
|
||||
/**
|
||||
* Enum for All Pointers (Touch/Mouse)
|
||||
*/
|
||||
export enum PointerInput {
|
||||
/** Horizontal Axis */
|
||||
Horizontal = 0,
|
||||
/** Vertical Axis */
|
||||
Vertical = 1,
|
||||
/** Left Click or Touch */
|
||||
LeftClick = 2,
|
||||
/** Middle Click */
|
||||
MiddleClick = 3,
|
||||
/** Right Click */
|
||||
RightClick = 4,
|
||||
/** Browser Back */
|
||||
BrowserBack = 5,
|
||||
/** Browser Forward */
|
||||
BrowserForward = 6,
|
||||
/** Mouse Wheel X */
|
||||
MouseWheelX = 7,
|
||||
/** Mouse Wheel Y */
|
||||
MouseWheelY = 8,
|
||||
/** Mouse Wheel Z */
|
||||
MouseWheelZ = 9,
|
||||
/** Delta X */
|
||||
DeltaHorizontal = 10,
|
||||
/** Delta Y */
|
||||
DeltaVertical = 11,
|
||||
/** MoveBeing Hijack for simultaneous buttons pressed for instance */
|
||||
FakeMove = 12,
|
||||
}
|
||||
37
packages/engine-render/src/basics/index.ts
Normal file
37
packages/engine-render/src/basics/index.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 * from '../components/docs/layout/shaping-engine/font-cache';
|
||||
export * from './cell-data';
|
||||
export { cjk } from './cjk-regexp';
|
||||
export * from './const';
|
||||
export * from './document-node-tools';
|
||||
export * from './draw';
|
||||
export * from './group-transform';
|
||||
export * from './i-document-skeleton-cached';
|
||||
export * from './i-events';
|
||||
export * from './interfaces';
|
||||
export * from './path2';
|
||||
export * from './performance-monitor';
|
||||
export * from './range';
|
||||
export * from './scroll-xy';
|
||||
export * from './text-rotation';
|
||||
export * from './tools';
|
||||
export * from './transform';
|
||||
export type { ITransformerConfig } from './transformer-config';
|
||||
export { DEFAULT_TRANSFORMER_CONFIG } from './transformer-config';
|
||||
export * from './vector2';
|
||||
export * from './zoom';
|
||||
213
packages/engine-render/src/basics/interfaces.ts
Normal file
213
packages/engine-render/src/basics/interfaces.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 {
|
||||
BooleanNumber,
|
||||
GridType,
|
||||
IDocStyleBase,
|
||||
IDocumentLayout,
|
||||
IOffset,
|
||||
IParagraphStyle,
|
||||
IReferenceSource,
|
||||
IScale,
|
||||
ISectionBreakBase,
|
||||
ISize,
|
||||
ITextStyle,
|
||||
ITransformState,
|
||||
LocaleService,
|
||||
} from '@univerjs/core';
|
||||
import type { IDocumentCompatibilityPolicy } from '../components/docs/document-compatibility';
|
||||
import type { DataStreamTreeNode } from '../components/docs/view-model/data-stream-tree-node';
|
||||
import type { DocumentViewModel } from '../components/docs/view-model/document-view-model';
|
||||
import type {
|
||||
DocumentSkeletonPageType,
|
||||
IDocumentSkeletonBullet,
|
||||
IDocumentSkeletonDrawing,
|
||||
IDocumentSkeletonDrawingAnchor,
|
||||
IDocumentSkeletonFontStyle,
|
||||
IDocumentSkeletonGlyph,
|
||||
IDocumentSkeletonHeaderFooter,
|
||||
IDocumentSkeletonTable,
|
||||
} from './i-document-skeleton-cached';
|
||||
import type { ITransformerConfig } from './transformer-config';
|
||||
import type { Vector2 } from './vector2';
|
||||
|
||||
export interface IObjectFullState extends ITransformState {
|
||||
strokeWidth?: number;
|
||||
zIndex?: number;
|
||||
forceRender?: boolean;
|
||||
debounceParentDirty?: boolean;
|
||||
transformerConfig?: ITransformerConfig;
|
||||
printable?: boolean;
|
||||
}
|
||||
|
||||
export interface IRect extends ISize, IOffset {
|
||||
points: Vector2[];
|
||||
}
|
||||
|
||||
/**
|
||||
* width
|
||||
* height
|
||||
* scaleX
|
||||
* scaleY
|
||||
*/
|
||||
export interface ISceneTransformState extends ISize, IScale {}
|
||||
|
||||
/**
|
||||
* Bad design! should use Bit Flags!
|
||||
*/
|
||||
export enum TRANSFORM_CHANGE_OBSERVABLE_TYPE {
|
||||
translate,
|
||||
resize,
|
||||
scale,
|
||||
skew,
|
||||
flip,
|
||||
all,
|
||||
}
|
||||
|
||||
export interface ITransformChangeState {
|
||||
type: TRANSFORM_CHANGE_OBSERVABLE_TYPE;
|
||||
value: IObjectFullState
|
||||
| ISceneTransformState;
|
||||
preValue: IObjectFullState
|
||||
| ISceneTransformState;
|
||||
}
|
||||
|
||||
export interface IFontLocale {
|
||||
fontList: string[];
|
||||
defaultFontSize: number;
|
||||
}
|
||||
|
||||
export interface IDocsConfig extends IReferenceSource, IDocumentLayout {
|
||||
localeService: LocaleService;
|
||||
documentTextStyle?: ITextStyle;
|
||||
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
|
||||
headerTreeMap: Map<string, DocumentViewModel>;
|
||||
footerTreeMap: Map<string, DocumentViewModel>;
|
||||
}
|
||||
|
||||
export interface IHeaderIds {
|
||||
defaultHeaderId?: string;
|
||||
evenPageHeaderId?: string;
|
||||
firstPageHeaderId?: string;
|
||||
}
|
||||
|
||||
export interface IFooterIds {
|
||||
defaultFooterId?: string;
|
||||
evenPageFooterId?: string;
|
||||
firstPageFooterId?: string;
|
||||
}
|
||||
|
||||
export interface ISectionBreakConfig extends IDocStyleBase, ISectionBreakBase, IDocsConfig {
|
||||
sectionId: string;
|
||||
/** Cell continuation pages retain their segment and physical row-slice budgets. */
|
||||
cellTableId?: string;
|
||||
cellPageHeights?: readonly number[];
|
||||
headerIds?: IHeaderIds;
|
||||
footerIds?: IFooterIds;
|
||||
useFirstPageHeaderFooter?: BooleanNumber;
|
||||
evenAndOddHeaders?: BooleanNumber;
|
||||
}
|
||||
|
||||
export interface IParagraphTableCache {
|
||||
tableId: string;
|
||||
table: IDocumentSkeletonTable;
|
||||
hasPositioned: boolean;
|
||||
isSlideTable: boolean;
|
||||
tableNode: DataStreamTreeNode;
|
||||
}
|
||||
|
||||
export interface IParagraphConfig {
|
||||
paragraphIndex: number;
|
||||
isInsideTable?: boolean;
|
||||
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
|
||||
useWordStyleLineHeight?: boolean;
|
||||
usePptxFontSizeLineHeight?: boolean;
|
||||
usePptxNominalFontLineHeight?: boolean;
|
||||
usePptxCompatibleLineSpacing?: boolean;
|
||||
usePptxPercentageLineSpacing?: boolean;
|
||||
pptxPercentageFontSize?: number;
|
||||
usePptxNormAutofitLineHeight?: boolean;
|
||||
pptxEmptyParagraphFontSize?: number;
|
||||
pptxHasExplicitEndParaFontSize?: boolean;
|
||||
sumPptxParagraphSpacing?: boolean;
|
||||
docxFallbackAnchorLeft?: IParagraphStyle['indentStart'];
|
||||
paragraphNonInlineSkeDrawings?: Map<string, IDocumentSkeletonDrawing>;
|
||||
paragraphInlineSkeDrawings?: Map<string, IDocumentSkeletonDrawing>;
|
||||
topBottomCustomBlockFlowBottom?: number;
|
||||
skeTablesInParagraph?: IParagraphTableCache[];
|
||||
// headerAndFooterAffectSkeDrawings?: Map<string, IDocumentSkeletonDrawing>;
|
||||
bulletSkeleton?: IDocumentSkeletonBullet;
|
||||
// pageContentWidth: number;
|
||||
// pageContentHeight: number;
|
||||
paragraphStyle?: IParagraphStyle;
|
||||
skeHeaders: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>;
|
||||
skeFooters: Map<string, Map<number, IDocumentSkeletonHeaderFooter>>;
|
||||
pDrawingAnchor?: Map<number, IDocumentSkeletonDrawingAnchor>;
|
||||
// sectionBreakConfig: ISectionBreakConfig;
|
||||
}
|
||||
|
||||
export interface IFontCreateConfig {
|
||||
fontStyle: IDocumentSkeletonFontStyle;
|
||||
textStyle: ITextStyle;
|
||||
charSpace: number;
|
||||
snapToGrid: BooleanNumber;
|
||||
documentCompatibilityPolicy?: IDocumentCompatibilityPolicy;
|
||||
gridType?: GridType;
|
||||
pageWidth?: number;
|
||||
}
|
||||
|
||||
// export interface IPageConfig {
|
||||
// pageNumberStart: number;
|
||||
// pageSize: ISize;
|
||||
// headerIds: IHeaderIds;
|
||||
// footerIds: IFooterIds;
|
||||
// footers?: IFooters;
|
||||
// headers?: IHeaders;
|
||||
// useFirstPageHeaderFooter?: boolean;
|
||||
// evenAndOddHeaders?: boolean;
|
||||
// }
|
||||
|
||||
export interface INodeInfo {
|
||||
node: IDocumentSkeletonGlyph;
|
||||
ratioX: number;
|
||||
ratioY: number;
|
||||
segmentId: string;
|
||||
segmentPage: number; // The index of the page where node is located.
|
||||
}
|
||||
|
||||
export interface INodeSearch {
|
||||
glyph: number;
|
||||
divide: number;
|
||||
line: number;
|
||||
column: number;
|
||||
section: number;
|
||||
page: number;
|
||||
segmentPage: number; // The index of the page where the header and footer reside.
|
||||
pageType: DocumentSkeletonPageType;
|
||||
path: (string | number)[];
|
||||
}
|
||||
|
||||
export interface INodePosition extends INodeSearch {
|
||||
isBack: boolean;
|
||||
}
|
||||
|
||||
export interface IAfterRender$Info {
|
||||
frameTimeMetric: Record<string, number | number[]>;
|
||||
tags: { scrolling: boolean } & Record<string, any>;
|
||||
}
|
||||
|
||||
export type ITimeMetric = [string, number];
|
||||
31
packages/engine-render/src/basics/offset-rotation-axis.ts
Normal file
31
packages/engine-render/src/basics/offset-rotation-axis.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Vector2 } from './vector2';
|
||||
import { degToRad } from './tools';
|
||||
|
||||
export function offsetRotationAxis(referenceCoords: Vector2, angleDegree: number, vertexCoords: Vector2, centerCoords: Vector2): Vector2 {
|
||||
const angleRad = degToRad(angleDegree);
|
||||
|
||||
const newVertexCoords = vertexCoords.clone().rotateByPoint(angleRad, referenceCoords);
|
||||
|
||||
const newCenterCoords = centerCoords.clone().rotateByPoint(angleRad, referenceCoords);
|
||||
|
||||
const finalPoint = newVertexCoords.clone();
|
||||
finalPoint.rotateByPoint(degToRad(-angleDegree), newCenterCoords);
|
||||
|
||||
return finalPoint;
|
||||
}
|
||||
98
packages/engine-render/src/basics/path2.ts
Normal file
98
packages/engine-render/src/basics/path2.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR 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 { Vector2 } from './vector2';
|
||||
|
||||
export const INITIAL_Path2: Vector2[] = [new Vector2(0, 0), new Vector2(1, 1)];
|
||||
|
||||
interface ILine {
|
||||
from: Vector2;
|
||||
to: Vector2;
|
||||
}
|
||||
|
||||
export class Path2 {
|
||||
constructor(private _lines: Vector2[] = INITIAL_Path2) {
|
||||
// empty
|
||||
}
|
||||
|
||||
intersection(lines: Vector2[]): Nullable<Vector2[]> {
|
||||
if (this._lines.length < 1 || lines.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const crossPoint: Vector2[] = [];
|
||||
|
||||
for (let o = 1; o < this._lines.length; o++) {
|
||||
const from = this._lines[o - 1];
|
||||
const to = this._lines[o];
|
||||
for (let n = 1; n < lines.length; n++) {
|
||||
const contrastFrom = lines[n - 1];
|
||||
const contrastTo = lines[n];
|
||||
|
||||
const point = this._intersection(
|
||||
{
|
||||
from,
|
||||
to,
|
||||
},
|
||||
{
|
||||
from: contrastFrom,
|
||||
to: contrastTo,
|
||||
}
|
||||
);
|
||||
|
||||
if (point) {
|
||||
crossPoint.push(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return crossPoint;
|
||||
}
|
||||
|
||||
private _intersection(line1: ILine, line2: ILine): Vector2 | false {
|
||||
const a = line1.from;
|
||||
const b = line1.to;
|
||||
const c = line2.from;
|
||||
const d = line2.to;
|
||||
|
||||
// 2 times the area of triangle abc
|
||||
const area_abc = (a.x - c.x) * (b.y - c.y) - (a.y - c.y) * (b.x - c.x);
|
||||
|
||||
// 2 times the area of the triangle abd
|
||||
const area_abd = (a.x - d.x) * (b.y - d.y) - (a.y - d.y) * (b.x - d.x);
|
||||
|
||||
// If the area symbols are the same, the two points are on the same side of the line segment and do not intersect (for the case where the points are on the line segment, this example is treated as disjoint);
|
||||
if (area_abc * area_abd >= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2 times the area of the triangle cda
|
||||
const area_cda = (c.x - a.x) * (d.y - a.y) - (c.y - a.y) * (d.x - a.x);
|
||||
// 2 times the area of the triangle cdb
|
||||
// Note: There is a small optimization here. There is no need to calculate the area with the formula, but by adding and subtracting the three known areas.
|
||||
const area_cdb = area_cda + area_abc - area_abd;
|
||||
if (area_cda * area_cdb >= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate intersection coordinates
|
||||
const t = area_cda / (area_abd - area_abc);
|
||||
const dx = t * (b.x - a.x);
|
||||
const dy = t * (b.y - a.y);
|
||||
return new Vector2(a.x + dx, a.y + dy);
|
||||
}
|
||||
}
|
||||
367
packages/engine-render/src/basics/performance-monitor.ts
Normal file
367
packages/engine-render/src/basics/performance-monitor.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR 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 { Disposable } from '@univerjs/core';
|
||||
|
||||
export const DEFAULT_FRAME_SAMPLE_SIZE = 60;
|
||||
|
||||
export const DEFAULT_FRAME_LIST_SIZE = 60 * 60; // 1min
|
||||
|
||||
const DEFAULT_ONE_SEC_MS = 1000;
|
||||
|
||||
const DEFAULT_FRAME_TIME = 16.67; // 60FPS
|
||||
const MIN_ANIMATION_FRAME_INTERVAL = 1000 / 240;
|
||||
const ANIMATION_FRAME_INTERVAL_PERCENTILE = 0.25;
|
||||
export interface IBasicFrameInfo {
|
||||
FPS: number;
|
||||
frameTime: number; // frame time in milliseconds
|
||||
elapsedTime: number; // elapsed time in milliseconds
|
||||
}
|
||||
|
||||
export interface IExtendFrameInfo extends IBasicFrameInfo {
|
||||
[key: string]: any;
|
||||
scrolling: boolean;
|
||||
}
|
||||
|
||||
export interface ISummaryFrameInfo {
|
||||
FPS: ISummaryMetric;
|
||||
frameTime: ISummaryMetric;
|
||||
[key: string]: ISummaryMetric;
|
||||
}
|
||||
|
||||
export interface ISummaryMetric {
|
||||
avg: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
/**
|
||||
* Performance monitor tracks rolling average frame-time and frame-time variance over a user defined sliding-window
|
||||
*/
|
||||
export class PerformanceMonitor extends Disposable {
|
||||
private _enabled: boolean = true;
|
||||
|
||||
private _rollingFrameTime!: RollingAverage;
|
||||
private _lastFrameTimeMs: Nullable<number>;
|
||||
|
||||
/**
|
||||
* Counting frame in a second.
|
||||
*/
|
||||
private _frameCountInLastSecond: number = 0;
|
||||
/**
|
||||
* The millisecond value of the last second. For counting frame in a second.
|
||||
*/
|
||||
private _lastSecondTimeMs: Nullable<number>;
|
||||
/**
|
||||
* The FPS values recorded in the past 1 second.
|
||||
*/
|
||||
private _recFPSValueLastSecond: number = 60;
|
||||
|
||||
// private _frameCount: number = 0; // number of all frames in whole life time.
|
||||
|
||||
/**
|
||||
* @param {number} frameSampleSize The number of samples required to saturate the sliding window
|
||||
*/
|
||||
constructor(frameSampleSize: number = DEFAULT_FRAME_SAMPLE_SIZE) {
|
||||
super();
|
||||
this._rollingFrameTime = new RollingAverage(frameSampleSize);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the average frame time in milliseconds of the sliding window (or the subset of frames sampled so far)
|
||||
*/
|
||||
get averageFrameTime(): number {
|
||||
return this._rollingFrameTime.averageFrameTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the variance frame time in milliseconds over the sliding window (or the subset of frames sampled so far)
|
||||
*/
|
||||
get averageFrameTimeVariance(): number {
|
||||
return this._rollingFrameTime.variance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the frame time of the last recent frame.
|
||||
*/
|
||||
get instantaneousFrameTime(): number {
|
||||
return this._rollingFrameTime.history(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the browser animation-frame cadence while ignoring isolated long frames.
|
||||
*/
|
||||
get estimatedFrameInterval(): number {
|
||||
const frameInterval = this._rollingFrameTime.percentile(ANIMATION_FRAME_INTERVAL_PERCENTILE);
|
||||
return frameInterval > 0 ? Math.max(frameInterval, MIN_ANIMATION_FRAME_INTERVAL) : DEFAULT_FRAME_TIME;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the average framerate in frames per second over the sliding window (or the subset of frames sampled so far)
|
||||
*/
|
||||
get averageFPS(): number {
|
||||
return this._recFPSValueLastSecond;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the average framerate in frames per second using the most recent frame time
|
||||
*/
|
||||
get instantaneousFPS(): number {
|
||||
const history = this._rollingFrameTime.history(0);
|
||||
|
||||
if (history === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DEFAULT_ONE_SEC_MS / history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if enough samples have been taken to completely fill the sliding window
|
||||
*/
|
||||
get isSaturated(): boolean {
|
||||
return this._rollingFrameTime.isSaturated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if sampling is enabled
|
||||
*/
|
||||
get isEnabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples current frame, set averageFPS instantaneousFrameTime
|
||||
* this method is called each frame by engine renderLoop --> endFrame.
|
||||
* @param timestamp A timestamp in milliseconds of the current frame to compare with other frames
|
||||
*/
|
||||
sampleFrame(timestamp: number = this.now()) {
|
||||
if (!this._enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
//#region FPS
|
||||
// this._frameCount++;
|
||||
this._frameCountInLastSecond++;
|
||||
if (this._lastSecondTimeMs != null) {
|
||||
const oneSecPassed = this._lastSecondTimeMs <= timestamp - DEFAULT_ONE_SEC_MS;
|
||||
// const halfSecPassed = this._lastSecondTimeMs <= timestamp - DEFAULT_ONE_SEC_MS / 2;
|
||||
if (oneSecPassed) {
|
||||
const passedTime = timestamp - this._lastSecondTimeMs;
|
||||
this._recFPSValueLastSecond = Math.round(this._frameCountInLastSecond / passedTime * DEFAULT_ONE_SEC_MS);
|
||||
this._lastSecondTimeMs = timestamp;
|
||||
this._frameCountInLastSecond = 0;
|
||||
}
|
||||
} else {
|
||||
this._lastSecondTimeMs = timestamp;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region frameTime
|
||||
if (this._lastFrameTimeMs != null) {
|
||||
const dt = timestamp - this._lastFrameTimeMs;
|
||||
this._rollingFrameTime.addFrameTime(dt);
|
||||
this._rollingFrameTime.calcAverageFrameTime();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
endFrame(timestamp: number) {
|
||||
this.sampleFrame(timestamp);
|
||||
this._lastFrameTimeMs = timestamp;
|
||||
}
|
||||
|
||||
now(): number {
|
||||
if (performance && performance.now) {
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables contributions to the sliding window sample set
|
||||
*/
|
||||
enable() {
|
||||
this._enabled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables contributions to the sliding window sample set
|
||||
* Samples will not be interpolated over the disabled period
|
||||
*/
|
||||
disable() {
|
||||
this._enabled = false;
|
||||
// clear last sample to avoid interpolating over the disabled period when next enabled
|
||||
this._lastFrameTimeMs = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets performance monitor
|
||||
*/
|
||||
reset() {
|
||||
// clear last sample to avoid interpolating over the disabled period when next enabled
|
||||
this._lastFrameTimeMs = null;
|
||||
// wipe record
|
||||
this._rollingFrameTime.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RollingAverage
|
||||
*
|
||||
* Utility to efficiently compute the rolling average and variance over a sliding window of samples
|
||||
*/
|
||||
export class RollingAverage {
|
||||
/**
|
||||
* Current average
|
||||
*/
|
||||
averageFrameTime: number = DEFAULT_FRAME_TIME;
|
||||
|
||||
/**
|
||||
* Current variance
|
||||
*/
|
||||
variance: number = 0;
|
||||
protected _samples: number[] = [];
|
||||
|
||||
/**
|
||||
* for isStaturated
|
||||
* max value of _sampleCount is length of _samples
|
||||
*/
|
||||
protected _sampleCount: number = 0;
|
||||
protected _pos: number = 0;
|
||||
protected _m2: number = 0; // sum of squares of differences from the (current) mean
|
||||
|
||||
/**
|
||||
* constructor
|
||||
* @param length The number of samples required to saturate the sliding window
|
||||
*/
|
||||
constructor(length: number) {
|
||||
this._samples = new Array<number>(length);
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calc average frameTime and variance.
|
||||
*/
|
||||
calcAverageFrameTime() {
|
||||
const frameDuration = this.history(0);
|
||||
let delta: number;
|
||||
|
||||
// we need to check if we've already wrapped round
|
||||
if (this.isSaturated()) {
|
||||
// remove bottom of stack from mean
|
||||
const bottomValue = this._samples[this._pos];
|
||||
delta = bottomValue - this.averageFrameTime;
|
||||
this._m2 -= delta * (bottomValue - this.averageFrameTime);
|
||||
} else {
|
||||
this._sampleCount++;
|
||||
}
|
||||
|
||||
// Once the window is full, exclude one minimum and maximum to reduce outlier bias.
|
||||
const shouldTrimExtremes = this.isSaturated() && this._sampleCount > 2;
|
||||
const sampleSum = this._samples.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
if (shouldTrimExtremes) {
|
||||
const min = Math.min(...this._samples);
|
||||
const max = Math.max(...this._samples);
|
||||
this.averageFrameTime = (sampleSum - min - max) / (this._sampleCount - 2);
|
||||
} else {
|
||||
this.averageFrameTime = sampleSum / this._sampleCount;
|
||||
}
|
||||
|
||||
// add new value to mean
|
||||
delta = frameDuration - this.averageFrameTime;
|
||||
this._m2 += delta * (frameDuration - this.averageFrameTime);
|
||||
|
||||
// set the new variance
|
||||
this.variance = this._m2 / (this._sampleCount - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a sample to the sample set
|
||||
* @param frameTime The sample value
|
||||
*/
|
||||
addFrameTime(frameTime: number) {
|
||||
// http://en.wikipedia.org/wiki/Algorithms_for_calculating_variance
|
||||
this._samples[this._pos] = frameTime;
|
||||
this._pos = ++this._pos % this._samples.length; // positive wrap around
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns previously added values or null if outside of history or outside the sliding window domain
|
||||
* @param i Index in history. For example, pass 0 for the most recent value and 1 for the value before that
|
||||
* @return Value previously recorded with add() or null if outside of range
|
||||
*/
|
||||
history(i: number): number {
|
||||
if (i >= this._sampleCount || i >= this._samples.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const i0 = this._wrapPosition(this._pos - 1.0);
|
||||
return this._samples[this._wrapPosition(i0 - i)];
|
||||
}
|
||||
|
||||
percentile(percentile: number): number {
|
||||
if (this._sampleCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sampleCount = Math.min(this._sampleCount, this._samples.length);
|
||||
const samples = new Array<number>(sampleCount);
|
||||
for (let i = 0; i < sampleCount; i++) {
|
||||
samples[i] = this.history(i);
|
||||
}
|
||||
samples.sort((a, b) => a - b);
|
||||
|
||||
const normalizedPercentile = Math.min(1, Math.max(0, percentile));
|
||||
const index = Math.min(sampleCount - 1, Math.floor(sampleCount * normalizedPercentile));
|
||||
return samples[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if enough samples have been taken to completely fill the sliding window
|
||||
* @return true if sample-set saturated
|
||||
*/
|
||||
isSaturated(): boolean {
|
||||
return this._sampleCount >= this._samples.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the rolling average (equivalent to 0 samples taken so far)
|
||||
*/
|
||||
reset() {
|
||||
this.averageFrameTime = DEFAULT_FRAME_TIME;
|
||||
this.variance = 0;
|
||||
this._sampleCount = 0;
|
||||
this._pos = 0;
|
||||
this._m2 = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a value around the sample range boundaries
|
||||
* @param i Position in sample range, for example if the sample length is 5, and i is -3, then 2 will be returned.
|
||||
* @return Wrapped position in sample range
|
||||
*/
|
||||
protected _wrapPosition(i: number): number {
|
||||
const max = this._samples.length;
|
||||
return ((i % max) + max) % max;
|
||||
}
|
||||
}
|
||||
59
packages/engine-render/src/basics/position.ts
Normal file
59
packages/engine-render/src/basics/position.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { BaseObject } from '../base-object';
|
||||
import type { IBoundRect } from './vector2';
|
||||
|
||||
export function getOffsetRectForDom(ele: HTMLElement) {
|
||||
const box = ele.getBoundingClientRect();
|
||||
const body = document.body;
|
||||
const docElem = document.documentElement;
|
||||
// Get page scrollTop and scrollLeft (compatibility approach)
|
||||
const scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop;
|
||||
const scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft;
|
||||
const clientTop = docElem.clientTop || body.clientTop;
|
||||
const clientLeft = docElem.clientLeft || body.clientLeft;
|
||||
const top = box.top + scrollTop - clientTop;
|
||||
const left = box.left + scrollLeft - clientLeft;
|
||||
|
||||
return {
|
||||
// Math.round for Firefox browser bug compatibility
|
||||
top,
|
||||
left,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformBoundingCoord(object: BaseObject, bounds: IBoundRect) {
|
||||
const tl = object.transform.clone().invert().applyPoint(bounds.tl);
|
||||
const tr = object.transform.clone().invert().applyPoint(bounds.tr);
|
||||
const bl = object.transform.clone().invert().applyPoint(bounds.bl);
|
||||
const br = object.transform.clone().invert().applyPoint(bounds.br);
|
||||
|
||||
const xList = [tl.x, tr.x, bl.x, br.x];
|
||||
const yList = [tl.y, tr.y, bl.y, br.y];
|
||||
|
||||
const maxX = Math.max(...xList);
|
||||
const minX = Math.min(...xList);
|
||||
const maxY = Math.max(...yList);
|
||||
const minY = Math.min(...yList);
|
||||
|
||||
return {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
};
|
||||
}
|
||||
32
packages/engine-render/src/basics/quick-event.ts
Normal file
32
packages/engine-render/src/basics/quick-event.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 { BaseObject } from '../base-object';
|
||||
import type { IMouseEvent, IPointerEvent } from './i-events';
|
||||
|
||||
export function attachObjectHover(
|
||||
o: BaseObject,
|
||||
hoverIn: (o: any, evt: IPointerEvent | IMouseEvent) => void,
|
||||
hoverOut: (o: any, evt: IPointerEvent | IMouseEvent) => void
|
||||
) {
|
||||
o.onPointerEnter$.subscribeEvent((evt) => {
|
||||
hoverIn(o, evt);
|
||||
});
|
||||
|
||||
o.onPointerLeave$.subscribeEvent((evt) => {
|
||||
hoverOut(o, evt);
|
||||
});
|
||||
}
|
||||
64
packages/engine-render/src/basics/range.ts
Normal file
64
packages/engine-render/src/basics/range.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ITextRangeParam, Nullable } from '@univerjs/core';
|
||||
import type { INodePosition } from './interfaces';
|
||||
|
||||
export interface ITextSelectionStyle {
|
||||
strokeWidth: number;
|
||||
stroke: string;
|
||||
strokeActive: string;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
export const NORMAL_TEXT_SELECTION_PLUGIN_STYLE: ITextSelectionStyle = {
|
||||
strokeWidth: 1.5,
|
||||
stroke: 'rgba(0, 0, 0, 0)',
|
||||
strokeActive: 'rgba(0, 0, 0, 1)',
|
||||
fill: 'rgba(0, 0, 0, 0.2)',
|
||||
};
|
||||
|
||||
export interface ITextRangeWithStyle extends ITextRangeParam {
|
||||
startNodePosition?: Nullable<INodePosition>;
|
||||
endNodePosition?: Nullable<INodePosition>;
|
||||
style?: ITextSelectionStyle;
|
||||
}
|
||||
|
||||
export interface IRectRangeWithStyle extends ITextRangeWithStyle {
|
||||
startRow: number;
|
||||
startColumn: number;
|
||||
endRow: number;
|
||||
endColumn: number;
|
||||
tableId: string;
|
||||
spanEntireRow: boolean;
|
||||
spanEntireColumn: boolean;
|
||||
spanEntireTable: boolean;
|
||||
}
|
||||
|
||||
// Only use in add/replaceDocRanges methods.
|
||||
export type ISuccinctDocRangeParam =
|
||||
Pick<ITextRangeWithStyle, 'startOffset' | 'endOffset' | 'segmentId' | 'segmentPage' | 'style' | 'rangeType'> &
|
||||
Partial<Pick<ITextRangeWithStyle, 'collapsed' | 'direction' | 'isActive' | 'startNodePosition' | 'endNodePosition'>>;
|
||||
|
||||
export interface IDocSelectionInnerParam {
|
||||
textRanges: ITextRangeWithStyle[];
|
||||
rectRanges: IRectRangeWithStyle[];
|
||||
segmentId: string;
|
||||
isEditing: boolean;
|
||||
style: ITextSelectionStyle;
|
||||
segmentPage: number;
|
||||
options?: { [key: string]: boolean };
|
||||
}
|
||||
36
packages/engine-render/src/basics/scroll-xy.ts
Normal file
36
packages/engine-render/src/basics/scroll-xy.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ScrollTimer } from '../scroll-timer';
|
||||
import { Vector2 } from './vector2';
|
||||
|
||||
export function getCurrentScrollXY(scrollTimer: ScrollTimer) {
|
||||
const scene = scrollTimer.getScene();
|
||||
const viewport = scene.findViewportByPosToScene(Vector2.FromArray([scrollTimer.offsetX, scrollTimer.offsetY]));
|
||||
const scrollX = 0;
|
||||
const scrollY = 0;
|
||||
if (!viewport) {
|
||||
return {
|
||||
scrollX,
|
||||
scrollY,
|
||||
};
|
||||
}
|
||||
const actualScroll = viewport.transScroll2ViewportScrollValue(viewport.scrollX, viewport.scrollY);
|
||||
return {
|
||||
scrollX: actualScroll.x,
|
||||
scrollY: actualScroll.y,
|
||||
};
|
||||
}
|
||||
32
packages/engine-render/src/basics/text-rotation.ts
Normal file
32
packages/engine-render/src/basics/text-rotation.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 { ITextRotation } from '@univerjs/core';
|
||||
import { BooleanNumber } from '@univerjs/core';
|
||||
|
||||
export const VERTICAL_ROTATE_ANGLE = 90;
|
||||
|
||||
export function convertTextRotation(textRotation?: ITextRotation) {
|
||||
const { a: angle = 0, v: isVertical = BooleanNumber.FALSE } = textRotation || { a: 0, v: BooleanNumber.FALSE };
|
||||
let centerAngle = 0;
|
||||
let vertexAngle = angle;
|
||||
if (isVertical === BooleanNumber.TRUE) {
|
||||
centerAngle = VERTICAL_ROTATE_ANGLE;
|
||||
vertexAngle = VERTICAL_ROTATE_ANGLE;
|
||||
}
|
||||
|
||||
return { centerAngle, vertexAngle };
|
||||
}
|
||||
684
packages/engine-render/src/basics/tools.ts
Normal file
684
packages/engine-render/src/basics/tools.ts
Normal file
@@ -0,0 +1,684 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IPosition,
|
||||
IRange,
|
||||
IRangeWithCoord,
|
||||
IScale,
|
||||
IStyleBase,
|
||||
Nullable,
|
||||
} from '@univerjs/core';
|
||||
import type { IDocumentSkeletonFontStyle } from './i-document-skeleton-cached';
|
||||
import type { IBoundRectNoAngle } from './vector2';
|
||||
import {
|
||||
BaselineOffset,
|
||||
ColorKit,
|
||||
DEFAULT_STYLES,
|
||||
FontStyleType,
|
||||
Rectangle,
|
||||
Tools,
|
||||
} from '@univerjs/core';
|
||||
import { FontCache } from '../components/docs/layout/shaping-engine/font-cache';
|
||||
import { DEFAULT_FONTFACE_PLANE } from './const';
|
||||
|
||||
const DEG180 = 180;
|
||||
|
||||
const PI_OVER_DEG180 = Math.PI / DEG180;
|
||||
const DEG180_OVER_PI = DEG180 / Math.PI;
|
||||
const RGB_PAREN = 'rgb(';
|
||||
const RGBA_PAREN = 'rgba(';
|
||||
const SCROLLABLE_OVERFLOW_EPSILON = 0.5;
|
||||
|
||||
export function hasScrollableOverflow(contentSize: number, viewportSize: number): boolean {
|
||||
return contentSize - viewportSize > SCROLLABLE_OVERFLOW_EPSILON;
|
||||
}
|
||||
|
||||
// TODO :move to core @jerry
|
||||
export const getColor = (RgbArray: number[], opacity?: number): string => {
|
||||
if (!RgbArray) {
|
||||
return `${RGB_PAREN}0,0,0)`;
|
||||
}
|
||||
|
||||
if (opacity != null) {
|
||||
return `${RGBA_PAREN + RgbArray.join(',')},${opacity})`;
|
||||
}
|
||||
|
||||
return `${RGB_PAREN + RgbArray.join(',')})`;
|
||||
};
|
||||
|
||||
const PERCENT_TO_NUMBER_DIVIDE = 100;
|
||||
|
||||
export const toPx = (num: number | string, ReferenceValue: Nullable<number>): number => {
|
||||
if (Tools.isNumber(num)) {
|
||||
return num;
|
||||
}
|
||||
if (ReferenceValue && num && Tools.isString(num) && num.substr(num.length - 1, 1) === '%') {
|
||||
const numFloat = Number.parseFloat(num) / PERCENT_TO_NUMBER_DIVIDE;
|
||||
return ReferenceValue * numFloat;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const ONE_FRAME_NUMBER = 16;
|
||||
|
||||
/**
|
||||
* Queue a new function into the requested animation frame pool (ie. this function will be executed byt the browser for the next frame)
|
||||
* @param func - the function to be called
|
||||
* @param requester - the object that will request the next frame. Falls back to window.
|
||||
* @returns frame number
|
||||
*/
|
||||
export const requestNewFrame = (func: Function, requester?: any): number => {
|
||||
if (!requester) {
|
||||
requester = window;
|
||||
}
|
||||
|
||||
if (requester.requestPostAnimationFrame) {
|
||||
return requester.requestPostAnimationFrame(func);
|
||||
}
|
||||
if (requester.requestAnimationFrame) {
|
||||
return requester.requestAnimationFrame(func);
|
||||
}
|
||||
if (requester.msRequestAnimationFrame) {
|
||||
return requester.msRequestAnimationFrame(func);
|
||||
}
|
||||
if (requester.webkitRequestAnimationFrame) {
|
||||
return requester.webkitRequestAnimationFrame(func);
|
||||
}
|
||||
if (requester.mozRequestAnimationFrame) {
|
||||
return requester.mozRequestAnimationFrame(func);
|
||||
}
|
||||
if (requester.oRequestAnimationFrame) {
|
||||
return requester.oRequestAnimationFrame(func);
|
||||
}
|
||||
return setTimeout(func, ONE_FRAME_NUMBER);
|
||||
};
|
||||
|
||||
export const cancelRequestFrame = (requestID: number, requester?: any) => {
|
||||
if (!requester) {
|
||||
requester = window;
|
||||
}
|
||||
|
||||
if (requester.requestPostAnimationFrame) {
|
||||
return requester.cancelPostAnimationFrame(requestID);
|
||||
}
|
||||
if (requester.requestAnimationFrame) {
|
||||
return requester.cancelAnimationFrame(requestID);
|
||||
}
|
||||
if (requester.msRequestAnimationFrame) {
|
||||
return requester.msCancelAnimationFrame(requestID);
|
||||
}
|
||||
if (requester.webkitRequestAnimationFrame) {
|
||||
return requester.webkitCancelAnimationFrame(requestID);
|
||||
}
|
||||
if (requester.mozRequestAnimationFrame) {
|
||||
return requester.mozCancelAnimationFrame(requestID);
|
||||
}
|
||||
if (requester.oRequestAnimationFrame) {
|
||||
return requester.oCancelAnimationFrame(requestID);
|
||||
}
|
||||
return clearTimeout(requestID);
|
||||
};
|
||||
|
||||
export const createCanvasElement = (): HTMLCanvasElement => {
|
||||
const canvas = document.createElement('canvas');
|
||||
// TODO: Remove this fallback when canvas/docs rendering fully supports RTL.
|
||||
// Keep canvas text rendering isolated from an RTL ancestor's inherited direction.
|
||||
canvas.dir = 'ltr';
|
||||
// on some environments canvas.style is readonly
|
||||
try {
|
||||
(canvas as any).style = canvas.style || {};
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export const createImageElement = () => document.createElement('img');
|
||||
|
||||
export const radToDeg = (rad: number) => rad * DEG180_OVER_PI;
|
||||
|
||||
// degree is °
|
||||
export const degToRad = (deg: number) => deg * PI_OVER_DEG180;
|
||||
|
||||
/**
|
||||
* Gets the pointer prefix to use
|
||||
* @param engine defines the engine we are finding the prefix for
|
||||
* @returns "pointer" if touch is enabled. Else returns "mouse"
|
||||
*/
|
||||
export const getPointerPrefix = (): string => {
|
||||
let eventPrefix = 'pointer';
|
||||
|
||||
// Check if pointer events are supported
|
||||
if (typeof window !== 'undefined' && !window.PointerEvent && typeof navigator !== 'undefined') {
|
||||
eventPrefix = 'mouse';
|
||||
}
|
||||
|
||||
// Special Fallback MacOS Safari...
|
||||
if (
|
||||
Tools.isTablet() &&
|
||||
!Tools.isIPhone() &&
|
||||
// And not ipad pros who claim to be macs...
|
||||
!(document && 'ontouchend' in document)
|
||||
) {
|
||||
eventPrefix = 'mouse';
|
||||
}
|
||||
|
||||
return eventPrefix;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to detect if the current user agent is Safari
|
||||
* @returns whether or not the current user agent is safari
|
||||
*/
|
||||
export const IsSafari = (): boolean => {
|
||||
if (Tools.getBrowserType() === 'safari') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const GENERATE_RANDOM_KEY_DEFAULT_LENGTH = 4;
|
||||
|
||||
export const generateRandomKey = (
|
||||
prefix: string = 'obj',
|
||||
keyLength: number = GENERATE_RANDOM_KEY_DEFAULT_LENGTH
|
||||
): string => {
|
||||
const userAgent = window.navigator.userAgent.replace(/[^a-zA-Z0-9]/g, '').split('');
|
||||
|
||||
let mid = '';
|
||||
|
||||
for (let i = 0; i < keyLength; i++) {
|
||||
mid += userAgent[Math.round(Math.random() * (userAgent.length - 1))];
|
||||
}
|
||||
|
||||
const time = new Date().getTime();
|
||||
|
||||
return `${prefix}_${mid}_${time}`;
|
||||
};
|
||||
|
||||
export function getValueType(value: unknown): string {
|
||||
return Object.prototype.toString.apply(value);
|
||||
}
|
||||
|
||||
export function isFunction(value?: unknown): value is boolean {
|
||||
return getValueType(value) === '[object Function]';
|
||||
}
|
||||
|
||||
export function isDate(value?: Date): value is Date {
|
||||
return getValueType(value) === '[object Date]';
|
||||
}
|
||||
|
||||
export function isRegExp(value?: unknown): value is RegExp {
|
||||
return getValueType(value) === '[object RegExp]';
|
||||
}
|
||||
|
||||
export function isArray<T>(value?: unknown): value is T[] {
|
||||
return getValueType(value) === '[object Array]';
|
||||
}
|
||||
|
||||
export function isString(value?: unknown): value is string {
|
||||
return getValueType(value) === '[object String]';
|
||||
}
|
||||
|
||||
export function isNumber(value?: unknown): value is number {
|
||||
return getValueType(value) === '[object Number]';
|
||||
}
|
||||
|
||||
export function isObject(value?: unknown): value is object {
|
||||
return getValueType(value) === '[object Object]';
|
||||
}
|
||||
|
||||
export function precisionTo(num: number, accurate: number) {
|
||||
accurate = 10 ** accurate;
|
||||
return Math.round(num * accurate) / accurate;
|
||||
}
|
||||
|
||||
/**
|
||||
* When drawing lines, it is necessary to align their precision.
|
||||
* performance testing
|
||||
* var time = performance.now(); for(let i=0;i<100000000;i++){ fixLineWidthByScale(i, 0.666); }; console.log(performance.now()-time);
|
||||
*/
|
||||
export function fixLineWidthByScale(num: number, scale: number) {
|
||||
return Math.round(num * scale) / scale;
|
||||
}
|
||||
|
||||
export function getFontStyleString(
|
||||
textStyle?: Nullable<IStyleBase>
|
||||
): IDocumentSkeletonFontStyle {
|
||||
const defaultFont = DEFAULT_STYLES.ff;
|
||||
|
||||
const defaultFontSize = DEFAULT_STYLES.fs;
|
||||
|
||||
if (!textStyle) {
|
||||
const fontString = `${defaultFontSize}pt ${defaultFont}`;
|
||||
|
||||
return {
|
||||
fontCache: fontString,
|
||||
fontString,
|
||||
fontSize: defaultFontSize,
|
||||
originFontSize: defaultFontSize,
|
||||
fontFamily: defaultFont,
|
||||
};
|
||||
}
|
||||
|
||||
// font-style
|
||||
let italic = FontStyleType.ITALIC;
|
||||
if (textStyle.it === 0 || textStyle.it === undefined) {
|
||||
italic = FontStyleType.NORMAL;
|
||||
}
|
||||
|
||||
// font-variant
|
||||
// font += `${FontStyleType.NORMAL} `;
|
||||
|
||||
// font-weight
|
||||
let bold = FontStyleType.BOLD;
|
||||
if (textStyle.bl === 0 || textStyle.bl === undefined) {
|
||||
bold = FontStyleType.NORMAL;
|
||||
}
|
||||
|
||||
// font-size/line-height
|
||||
let originFontSize = defaultFontSize;
|
||||
if (textStyle.fs) {
|
||||
originFontSize = textStyle.fs;
|
||||
}
|
||||
let fontSize = originFontSize;
|
||||
|
||||
const fontFamilyResult = normalizeFontFamily(textStyle.ff, defaultFont);
|
||||
|
||||
const { va: baselineOffset } = textStyle;
|
||||
|
||||
if (
|
||||
baselineOffset === BaselineOffset.SUBSCRIPT ||
|
||||
baselineOffset === BaselineOffset.SUPERSCRIPT
|
||||
) {
|
||||
const baselineOffsetInfo = FontCache.getBaselineOffsetInfo(fontFamilyResult, fontSize);
|
||||
const { sbr, spr } = baselineOffsetInfo;
|
||||
|
||||
fontSize *= baselineOffset === BaselineOffset.SUBSCRIPT ? sbr : spr;
|
||||
}
|
||||
|
||||
const fontStringPure = `${italic} ${bold} ${fontSize}pt ${fontFamilyResult}`;
|
||||
|
||||
const fontString = `${fontStringPure}, ${DEFAULT_FONTFACE_PLANE} `;
|
||||
|
||||
return {
|
||||
fontCache: fontStringPure,
|
||||
fontString,
|
||||
fontSize,
|
||||
originFontSize,
|
||||
fontFamily: fontFamilyResult,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFontFamily(fontFamily: Nullable<string>, defaultFont: string): string {
|
||||
if (!fontFamily?.trim()) {
|
||||
return defaultFont;
|
||||
}
|
||||
|
||||
return fontFamily
|
||||
.split(',')
|
||||
.map((item) => item.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter(Boolean)
|
||||
.map((family) => /^[\p{L}_-][\p{L}\p{N}_-]*$/u.test(family)
|
||||
? family
|
||||
: `"${family.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
export function hasAllLatin(text: string) {
|
||||
const pattern = /[\u0000-\u024F]/gi;
|
||||
if (!pattern.exec(text)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasBasicLatin(text: string) {
|
||||
const pattern = /[\u0000-\u007F]/gi;
|
||||
if (!pattern.exec(text)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasLatinOneSupplement(text: string) {
|
||||
const pattern = /[\u0080-\u00FF]/gi;
|
||||
if (!pattern.exec(text)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasLatinExtendedA(text: string) {
|
||||
const pattern = /[\u0100-\u017F]/gi;
|
||||
if (!pattern.exec(text)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasLatinExtendedB(text: string) {
|
||||
const pattern = /[\u0180-\u024F]/gi;
|
||||
if (!pattern.exec(text)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Emoji check logic
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
|
||||
export function getFirstGrapheme(text: string): string | null {
|
||||
// One UTF-16 code unit cannot span multiple graphemes. CJK letter glyphs
|
||||
// already arrive individually, so avoid allocating a segment iterator.
|
||||
if (text.length <= 1) {
|
||||
return text || null;
|
||||
}
|
||||
const it = segmenter.segment(text)[Symbol.iterator]();
|
||||
return it.next().value?.segment ?? null;
|
||||
}
|
||||
|
||||
export function isEmojiGrapheme(grapheme: string): boolean {
|
||||
// emoji + ZWJ + skin tone
|
||||
if (/\p{Extended_Pictographic}/u.test(grapheme)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// flag 🇸🇬
|
||||
if (/^\p{Regional_Indicator}{2}$/u.test(grapheme)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// keycap 1️⃣ #️⃣ *️⃣
|
||||
if (/^[0-9#*]\uFE0F?\u20E3$/u.test(grapheme)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startWithEmoji(text: string): boolean {
|
||||
const firstCodeUnit = text.charCodeAt(0);
|
||||
const canStartKeycapEmoji = firstCodeUnit === 0x23 || firstCodeUnit === 0x2A || (firstCodeUnit >= 0x30 && firstCodeUnit <= 0x39);
|
||||
if (firstCodeUnit <= 0x7F && !canStartKeycapEmoji) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const first = getFirstGrapheme(text);
|
||||
return first ? isEmojiGrapheme(first) : false;
|
||||
}
|
||||
|
||||
export function hasArabic(text: string) {
|
||||
const pattern = /[\u0600-\u06FF]|[\u0750-\u0750]|[\u0870-\u08FF]|[\uFB50-\uFDFF]|[\uFE70-\uFEFF]/gi;
|
||||
|
||||
return pattern.test(text);
|
||||
}
|
||||
|
||||
export function hasTibetan(text: string) {
|
||||
const pattern = /[\u0180-\u024F]/gi;
|
||||
|
||||
return pattern.test(text);
|
||||
}
|
||||
|
||||
export function hasThai(text: string) {
|
||||
const pattern = /[\u0E00-\u0E7F]/;
|
||||
|
||||
return pattern.test(text);
|
||||
}
|
||||
|
||||
export function hasSpace(text: string) {
|
||||
const pattern = /\s+/g;
|
||||
|
||||
return pattern.test(text);
|
||||
}
|
||||
|
||||
// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
|
||||
export function isCjkLeftAlignedPunctuation(text: string) {
|
||||
const LEFT_ALIGNED_PUNCTUATION = ['”', '’', ',', '。', '.', '、', ':', ';', '?', '!', '》', ')', '』', '」', '】', '〗', '〕', '〉', ']', '}'];
|
||||
|
||||
return LEFT_ALIGNED_PUNCTUATION.indexOf(text) > -1;
|
||||
}
|
||||
|
||||
// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
|
||||
export function isCjkRightAlignedPunctuation(text: string) {
|
||||
const RIGHT_ALIGNED_PUNCTUATION = ['“', '‘', '《', '(', '『', '「', '【', '〖', '〔', '〈', '[', '{'];
|
||||
|
||||
return RIGHT_ALIGNED_PUNCTUATION.indexOf(text) > -1;
|
||||
}
|
||||
|
||||
// See <https://www.w3.org/TR/clreq/#punctuation_width_adjustment>
|
||||
export function isCjkCenterAlignedPunctuation(text: string) {
|
||||
// U+30FB: Katakana Middle Dot
|
||||
// U+00B7: Middle Dot
|
||||
const CENTER_ALIGNED_PUNCTUATION = ['\u{30FB}', '\u{00B7}'];
|
||||
|
||||
return CENTER_ALIGNED_PUNCTUATION.indexOf(text) > -1;
|
||||
}
|
||||
|
||||
const one_thousand = 1000;
|
||||
|
||||
// Return screen DPI
|
||||
let dpi_cache: Nullable<number>;
|
||||
export function getDPI() {
|
||||
if (dpi_cache) {
|
||||
return dpi_cache;
|
||||
}
|
||||
let i = 56;
|
||||
for (; i < one_thousand * 2; i++) {
|
||||
if (matchMedia(`(max-resolution: ${i}dpi)`).matches === true) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
dpi_cache = i;
|
||||
setTimeout(() => {
|
||||
dpi_cache = null;
|
||||
}, one_thousand);
|
||||
return i;
|
||||
}
|
||||
|
||||
const BENCHMARK_DPI = 72;
|
||||
const BENCHMARK_CONVERT_MM = 25.4;
|
||||
|
||||
// ptToPx
|
||||
export function ptToPx(pt: number) {
|
||||
return pt / (BENCHMARK_DPI / getDPI());
|
||||
}
|
||||
|
||||
// pxToPt
|
||||
export function pxToPt(px: number) {
|
||||
return px * (BENCHMARK_DPI / getDPI());
|
||||
}
|
||||
|
||||
// pxToMM
|
||||
export function ptToMM(px: number) {
|
||||
return px * (getDPI() / BENCHMARK_CONVERT_MM);
|
||||
}
|
||||
|
||||
// pxToInch
|
||||
export function pxToInch(px: number) {
|
||||
return px * getDPI();
|
||||
}
|
||||
|
||||
export function getScale(parentScale: IScale) {
|
||||
const { scaleX = 1, scaleY = 1 } = parentScale;
|
||||
|
||||
return Math.max(scaleX, scaleY);
|
||||
}
|
||||
|
||||
export function getCellPositionByIndex(
|
||||
row: number,
|
||||
column: number,
|
||||
rowHeightAccumulation: number[],
|
||||
columnWidthAccumulation: number[]
|
||||
): IPosition {
|
||||
const startRow = row - 1;
|
||||
const startColumn = column - 1;
|
||||
|
||||
const startY = rowHeightAccumulation[startRow] || 0;
|
||||
let endY = rowHeightAccumulation[row];
|
||||
|
||||
if (endY == null) {
|
||||
endY = rowHeightAccumulation[rowHeightAccumulation.length - 1];
|
||||
}
|
||||
|
||||
const startX = columnWidthAccumulation[startColumn] || 0;
|
||||
let endX = columnWidthAccumulation[column];
|
||||
|
||||
if (endX == null) {
|
||||
endX = columnWidthAccumulation[columnWidthAccumulation.length - 1];
|
||||
}
|
||||
|
||||
return {
|
||||
startY,
|
||||
endY,
|
||||
startX,
|
||||
endX,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeInfoOffset(mergeInfo: IRangeWithCoord, offsetX: number, offsetY: number) {
|
||||
return {
|
||||
...mergeInfo,
|
||||
startY: mergeInfo.startY + offsetY,
|
||||
endY: mergeInfo.endY + offsetY,
|
||||
startX: mergeInfo.startX + offsetX,
|
||||
endX: mergeInfo.endX + offsetX,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRectIntersect(rect1: IBoundRectNoAngle, rect2: IBoundRectNoAngle) {
|
||||
return !(
|
||||
rect1.left > rect2.right ||
|
||||
rect1.top > rect2.bottom ||
|
||||
rect2.left > rect1.right ||
|
||||
rect2.top > rect1.bottom
|
||||
);
|
||||
}
|
||||
|
||||
export function injectStyle(styles: string[]) {
|
||||
const styleElement = document.createElement('style');
|
||||
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
const styleSheet = styleElement.sheet;
|
||||
|
||||
for (const style of styles) {
|
||||
styleSheet?.insertRule(style, styleSheet.cssRules.length);
|
||||
}
|
||||
}
|
||||
|
||||
export function checkStyle(content: string) {
|
||||
for (let i = 0, len = document.head.childNodes.length; i < len; i++) {
|
||||
const node = document.head.childNodes[i];
|
||||
if (node.nodeName === 'STYLE' && node.textContent && node.textContent.indexOf(content) > -1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function pxToNum(unit: string) {
|
||||
return Number(unit.replace(/px/gi, ''));
|
||||
}
|
||||
|
||||
export function getSizeForDom(dom: HTMLElement) {
|
||||
const style = getComputedStyle(dom);
|
||||
const { borderTopWidth, borderLeftWidth, borderRightWidth, borderBottomWidth, width, height } = style;
|
||||
return {
|
||||
top: pxToNum(borderTopWidth),
|
||||
left: pxToNum(borderLeftWidth),
|
||||
right: pxToNum(borderRightWidth),
|
||||
bottom: pxToNum(borderBottomWidth),
|
||||
width: pxToNum(width),
|
||||
height: pxToNum(height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*1 pixel * 0.75 = 1 pt
|
||||
*/
|
||||
const PX_TO_PT_RATIO = 0.75;
|
||||
|
||||
export function ptToPixel(pt: number) {
|
||||
return pt / PX_TO_PT_RATIO;
|
||||
}
|
||||
|
||||
export function pixelToPt(px: number) {
|
||||
return px * PX_TO_PT_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is cell in view ranges.
|
||||
* @param ranges
|
||||
* @param rowIndex
|
||||
* @param colIndex
|
||||
* @returns boolean
|
||||
*/
|
||||
export function inViewRanges(ranges: IRange[], rowIndex: number, colIndex: number): boolean {
|
||||
for (const range of ranges) {
|
||||
if (rowIndex >= range.startRow && rowIndex <= range.endRow &&
|
||||
colIndex >= range.startColumn && colIndex <= range.endColumn) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If there is an intersection in ranges to the mainRanges, extend it to the first set of ranges.
|
||||
* @param {IRange[]} mainRanges target ranges
|
||||
* @param {IRange[]} ranges
|
||||
*/
|
||||
export function expandRangeIfIntersects(mainRanges: IRange[], ranges: IRange[]) {
|
||||
const intersects = [];
|
||||
for (const mainRange of mainRanges) {
|
||||
for (const range of ranges) {
|
||||
if (Rectangle.simpleRangesIntersect(mainRange, range)) {
|
||||
intersects.push(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mainRanges.concat(intersects); // do not use [...mainRanges, ...intersects], because concat is slightly faster than spread
|
||||
}
|
||||
|
||||
export function clampRange(range: IRange, maxRow: number, maxColumn: number) {
|
||||
return {
|
||||
startRow: Tools.clamp(range.startRow, 0, maxRow),
|
||||
endRow: Tools.clamp(range.endRow, 0, maxRow),
|
||||
startColumn: Tools.clamp(range.startColumn, 0, maxColumn),
|
||||
endColumn: Tools.clamp(range.endColumn, 0, maxColumn),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system highlight color in rgb format.
|
||||
*/
|
||||
export function getSystemHighlightColor() {
|
||||
const hiddenEle = document.createElement('div');
|
||||
hiddenEle.style.width = '0';
|
||||
hiddenEle.style.height = '0';
|
||||
hiddenEle.style.backgroundColor = 'highlight';
|
||||
document.body.append(hiddenEle);
|
||||
|
||||
const highlightColor = getComputedStyle(hiddenEle).backgroundColor;
|
||||
|
||||
hiddenEle.remove();
|
||||
|
||||
const colorParser = new ColorKit(highlightColor);
|
||||
|
||||
return colorParser.toRgb();
|
||||
}
|
||||
418
packages/engine-render/src/basics/transform.ts
Normal file
418
packages/engine-render/src/basics/transform.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ITransformState } from '@univerjs/core';
|
||||
import type { IRect } from './interfaces';
|
||||
import { degToRad, precisionTo, radToDeg } from './tools';
|
||||
import { Vector2 } from './vector2';
|
||||
|
||||
export const INITIAL_MATRIX: number[] = [1, 0, 0, 1, 0, 0];
|
||||
|
||||
export class Transform {
|
||||
dirty = false;
|
||||
|
||||
private _m: number[];
|
||||
|
||||
constructor(m = INITIAL_MATRIX) {
|
||||
this._m = (m && m.slice()) || INITIAL_MATRIX;
|
||||
}
|
||||
|
||||
static create(m = INITIAL_MATRIX) {
|
||||
return new this(m);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._m[0] = 1;
|
||||
this._m[1] = 0;
|
||||
this._m[2] = 0;
|
||||
this._m[3] = 1;
|
||||
this._m[4] = 0;
|
||||
this._m[5] = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy universheet.Transform object
|
||||
* @method
|
||||
* @name universheet.Transform#copy
|
||||
* @returns {universheet.Transform}
|
||||
* @example
|
||||
* const tr = shape.getTransform().copy()
|
||||
*/
|
||||
copy() {
|
||||
return new Transform(this._m);
|
||||
}
|
||||
|
||||
copyInto(tr: Transform) {
|
||||
tr._m[0] = this._m[0];
|
||||
tr._m[1] = this._m[1];
|
||||
tr._m[2] = this._m[2];
|
||||
tr._m[3] = this._m[3];
|
||||
tr._m[4] = this._m[4];
|
||||
tr._m[5] = this._m[5];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform point
|
||||
* @method
|
||||
* @name universheet.Transform#point
|
||||
* @param {object} point 2D point(x, y)
|
||||
* @returns {object} 2D point(x, y)
|
||||
*/
|
||||
applyPoint(point: Vector2, ignoreOffset: boolean = false): Vector2 {
|
||||
const m = this._m;
|
||||
const xNew = m[0] * point.x + m[2] * point.y;
|
||||
const yNew = m[1] * point.x + m[3] * point.y;
|
||||
|
||||
if (ignoreOffset) {
|
||||
return new Vector2(xNew, yNew);
|
||||
}
|
||||
|
||||
return new Vector2(xNew + m[4], yNew + m[5]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply translation
|
||||
* @method
|
||||
* @name universheet.Transform#translate
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
translate(x: number, y: number) {
|
||||
this._m[4] += this._m[0] * x + this._m[2] * y;
|
||||
this._m[5] += this._m[1] * x + this._m[3] * y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply scale
|
||||
* @method
|
||||
* @name universheet.Transform#scale
|
||||
* @param {number} sx
|
||||
* @param {number} sy
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
scale(sx: number, sy: number) {
|
||||
this._m[0] *= sx;
|
||||
this._m[1] *= sx;
|
||||
this._m[2] *= sy;
|
||||
this._m[3] *= sy;
|
||||
// this._m[4] *= sx;
|
||||
// this._m[5] *= sy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply rotation
|
||||
* @method
|
||||
* @name universheet.Transform#rotate
|
||||
* @param {number} Degree Angle in Degree
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
rotate(deg: number) {
|
||||
const rad = degToRad(deg);
|
||||
const c = Math.cos(rad);
|
||||
const s = Math.sin(rad);
|
||||
const m11 = this._m[0] * c + this._m[2] * s;
|
||||
const m12 = this._m[1] * c + this._m[3] * s;
|
||||
const m21 = this._m[0] * -s + this._m[2] * c;
|
||||
const m22 = this._m[1] * -s + this._m[3] * c;
|
||||
this._m[0] = m11;
|
||||
this._m[1] = m12;
|
||||
this._m[2] = m21;
|
||||
this._m[3] = m22;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translation
|
||||
* @method
|
||||
* @name universheet.Transform#getTranslation
|
||||
* @returns {object} 2D point(x, y)
|
||||
*/
|
||||
getTranslation() {
|
||||
return {
|
||||
x: this._m[4],
|
||||
y: this._m[5],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply skew
|
||||
* @method
|
||||
* @name universheet.Transform#skew
|
||||
* @param {number} sx
|
||||
* @param {number} sy
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
skew(sx: number, sy: number) {
|
||||
const m11 = this._m[0] + this._m[2] * sy;
|
||||
const m12 = this._m[1] + this._m[3] * sy;
|
||||
const m21 = this._m[2] + this._m[0] * sx;
|
||||
const m22 = this._m[3] + this._m[1] * sx;
|
||||
this._m[0] = m11;
|
||||
this._m[1] = m12;
|
||||
this._m[2] = m21;
|
||||
this._m[3] = m22;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform multiplication
|
||||
* @method
|
||||
* @name universheet.Transform#multiply
|
||||
* @param {universheet.Transform} matrix
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
multiply(matrix: Transform) {
|
||||
const m11 = this._m[0] * matrix._m[0] + this._m[2] * matrix._m[1];
|
||||
const m12 = this._m[1] * matrix._m[0] + this._m[3] * matrix._m[1];
|
||||
|
||||
const m21 = this._m[0] * matrix._m[2] + this._m[2] * matrix._m[3];
|
||||
const m22 = this._m[1] * matrix._m[2] + this._m[3] * matrix._m[3];
|
||||
|
||||
const dx = this._m[0] * matrix._m[4] + this._m[2] * matrix._m[5] + this._m[4];
|
||||
const dy = this._m[1] * matrix._m[4] + this._m[3] * matrix._m[5] + this._m[5];
|
||||
|
||||
this._m[0] = m11;
|
||||
this._m[1] = m12;
|
||||
this._m[2] = m21;
|
||||
this._m[3] = m22;
|
||||
this._m[4] = dx;
|
||||
this._m[5] = dy;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert the matrix
|
||||
* @method
|
||||
* @name universheet.Transform#invert
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
invert() {
|
||||
const d = 1 / (this._m[0] * this._m[3] - this._m[1] * this._m[2]);
|
||||
const m0 = this._m[3] * d;
|
||||
const m1 = -this._m[1] * d;
|
||||
const m2 = -this._m[2] * d;
|
||||
const m3 = this._m[0] * d;
|
||||
const m4 = d * (this._m[2] * this._m[5] - this._m[3] * this._m[4]);
|
||||
const m5 = d * (this._m[1] * this._m[4] - this._m[0] * this._m[5]);
|
||||
this._m[0] = m0;
|
||||
this._m[1] = m1;
|
||||
this._m[2] = m2;
|
||||
this._m[3] = m3;
|
||||
this._m[4] = m4;
|
||||
this._m[5] = m5;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* return matrix
|
||||
* @method
|
||||
* @name universheet.Transform#getMatrix
|
||||
*/
|
||||
getMatrix() {
|
||||
return this._m;
|
||||
}
|
||||
|
||||
/**
|
||||
* return matrix
|
||||
* @method
|
||||
* @name universheet.Transform#getMatrix
|
||||
*/
|
||||
getMatrixByAccurate(accurate = 3) {
|
||||
return this._m.map((value) => precisionTo(value, accurate));
|
||||
}
|
||||
|
||||
/**
|
||||
* set to absolute position via translation
|
||||
* @method
|
||||
* @name universheet.Transform#setAbsolutePosition
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
setAbsolutePosition(coord: Vector2) {
|
||||
const m0 = this._m[0];
|
||||
const m1 = this._m[1];
|
||||
const m2 = this._m[2];
|
||||
const m3 = this._m[3];
|
||||
const m4 = this._m[4];
|
||||
const m5 = this._m[5];
|
||||
const yt = (m0 * (coord.y - m5) - m1 * (coord.x - m4)) / (m0 * m3 - m1 * m2);
|
||||
const xt = (coord.x - m4 - m2 * yt) / m0;
|
||||
|
||||
return this.translate(xt, yt);
|
||||
}
|
||||
|
||||
/**
|
||||
* convert transformation matrix back into node's attributes
|
||||
* @method
|
||||
* @name universheet.Transform#decompose
|
||||
* @returns {universheet.Transform}
|
||||
*/
|
||||
decompose() {
|
||||
const a = this._m[0];
|
||||
const b = this._m[1];
|
||||
const c = this._m[2];
|
||||
const d = this._m[3];
|
||||
const e = this._m[4];
|
||||
const f = this._m[5];
|
||||
|
||||
const delta = a * d - b * c;
|
||||
|
||||
const result = {
|
||||
x: e,
|
||||
y: f,
|
||||
angle: 0,
|
||||
scaleX: 0,
|
||||
scaleY: 0,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
};
|
||||
|
||||
// Apply the QR-like decomposition.
|
||||
if (a !== 0 || b !== 0) {
|
||||
const r = Math.sqrt(a * a + b * b);
|
||||
result.angle = b > 0 ? Math.acos(a / r) : -Math.acos(a / r);
|
||||
result.scaleX = r;
|
||||
result.scaleY = delta / r;
|
||||
result.skewX = (a * c + b * d) / delta;
|
||||
result.skewY = 0;
|
||||
} else if (c !== 0 || d !== 0) {
|
||||
const s = Math.sqrt(c * c + d * d);
|
||||
result.angle = Math.PI / 2 - (d > 0 ? Math.acos(-c / s) : -Math.acos(c / s));
|
||||
result.scaleX = delta / s;
|
||||
result.scaleY = s;
|
||||
result.skewX = 0;
|
||||
result.skewY = (a * c + b * d) / delta;
|
||||
} else {
|
||||
// a = b = c = d = 0
|
||||
}
|
||||
|
||||
result.angle = radToDeg(result.angle);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
makeBoundingBoxFromPoints(points: Vector2[]): IRect {
|
||||
if (this) {
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
points[i] = this.applyPoint(points[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const xPoints = [points[0].x, points[1].x, points[2].x, points[3].x];
|
||||
const minX = Math.min(...xPoints);
|
||||
const maxX = Math.max(...xPoints);
|
||||
const width = maxX - minX;
|
||||
const yPoints = [points[0].y, points[1].y, points[2].y, points[3].y];
|
||||
const minY = Math.min(...yPoints);
|
||||
const maxY = Math.max(...yPoints);
|
||||
const height = maxY - minY;
|
||||
|
||||
return {
|
||||
left: minX,
|
||||
top: minY,
|
||||
width,
|
||||
height,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
composeMatrix(options: ITransformState) {
|
||||
const matrix = new Transform([1, 0, 0, 1, options.left || 0, options.top || 0]);
|
||||
if (options.angle) {
|
||||
matrix.rotate(options.angle);
|
||||
}
|
||||
if (
|
||||
options.scaleX !== 1 ||
|
||||
options.scaleY !== 1 ||
|
||||
options.skewX ||
|
||||
options.skewY ||
|
||||
options.flipX ||
|
||||
options.flipY
|
||||
) {
|
||||
matrix.multiply(this._calcDimensionsMatrix(options));
|
||||
}
|
||||
this._m = matrix.getMatrix();
|
||||
return this;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new Transform([...this._m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a transform matrix starting from an object of the same kind of
|
||||
* the one returned from qrDecompose, useful also if you want to calculate some
|
||||
* transformations from an object that is not enLived yet.
|
||||
* is called DimensionsTransformMatrix because those properties are the one that influence
|
||||
* the size of the resulting box of the object.
|
||||
* @param {object} options
|
||||
* @param {number} [options.scaleX]
|
||||
* @param {number} [options.scaleY]
|
||||
* @param {boolean} [options.flipX]
|
||||
* @param {boolean} [options.flipY]
|
||||
* @param {number} [options.skewX]
|
||||
* @param {number} [options.skewX]
|
||||
* @return {number[]} transform matrix
|
||||
*/
|
||||
private _calcDimensionsMatrix(options: ITransformState) {
|
||||
const scaleX = options.scaleX ?? 1;
|
||||
const scaleY = options.scaleY ?? 1;
|
||||
const scaleMatrix = new Transform([
|
||||
options.flipX ? -scaleX : scaleX,
|
||||
0,
|
||||
0,
|
||||
options.flipY ? -scaleY : scaleY,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
if (options.skewX) {
|
||||
scaleMatrix.multiply(new Transform([1, 0, Math.tan(degToRad(options.skewX)), 1, 0, 0]));
|
||||
}
|
||||
if (options.skewY) {
|
||||
scaleMatrix.multiply(new Transform([1, Math.tan(degToRad(options.skewY)), 0, 1, 0, 0]));
|
||||
}
|
||||
return scaleMatrix;
|
||||
}
|
||||
|
||||
convert2DOMMatrix2D() {
|
||||
const m = this.getMatrix();
|
||||
return {
|
||||
a: m[0],
|
||||
b: m[1],
|
||||
c: m[2],
|
||||
d: m[3],
|
||||
e: m[4],
|
||||
f: m[5],
|
||||
};
|
||||
}
|
||||
|
||||
// static createTransformByState(state: positionState) {
|
||||
// const newTr = new this();
|
||||
// let { x, y, angle, scaleX, scaleY, skewX, skewY, flipX, flipY } = state;
|
||||
// x = x ?? 0;
|
||||
// y = y ?? 0;
|
||||
// newTr.translate(x, y);
|
||||
// if (angle) {
|
||||
// newTr.rotate(angle);
|
||||
// }
|
||||
// scaleX = scaleX ?? 1;
|
||||
// scaleY = scaleY ?? 1;
|
||||
// newTr.scale(scaleX, scaleY);
|
||||
// return newTr;
|
||||
// }
|
||||
}
|
||||
118
packages/engine-render/src/basics/transformer-config.ts
Normal file
118
packages/engine-render/src/basics/transformer-config.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ITransformState, Nullable } from '@univerjs/core';
|
||||
import type { BaseObject } from '../base-object';
|
||||
import type { IMouseEvent, IPointerEvent } from './i-events';
|
||||
|
||||
export interface ITransformerConfig {
|
||||
// for image copper
|
||||
isCropper?: boolean;
|
||||
/** Minimum crop-handle hit target in screen pixels; does not change its appearance. */
|
||||
cropAnchorHitSize?: number;
|
||||
|
||||
hoverEnabled?: boolean;
|
||||
hoverEnterFunc?: Nullable<(e: IPointerEvent | IMouseEvent) => void>;
|
||||
hoverLeaveFunc?: Nullable<(e: IPointerEvent | IMouseEvent) => void>;
|
||||
|
||||
rotateEnabled?: boolean;
|
||||
rotationSnaps?: number[];
|
||||
rotationSnapTolerance?: number;
|
||||
rotateAnchorOffset?: number;
|
||||
rotateAnchorPosition?: 'top' | 'bottom';
|
||||
rotateLineEnabled?: boolean;
|
||||
rotateSize?: number;
|
||||
rotateCornerRadius?: number;
|
||||
rotateFill?: string;
|
||||
rotateStroke?: string;
|
||||
rotateStrokeWidth?: number;
|
||||
rotateIconEnabled?: boolean;
|
||||
rotateIconStroke?: string;
|
||||
rotateIconStrokeWidth?: number;
|
||||
|
||||
borderEnabled?: boolean;
|
||||
borderStroke?: string;
|
||||
borderStrokeWidth?: number;
|
||||
borderDash?: number[];
|
||||
borderSpacing?: number;
|
||||
|
||||
resizeEnabled?: boolean;
|
||||
moveEnabled?: boolean;
|
||||
/** Start moving only when the object was already selected before pointer down. */
|
||||
moveOnlyWhenSelected?: boolean;
|
||||
enabledAnchors?: number[];
|
||||
anchorFill?: string;
|
||||
anchorStroke?: string;
|
||||
anchorStrokeWidth?: number;
|
||||
anchorSize?: number;
|
||||
anchorCornerRadius?: number;
|
||||
anchorStyle?: 'default' | 'canva';
|
||||
anchorSideLongSize?: number;
|
||||
anchorSideShortSize?: number;
|
||||
anchorSideCornerRadius?: number;
|
||||
anchorShadowColor?: string;
|
||||
anchorShadowBlur?: number;
|
||||
anchorShadowOffsetX?: number;
|
||||
anchorShadowOffsetY?: number;
|
||||
|
||||
keepRatio?: boolean;
|
||||
centeredScaling?: boolean;
|
||||
|
||||
flipEnabled?: boolean;
|
||||
ignoreStroke?: boolean;
|
||||
boundBoxFunc?: Nullable<(oldBox: BaseObject, newBox: BaseObject) => BaseObject>;
|
||||
useSingleNodeRotation?: boolean;
|
||||
shouldOverdrawWholeArea?: boolean;
|
||||
|
||||
/** Render transformer controls on a layer independent from the selected object. */
|
||||
controlLayerIndex?: number;
|
||||
|
||||
/** Resolve display geometry for controls without changing the object's persisted transform. */
|
||||
controlStateResolver?: (object: BaseObject) => ITransformState;
|
||||
|
||||
zeroLeft?: number;
|
||||
zeroTop?: number;
|
||||
moveBoundaryEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSFORMER_CONFIG = {
|
||||
moveEnabled: true,
|
||||
resizeEnabled: true,
|
||||
rotateEnabled: true,
|
||||
rotateAnchorOffset: 28,
|
||||
rotateAnchorPosition: 'bottom',
|
||||
rotateLineEnabled: false,
|
||||
rotateSize: 18,
|
||||
rotateCornerRadius: 9,
|
||||
rotateFill: '#ffffff',
|
||||
rotateStroke: '#4086f4',
|
||||
rotateStrokeWidth: 1,
|
||||
rotateIconEnabled: true,
|
||||
rotateIconStroke: '#4086f4',
|
||||
rotateIconStrokeWidth: 1.25,
|
||||
borderEnabled: true,
|
||||
borderStroke: '#4086f4',
|
||||
borderStrokeWidth: 1,
|
||||
borderSpacing: 2,
|
||||
anchorFill: '#ffffff',
|
||||
anchorStroke: '#4086f4',
|
||||
anchorStrokeWidth: 1.5,
|
||||
anchorSize: 8,
|
||||
anchorCornerRadius: 2,
|
||||
anchorStyle: 'canva',
|
||||
keepRatio: true,
|
||||
moveBoundaryEnabled: true,
|
||||
} satisfies ITransformerConfig;
|
||||
906
packages/engine-render/src/basics/vector2.ts
Normal file
906
packages/engine-render/src/basics/vector2.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Canvas } from '../canvas';
|
||||
import type { SHEET_VIEWPORT_KEY } from '../components/sheets/interfaces';
|
||||
import type { DeepImmutable, FloatArray } from './i-events';
|
||||
import type { Transform } from './transform';
|
||||
|
||||
export interface IPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class Vector2 implements IPoint {
|
||||
/**
|
||||
* Creates a new Vector2 from the given x and y coordinates
|
||||
* @param x defines the first coordinate
|
||||
* @param y defines the second coordinate
|
||||
*/
|
||||
constructor(
|
||||
/** defines the first coordinate */
|
||||
public x: number = 0,
|
||||
/** defines the second coordinate */
|
||||
public y: number = 0
|
||||
) {
|
||||
// empty
|
||||
}
|
||||
|
||||
// Statics
|
||||
|
||||
/**
|
||||
* Gets a new Vector2(0, 0)
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Zero(): Vector2 {
|
||||
return new Vector2(0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2(1, 1)
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static One(): Vector2 {
|
||||
return new Vector2(1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 set from the given index element of the given array
|
||||
* @param array defines the data source
|
||||
* @param offset defines the offset in the data source
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static FromArray(array: DeepImmutable<ArrayLike<number>>, offset: number = 0): Vector2 {
|
||||
return new Vector2(array[offset], array[offset + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets "result" from the given index element of the given array
|
||||
* @param array defines the data source
|
||||
* @param offset defines the offset in the data source
|
||||
* @param result defines the target vector
|
||||
*/
|
||||
static FromArrayToRef(array: ArrayLike<number>, offset: number, result: Vector2): void {
|
||||
result.x = array[offset];
|
||||
result.y = array[offset + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 located for "amount" (float) on the CatmullRom spline defined by the given four Vector2
|
||||
* @param value1 defines 1st point of control
|
||||
* @param value2 defines 2nd point of control
|
||||
* @param value3 defines 3rd point of control
|
||||
* @param value4 defines 4th point of control
|
||||
* @param amount defines the interpolation factor
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static CatmullRom(
|
||||
value1: DeepImmutable<Vector2>,
|
||||
value2: DeepImmutable<Vector2>,
|
||||
value3: DeepImmutable<Vector2>,
|
||||
value4: DeepImmutable<Vector2>,
|
||||
amount: number
|
||||
): Vector2 {
|
||||
const squared = amount * amount;
|
||||
const cubed = amount * squared;
|
||||
|
||||
const x =
|
||||
0.5 *
|
||||
(2.0 * value2.x +
|
||||
(-value1.x + value3.x) * amount +
|
||||
(2.0 * value1.x - 5.0 * value2.x + 4.0 * value3.x - value4.x) * squared +
|
||||
(-value1.x + 3.0 * value2.x - 3.0 * value3.x + value4.x) * cubed);
|
||||
|
||||
const y =
|
||||
0.5 *
|
||||
(2.0 * value2.y +
|
||||
(-value1.y + value3.y) * amount +
|
||||
(2.0 * value1.y - 5.0 * value2.y + 4.0 * value3.y - value4.y) * squared +
|
||||
(-value1.y + 3.0 * value2.y - 3.0 * value3.y + value4.y) * cubed);
|
||||
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 set with same the coordinates than "value" ones if the vector "value" is in the square defined by "min" and "max".
|
||||
* If a coordinate of "value" is lower than "min" coordinates, the returned Vector2 is given this "min" coordinate.
|
||||
* If a coordinate of "value" is greater than "max" coordinates, the returned Vector2 is given this "max" coordinate
|
||||
* @param value defines the value to clamp
|
||||
* @param min defines the lower limit
|
||||
* @param max defines the upper limit
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Clamp(value: DeepImmutable<Vector2>, min: DeepImmutable<Vector2>, max: DeepImmutable<Vector2>): Vector2 {
|
||||
let x = value.x;
|
||||
x = x > max.x ? max.x : x;
|
||||
x = x < min.x ? min.x : x;
|
||||
|
||||
let y = value.y;
|
||||
y = y > max.y ? max.y : y;
|
||||
y = y < min.y ? min.y : y;
|
||||
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 located for "amount" (float) on the Hermite spline defined by the vectors "value1", "value2", "tangent1", "tangent2"
|
||||
* @param value1 defines the 1st control point
|
||||
* @param tangent1 defines the outgoing tangent
|
||||
* @param value2 defines the 2nd control point
|
||||
* @param tangent2 defines the incoming tangent
|
||||
* @param amount defines the interpolation factor
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Hermite(
|
||||
value1: DeepImmutable<Vector2>,
|
||||
tangent1: DeepImmutable<Vector2>,
|
||||
value2: DeepImmutable<Vector2>,
|
||||
tangent2: DeepImmutable<Vector2>,
|
||||
amount: number
|
||||
): Vector2 {
|
||||
const squared = amount * amount;
|
||||
const cubed = amount * squared;
|
||||
const part1 = 2.0 * cubed - 3.0 * squared + 1.0;
|
||||
const part2 = -2.0 * cubed + 3.0 * squared;
|
||||
const part3 = cubed - 2.0 * squared + amount;
|
||||
const part4 = cubed - squared;
|
||||
|
||||
const x = value1.x * part1 + value2.x * part2 + tangent1.x * part3 + tangent2.x * part4;
|
||||
const y = value1.y * part1 + value2.y * part2 + tangent1.y * part3 + tangent2.y * part4;
|
||||
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 which is the 1st derivative of the Hermite spline defined by the vectors "value1", "value2", "tangent1", "tangent2".
|
||||
* @param value1 defines the first control point
|
||||
* @param tangent1 defines the first tangent
|
||||
* @param value2 defines the second control point
|
||||
* @param tangent2 defines the second tangent
|
||||
* @param time define where the derivative must be done
|
||||
* @returns 1st derivative
|
||||
*/
|
||||
static Hermite1stDerivative(
|
||||
value1: DeepImmutable<Vector2>,
|
||||
tangent1: DeepImmutable<Vector2>,
|
||||
value2: DeepImmutable<Vector2>,
|
||||
tangent2: DeepImmutable<Vector2>,
|
||||
time: number
|
||||
): Vector2 {
|
||||
const result = Vector2.Zero();
|
||||
|
||||
this.Hermite1stDerivativeToRef(value1, tangent1, value2, tangent2, time, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 which is the 1st derivative of the Hermite spline defined by the vectors "value1", "value2", "tangent1", "tangent2".
|
||||
* @param value1 defines the first control point
|
||||
* @param tangent1 defines the first tangent
|
||||
* @param value2 defines the second control point
|
||||
* @param tangent2 defines the second tangent
|
||||
* @param time define where the derivative must be done
|
||||
* @param result define where the derivative will be stored
|
||||
*/
|
||||
static Hermite1stDerivativeToRef(
|
||||
value1: DeepImmutable<Vector2>,
|
||||
tangent1: DeepImmutable<Vector2>,
|
||||
value2: DeepImmutable<Vector2>,
|
||||
tangent2: DeepImmutable<Vector2>,
|
||||
time: number,
|
||||
result: Vector2
|
||||
) {
|
||||
const t2 = time * time;
|
||||
|
||||
result.x =
|
||||
(t2 - time) * 6 * value1.x +
|
||||
(3 * t2 - 4 * time + 1) * tangent1.x +
|
||||
(-t2 + time) * 6 * value2.x +
|
||||
(3 * t2 - 2 * time) * tangent2.x;
|
||||
result.y =
|
||||
(t2 - time) * 6 * value1.y +
|
||||
(3 * t2 - 4 * time + 1) * tangent1.y +
|
||||
(-t2 + time) * 6 * value2.y +
|
||||
(3 * t2 - 2 * time) * tangent2.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 located for "amount" (float) on the linear interpolation between the vector "start" adn the vector "end".
|
||||
* @param start defines the start vector
|
||||
* @param end defines the end vector
|
||||
* @param amount defines the interpolation factor
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Lerp(start: DeepImmutable<Vector2>, end: DeepImmutable<Vector2>, amount: number): Vector2 {
|
||||
const x = start.x + (end.x - start.x) * amount;
|
||||
const y = start.y + (end.y - start.y) * amount;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dot product of the vector "left" and the vector "right"
|
||||
* @param left defines first vector
|
||||
* @param right defines second vector
|
||||
* @returns the dot product (float)
|
||||
*/
|
||||
static Dot(left: DeepImmutable<Vector2>, right: DeepImmutable<Vector2>): number {
|
||||
return left.x * right.x + left.y * right.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 equal to the normalized given vector
|
||||
* @param vector defines the vector to normalize
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Normalize(vector: DeepImmutable<Vector2>): Vector2 {
|
||||
const newVector = Vector2.Zero();
|
||||
this.NormalizeToRef(vector, newVector);
|
||||
return newVector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a given vector into a second one
|
||||
* @param vector defines the vector to normalize
|
||||
* @param result defines the vector where to store the result
|
||||
*/
|
||||
static NormalizeToRef(vector: DeepImmutable<Vector2>, result: Vector2) {
|
||||
const len = vector.length();
|
||||
|
||||
if (len === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
result.x = vector.x / len;
|
||||
result.y = vector.y / len;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 set with the minimal coordinate values from the "left" and "right" vectors
|
||||
* @param left defines 1st vector
|
||||
* @param right defines 2nd vector
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Minimize(left: DeepImmutable<Vector2>, right: DeepImmutable<Vector2>): Vector2 {
|
||||
const x = left.x < right.x ? left.x : right.x;
|
||||
const y = left.y < right.y ? left.y : right.y;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 set with the maximal coordinate values from the "left" and "right" vectors
|
||||
* @param left defines 1st vector
|
||||
* @param right defines 2nd vector
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Maximize(left: DeepImmutable<Vector2>, right: DeepImmutable<Vector2>): Vector2 {
|
||||
const x = left.x > right.x ? left.x : right.x;
|
||||
const y = left.y > right.y ? left.y : right.y;
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Transforms the given vector coordinates by the given transformation Transform and stores the result in the vector "result" coordinates
|
||||
// * @param vector defines the vector to transform
|
||||
// * @param transformation defines the Transform to apply
|
||||
// * @param result defines the target vector
|
||||
// */
|
||||
// static Transform(vector: DeepImmutable<Vector2>, transformation: DeepImmutable<Transform>, ignoreOffset: boolean = false) {
|
||||
// const t = transformation as IKeyValue;
|
||||
// const p = vector;
|
||||
|
||||
// if (ignoreOffset) {
|
||||
// return new Vector2(t[0] * p.x + t[2] * p.y, t[1] * p.x + t[3] * p.y);
|
||||
// }
|
||||
// return new Vector2(t[0] * p.x + t[2] * p.y + t[4], t[1] * p.x + t[3] * p.y + t[5]);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Determines if a given vector is included in a triangle
|
||||
* @param p defines the vector to test
|
||||
* @param p0 defines 1st triangle point
|
||||
* @param p1 defines 2nd triangle point
|
||||
* @param p2 defines 3rd triangle point
|
||||
* @returns true if the point "p" is in the triangle defined by the vectors "p0", "p1", "p2"
|
||||
*/
|
||||
static PointInTriangle(
|
||||
p: DeepImmutable<Vector2>,
|
||||
p0: DeepImmutable<Vector2>,
|
||||
p1: DeepImmutable<Vector2>,
|
||||
p2: DeepImmutable<Vector2>
|
||||
) {
|
||||
const a = (1 / 2) * (-p1.y * p2.x + p0.y * (-p1.x + p2.x) + p0.x * (p1.y - p2.y) + p1.x * p2.y);
|
||||
const sign = a < 0 ? -1 : 1;
|
||||
const s = (p0.y * p2.x - p0.x * p2.y + (p2.y - p0.y) * p.x + (p0.x - p2.x) * p.y) * sign;
|
||||
const t = (p0.x * p1.y - p0.y * p1.x + (p0.y - p1.y) * p.x + (p1.x - p0.x) * p.y) * sign;
|
||||
|
||||
return s > 0 && t > 0 && s + t < 2 * a * sign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the distance between the vectors "value1" and "value2"
|
||||
* @param value1 defines first vector
|
||||
* @param value2 defines second vector
|
||||
* @returns the distance between vectors
|
||||
*/
|
||||
static Distance(value1: DeepImmutable<Vector2>, value2: DeepImmutable<Vector2>): number {
|
||||
return Math.sqrt(Vector2.DistanceSquared(value1, value2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the squared distance between the vectors "value1" and "value2"
|
||||
* @param value1 defines first vector
|
||||
* @param value2 defines second vector
|
||||
* @returns the squared distance between vectors
|
||||
*/
|
||||
static DistanceSquared(value1: DeepImmutable<Vector2>, value2: DeepImmutable<Vector2>): number {
|
||||
const x = value1.x - value2.x;
|
||||
const y = value1.y - value2.y;
|
||||
return x * x + y * y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 located at the center of the vectors "value1" and "value2"
|
||||
* @param value1 defines first vector
|
||||
* @param value2 defines second vector
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
static Center(value1: DeepImmutable<Vector2>, value2: DeepImmutable<Vector2>): Vector2 {
|
||||
return Vector2.CenterToRef(value1, value2, Vector2.Zero());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the center of the vectors "value1" and "value2" and stores the result in the vector "ref"
|
||||
* @param value1 defines first vector
|
||||
* @param value2 defines second vector
|
||||
* @param ref defines third vector
|
||||
* @returns ref
|
||||
*/
|
||||
static CenterToRef(
|
||||
value1: DeepImmutable<Vector2>,
|
||||
value2: DeepImmutable<Vector2>,
|
||||
ref: DeepImmutable<Vector2>
|
||||
): Vector2 {
|
||||
return ref.copyFromFloats((value1.x + value2.x) / 2, (value1.y + value2.y) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the shortest distance (float) between the point "p" and the segment defined by the two points "segA" and "segB".
|
||||
* @param p defines the middle point
|
||||
* @param segA defines one point of the segment
|
||||
* @param segB defines the other point of the segment
|
||||
* @returns the shortest distance
|
||||
*/
|
||||
static DistanceOfPointFromSegment(
|
||||
p: DeepImmutable<Vector2>,
|
||||
segA: DeepImmutable<Vector2>,
|
||||
segB: DeepImmutable<Vector2>
|
||||
): number {
|
||||
const l2 = Vector2.DistanceSquared(segA, segB);
|
||||
if (l2 === 0.0) {
|
||||
return Vector2.Distance(p, segA);
|
||||
}
|
||||
const v = segB.subtract(segA);
|
||||
const t = Math.max(0, Math.min(1, Vector2.Dot(p.subtract(segA), v) / l2));
|
||||
const proj = segA.add(v.multiplyByFloats(t, t));
|
||||
return Vector2.Distance(p, proj);
|
||||
}
|
||||
|
||||
static create(x: number, y: number) {
|
||||
return new Vector2(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a string with the Vector2 coordinates
|
||||
* @returns a string with the Vector2 coordinates
|
||||
*/
|
||||
toString(): string {
|
||||
return `{X: ${this.x} Y: ${this.y}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets class name
|
||||
* @returns the string "Vector2"
|
||||
*/
|
||||
getClassName(): string {
|
||||
return 'Vector2';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current vector hash code
|
||||
* @returns the Vector2 hash code as a number
|
||||
*/
|
||||
getHashCode(): number {
|
||||
let hash = this.x | 0;
|
||||
hash = (hash * 397) ^ (this.y | 0);
|
||||
return hash;
|
||||
}
|
||||
|
||||
// Operators
|
||||
|
||||
/**
|
||||
* Sets the Vector2 coordinates in the given array or Float32Array from the given index.
|
||||
* @param array defines the source array
|
||||
* @param index defines the offset in source array
|
||||
* @returns the current Vector2
|
||||
*/
|
||||
toArray(array: FloatArray, index: number = 0): Vector2 {
|
||||
array[index] = this.x;
|
||||
array[index + 1] = this.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current vector from an array
|
||||
* @param array defines the destination array
|
||||
* @param index defines the offset in the destination array
|
||||
* @returns the current Vector3
|
||||
*/
|
||||
fromArray(array: FloatArray, index: number = 0): Vector2 {
|
||||
Vector2.FromArrayToRef(array, index, this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current vector to an array
|
||||
* @returns a new array with 2 elements: the Vector2 coordinates.
|
||||
*/
|
||||
asArray(): number[] {
|
||||
const result = new Array<number>();
|
||||
this.toArray(result, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Vector2 coordinates with the given Vector2 coordinates
|
||||
* @param source defines the source Vector2
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
copyFrom(source: DeepImmutable<Vector2>): Vector2 {
|
||||
this.x = source.x;
|
||||
this.y = source.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Vector2 coordinates with the given floats
|
||||
* @param x defines the first coordinate
|
||||
* @param y defines the second coordinate
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
copyFromFloats(x: number, y: number): Vector2 {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Vector2 coordinates with the given floats
|
||||
* @param x defines the first coordinate
|
||||
* @param y defines the second coordinate
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
set(x: number, y: number): Vector2 {
|
||||
return this.copyFromFloats(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add another vector with the current one
|
||||
* @param otherVector defines the other vector
|
||||
* @returns a new Vector2 set with the addition of the current Vector2 and the given one coordinates
|
||||
*/
|
||||
add(otherVector: DeepImmutable<Vector2>): Vector2 {
|
||||
return new Vector2(this.x + otherVector.x, this.y + otherVector.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the "result" coordinates with the addition of the current Vector2 and the given one coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @param result defines the target vector
|
||||
* @returns the unmodified current Vector2
|
||||
*/
|
||||
addToRef(otherVector: DeepImmutable<Vector2>, result: Vector2): Vector2 {
|
||||
result.x = this.x + otherVector.x;
|
||||
result.y = this.y + otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
addByPoint(x: number, y: number) {
|
||||
return new Vector2(this.x + x, this.y + y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Vector2 coordinates by adding the given Vector2 coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
addInPlace(otherVector: DeepImmutable<Vector2>): Vector2 {
|
||||
this.x += otherVector.x;
|
||||
this.y += otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 set with the subtracted coordinates of the given one from the current Vector2
|
||||
* @param otherVector defines the other vector
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
subtract(otherVector: Vector2): Vector2 {
|
||||
return new Vector2(this.x - otherVector.x, this.y - otherVector.y);
|
||||
}
|
||||
|
||||
subtractByPoint(x: number, y: number) {
|
||||
return new Vector2(this.x - x, this.y - y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the "result" coordinates with the subtraction of the given one from the current Vector2 coordinates.
|
||||
* @param otherVector defines the other vector
|
||||
* @param result defines the target vector
|
||||
* @returns the unmodified current Vector2
|
||||
*/
|
||||
subtractToRef(otherVector: DeepImmutable<Vector2>, result: Vector2): Vector2 {
|
||||
result.x = this.x - otherVector.x;
|
||||
result.y = this.y - otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current Vector2 coordinates by subtracting from it the given one coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
subtractInPlace(otherVector: DeepImmutable<Vector2>): Vector2 {
|
||||
this.x -= otherVector.x;
|
||||
this.y -= otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies in place the current Vector2 coordinates by the given ones
|
||||
* @param otherVector defines the other vector
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
multiplyInPlace(otherVector: DeepImmutable<Vector2>): Vector2 {
|
||||
this.x *= otherVector.x;
|
||||
this.y *= otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 set with the multiplication of the current Vector2 and the given one coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
multiply(otherVector: DeepImmutable<Vector2>): Vector2 {
|
||||
return new Vector2(this.x * otherVector.x, this.y * otherVector.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets "result" coordinates with the multiplication of the current Vector2 and the given one coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @param result defines the target vector
|
||||
* @returns the unmodified current Vector2
|
||||
*/
|
||||
multiplyToRef(otherVector: DeepImmutable<Vector2>, result: Vector2): Vector2 {
|
||||
result.x = this.x * otherVector.x;
|
||||
result.y = this.y * otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 set with the Vector2 coordinates multiplied by the given floats
|
||||
* @param x defines the first coordinate
|
||||
* @param y defines the second coordinate
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
multiplyByFloats(x: number, y: number): Vector2 {
|
||||
return new Vector2(this.x * x, this.y * y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 set with the Vector2 coordinates divided by the given one coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
divide(otherVector: Vector2): Vector2 {
|
||||
return new Vector2(this.x / otherVector.x, this.y / otherVector.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the "result" coordinates with the Vector2 divided by the given one coordinates
|
||||
* @param otherVector defines the other vector
|
||||
* @param result defines the target vector
|
||||
* @returns the unmodified current Vector2
|
||||
*/
|
||||
divideToRef(otherVector: DeepImmutable<Vector2>, result: Vector2): Vector2 {
|
||||
result.x = this.x / otherVector.x;
|
||||
result.y = this.y / otherVector.y;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divides the current Vector2 coordinates by the given ones
|
||||
* @param otherVector defines the other vector
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
divideInPlace(otherVector: DeepImmutable<Vector2>): Vector2 {
|
||||
return this.divideToRef(otherVector, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 with current Vector2 negated coordinates
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
negate(): Vector2 {
|
||||
return new Vector2(-this.x, -this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Negate this vector in place
|
||||
* @returns this
|
||||
*/
|
||||
negateInPlace(): Vector2 {
|
||||
this.x *= -1;
|
||||
this.y *= -1;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Negate the current Vector2 and stores the result in the given vector "result" coordinates
|
||||
* @param result defines the Vector3 object where to store the result
|
||||
* @returns the current Vector2
|
||||
*/
|
||||
negateToRef(result: Vector2): Vector2 {
|
||||
return result.copyFromFloats(this.x * -1, this.y * -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiply the Vector2 coordinates by scale
|
||||
* @param scale defines the scaling factor
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
scaleInPlace(scale: number): Vector2 {
|
||||
this.x *= scale;
|
||||
this.y *= scale;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new Vector2 scaled by "scale" from the current Vector2
|
||||
* @param scale defines the scaling factor
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
scale(scale: number): Vector2 {
|
||||
const result = new Vector2(0, 0);
|
||||
this.scaleToRef(scale, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the current Vector2 values by a factor to a given Vector2
|
||||
* @param scale defines the scale factor
|
||||
* @param result defines the Vector2 object where to store the result
|
||||
* @returns the unmodified current Vector2
|
||||
*/
|
||||
scaleToRef(scale: number, result: Vector2): Vector2 {
|
||||
result.x = this.x * scale;
|
||||
result.y = this.y * scale;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale the current Vector2 values by a factor and add the result to a given Vector2
|
||||
* @param scale defines the scale factor
|
||||
* @param result defines the Vector2 object where to store the result
|
||||
* @returns the unmodified current Vector2
|
||||
*/
|
||||
scaleAndAddToRef(scale: number, result: Vector2): Vector2 {
|
||||
result.x += this.x * scale;
|
||||
result.y += this.y * scale;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a boolean if two vectors are equals
|
||||
* @param otherVector defines the other vector
|
||||
* @returns true if the given vector coordinates strictly equal the current Vector2 ones
|
||||
*/
|
||||
equals(otherVector: DeepImmutable<Vector2>): boolean {
|
||||
return otherVector && this.x === otherVector.x && this.y === otherVector.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 from current Vector2 floored values
|
||||
* eg (1.2, 2.31) returns (1, 2)
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
floor(): Vector2 {
|
||||
return new Vector2(Math.floor(this.x), Math.floor(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 from current Vector2 fractional values
|
||||
* eg (1.2, 2.31) returns (0.2, 0.31)
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
fract(): Vector2 {
|
||||
return new Vector2(this.x - Math.floor(this.x), this.y - Math.floor(this.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the current vector into a given result vector
|
||||
* @param angle defines the rotation angle
|
||||
* @returns the current vector
|
||||
*/
|
||||
rotate(angle: number) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const rx = cos * this.x - sin * this.y;
|
||||
const ry = sin * this.x + cos * this.y;
|
||||
this.x = rx;
|
||||
this.y = ry;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the current vector into a given result vector
|
||||
* @param angle defines the rotation angle
|
||||
* @param result defines the result vector where to store the rotated vector
|
||||
* @returns the current vector
|
||||
*/
|
||||
rotateToRef(angle: number, result: Vector2) {
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
result.x = cos * this.x - sin * this.y;
|
||||
result.y = sin * this.x + cos * this.y;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
rotateByPoint(angle: number, originPoint = Vector2.create(0, 0)) {
|
||||
const cosA = Math.cos(angle);
|
||||
const sinA = Math.sin(angle);
|
||||
const rx = originPoint.x + (this.x - originPoint.x) * cosA - (this.y - originPoint.y) * sinA;
|
||||
const ry = originPoint.y + (this.x - originPoint.x) * sinA + (this.y - originPoint.y) * cosA;
|
||||
|
||||
this.x = rx;
|
||||
this.y = ry;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
transformCoordinateOnRotate(angle: number) {
|
||||
const hypotenuse = Math.sqrt(this.x ** 2 + this.y ** 2);
|
||||
const beta = Math.atan2(this.y, this.x);
|
||||
const theta = -angle + beta;
|
||||
this.x = hypotenuse * Math.cos(theta);
|
||||
this.y = hypotenuse * Math.sin(theta);
|
||||
return this;
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
/**
|
||||
* Gets the length of the vector
|
||||
* @returns the vector length (float)
|
||||
*/
|
||||
length(): number {
|
||||
return Math.sqrt(this.x * this.x + this.y * this.y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the vector squared length
|
||||
* @returns the vector squared length (float)
|
||||
*/
|
||||
lengthSquared(): number {
|
||||
return this.x * this.x + this.y * this.y;
|
||||
}
|
||||
|
||||
// Methods
|
||||
|
||||
/**
|
||||
* Normalize the vector
|
||||
* @returns the current updated Vector2
|
||||
*/
|
||||
normalize(): Vector2 {
|
||||
Vector2.NormalizeToRef(this, this);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a new Vector2 copied from the Vector2
|
||||
* @returns a new Vector2
|
||||
*/
|
||||
clone(): Vector2 {
|
||||
return new Vector2(this.x, this.y);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface IBoundRect {
|
||||
tl: Vector2;
|
||||
tr: Vector2;
|
||||
bl: Vector2;
|
||||
br: Vector2;
|
||||
dx: number;
|
||||
dy: number;
|
||||
}
|
||||
|
||||
export interface IBoundRectNoAngle {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface IViewportInfo {
|
||||
viewBound: IBoundRectNoAngle;
|
||||
diffBounds: IBoundRectNoAngle[];
|
||||
|
||||
/**
|
||||
* scroll right further diffX < 0
|
||||
* previewBound.x - viewbound.x
|
||||
*/
|
||||
diffX: number;
|
||||
diffY: number;
|
||||
|
||||
/**
|
||||
* The physical position of the frozen rows and columns on the canvas, used for drawImage.
|
||||
* For example, if the freezing starts from the fourth column, the left position would be 4 * column + rowHeaderWidth.
|
||||
* The physical position means the top and left values have already considered the scaling factor.
|
||||
*/
|
||||
viewPortPosition: IBoundRectNoAngle;
|
||||
viewportKey: string | SHEET_VIEWPORT_KEY;
|
||||
/**
|
||||
* In the future, a number will be used to indicate the reason for the "dirty" status
|
||||
* Here, a binary value is used to facilitate computation.
|
||||
*/
|
||||
isDirty?: number;
|
||||
isForceDirty?: boolean;
|
||||
|
||||
allowCache?: boolean;
|
||||
cacheBound: IBoundRectNoAngle;
|
||||
diffCacheBounds: IBoundRectNoAngle[];
|
||||
cacheViewPortPosition: IBoundRectNoAngle;
|
||||
|
||||
shouldCacheUpdate: number;
|
||||
sceneTrans: Transform;
|
||||
cacheCanvas?: Canvas;
|
||||
|
||||
leftOrigin: number;
|
||||
topOrigin: number;
|
||||
|
||||
bufferEdgeX: number;
|
||||
bufferEdgeY: number;
|
||||
|
||||
updatePrevCacheBounds?: (viewbound: IBoundRectNoAngle) => void;
|
||||
}
|
||||
|
||||
export interface IViewportInfos {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
77
packages/engine-render/src/basics/viewport-subtract.ts
Normal file
77
packages/engine-render/src/basics/viewport-subtract.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IRange } from '@univerjs/core';
|
||||
|
||||
export function subtractViewportRange(range1: IRange, range2: IRange): IRange[] {
|
||||
// If there is no intersection, return range1.
|
||||
|
||||
if (
|
||||
range2.startRow >= range1.endRow ||
|
||||
range2.endRow <= range1.startRow ||
|
||||
range2.startColumn >= range1.endColumn ||
|
||||
range2.endColumn <= range1.startColumn
|
||||
) {
|
||||
return [range1];
|
||||
}
|
||||
|
||||
const ranges: IRange[] = [];
|
||||
|
||||
// top
|
||||
if (range2.startRow > range1.startRow) {
|
||||
ranges.push({
|
||||
startRow: range1.startRow,
|
||||
startColumn: range1.startColumn,
|
||||
endRow: range2.startRow,
|
||||
endColumn: range1.endColumn,
|
||||
});
|
||||
}
|
||||
|
||||
// bottom
|
||||
if (range2.endRow < range1.endRow) {
|
||||
ranges.push({
|
||||
startRow: range2.endRow,
|
||||
startColumn: range1.startColumn,
|
||||
endRow: range1.endRow,
|
||||
endColumn: range1.endColumn,
|
||||
});
|
||||
}
|
||||
|
||||
// left
|
||||
const topBoundary = Math.max(range1.startRow, range2.startRow);
|
||||
const bottomBoundary = Math.min(range1.endRow, range2.endRow);
|
||||
|
||||
if (range2.startColumn > range1.startColumn) {
|
||||
ranges.push({
|
||||
startRow: topBoundary,
|
||||
startColumn: range1.startColumn,
|
||||
endRow: bottomBoundary,
|
||||
endColumn: range2.startColumn,
|
||||
});
|
||||
}
|
||||
|
||||
// right
|
||||
if (range2.endColumn < range1.endColumn) {
|
||||
ranges.push({
|
||||
startRow: topBoundary,
|
||||
startColumn: range2.endColumn,
|
||||
endRow: bottomBoundary,
|
||||
endColumn: range1.endColumn,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
64
packages/engine-render/src/basics/zoom.ts
Normal file
64
packages/engine-render/src/basics/zoom.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IWheelEvent } from './i-events';
|
||||
import { EventConstants } from './i-events';
|
||||
|
||||
const MIN_ZOOM_RATIO = 0.1;
|
||||
const MAX_ZOOM_RATIO = 4;
|
||||
const PIXEL_DELTA_TO_ZOOM_RATIO = 0.008;
|
||||
const MAX_WHEEL_ZOOM_STEP = 0.14;
|
||||
const MAX_WHEEL_ZOOM_STEP_BELOW_ONE = 0.07;
|
||||
const LINE_DELTA_PIXEL_SIZE = 16;
|
||||
const PAGE_DELTA_PIXEL_SIZE = 800;
|
||||
const ZOOM_RATIO_PRECISION = 100;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeWheelDelta(event: Pick<IWheelEvent, 'deltaMode' | 'deltaX' | 'deltaY' | 'wheelDelta'>): number {
|
||||
const deltaY = Number.isFinite(event.deltaY) ? event.deltaY : 0;
|
||||
const deltaX = Number.isFinite(event.deltaX) ? event.deltaX : 0;
|
||||
let delta = Math.abs(deltaY) >= Math.abs(deltaX) ? deltaY : deltaX;
|
||||
|
||||
if (delta === 0 && typeof event.wheelDelta === 'number') {
|
||||
delta = -event.wheelDelta / 3;
|
||||
}
|
||||
|
||||
if (event.deltaMode === EventConstants.DOM_DELTA_LINE) {
|
||||
delta *= LINE_DELTA_PIXEL_SIZE;
|
||||
} else if (event.deltaMode === EventConstants.DOM_DELTA_PAGE) {
|
||||
delta *= PAGE_DELTA_PIXEL_SIZE;
|
||||
}
|
||||
|
||||
return delta;
|
||||
}
|
||||
|
||||
export function getNextWheelZoomRatio(currentRatio: number, event: Pick<IWheelEvent, 'deltaMode' | 'deltaX' | 'deltaY' | 'wheelDelta'>): number {
|
||||
const validCurrentRatio = Number.isFinite(currentRatio) ? currentRatio : 1;
|
||||
|
||||
const normalizedDelta = normalizeWheelDelta(event);
|
||||
if (normalizedDelta === 0) {
|
||||
return clamp(validCurrentRatio, MIN_ZOOM_RATIO, MAX_ZOOM_RATIO);
|
||||
}
|
||||
|
||||
const maxStep = validCurrentRatio < 1 ? MAX_WHEEL_ZOOM_STEP_BELOW_ONE : MAX_WHEEL_ZOOM_STEP;
|
||||
const ratioDelta = clamp(-normalizedDelta * PIXEL_DELTA_TO_ZOOM_RATIO, -maxStep, maxStep);
|
||||
const nextRatio = Math.round((validCurrentRatio + ratioDelta) * ZOOM_RATIO_PRECISION) / ZOOM_RATIO_PRECISION;
|
||||
|
||||
return clamp(nextRatio, MIN_ZOOM_RATIO, MAX_ZOOM_RATIO);
|
||||
}
|
||||
224
packages/engine-render/src/canvas.ts
Normal file
224
packages/engine-render/src/canvas.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR 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 { ICanvasColorService } from './services/canvas-color.service';
|
||||
import { getDevicePixelRatio } from './basics/draw';
|
||||
import { createCanvasElement } from './basics/tools';
|
||||
import { UniverPrintingContext, UniverRenderingContext } from './context';
|
||||
|
||||
/**
|
||||
* canvas render mode
|
||||
*/
|
||||
export enum CanvasRenderMode {
|
||||
/**
|
||||
* Normal canvas render mode
|
||||
*/
|
||||
Rendering,
|
||||
/**
|
||||
* Printing render mode,
|
||||
* in case of to generate high dpi pdf,
|
||||
* some canvas api was disabled by some unknown reason.
|
||||
*/
|
||||
Printing,
|
||||
}
|
||||
|
||||
interface ICanvasProps {
|
||||
colorService?: ICanvasColorService;
|
||||
id?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
pixelRatio?: number;
|
||||
mode?: CanvasRenderMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* View Renderer constructor. It is a wrapper around native canvas element.
|
||||
* Usually you don't need to use it manually.
|
||||
* @constructor
|
||||
* @abstract
|
||||
* @param {object} props
|
||||
* @param {number} props.width
|
||||
* @param {number} props.height
|
||||
* @param {number} props.pixelRatio
|
||||
*/
|
||||
export class Canvas {
|
||||
isCache = false;
|
||||
|
||||
private _pixelRatio = 1;
|
||||
private _canvasEle: Nullable<HTMLCanvasElement>;
|
||||
private _context: Nullable<UniverRenderingContext>;
|
||||
private _width = 0;
|
||||
private _height = 0;
|
||||
|
||||
constructor(_props?: ICanvasProps) {
|
||||
const props = _props || {};
|
||||
|
||||
this._canvasEle = createCanvasElement();
|
||||
// set inline styles
|
||||
this._canvasEle.style.padding = '0';
|
||||
this._canvasEle.style.margin = '0';
|
||||
this._canvasEle.style.border = '0';
|
||||
this._canvasEle.style.background = 'transparent';
|
||||
this._canvasEle.style.position = 'absolute';
|
||||
this._canvasEle.style.top = '0';
|
||||
this._canvasEle.style.left = '0';
|
||||
this._canvasEle.style.zIndex = '8';
|
||||
|
||||
this._canvasEle.dataset.uComp = 'render-canvas';
|
||||
|
||||
// focusable
|
||||
this._canvasEle.tabIndex = 1;
|
||||
this._canvasEle.style.touchAction = 'none';
|
||||
this._canvasEle.style.outline = '0';
|
||||
|
||||
const context = this._canvasEle.getContext('2d');
|
||||
if (context == null) {
|
||||
throw new Error('context is not support');
|
||||
}
|
||||
|
||||
if (props.mode === CanvasRenderMode.Printing) {
|
||||
this._context = new UniverPrintingContext(context, {
|
||||
canvasColorService: props.colorService,
|
||||
});
|
||||
} else {
|
||||
this._context = new UniverRenderingContext(context, {
|
||||
canvasColorService: props.colorService,
|
||||
});
|
||||
}
|
||||
|
||||
this.setSize(props.width, props.height, props.pixelRatio);
|
||||
}
|
||||
|
||||
getCanvasEle() {
|
||||
return this._canvasEle!;
|
||||
}
|
||||
|
||||
/**
|
||||
* get canvas context
|
||||
* @method
|
||||
* @returns {CanvasContext} context
|
||||
*/
|
||||
getContext() {
|
||||
return this._context!;
|
||||
}
|
||||
|
||||
getPixelRatio() {
|
||||
return this._pixelRatio;
|
||||
}
|
||||
|
||||
getWidth() {
|
||||
return this._width;
|
||||
}
|
||||
|
||||
getHeight() {
|
||||
return this._height;
|
||||
}
|
||||
|
||||
setId(id: string) {
|
||||
this._canvasEle!.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize canvas when width or height or devicePixelRatio changed.
|
||||
* @param width
|
||||
* @param height
|
||||
* @param devicePixelRatio
|
||||
*/
|
||||
setSize(width?: number, height?: number, devicePixelRatio?: number) {
|
||||
this._pixelRatio = devicePixelRatio || getDevicePixelRatio();
|
||||
const canvasElement = this.getCanvasEle();
|
||||
|
||||
if (canvasElement && width !== undefined) {
|
||||
// canvasElement.width & height requires integer value.
|
||||
// canvasElement.width would return a integer even you set a decimal number.
|
||||
canvasElement.width = width * this._pixelRatio;
|
||||
this._width = canvasElement.width / this._pixelRatio;
|
||||
canvasElement.style.width = `${this._width}px`;
|
||||
}
|
||||
|
||||
if (canvasElement && height !== undefined) {
|
||||
canvasElement.height = height * this._pixelRatio;
|
||||
this._height = canvasElement.height / this._pixelRatio;
|
||||
canvasElement.style.height = `${this._height}px`;
|
||||
}
|
||||
|
||||
this.getContext()?.setTransform(this._pixelRatio, 0, 0, this._pixelRatio, 0, 0);
|
||||
}
|
||||
|
||||
setPixelRatio(pixelRatio: number) {
|
||||
if (this._width === 0 || this._height === 0) {
|
||||
return;
|
||||
}
|
||||
if (pixelRatio < 1) {
|
||||
pixelRatio = 1;
|
||||
}
|
||||
this.setSize(this._width, this._height, pixelRatio);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.clear();
|
||||
this._canvasEle?.remove();
|
||||
this._canvasEle = null;
|
||||
this._context = null;
|
||||
}
|
||||
|
||||
clear() {
|
||||
const ctx = this.getContext();
|
||||
ctx.clearRect(0, 0, this._width * this._pixelRatio, this._height * this._pixelRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* to data url
|
||||
* @method
|
||||
* @param {string} mimeType
|
||||
* @param {number} quality between 0 and 1 for jpg mime types
|
||||
* @returns {string} data url string
|
||||
*/
|
||||
toDataURL(mimeType: string, quality: number) {
|
||||
try {
|
||||
// If this call fails (due to browser bug, like in Firefox 3.6),
|
||||
// then revert to previous no-parameter image/png behavior
|
||||
return this.getCanvasEle().toDataURL(mimeType, quality);
|
||||
} catch (e) {
|
||||
try {
|
||||
return this.getCanvasEle().toDataURL();
|
||||
} catch (err: unknown) {
|
||||
const { message } = err as Error;
|
||||
console.error(
|
||||
`Unable to get data URL. ${message} For more info read https://universheet.net/docs/Canvas.html.`
|
||||
);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SceneCanvas extends Canvas {
|
||||
constructor(props: ICanvasProps = { width: 0, height: 0 }) {
|
||||
super(props);
|
||||
this.setSize(props.width!, props.height!);
|
||||
}
|
||||
}
|
||||
|
||||
export class HitCanvas extends Canvas {
|
||||
hitCanvas = true;
|
||||
|
||||
constructor(props: ICanvasProps = { width: 0, height: 0 }) {
|
||||
super(props);
|
||||
this.setSize(props.width!, props.height!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IScale } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ComponentExtension } from '../extension';
|
||||
|
||||
class TestExtension extends ComponentExtension<unknown, string, unknown> {
|
||||
constructor() {
|
||||
super();
|
||||
this.uKey = 'test-extension';
|
||||
this.type = 'test';
|
||||
}
|
||||
|
||||
getParentScale(parentScale: IScale) {
|
||||
return this._getScale(parentScale);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ComponentExtension', () => {
|
||||
it('uses the larger parent scale for extension rendering math', () => {
|
||||
const extension = new TestExtension();
|
||||
|
||||
expect(extension.getParentScale({ scaleX: 1.25, scaleY: 2 })).toBe(2);
|
||||
expect(extension.getParentScale({})).toBe(1);
|
||||
});
|
||||
|
||||
it('clears transient render offsets and parent links on dispose', () => {
|
||||
const extension = new TestExtension();
|
||||
extension.parent = {} as never;
|
||||
extension.extensionOffset = {
|
||||
renderConfig: {},
|
||||
};
|
||||
|
||||
extension.dispose();
|
||||
|
||||
expect(extension.parent).toBeNull();
|
||||
expect(extension.extensionOffset).toEqual({});
|
||||
});
|
||||
});
|
||||
74
packages/engine-render/src/components/component.ts
Normal file
74
packages/engine-render/src/components/component.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDisposable } from '@univerjs/core';
|
||||
import type { IViewportInfo } from '../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../context';
|
||||
import type { ComponentExtension } from './extension';
|
||||
import { DisposableCollection, sortRules, toDisposable } from '@univerjs/core';
|
||||
import { BaseObject } from '../base-object';
|
||||
|
||||
export class RenderComponent<T, U, V> extends BaseObject {
|
||||
private _extensions = new Map<string, ComponentExtension<T, U, V>>();
|
||||
|
||||
get extensions() {
|
||||
return this._extensions;
|
||||
}
|
||||
|
||||
register(...extensions: Array<ComponentExtension<T, U, V>>): IDisposable {
|
||||
const disposableCollection = new DisposableCollection();
|
||||
for (const extension of extensions) {
|
||||
extension.parent = this;
|
||||
this._extensions.set(extension.uKey, extension);
|
||||
|
||||
disposableCollection.add(
|
||||
toDisposable(() => {
|
||||
this._extensions.delete(extension.uKey);
|
||||
extension.parent = null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.disposeWithMe(disposableCollection);
|
||||
|
||||
return disposableCollection;
|
||||
}
|
||||
|
||||
getExtensionsByOrder() {
|
||||
const extensionArray = Array.from(this._extensions.values());
|
||||
extensionArray.sort(sortRules);
|
||||
|
||||
return extensionArray;
|
||||
}
|
||||
|
||||
getExtensionByKey(uKey: string) {
|
||||
return this._extensions.get(uKey);
|
||||
}
|
||||
|
||||
draw(ctx: UniverRenderingContext, bounds?: IViewportInfo) {
|
||||
/* abstract */
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
|
||||
for (const extension of this._extensions.values()) {
|
||||
extension.dispose();
|
||||
}
|
||||
|
||||
this._extensions.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonPage } from '../../../basics/i-document-skeleton-cached';
|
||||
import { createDocumentModelWithStyle, DocumentFlavor, LocaleService, Univer } from '@univerjs/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PageLayoutType } from '../../../basics/i-document-skeleton-cached';
|
||||
import { Path, Rect } from '../../../shape';
|
||||
import { DocBackground } from '../doc-background';
|
||||
import { DocumentSkeleton } from '../layout/doc-skeleton';
|
||||
import { DocumentViewModel } from '../view-model/document-view-model';
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createPage(overrides: Partial<IDocumentSkeletonPage> = {}) {
|
||||
return {
|
||||
width: 200,
|
||||
height: 120,
|
||||
pageWidth: 200,
|
||||
pageHeight: 120,
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
originMarginTop: 16,
|
||||
originMarginBottom: 16,
|
||||
sections: [],
|
||||
...overrides,
|
||||
} as IDocumentSkeletonPage;
|
||||
}
|
||||
|
||||
function createSkeleton(pages: IDocumentSkeletonPage[], documentFlavor = DocumentFlavor.TRADITIONAL, progress?: object) {
|
||||
return {
|
||||
getSkeletonData: () => ({ pages }),
|
||||
getLayoutProgress: () => progress,
|
||||
getViewModel: () => ({
|
||||
getDataModel: () => ({
|
||||
getSnapshot: () => ({
|
||||
documentStyle: {
|
||||
documentFlavor,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createModernLayout() {
|
||||
const univer = new Univer();
|
||||
const model = createDocumentModelWithStyle('First paragraph\r\rLast paragraph\r', {});
|
||||
model.updateDocumentStyle({ documentFlavor: DocumentFlavor.MODERN, pageSize: { width: 400, height: 600 } });
|
||||
const skeleton = DocumentSkeleton.create(new DocumentViewModel(model), univer.__getInjector().get(LocaleService));
|
||||
skeleton.calculate();
|
||||
const background = DocBackground.create('modern-delayed-skeleton', skeleton);
|
||||
background.resize(400, 600);
|
||||
const ctx = Object.assign(createCtx(), {
|
||||
beginPath: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
fill: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
});
|
||||
vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
return { skeleton, background, ctx, dispose: () => {
|
||||
background.dispose();
|
||||
skeleton.dispose();
|
||||
univer.dispose();
|
||||
} };
|
||||
}
|
||||
|
||||
describe('DocBackground', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it.each(['initial', 'edit'] as const)('delays modern skeleton lines for two seconds during %s layout', (reason) => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = createModernLayout();
|
||||
const { skeleton, background, ctx } = fixture;
|
||||
try {
|
||||
skeleton.beginExternalLayout({ reason });
|
||||
background.draw(ctx);
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1_999);
|
||||
background.draw(ctx);
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
background.draw(ctx);
|
||||
expect(ctx.roundRect).toHaveBeenCalled();
|
||||
} finally {
|
||||
fixture.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('never flashes modern skeleton lines for edits completed within two seconds', () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = createModernLayout();
|
||||
const { skeleton, background, ctx } = fixture;
|
||||
try {
|
||||
for (let edit = 0; edit < 3; edit++) {
|
||||
const generation = skeleton.startIncrementalLayout({ reason: 'edit', anchor: 0 });
|
||||
background.draw(ctx);
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(skeleton.stepIncrementalLayout(generation, Number.POSITIVE_INFINITY).complete).toBe(true);
|
||||
background.draw(ctx);
|
||||
vi.advanceTimersByTime(2_000);
|
||||
background.draw(ctx);
|
||||
}
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fixture.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('starts a fresh delay after cancellation and cancels the pending timer on disposal', () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = createModernLayout();
|
||||
const { skeleton, background, ctx } = fixture;
|
||||
try {
|
||||
skeleton.beginExternalLayout({ reason: 'edit' });
|
||||
background.draw(ctx);
|
||||
vi.advanceTimersByTime(1_500);
|
||||
skeleton.cancelExternalLayout();
|
||||
skeleton.beginExternalLayout({ reason: 'edit' });
|
||||
background.draw(ctx);
|
||||
vi.advanceTimersByTime(500);
|
||||
background.draw(ctx);
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled();
|
||||
background.dispose();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
fixture.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('draws traditional workspaces with gray 100 and pages with the gray 0 theme token', () => {
|
||||
const background = DocBackground.create('traditional-background', createSkeleton([createPage()]));
|
||||
background.resize(320, 180);
|
||||
const rectDraw = vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(createCtx());
|
||||
|
||||
expect(rectDraw.mock.calls[0][1]).toMatchObject({ fill: 'gray.100' });
|
||||
expect(rectDraw.mock.calls[1][1]).toMatchObject({ fill: 'gray.0' });
|
||||
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it('draws modern workspaces with the gray 0 theme token', () => {
|
||||
const background = DocBackground.create(
|
||||
'modern-background',
|
||||
createSkeleton([createPage()], DocumentFlavor.MODERN)
|
||||
);
|
||||
background.resize(320, 180);
|
||||
const rectDraw = vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(createCtx());
|
||||
|
||||
expect(rectDraw).toHaveBeenCalledTimes(1);
|
||||
expect(rectDraw.mock.calls[0][1]).toMatchObject({ fill: 'gray.0' });
|
||||
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it('keeps unspecified documents on the legacy gray workspace and page fills', () => {
|
||||
const background = DocBackground.create(
|
||||
'unspecified-background',
|
||||
createSkeleton([createPage()], DocumentFlavor.UNSPECIFIED)
|
||||
);
|
||||
background.resize(320, 180);
|
||||
const rectDraw = vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(createCtx());
|
||||
|
||||
expect(rectDraw.mock.calls[0][1]).toMatchObject({ fill: 'gray.100' });
|
||||
expect(rectDraw.mock.calls[1][1]).toMatchObject({ fill: 'gray.50' });
|
||||
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it('positions multiple traditional pages horizontally and draws their margin identifiers', () => {
|
||||
const background = DocBackground.create(
|
||||
'horizontal-background',
|
||||
createSkeleton([createPage(), createPage({ pageWidth: 180, pageHeight: 100 })]),
|
||||
{
|
||||
pageLayoutType: PageLayoutType.HORIZONTAL,
|
||||
pageMarginLeft: 24,
|
||||
pageMarginTop: 18,
|
||||
backgroundFillColor: '#eeeeee',
|
||||
pageFillColor: '#ffffff',
|
||||
pageStrokeColor: '#999999',
|
||||
marginStrokeColor: '#3366ff',
|
||||
}
|
||||
);
|
||||
background.resize(500, 240);
|
||||
const ctx = createCtx();
|
||||
const rectDraw = vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
const pathDraw = vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(ctx);
|
||||
|
||||
expect(rectDraw).toHaveBeenCalledTimes(3);
|
||||
expect(rectDraw.mock.calls[0][1]).toMatchObject({ width: 500, height: 240, fill: '#eeeeee' });
|
||||
expect(rectDraw.mock.calls[1][1]).toMatchObject({ width: 200, height: 120, fill: '#ffffff', stroke: '#999999' });
|
||||
expect(rectDraw.mock.calls[2][1]).toMatchObject({ width: 180, height: 100, fill: '#ffffff', stroke: '#999999' });
|
||||
expect(ctx.translate).toHaveBeenNthCalledWith(1, 0, 0);
|
||||
expect(ctx.translate).toHaveBeenNthCalledWith(2, -0.5, -0.5);
|
||||
expect(ctx.translate).toHaveBeenNthCalledWith(3, 223.5, -0.5);
|
||||
expect(pathDraw).toHaveBeenCalledTimes(2);
|
||||
const firstMarginPath = pathDraw.mock.calls[0]?.[1] as any;
|
||||
expect(firstMarginPath).toMatchObject({ stroke: '#3366ff', strokeWidth: 1.5 });
|
||||
expect(firstMarginPath.dataArray[0].points).toEqual([5, 16]);
|
||||
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it('updates fill colors for the next draw and skips unchanged empty viewport paints', () => {
|
||||
const background = new DocBackground(
|
||||
'modern-background',
|
||||
createSkeleton([createPage()], DocumentFlavor.MODERN),
|
||||
{
|
||||
pageLayoutType: PageLayoutType.VERTICAL,
|
||||
pageMarginLeft: 24,
|
||||
pageMarginTop: 18,
|
||||
}
|
||||
);
|
||||
background.resize(320, 180);
|
||||
const rectDraw = vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.setFillColors('#111111', '#222222', '#333333', '#444444');
|
||||
background.draw(createCtx());
|
||||
expect(rectDraw).toHaveBeenCalledTimes(1);
|
||||
expect(rectDraw.mock.calls[0][1]).toMatchObject({ width: 320, height: 180, fill: '#111111' });
|
||||
|
||||
rectDraw.mockClear();
|
||||
background.setFillColors('#111111', '#222222', '#333333', '#444444');
|
||||
background.draw(createCtx(), {
|
||||
viewBound: { left: 10, top: 20, right: 10, bottom: 120 },
|
||||
} as any);
|
||||
expect(rectDraw).not.toHaveBeenCalled();
|
||||
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it('draws viewport-visible placeholder pages beyond the completed tail', () => {
|
||||
const background = DocBackground.create(
|
||||
'incremental-background',
|
||||
createSkeleton([createPage()], DocumentFlavor.TRADITIONAL, {
|
||||
complete: false,
|
||||
pageCount: 1,
|
||||
estimatedPageCount: 3,
|
||||
}),
|
||||
{
|
||||
pageLayoutType: PageLayoutType.VERTICAL,
|
||||
pageMarginLeft: 24,
|
||||
pageMarginTop: 18,
|
||||
}
|
||||
);
|
||||
background.resize(240, 432);
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
const ctx = {
|
||||
...createCtx(),
|
||||
beginPath: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => gradient),
|
||||
fill: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
set fillStyle(_value: unknown) {},
|
||||
} as any;
|
||||
const rectDraw = vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(ctx, {
|
||||
viewBound: { left: 0, top: 270, right: 240, bottom: 400 },
|
||||
cacheBound: { left: 0, top: 270, right: 240, bottom: 400 },
|
||||
} as any);
|
||||
|
||||
expect(rectDraw).toHaveBeenCalledTimes(2);
|
||||
expect(rectDraw.mock.calls[1][1]).toMatchObject({ width: 200, height: 120 });
|
||||
expect(ctx.fill).toHaveBeenCalled();
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it.each(['isLayoutPlaceholder', 'isMaterializationPlaceholder'] as const)(
|
||||
'draws visible skeleton lines below the first twelve rows for %s',
|
||||
(placeholderFlag) => {
|
||||
const background = DocBackground.create(
|
||||
'scrolled-placeholder-background',
|
||||
createSkeleton([createPage({ pageHeight: 1000, [placeholderFlag]: true })])
|
||||
);
|
||||
background.resize(240, 1000);
|
||||
const ctx = Object.assign(createCtx(), {
|
||||
beginPath: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
fill: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
});
|
||||
vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(ctx, {
|
||||
viewBound: { left: 0, top: 600, right: 240, bottom: 900 },
|
||||
cacheBound: { left: 0, top: 600, right: 240, bottom: 900 },
|
||||
} as any);
|
||||
|
||||
expect(ctx.roundRect.mock.calls.length).toBeGreaterThan(0);
|
||||
// Work is bounded by visible rows, not the full page height.
|
||||
expect(ctx.roundRect.mock.calls.length).toBeLessThanOrEqual(13);
|
||||
for (const [, y, , height] of ctx.roundRect.mock.calls) {
|
||||
expect(y + height).toBeGreaterThanOrEqual(600);
|
||||
expect(y).toBeLessThanOrEqual(900);
|
||||
}
|
||||
background.dispose();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps a visual layout preview free of skeleton lines', () => {
|
||||
const background = DocBackground.create(
|
||||
'visual-layout-preview-background',
|
||||
createSkeleton([createPage({ isLayoutPlaceholder: true, sections: [{} as never] })])
|
||||
);
|
||||
background.resize(240, 1000);
|
||||
const ctx = Object.assign(createCtx(), {
|
||||
beginPath: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
|
||||
fill: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
});
|
||||
vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(ctx);
|
||||
|
||||
expect(ctx.roundRect).not.toHaveBeenCalled();
|
||||
background.dispose();
|
||||
});
|
||||
|
||||
it('does not draw layout skeleton lines inside the current published traditional page', () => {
|
||||
const background = DocBackground.create(
|
||||
'incremental-current-page-background',
|
||||
createSkeleton([createPage()], DocumentFlavor.TRADITIONAL, {
|
||||
complete: false,
|
||||
pageCount: 1,
|
||||
estimatedPageCount: 1,
|
||||
publishedPageCount: 1,
|
||||
}),
|
||||
{
|
||||
pageLayoutType: PageLayoutType.VERTICAL,
|
||||
pageMarginLeft: 24,
|
||||
pageMarginTop: 18,
|
||||
}
|
||||
);
|
||||
background.resize(240, 160);
|
||||
const gradient = { addColorStop: vi.fn() };
|
||||
const ctx = Object.assign(createCtx(), {
|
||||
beginPath: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => gradient),
|
||||
fill: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
});
|
||||
vi.spyOn(Rect, 'drawWith').mockImplementation(() => {});
|
||||
vi.spyOn(Path, 'drawWith').mockImplementation(() => {});
|
||||
|
||||
background.draw(ctx);
|
||||
|
||||
expect(ctx.createLinearGradient).not.toHaveBeenCalled();
|
||||
expect(ctx.fill).not.toHaveBeenCalled();
|
||||
background.dispose();
|
||||
});
|
||||
});
|
||||
@@ -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 { IViewportInfo } from '../../../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../../../context';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RENDER_CLASS_TYPE } from '../../../basics/const';
|
||||
import { PageLayoutType } from '../../../basics/i-document-skeleton-cached';
|
||||
import { DocComponent } from '../doc-component';
|
||||
|
||||
class TestDocComponent extends DocComponent {
|
||||
readonly drawCalls: Array<{ ctx: UniverRenderingContext; bounds?: Partial<IViewportInfo> }> = [];
|
||||
|
||||
protected override _draw(ctx: UniverRenderingContext, bounds?: Partial<IViewportInfo>): void {
|
||||
this.drawCalls.push({ ctx, bounds });
|
||||
}
|
||||
}
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
transform: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createPage() {
|
||||
return {
|
||||
pageWidth: 100,
|
||||
pageHeight: 80,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('DocComponent', () => {
|
||||
it('uses default document layout config when no config is provided', () => {
|
||||
const component = new TestDocComponent('doc-component');
|
||||
|
||||
expect(component.pageMarginLeft).toBe(17);
|
||||
expect(component.pageMarginTop).toBe(14);
|
||||
expect(component.pageLayoutType).toBe(PageLayoutType.VERTICAL);
|
||||
expect(component.getSkeleton()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders only when a skeleton is attached and the component is visible', () => {
|
||||
const component = new TestDocComponent('doc-component');
|
||||
const ctx = createCtx();
|
||||
|
||||
component.render(ctx);
|
||||
expect(ctx.save).not.toHaveBeenCalled();
|
||||
|
||||
component.setSkeleton({} as any);
|
||||
component.render(ctx, { viewBound: { left: 0, top: 0, right: 100, bottom: 100 } } as IViewportInfo);
|
||||
expect(ctx.save).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.transform).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.restore).toHaveBeenCalledTimes(1);
|
||||
expect(component.drawCalls).toHaveLength(1);
|
||||
|
||||
component.hide();
|
||||
component.render(ctx);
|
||||
expect(component.drawCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses scene ancestor scale and skips pages outside the viewport', () => {
|
||||
const component = new TestDocComponent('doc-component', {} as any, {
|
||||
pageMarginLeft: 24,
|
||||
pageMarginTop: 18,
|
||||
pageLayoutType: PageLayoutType.HORIZONTAL,
|
||||
});
|
||||
component.parent = {
|
||||
classType: RENDER_CLASS_TYPE.SCENE,
|
||||
scaleX: 2,
|
||||
scaleY: 3,
|
||||
ancestorScaleX: 1.25,
|
||||
ancestorScaleY: 1.5,
|
||||
} as any;
|
||||
const bounds = { viewBound: { left: 200, top: 200, right: 300, bottom: 300 } } as IViewportInfo;
|
||||
|
||||
expect(component.pageMarginLeft).toBe(24);
|
||||
expect(component.pageMarginTop).toBe(18);
|
||||
expect(component.pageLayoutType).toBe(PageLayoutType.HORIZONTAL);
|
||||
expect(component.getParentScale()).toEqual({ scaleX: 1.25, scaleY: 1.5 });
|
||||
expect(component.isSkipByDiffBounds(createPage(), 0, 0, bounds)).toBe(true);
|
||||
expect(component.isSkipByDiffBounds(createPage(), 220, 220, bounds)).toBe(false);
|
||||
expect(component.isSkipByDiffBounds({ ...createPage(), pageWidth: Number.POSITIVE_INFINITY }, 220, 400, bounds)).toBe(false);
|
||||
expect(component.isSkipByDiffBounds({ ...createPage(), pageHeight: Number.POSITIVE_INFINITY }, 400, 220, bounds)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonBoundingBox, IDocumentSkeletonFontStyle } from '../../../basics/i-document-skeleton-cached';
|
||||
import { DocumentFlavor } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyFontMetricCompatibility, getDocumentCompatibilityPolicy } from '../document-compatibility';
|
||||
|
||||
const fontStyle = {
|
||||
fontString: 'normal bold 24pt "Calibri", Arial',
|
||||
fontSize: 24,
|
||||
originFontSize: 24,
|
||||
fontFamily: '"Calibri", Arial',
|
||||
fontCache: 'normal bold 24pt "Calibri"',
|
||||
} as IDocumentSkeletonFontStyle;
|
||||
|
||||
const bBox = {
|
||||
width: 16,
|
||||
ba: 30,
|
||||
bd: 9,
|
||||
aba: 30,
|
||||
abd: 9,
|
||||
sp: 0,
|
||||
sbr: 0.6,
|
||||
sbo: 1,
|
||||
spr: 0.6,
|
||||
spo: 1,
|
||||
} as IDocumentSkeletonBoundingBox;
|
||||
|
||||
describe('document compatibility policy', () => {
|
||||
it('applies traditional Word font metric width rules without changing modern documents', () => {
|
||||
const traditional = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
|
||||
const modern = getDocumentCompatibilityPolicy(DocumentFlavor.MODERN);
|
||||
|
||||
expect(applyFontMetricCompatibility('5', fontStyle, bBox, traditional).width).toBeCloseTo(14.72);
|
||||
expect(applyFontMetricCompatibility('5', fontStyle, bBox, modern).width).toBe(16);
|
||||
});
|
||||
|
||||
it('scales only non-bold primary Arial Latin glyphs in traditional documents', () => {
|
||||
const normalArial = {
|
||||
...fontStyle,
|
||||
fontString: 'normal normal 11.5pt Arial',
|
||||
fontSize: 11.5,
|
||||
originFontSize: 11.5,
|
||||
fontFamily: 'Arial',
|
||||
fontCache: 'normal normal 11.5pt Arial',
|
||||
} as IDocumentSkeletonFontStyle;
|
||||
const boldArial = {
|
||||
...normalArial,
|
||||
fontString: 'normal bold 11.5pt Arial',
|
||||
fontCache: 'normal bold 11.5pt Arial',
|
||||
} as IDocumentSkeletonFontStyle;
|
||||
const browserMeasuredGlyph = { ...bBox, width: 10 };
|
||||
const traditional = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
|
||||
const modern = getDocumentCompatibilityPolicy(DocumentFlavor.MODERN);
|
||||
|
||||
expect(applyFontMetricCompatibility('A', normalArial, browserMeasuredGlyph, traditional).width).toBeCloseTo(9.8);
|
||||
expect(applyFontMetricCompatibility('A', boldArial, browserMeasuredGlyph, traditional).width).toBe(10);
|
||||
expect(applyFontMetricCompatibility('A', normalArial, browserMeasuredGlyph, modern).width).toBe(10);
|
||||
expect(applyFontMetricCompatibility('中', normalArial, browserMeasuredGlyph, traditional).width).toBe(10);
|
||||
});
|
||||
|
||||
it('applies the traditional SimSun width policy when it is the East Asia fallback family', () => {
|
||||
const simSunFallback = {
|
||||
...fontStyle,
|
||||
fontString: 'normal normal 12pt "Times New Roman", 宋体',
|
||||
fontSize: 12,
|
||||
originFontSize: 12,
|
||||
fontFamily: '"Times New Roman", 宋体',
|
||||
fontCache: 'normal normal 12pt "Times New Roman", 宋体',
|
||||
} as IDocumentSkeletonFontStyle;
|
||||
const traditional = getDocumentCompatibilityPolicy(DocumentFlavor.TRADITIONAL);
|
||||
|
||||
expect(applyFontMetricCompatibility('中', simSunFallback, { ...bBox, width: 10 }, traditional).width)
|
||||
.toBeCloseTo(9.7);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface IDocsCustomBlockRenderViewportInput {
|
||||
blockLeft?: number;
|
||||
fallbackHeight: number;
|
||||
fallbackWidth: number;
|
||||
pageMarginLeft?: number;
|
||||
pageMarginRight?: number;
|
||||
pageWidth?: number;
|
||||
}
|
||||
|
||||
export interface IDocsCustomBlockRenderViewport {
|
||||
bleedLeft?: number;
|
||||
bleedWidth?: number;
|
||||
contentHeight?: number;
|
||||
contentWidth?: number;
|
||||
height: number;
|
||||
layoutWidth?: number;
|
||||
offsetLeft?: number;
|
||||
pageContentWidth?: number;
|
||||
viewScale?: number;
|
||||
viewportHeight?: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export type DocsCustomBlockRenderViewportProvider = (
|
||||
unitId: string,
|
||||
blockId: string,
|
||||
input: IDocsCustomBlockRenderViewportInput
|
||||
) => IDocsCustomBlockRenderViewport | null | undefined;
|
||||
|
||||
let docsCustomBlockRenderViewportProvider: DocsCustomBlockRenderViewportProvider | null = null;
|
||||
const docsCustomBlockRenderViewportProviders = new Set<DocsCustomBlockRenderViewportProvider>();
|
||||
|
||||
export function setDocsCustomBlockRenderViewportProvider(provider: DocsCustomBlockRenderViewportProvider | null): () => void {
|
||||
if (provider == null) {
|
||||
docsCustomBlockRenderViewportProvider = null;
|
||||
docsCustomBlockRenderViewportProviders.clear();
|
||||
return () => {};
|
||||
}
|
||||
|
||||
docsCustomBlockRenderViewportProvider = provider;
|
||||
docsCustomBlockRenderViewportProviders.add(provider);
|
||||
return () => {
|
||||
docsCustomBlockRenderViewportProviders.delete(provider);
|
||||
const providers = Array.from(docsCustomBlockRenderViewportProviders);
|
||||
docsCustomBlockRenderViewportProvider = providers[providers.length - 1] ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
export function getDocsCustomBlockRenderViewport(
|
||||
unitId: string,
|
||||
blockId: string,
|
||||
input: IDocsCustomBlockRenderViewportInput
|
||||
): IDocsCustomBlockRenderViewport | null {
|
||||
const providers = docsCustomBlockRenderViewportProviders.size
|
||||
? Array.from(docsCustomBlockRenderViewportProviders).reverse()
|
||||
: docsCustomBlockRenderViewportProvider
|
||||
? [docsCustomBlockRenderViewportProvider]
|
||||
: [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const viewport = provider(unitId, blockId, input);
|
||||
if (viewport != null) {
|
||||
return viewport;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
438
packages/engine-render/src/components/docs/doc-background.ts
Normal file
438
packages/engine-render/src/components/docs/doc-background.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonPage } from '../../basics/i-document-skeleton-cached';
|
||||
import type { IViewportInfo } from '../../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../../context';
|
||||
import type { IPathProps } from '../../shape';
|
||||
import type { IDocumentsConfig } from './doc-component';
|
||||
import type { DocumentSkeleton } from './layout/doc-skeleton';
|
||||
import { DocumentFlavor } from '@univerjs/core';
|
||||
import { Path, Rect } from '../../shape';
|
||||
import { DocComponent } from './doc-component';
|
||||
import { Liquid } from './liquid';
|
||||
|
||||
const PAGE_STROKE_COLOR = 'gray.200';
|
||||
const PAGE_FILL_COLOR = 'gray.0';
|
||||
const UNSPECIFIED_PAGE_FILL_COLOR = 'gray.50';
|
||||
const DOCS_WORKSPACE_FILL_COLOR = 'gray.100';
|
||||
const MARGIN_STROKE_COLOR = 'gray.300';
|
||||
const SKELETON_BASE_COLOR = 'rgba(148, 163, 184, 0.18)';
|
||||
const SKELETON_HIGHLIGHT_COLOR = 'rgba(255, 255, 255, 0.5)';
|
||||
const SKELETON_LINE_HEIGHT = 12;
|
||||
const SKELETON_LINE_GAP = 14;
|
||||
const CONTINUOUS_SKELETON_DELAY_MS = 2_000;
|
||||
|
||||
export class DocBackground extends DocComponent {
|
||||
private _drawLiquid: Liquid;
|
||||
private _backgroundFillColor?: string;
|
||||
private _pageFillColor?: string;
|
||||
private _pageStrokeColor?: string;
|
||||
private _marginStrokeColor?: string;
|
||||
private _pageBackgroundSource?: string;
|
||||
private _pageBackgroundImage?: HTMLImageElement;
|
||||
private _continuousSkeletonGeneration: number | undefined;
|
||||
private _continuousSkeletonTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _continuousSkeletonVisible = false;
|
||||
|
||||
constructor(oKey: string, documentSkeleton?: DocumentSkeleton, config?: IDocumentsConfig) {
|
||||
super(oKey, documentSkeleton, config);
|
||||
|
||||
this._drawLiquid = new Liquid();
|
||||
this._backgroundFillColor = config?.backgroundFillColor;
|
||||
this._pageFillColor = config?.pageFillColor;
|
||||
this._pageStrokeColor = config?.pageStrokeColor;
|
||||
this._marginStrokeColor = config?.marginStrokeColor;
|
||||
|
||||
this.makeDirty(true);
|
||||
}
|
||||
|
||||
static create(oKey: string, documentSkeleton?: DocumentSkeleton, config?: IDocumentsConfig) {
|
||||
return new DocBackground(oKey, documentSkeleton, config);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._resetContinuousSkeleton();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
setFillColors(backgroundFillColor?: string, pageFillColor?: string, pageStrokeColor?: string, marginStrokeColor?: string) {
|
||||
if (
|
||||
this._backgroundFillColor === backgroundFillColor &&
|
||||
this._pageFillColor === pageFillColor &&
|
||||
this._pageStrokeColor === pageStrokeColor &&
|
||||
this._marginStrokeColor === marginStrokeColor
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._backgroundFillColor = backgroundFillColor;
|
||||
this._pageFillColor = pageFillColor;
|
||||
this._pageStrokeColor = pageStrokeColor;
|
||||
this._marginStrokeColor = marginStrokeColor;
|
||||
this.makeDirty(true);
|
||||
}
|
||||
|
||||
override draw(ctx: UniverRenderingContext, bounds?: IViewportInfo) {
|
||||
const skeletonData = this.getSkeleton()?.getSkeletonData();
|
||||
const docDataModel = this.getSkeleton()?.getViewModel().getDataModel();
|
||||
|
||||
if (skeletonData == null || docDataModel == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { documentFlavor, background } = docDataModel.getSnapshot().documentStyle;
|
||||
|
||||
const workspaceFill = this._backgroundFillColor ?? (documentFlavor === DocumentFlavor.MODERN ? PAGE_FILL_COLOR : DOCS_WORKSPACE_FILL_COLOR);
|
||||
this._drawWorkspaceBackground(ctx, workspaceFill, bounds);
|
||||
|
||||
if (documentFlavor === DocumentFlavor.MODERN) {
|
||||
this._drawContinuousLayoutSkeleton(ctx, bounds);
|
||||
return;
|
||||
}
|
||||
|
||||
this._drawLiquid.reset();
|
||||
|
||||
const { pages } = skeletonData;
|
||||
|
||||
// broadcasting the pageTop and pageLeft for each page in the document with multiple pages.
|
||||
let pageTop = 0;
|
||||
let pageLeft = 0;
|
||||
|
||||
for (let i = 0, len = pages.length; i < len; i++) {
|
||||
const page = pages[i];
|
||||
|
||||
if (this.isSkipByDiffBounds(page, pageTop, pageLeft, bounds)) {
|
||||
const { x, y } = this._drawLiquid.translatePage(
|
||||
page,
|
||||
this.pageLayoutType,
|
||||
this.pageMarginLeft,
|
||||
this.pageMarginTop
|
||||
);
|
||||
pageLeft += x;
|
||||
pageTop += y;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Draw background and margin identifier.
|
||||
const { width, pageWidth, height, pageHeight, originMarginTop, originMarginBottom, marginLeft, marginRight } = page;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(pageLeft - 0.5, pageTop - 0.5);
|
||||
const backgroundOptions = {
|
||||
width: pageWidth ?? width,
|
||||
height: pageHeight ?? height,
|
||||
strokeWidth: 1,
|
||||
stroke: this._pageStrokeColor ?? PAGE_STROKE_COLOR,
|
||||
fill: this._pageFillColor ?? (documentFlavor === DocumentFlavor.TRADITIONAL ? PAGE_FILL_COLOR : UNSPECIFIED_PAGE_FILL_COLOR),
|
||||
zIndex: 3,
|
||||
};
|
||||
|
||||
Rect.drawWith(ctx, backgroundOptions);
|
||||
this._drawPageBackgroundImage(ctx, background?.source, pageWidth ?? width, pageHeight ?? height);
|
||||
|
||||
const IDENTIFIER_WIDTH = 15;
|
||||
const marginIdentification: IPathProps = {
|
||||
dataArray: [{
|
||||
command: 'M',
|
||||
points: [marginLeft - IDENTIFIER_WIDTH, originMarginTop],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [marginLeft, originMarginTop],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [marginLeft, originMarginTop - IDENTIFIER_WIDTH],
|
||||
}, {
|
||||
command: 'M',
|
||||
points: [pageWidth - marginRight + IDENTIFIER_WIDTH, originMarginTop],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [pageWidth - marginRight, originMarginTop],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [pageWidth - marginRight, originMarginTop - IDENTIFIER_WIDTH],
|
||||
}, {
|
||||
command: 'M',
|
||||
points: [marginLeft - IDENTIFIER_WIDTH, pageHeight - originMarginBottom],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [marginLeft, pageHeight - originMarginBottom],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [marginLeft, pageHeight - originMarginBottom + IDENTIFIER_WIDTH],
|
||||
}, {
|
||||
command: 'M',
|
||||
points: [pageWidth - marginRight + IDENTIFIER_WIDTH, pageHeight - originMarginBottom],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [pageWidth - marginRight, pageHeight - originMarginBottom],
|
||||
}, {
|
||||
command: 'L',
|
||||
points: [pageWidth - marginRight, pageHeight - originMarginBottom + IDENTIFIER_WIDTH],
|
||||
}] as unknown as IPathProps['dataArray'],
|
||||
strokeWidth: 1.5,
|
||||
stroke: this._marginStrokeColor ?? MARGIN_STROKE_COLOR,
|
||||
};
|
||||
Path.drawWith(ctx, marginIdentification);
|
||||
ctx.restore();
|
||||
|
||||
if (
|
||||
page.isMaterializationPlaceholder ||
|
||||
(page.isLayoutPlaceholder && page.sections.length === 0)
|
||||
) {
|
||||
const contentWidth = Math.max(80, pageWidth - marginLeft - marginRight);
|
||||
const contentHeight = Math.max(160, pageHeight - originMarginTop - originMarginBottom - 48);
|
||||
this._drawSkeletonLines(
|
||||
ctx,
|
||||
pageLeft + marginLeft,
|
||||
pageTop + originMarginTop + 24,
|
||||
contentWidth,
|
||||
contentHeight,
|
||||
bounds
|
||||
);
|
||||
}
|
||||
|
||||
const { x, y } = this._drawLiquid.translatePage(
|
||||
page,
|
||||
this.pageLayoutType,
|
||||
this.pageMarginLeft,
|
||||
this.pageMarginTop
|
||||
);
|
||||
|
||||
pageLeft += x;
|
||||
pageTop += y;
|
||||
}
|
||||
|
||||
this._drawPaginatedPlaceholderPages(ctx, pages[pages.length - 1], pageLeft, pageTop, bounds);
|
||||
}
|
||||
|
||||
private _drawWorkspaceBackground(ctx: UniverRenderingContext, fill: string, bounds?: IViewportInfo) {
|
||||
const visibleBound = bounds?.cacheBound ?? bounds?.viewBound;
|
||||
const left = visibleBound?.left ?? 0;
|
||||
const top = visibleBound?.top ?? 0;
|
||||
const width = visibleBound == null ? this.width : visibleBound.right - visibleBound.left;
|
||||
const height = visibleBound == null ? this.height : visibleBound.bottom - visibleBound.top;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(left, top);
|
||||
Rect.drawWith(ctx, {
|
||||
width,
|
||||
height,
|
||||
fill,
|
||||
zIndex: 0,
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _drawPaginatedPlaceholderPages(
|
||||
ctx: UniverRenderingContext,
|
||||
templatePage: IDocumentSkeletonPage,
|
||||
initialPageLeft: number,
|
||||
initialPageTop: number,
|
||||
bounds?: IViewportInfo
|
||||
): void {
|
||||
const progress = this.getSkeleton()?.getLayoutProgress?.();
|
||||
const publishedPageCount = this.getSkeleton()?.getSkeletonData()?.pages.length ?? progress?.pageCount ?? 0;
|
||||
if (progress == null || progress.complete || progress.estimatedPageCount <= publishedPageCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleBound = bounds?.cacheBound ?? bounds?.viewBound;
|
||||
let pageLeft = initialPageLeft;
|
||||
let pageTop = initialPageTop;
|
||||
|
||||
for (let pageIndex = publishedPageCount; pageIndex < progress.estimatedPageCount; pageIndex++) {
|
||||
const isVisible = visibleBound == null || !(
|
||||
pageLeft + templatePage.pageWidth < visibleBound.left ||
|
||||
pageLeft > visibleBound.right ||
|
||||
pageTop + templatePage.pageHeight < visibleBound.top ||
|
||||
pageTop > visibleBound.bottom
|
||||
);
|
||||
|
||||
if (isVisible) {
|
||||
ctx.save();
|
||||
ctx.translate(pageLeft - 0.5, pageTop - 0.5);
|
||||
Rect.drawWith(ctx, {
|
||||
width: templatePage.pageWidth,
|
||||
height: templatePage.pageHeight,
|
||||
strokeWidth: 1,
|
||||
stroke: this._pageStrokeColor ?? PAGE_STROKE_COLOR,
|
||||
fill: this._pageFillColor ?? PAGE_FILL_COLOR,
|
||||
zIndex: 3,
|
||||
});
|
||||
ctx.restore();
|
||||
|
||||
const contentLeft = pageLeft + templatePage.marginLeft;
|
||||
const contentTop = pageTop + templatePage.marginTop + 24;
|
||||
const contentWidth = Math.max(
|
||||
80,
|
||||
templatePage.pageWidth - templatePage.marginLeft - templatePage.marginRight
|
||||
);
|
||||
const contentHeight = Math.max(
|
||||
160,
|
||||
templatePage.pageHeight - templatePage.marginTop - templatePage.marginBottom - 48
|
||||
);
|
||||
this._drawSkeletonLines(ctx, contentLeft, contentTop, contentWidth, contentHeight, bounds);
|
||||
}
|
||||
|
||||
const offset = this._drawLiquid.translatePage(
|
||||
templatePage,
|
||||
this.pageLayoutType,
|
||||
this.pageMarginLeft,
|
||||
this.pageMarginTop
|
||||
);
|
||||
pageLeft += offset.x;
|
||||
pageTop += offset.y;
|
||||
}
|
||||
}
|
||||
|
||||
private _drawContinuousLayoutSkeleton(ctx: UniverRenderingContext, bounds?: IViewportInfo): void {
|
||||
const skeleton = this.getSkeleton();
|
||||
const progress = skeleton?.getLayoutProgress?.();
|
||||
const page = skeleton?.getSkeletonData()?.pages[0];
|
||||
if (progress == null || progress.complete || progress.cancelled || page == null) {
|
||||
this._resetContinuousSkeleton();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._continuousSkeletonGeneration !== progress.generation) {
|
||||
this._resetContinuousSkeleton();
|
||||
this._continuousSkeletonGeneration = progress.generation;
|
||||
// Short edits finish without painting a loading overlay or animating the background.
|
||||
this._continuousSkeletonTimer = setTimeout(() => {
|
||||
this._continuousSkeletonTimer = null;
|
||||
const latest = this.getSkeleton()?.getLayoutProgress?.();
|
||||
if (this.getSkeleton() !== skeleton || latest == null || latest.generation !== progress.generation || latest.complete || latest.cancelled) {
|
||||
this._resetContinuousSkeleton();
|
||||
return;
|
||||
}
|
||||
this._continuousSkeletonVisible = true;
|
||||
this.makeDirty(true);
|
||||
}, CONTINUOUS_SKELETON_DELAY_MS);
|
||||
}
|
||||
if (!this._continuousSkeletonVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleBound = bounds?.cacheBound ?? bounds?.viewBound;
|
||||
const startX = page.marginLeft;
|
||||
const startY = Math.max(page.marginTop, page.height + 20);
|
||||
const width = Math.max(80, page.pageWidth - page.marginLeft - page.marginRight);
|
||||
const visibleBottom = visibleBound?.bottom ?? startY + 320;
|
||||
this._drawSkeletonLines(ctx, startX, startY, width, Math.max(160, visibleBottom - startY), bounds);
|
||||
}
|
||||
|
||||
private _resetContinuousSkeleton(): void {
|
||||
if (this._continuousSkeletonTimer != null) {
|
||||
clearTimeout(this._continuousSkeletonTimer);
|
||||
this._continuousSkeletonTimer = null;
|
||||
}
|
||||
this._continuousSkeletonGeneration = undefined;
|
||||
this._continuousSkeletonVisible = false;
|
||||
}
|
||||
|
||||
private _drawSkeletonLines(
|
||||
ctx: UniverRenderingContext,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
bounds?: IViewportInfo
|
||||
): void {
|
||||
if (height <= SKELETON_LINE_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleBound = bounds?.cacheBound ?? bounds?.viewBound;
|
||||
const animationPhase = (Date.now() % 1200) / 1200;
|
||||
const highlightWidth = Math.max(80, width * 0.35);
|
||||
const highlightLeft = left - highlightWidth + (width + highlightWidth * 2) * animationPhase;
|
||||
const gradient = ctx.createLinearGradient(highlightLeft, 0, highlightLeft + highlightWidth, 0);
|
||||
gradient.addColorStop(0, SKELETON_BASE_COLOR);
|
||||
gradient.addColorStop(0.5, SKELETON_HIGHLIGHT_COLOR);
|
||||
gradient.addColorStop(1, SKELETON_BASE_COLOR);
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = gradient;
|
||||
const lineStep = SKELETON_LINE_HEIGHT + SKELETON_LINE_GAP;
|
||||
const lineCount = Math.ceil(height / lineStep);
|
||||
// Keep the page-local row grid, but paint only rows intersecting the
|
||||
// cache/viewport. A fixed first-twelve-row cap leaves scrolled pages blank.
|
||||
const firstLine = visibleBound == null
|
||||
? 0
|
||||
: Math.max(0, Math.ceil((visibleBound.top - top - SKELETON_LINE_HEIGHT) / lineStep));
|
||||
const endLine = visibleBound == null
|
||||
? Math.min(12, lineCount)
|
||||
: Math.min(lineCount, Math.floor((visibleBound.bottom - top) / lineStep) + 1);
|
||||
for (let index = firstLine; index < endLine; index++) {
|
||||
const y = top + index * lineStep;
|
||||
const lineWidth = index % 4 === 3 ? width * 0.62 : width * (0.82 + (index % 3) * 0.07);
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(left, y, lineWidth, SKELETON_LINE_HEIGHT, SKELETON_LINE_HEIGHT / 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Keep only the lightweight background layer animating while layout is incomplete.
|
||||
this.makeDirty(true);
|
||||
}
|
||||
|
||||
private _drawPageBackgroundImage(ctx: UniverRenderingContext, source: string | undefined, width: number, height: number) {
|
||||
if (!source || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const image = this._getPageBackgroundImage(source);
|
||||
if (!image.complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.drawImage(image, 0, 0, width, height);
|
||||
}
|
||||
|
||||
private _getPageBackgroundImage(source: string) {
|
||||
if (this._pageBackgroundSource === source && this._pageBackgroundImage != null) {
|
||||
return this._pageBackgroundImage;
|
||||
}
|
||||
|
||||
const image = document.createElement('img');
|
||||
image.crossOrigin = 'anonymous';
|
||||
image.onload = () => this.makeDirty(true);
|
||||
image.src = source;
|
||||
this._pageBackgroundSource = source;
|
||||
this._pageBackgroundImage = image;
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
changeSkeleton(newSkeleton: DocumentSkeleton) {
|
||||
if (newSkeleton !== this.getSkeleton()) {
|
||||
this._resetContinuousSkeleton();
|
||||
}
|
||||
this.setSkeleton(newSkeleton);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
protected override _draw(ctx: UniverRenderingContext, bounds?: IViewportInfo) {
|
||||
this.draw(ctx, bounds);
|
||||
}
|
||||
}
|
||||
167
packages/engine-render/src/components/docs/doc-component.ts
Normal file
167
packages/engine-render/src/components/docs/doc-component.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
IDocumentSkeletonGlyph,
|
||||
IDocumentSkeletonLine,
|
||||
IDocumentSkeletonPage,
|
||||
} from '../../basics/i-document-skeleton-cached';
|
||||
import type { IBoundRectNoAngle, IViewportInfo } from '../../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../../context';
|
||||
import type { DOCS_EXTENSION_TYPE } from './doc-extension';
|
||||
import type { DocumentSkeleton } from './layout/doc-skeleton';
|
||||
import { RENDER_CLASS_TYPE } from '../../basics/const';
|
||||
import { PageLayoutType } from '../../basics/i-document-skeleton-cached';
|
||||
import { RenderComponent } from '../component';
|
||||
|
||||
export interface IPageMarginLayout {
|
||||
pageMarginLeft: number;
|
||||
pageMarginTop: number;
|
||||
pageLayoutType?: PageLayoutType;
|
||||
}
|
||||
|
||||
export interface IDocumentsConfig extends IPageMarginLayout {
|
||||
hasEditor?: boolean;
|
||||
backgroundFillColor?: string;
|
||||
pageFillColor?: string;
|
||||
pageStrokeColor?: string;
|
||||
marginStrokeColor?: string;
|
||||
/** Called after an image used by a text fill finishes loading. */
|
||||
onTextFillImageLoaded?: () => void;
|
||||
}
|
||||
|
||||
export abstract class DocComponent extends RenderComponent<
|
||||
IDocumentSkeletonGlyph | IDocumentSkeletonLine,
|
||||
DOCS_EXTENSION_TYPE,
|
||||
IBoundRectNoAngle[]
|
||||
> {
|
||||
pageMarginLeft: number = 0;
|
||||
|
||||
pageMarginTop: number = 0;
|
||||
|
||||
pageLayoutType: PageLayoutType = PageLayoutType.VERTICAL;
|
||||
|
||||
readonly onTextFillImageLoaded: () => void;
|
||||
|
||||
constructor(
|
||||
oKey: string,
|
||||
private _skeleton?: DocumentSkeleton,
|
||||
config?: IDocumentsConfig
|
||||
) {
|
||||
super(oKey);
|
||||
|
||||
this.onTextFillImageLoaded = () => {
|
||||
// Picture text fills are unavailable during the first paint; loading must invalidate the document.
|
||||
this.makeDirty(true);
|
||||
config?.onTextFillImageLoaded?.();
|
||||
};
|
||||
this._setConfig(config);
|
||||
}
|
||||
|
||||
getSkeleton() {
|
||||
return this._skeleton;
|
||||
}
|
||||
|
||||
setSkeleton(skeleton: DocumentSkeleton) {
|
||||
this._skeleton = skeleton;
|
||||
}
|
||||
|
||||
private _setConfig(config?: IDocumentsConfig) {
|
||||
if (config?.pageMarginLeft != null) {
|
||||
this.pageMarginLeft = config?.pageMarginLeft;
|
||||
} else {
|
||||
this.pageMarginLeft = 17;
|
||||
}
|
||||
|
||||
if (config?.pageMarginTop != null) {
|
||||
this.pageMarginTop = config?.pageMarginTop;
|
||||
} else {
|
||||
this.pageMarginTop = 14;
|
||||
}
|
||||
|
||||
if (config?.pageLayoutType != null) {
|
||||
this.pageLayoutType = config?.pageLayoutType;
|
||||
} else {
|
||||
this.pageLayoutType = PageLayoutType.VERTICAL;
|
||||
}
|
||||
}
|
||||
|
||||
override render(mainCtx: UniverRenderingContext, bounds?: Partial<IViewportInfo>) {
|
||||
if (!this.visible) {
|
||||
this.makeDirty(false);
|
||||
return this;
|
||||
}
|
||||
|
||||
if (!this._skeleton) {
|
||||
return;
|
||||
}
|
||||
|
||||
const m = this.transform.getMatrix();
|
||||
|
||||
mainCtx.save();
|
||||
|
||||
mainCtx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
||||
this._draw(mainCtx, bounds);
|
||||
mainCtx.restore();
|
||||
}
|
||||
|
||||
getParentScale() {
|
||||
if (!this.parent) {
|
||||
return { scaleX: 1, scaleY: 1 };
|
||||
}
|
||||
let { scaleX = 1, scaleY = 1 } = this.parent;
|
||||
|
||||
if (this.parent.classType === RENDER_CLASS_TYPE.SCENE) {
|
||||
scaleX = this.parent.ancestorScaleX || 1;
|
||||
scaleY = this.parent.ancestorScaleY || 1;
|
||||
}
|
||||
|
||||
return {
|
||||
scaleX,
|
||||
scaleY,
|
||||
};
|
||||
}
|
||||
|
||||
isSkipByDiffBounds(page: IDocumentSkeletonPage, pageTop: number, pageLeft: number, bounds?: IViewportInfo) {
|
||||
if (bounds === null || bounds === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { pageWidth, pageHeight, marginBottom, marginTop, marginLeft, marginRight } = page;
|
||||
|
||||
const pageRight = pageLeft + pageWidth + marginLeft + marginRight;
|
||||
|
||||
const pageBottom = pageTop + pageHeight + marginBottom + marginTop;
|
||||
|
||||
const { left, top, right, bottom } = bounds.viewBound;
|
||||
|
||||
if (pageRight < left || pageBottom < top) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pageLeft > right && pageWidth !== Number.POSITIVE_INFINITY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pageTop > bottom && pageHeight !== Number.POSITIVE_INFINITY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected abstract _draw(ctx: UniverRenderingContext, bounds?: Partial<IViewportInfo>): void;
|
||||
}
|
||||
38
packages/engine-render/src/components/docs/doc-extension.ts
Normal file
38
packages/engine-render/src/components/docs/doc-extension.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonGlyph, IDocumentSkeletonLine } from '../../basics/i-document-skeleton-cached';
|
||||
import type { IBoundRectNoAngle } from '../../basics/vector2';
|
||||
import { ComponentExtension } from '../extension';
|
||||
|
||||
export enum DOCS_EXTENSION_TYPE {
|
||||
SPAN,
|
||||
LINE,
|
||||
/** Draws range or block backgrounds before inline backgrounds and text. */
|
||||
BACKGROUND,
|
||||
}
|
||||
|
||||
export class docExtension extends ComponentExtension<
|
||||
IDocumentSkeletonGlyph | IDocumentSkeletonLine,
|
||||
DOCS_EXTENSION_TYPE,
|
||||
IBoundRectNoAngle
|
||||
> {
|
||||
override type = DOCS_EXTENSION_TYPE.SPAN;
|
||||
|
||||
override translateX = 0;
|
||||
|
||||
override translateY = 0;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ITable } from '@univerjs/core';
|
||||
import type {
|
||||
IDocumentSkeletonBoundingBox,
|
||||
IDocumentSkeletonFontStyle,
|
||||
} from '../../basics/i-document-skeleton-cached';
|
||||
import { DocumentFlavor } from '@univerjs/core';
|
||||
|
||||
interface IFontMetricScaleRule {
|
||||
fontFamily: RegExp;
|
||||
minFontSize?: number;
|
||||
fontString?: RegExp;
|
||||
content?: RegExp;
|
||||
widthScale?: number;
|
||||
}
|
||||
|
||||
export interface IDocumentCompatibilityPolicy {
|
||||
mode: 'modern' | 'traditional' | 'unspecified';
|
||||
applyDocumentDefaultParagraphStyle: boolean;
|
||||
useWordStyleLineHeight: boolean;
|
||||
font: {
|
||||
metricScaleRules: IFontMetricScaleRule[];
|
||||
};
|
||||
table: {
|
||||
currentPageOverflowTolerance: number;
|
||||
rowOverflowTolerance: number;
|
||||
allowImportedTableMarginOverflow: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const MODERN_DOCUMENT_COMPATIBILITY_POLICY: IDocumentCompatibilityPolicy = {
|
||||
mode: 'modern',
|
||||
applyDocumentDefaultParagraphStyle: true,
|
||||
useWordStyleLineHeight: true,
|
||||
font: {
|
||||
metricScaleRules: [],
|
||||
},
|
||||
table: {
|
||||
currentPageOverflowTolerance: 0,
|
||||
rowOverflowTolerance: 0,
|
||||
allowImportedTableMarginOverflow: false,
|
||||
},
|
||||
};
|
||||
|
||||
const TRADITIONAL_DOCUMENT_COMPATIBILITY_POLICY: IDocumentCompatibilityPolicy = {
|
||||
mode: 'traditional',
|
||||
applyDocumentDefaultParagraphStyle: false,
|
||||
useWordStyleLineHeight: true,
|
||||
font: {
|
||||
metricScaleRules: [
|
||||
{
|
||||
fontFamily: /^arial$/i,
|
||||
fontString: /^\S+\s+normal\s+\d+(?:\.\d+)?pt\s+["']?Arial["']?(?:,|$)/i,
|
||||
content: /^[\u0000-\u024F\u2000-\u206F]$/u,
|
||||
widthScale: 0.98,
|
||||
},
|
||||
{
|
||||
fontFamily: /^calibri$/i,
|
||||
minFontSize: 20,
|
||||
fontString: /\bbold\b/i,
|
||||
content: /^[\d/]+$/u,
|
||||
widthScale: 0.92,
|
||||
},
|
||||
{
|
||||
fontFamily: /^(?:宋体|SimSun)$/i,
|
||||
content: /^[\u2E80-\u2FFF\u31C0-\u31EF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]$/u,
|
||||
widthScale: 0.97,
|
||||
},
|
||||
],
|
||||
},
|
||||
table: {
|
||||
currentPageOverflowTolerance: 12,
|
||||
rowOverflowTolerance: 4,
|
||||
allowImportedTableMarginOverflow: true,
|
||||
},
|
||||
};
|
||||
|
||||
const UNSPECIFIED_DOCUMENT_COMPATIBILITY_POLICY: IDocumentCompatibilityPolicy = {
|
||||
mode: 'unspecified',
|
||||
applyDocumentDefaultParagraphStyle: false,
|
||||
useWordStyleLineHeight: false,
|
||||
font: {
|
||||
metricScaleRules: [],
|
||||
},
|
||||
table: {
|
||||
currentPageOverflowTolerance: 0,
|
||||
rowOverflowTolerance: 0,
|
||||
allowImportedTableMarginOverflow: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function getDocumentCompatibilityPolicy(documentFlavor?: DocumentFlavor): IDocumentCompatibilityPolicy {
|
||||
if (documentFlavor === DocumentFlavor.MODERN) {
|
||||
return MODERN_DOCUMENT_COMPATIBILITY_POLICY;
|
||||
}
|
||||
|
||||
if (documentFlavor === DocumentFlavor.TRADITIONAL) {
|
||||
return TRADITIONAL_DOCUMENT_COMPATIBILITY_POLICY;
|
||||
}
|
||||
|
||||
return UNSPECIFIED_DOCUMENT_COMPATIBILITY_POLICY;
|
||||
}
|
||||
|
||||
export function applyFontMetricCompatibility(
|
||||
content: string,
|
||||
fontStyle: IDocumentSkeletonFontStyle,
|
||||
bBox: IDocumentSkeletonBoundingBox,
|
||||
policy: IDocumentCompatibilityPolicy
|
||||
): IDocumentSkeletonBoundingBox {
|
||||
const fontFamilies = fontStyle.fontFamily
|
||||
.split(',')
|
||||
.map((item) => item.trim().replace(/^['"]|['"]$/g, ''));
|
||||
|
||||
const rule = policy.font.metricScaleRules.find((rule) =>
|
||||
(rule.minFontSize == null || fontStyle.originFontSize >= rule.minFontSize) &&
|
||||
(rule.fontString == null || rule.fontString.test(fontStyle.fontString)) &&
|
||||
(rule.content == null || rule.content.test(content)) &&
|
||||
fontFamilies.some((family) => rule.fontFamily.test(family))
|
||||
);
|
||||
|
||||
if (rule?.widthScale == null) {
|
||||
return bBox;
|
||||
}
|
||||
|
||||
return {
|
||||
...bBox,
|
||||
width: bBox.width * rule.widthScale,
|
||||
};
|
||||
}
|
||||
|
||||
export function isTraditionalDocumentCompatibility(policy?: IDocumentCompatibilityPolicy): boolean {
|
||||
return policy?.mode === 'traditional';
|
||||
}
|
||||
|
||||
export function shouldAllowImportedTableMarginOverflow(
|
||||
policy: IDocumentCompatibilityPolicy,
|
||||
tableSource: ITable | unknown
|
||||
): boolean {
|
||||
if (!policy.table.allowImportedTableMarginOverflow || tableSource == null || typeof tableSource !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const table = tableSource as Partial<ITable>;
|
||||
return table.size?.width != null;
|
||||
}
|
||||
1867
packages/engine-render/src/components/docs/document.ts
Normal file
1867
packages/engine-render/src/components/docs/document.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { Background } from '../background';
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
fillRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createGlyph(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: 'A',
|
||||
width: 24,
|
||||
ts: {
|
||||
bg: { rgb: '#ff0000' },
|
||||
},
|
||||
parent: {
|
||||
parent: {
|
||||
contentHeight: 12,
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('docs background extension', () => {
|
||||
it('draws span background using the span start point and line height', () => {
|
||||
const extension = new Background();
|
||||
extension.extensionOffset = {
|
||||
spanStartPoint: Vector2.create(10, 20),
|
||||
} as any;
|
||||
const ctx = createCtx();
|
||||
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph());
|
||||
|
||||
expect(ctx.fillStyle).toBe('#ff0000');
|
||||
expect(ctx.fillRect).toHaveBeenCalledWith(9.5, 21, 25, 14);
|
||||
});
|
||||
|
||||
it('skips spans without layout line, background color or visible content', () => {
|
||||
const extension = new Background();
|
||||
const ctx = createCtx();
|
||||
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph({ parent: null }));
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph({ ts: {} }));
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph({ content: '\r' }));
|
||||
|
||||
expect(ctx.fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears cached color state', () => {
|
||||
const extension = new Background() as any;
|
||||
extension._preBackgroundColor = '#fff';
|
||||
|
||||
extension.clearCache();
|
||||
|
||||
expect(extension._preBackgroundColor).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BorderStyleTypes } from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BORDER_TYPE } from '../../../../basics/const';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { Border } from '../border';
|
||||
|
||||
const drawLineByBorderTypeMock = vi.fn((..._args: any[]) => undefined);
|
||||
const getLineWidthMock = vi.fn((..._args: any[]) => 2);
|
||||
const setLineTypeMock = vi.fn((..._args: any[]) => undefined);
|
||||
|
||||
vi.mock('../../../../basics/draw', () => ({
|
||||
drawLineByBorderType: (...args: any[]) => drawLineByBorderTypeMock(...args),
|
||||
getLineWidth: (...args: any[]) => getLineWidthMock(...args),
|
||||
setLineType: (...args: any[]) => setLineTypeMock(...args),
|
||||
}));
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
translateWithPrecisionRatio: vi.fn(),
|
||||
setLineWidthByPrecision: vi.fn(),
|
||||
getScale: vi.fn(() => ({ scaleX: 2, scaleY: 2 })),
|
||||
strokeStyle: '',
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createGlyph(borderData?: any) {
|
||||
return {
|
||||
ts: borderData ? { bd: borderData } : undefined,
|
||||
left: 0,
|
||||
width: 40,
|
||||
parent: {
|
||||
parent: {
|
||||
asc: 12,
|
||||
lineHeight: 24,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('docs border extension', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns early without line, text style or border data', () => {
|
||||
const extension = new Border();
|
||||
const ctx = createCtx();
|
||||
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, { ts: { bd: {} } } as any);
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph());
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, { ...createGlyph(), ts: {} } as any);
|
||||
|
||||
expect(ctx.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws each span border and reuses cached line style/color until cleared', () => {
|
||||
const extension = new Border();
|
||||
extension.extensionOffset = { spanStartPoint: Vector2.create(5, 7) } as any;
|
||||
const ctx = createCtx();
|
||||
const borderData = {
|
||||
t: { s: BorderStyleTypes.THIN, cl: { rgb: '#111111' } },
|
||||
b: { s: BorderStyleTypes.THIN, cl: { rgb: '#111111' } },
|
||||
l: { s: BorderStyleTypes.DASHED, cl: { rgb: '#222222' } },
|
||||
r: { s: BorderStyleTypes.DASHED },
|
||||
};
|
||||
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph(borderData));
|
||||
|
||||
expect(ctx.save).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.translateWithPrecisionRatio).toHaveBeenCalled();
|
||||
expect(drawLineByBorderTypeMock).toHaveBeenCalledTimes(4);
|
||||
expect(drawLineByBorderTypeMock).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
BORDER_TYPE.TOP,
|
||||
0.25,
|
||||
expect.objectContaining({
|
||||
startX: 5,
|
||||
startY: 7,
|
||||
endX: 45,
|
||||
endY: 31,
|
||||
})
|
||||
);
|
||||
expect(setLineTypeMock).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.setLineWidthByPrecision).toHaveBeenCalledWith(2);
|
||||
expect(ctx.strokeStyle).toBe('rgb(0,0,0)');
|
||||
|
||||
extension.clearCache();
|
||||
extension.draw(ctx, { scaleX: 1, scaleY: 1 }, createGlyph({
|
||||
t: { s: BorderStyleTypes.THIN, cl: { rgb: '#333333' } },
|
||||
}));
|
||||
|
||||
expect(setLineTypeMock).toHaveBeenCalledTimes(3);
|
||||
expect(ctx.restore).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IScale } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../../basics/i-document-skeleton-cached';
|
||||
import type { UniverRenderingContext } from '../../../../context';
|
||||
import type { IExtensionConfig } from '../../../extension';
|
||||
import { BaselineOffset } from '@univerjs/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { COLOR_BLACK_RGB } from '../../../../basics/const';
|
||||
import { GlyphType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { CheckboxShape } from '../../../../shape/checkbox';
|
||||
import { FontAndBaseLine } from '../font-and-base-line';
|
||||
|
||||
type MockRenderContext = UniverRenderingContext & {
|
||||
save: ReturnType<typeof vi.fn>;
|
||||
restore: ReturnType<typeof vi.fn>;
|
||||
fillText: ReturnType<typeof vi.fn>;
|
||||
translate: ReturnType<typeof vi.fn>;
|
||||
rotate: ReturnType<typeof vi.fn>;
|
||||
createLinearGradient: ReturnType<typeof vi.fn>;
|
||||
createRadialGradient: ReturnType<typeof vi.fn>;
|
||||
createPattern: ReturnType<typeof vi.fn>;
|
||||
_context: {
|
||||
createConicGradient: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
type GlyphOverrides = Omit<Partial<IDocumentSkeletonGlyph>, 'bBox' | 'parent'> & {
|
||||
bBox?: Partial<IDocumentSkeletonGlyph['bBox']>;
|
||||
parent?: IDocumentSkeletonGlyph['parent'] | null;
|
||||
};
|
||||
|
||||
interface ITestableFontAndBaseLine {
|
||||
_clamp: (value: number, min: number, max: number) => number;
|
||||
_colorWithOpacity: (color: string, opacity?: number) => string;
|
||||
_getTextFillImage: (source: string) => HTMLImageElement | null;
|
||||
_normalizeGradientStops: (stops: undefined, fallbackColor: string) => unknown;
|
||||
_textFillImageCache: Map<string, unknown>;
|
||||
draw: FontAndBaseLine['draw'];
|
||||
extensionOffset: IExtensionConfig;
|
||||
parent: { onTextFillImageLoaded?: () => void };
|
||||
}
|
||||
|
||||
const DEFAULT_SCALE: IScale = { scaleX: 1, scaleY: 1 };
|
||||
|
||||
function createContext(): MockRenderContext {
|
||||
const gradient = {
|
||||
addColorStop: vi.fn(),
|
||||
};
|
||||
return {
|
||||
fillStyle: '',
|
||||
filter: 'none',
|
||||
font: '10px Arial',
|
||||
globalCompositeOperation: 'source-over',
|
||||
shadowColor: 'transparent',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
translate: vi.fn(),
|
||||
rotate: vi.fn(),
|
||||
createLinearGradient: vi.fn(() => gradient),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
createPattern: vi.fn(() => ({ setTransform: vi.fn() })),
|
||||
_context: {
|
||||
createConicGradient: vi.fn(() => gradient),
|
||||
},
|
||||
} as unknown as MockRenderContext;
|
||||
}
|
||||
|
||||
function createGlyph(content: string, overrides?: GlyphOverrides): IDocumentSkeletonGlyph {
|
||||
const baseGlyph = {
|
||||
content,
|
||||
raw: content,
|
||||
width: 16,
|
||||
left: 0,
|
||||
xOffset: 0,
|
||||
glyphType: GlyphType.WORD,
|
||||
streamType: 'LETTER',
|
||||
count: 1,
|
||||
adjustability: {
|
||||
stretchability: [0, 0],
|
||||
shrinkability: [0, 0],
|
||||
},
|
||||
isJustifiable: false,
|
||||
parent: {
|
||||
parent: {},
|
||||
},
|
||||
bBox: {
|
||||
width: 10,
|
||||
aba: 10,
|
||||
abd: 2,
|
||||
sbo: 2,
|
||||
spo: 3,
|
||||
},
|
||||
fontStyle: {
|
||||
fontString: '12px Arial',
|
||||
},
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#223344' },
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...baseGlyph,
|
||||
...overrides,
|
||||
parent: overrides?.parent === null ? undefined : overrides?.parent ?? baseGlyph.parent,
|
||||
bBox: {
|
||||
...baseGlyph.bBox,
|
||||
...overrides?.bBox,
|
||||
},
|
||||
} as unknown as IDocumentSkeletonGlyph;
|
||||
}
|
||||
|
||||
describe('docs font and baseline extension', () => {
|
||||
it('keeps existing document text colors unchanged', () => {
|
||||
const screenExtension = new FontAndBaseLine();
|
||||
const ScreenContext = createContext();
|
||||
ScreenContext.__mode = 'rendering';
|
||||
screenExtension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(12, 20),
|
||||
spanStartPoint: Vector2.create(10, 10),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
};
|
||||
|
||||
screenExtension.draw(ScreenContext, DEFAULT_SCALE, createGlyph('Header', {
|
||||
ts: { fs: 12 },
|
||||
}));
|
||||
|
||||
expect(ScreenContext.fillStyle).toBe(COLOR_BLACK_RGB);
|
||||
|
||||
screenExtension.draw(ScreenContext, DEFAULT_SCALE, createGlyph('Explicit black', {
|
||||
ts: { fs: 12, cl: { rgb: '#000000' } },
|
||||
}));
|
||||
|
||||
expect(ScreenContext.fillStyle).toBe('#000000');
|
||||
});
|
||||
|
||||
it('draws footnote separator marks with their measured width and text color', () => {
|
||||
const extension = new FontAndBaseLine();
|
||||
const SeparatorContext = Object.assign(createContext(), {
|
||||
beginPath: vi.fn(),
|
||||
moveToByPrecision: vi.fn(),
|
||||
lineToByPrecision: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
});
|
||||
extension.extensionOffset = { spanPointWithFont: Vector2.create(12, 20) };
|
||||
extension.draw(SeparatorContext, DEFAULT_SCALE, createGlyph(' ', { noteSeparator: true, width: 192 }));
|
||||
expect(SeparatorContext.moveToByPrecision).toHaveBeenCalledWith(12, 20);
|
||||
expect(SeparatorContext.lineToByPrecision).toHaveBeenCalledWith(204, 20);
|
||||
expect(SeparatorContext.strokeStyle).toBe('#223344');
|
||||
expect(SeparatorContext.stroke).toHaveBeenCalledOnce();
|
||||
expect(SeparatorContext.fillText).not.toHaveBeenCalled();
|
||||
expect(SeparatorContext.restore).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders public glow and outer shadow behind the source text without canvas filters', () => {
|
||||
const extension = new FontAndBaseLine();
|
||||
const TestContext = createContext();
|
||||
const effectsDuringDraw: Array<{ composite: string; color: string; blur: number; offsetX: number; offsetY: number }> = [];
|
||||
TestContext.fillText.mockImplementation(() => {
|
||||
effectsDuringDraw.push({
|
||||
composite: TestContext.globalCompositeOperation,
|
||||
color: TestContext.shadowColor,
|
||||
blur: TestContext.shadowBlur,
|
||||
offsetX: TestContext.shadowOffsetX,
|
||||
offsetY: TestContext.shadowOffsetY,
|
||||
});
|
||||
});
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(12, 20),
|
||||
spanStartPoint: Vector2.create(10, 10),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
};
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('A', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#223344' },
|
||||
glow: {
|
||||
color: '#5b9bd5',
|
||||
radius: 4,
|
||||
},
|
||||
outerShadow: {
|
||||
color: '#000000',
|
||||
opacity: 0.4,
|
||||
blurRadius: 3,
|
||||
distance: 2,
|
||||
direction: 0,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
expect(TestContext.fillText).toHaveBeenCalledTimes(2);
|
||||
expect(effectsDuringDraw[0]).toEqual({
|
||||
composite: 'source-over',
|
||||
color: '#5b9bd5',
|
||||
blur: 2,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
});
|
||||
expect(effectsDuringDraw[1]).toEqual({
|
||||
composite: 'source-over',
|
||||
color: 'rgba(0,0,0,0.4)',
|
||||
blur: 3,
|
||||
offsetX: 2,
|
||||
offsetY: 0,
|
||||
});
|
||||
expect(TestContext.filter).toBe('none');
|
||||
expect(TestContext.save).toHaveBeenCalledTimes(1);
|
||||
expect(TestContext.restore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles text drawing with baseline offsets and vertical text branch', () => {
|
||||
const extension = new FontAndBaseLine();
|
||||
const TestContext = createContext();
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(12, 20),
|
||||
spanStartPoint: Vector2.create(10, 10),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('A'));
|
||||
expect(TestContext.fillText).toHaveBeenCalledWith('A', 12, 20);
|
||||
|
||||
const superscript = createGlyph('S', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
va: BaselineOffset.SUPERSCRIPT,
|
||||
},
|
||||
});
|
||||
extension.draw(TestContext, DEFAULT_SCALE, superscript);
|
||||
expect(TestContext.fillText).toHaveBeenCalledWith('S', 12, 17);
|
||||
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(12, 20),
|
||||
spanStartPoint: Vector2.create(10, 10),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
const subscript = createGlyph('s', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
va: BaselineOffset.SUBSCRIPT,
|
||||
},
|
||||
});
|
||||
extension.draw(TestContext, DEFAULT_SCALE, subscript);
|
||||
expect(TestContext.fillText).toHaveBeenCalledWith('s', 12, 22);
|
||||
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(16, 26),
|
||||
spanStartPoint: Vector2.create(16, 26),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 90,
|
||||
centerAngle: 90,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('X'));
|
||||
expect(TestContext.rotate).toHaveBeenCalled();
|
||||
|
||||
TestContext.fillText.mockClear();
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('阿', {
|
||||
bBox: {
|
||||
width: 12,
|
||||
aba: 10,
|
||||
abd: 2,
|
||||
ba: 10,
|
||||
bd: 2,
|
||||
sp: 0,
|
||||
sbr: 0,
|
||||
sbo: 2,
|
||||
spr: 0,
|
||||
spo: 3,
|
||||
},
|
||||
}));
|
||||
expect(TestContext.fillText).toHaveBeenCalledWith('阿', 18, 22);
|
||||
});
|
||||
|
||||
it('renders checkbox list glyphs and clears cache', () => {
|
||||
const extension = new FontAndBaseLine();
|
||||
const TestContext = createContext();
|
||||
const checkSpy = vi.spyOn(CheckboxShape, 'drawWith').mockImplementation(() => undefined);
|
||||
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(30, 50),
|
||||
spanStartPoint: Vector2.create(24, 44),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
|
||||
const checkedGlyph = createGlyph('\u2611', {
|
||||
glyphType: GlyphType.LIST,
|
||||
bBox: {
|
||||
width: 18,
|
||||
aba: 14,
|
||||
abd: 4,
|
||||
},
|
||||
ts: {
|
||||
fs: 10,
|
||||
cl: { rgb: '#111111' },
|
||||
},
|
||||
});
|
||||
extension.draw(TestContext, DEFAULT_SCALE, checkedGlyph);
|
||||
expect(TestContext.translate).toHaveBeenCalledWith(29.5, 35.5);
|
||||
expect(checkSpy).toHaveBeenCalledWith(TestContext, {
|
||||
width: 18,
|
||||
height: 18,
|
||||
checked: true,
|
||||
});
|
||||
|
||||
extension.clearCache();
|
||||
extension.extensionOffset = {
|
||||
renderConfig: {},
|
||||
} as IExtensionConfig;
|
||||
TestContext.fillText.mockClear();
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('A', { parent: null }));
|
||||
expect(TestContext.fillText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders solid and gradient text fills with normalized colors', () => {
|
||||
const extension = new FontAndBaseLine() as unknown as ITestableFontAndBaseLine;
|
||||
const TestContext = createContext();
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(10, 24),
|
||||
spanStartPoint: Vector2.create(10, 24),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('S', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#000000' },
|
||||
textFill: {
|
||||
type: 'solid',
|
||||
color: '#abc',
|
||||
opacity: 0.5,
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext.fillStyle).toBe('rgba(170, 187, 204, 0.5)');
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('L', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: {
|
||||
type: 'gradient',
|
||||
color: '#123456',
|
||||
opacity: 0.5,
|
||||
gradient: {
|
||||
type: 'linear',
|
||||
angle: 45,
|
||||
stops: [
|
||||
{ offset: 0, color: '#000000', opacity: 1 },
|
||||
{ offset: 100, color: '#ffffff', opacity: 0.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext.createLinearGradient).toHaveBeenCalled();
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('R', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: {
|
||||
type: 'gradient',
|
||||
color: '#123456',
|
||||
gradient: { type: 'radial' },
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('D', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: {
|
||||
type: 'gradient',
|
||||
color: '#123456',
|
||||
gradient: { type: 'diamond' },
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext.createRadialGradient).toHaveBeenCalledTimes(2);
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('A', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: {
|
||||
type: 'gradient',
|
||||
color: '#123456',
|
||||
gradient: { type: 'angular', angle: 180 },
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext._context.createConicGradient).toHaveBeenCalled();
|
||||
|
||||
expect(extension._normalizeGradientStops(undefined, '#010203')).toEqual([
|
||||
{ color: '#010203', offset: 0, opacity: undefined },
|
||||
{ color: '#ffffff', offset: 1, opacity: undefined },
|
||||
]);
|
||||
expect(extension._colorWithOpacity('#123456', 0.25)).toBe('rgba(18, 52, 86, 0.25)');
|
||||
expect(extension._colorWithOpacity('rgb(1,2,3)', 0.25)).toBe('rgb(1,2,3)');
|
||||
expect(extension._clamp(2, 0, 1)).toBe(1);
|
||||
});
|
||||
|
||||
it('renders picture text fills from cached images and ignores unsupported fill cases', () => {
|
||||
const extension = new FontAndBaseLine() as unknown as ITestableFontAndBaseLine;
|
||||
const TestContext = createContext();
|
||||
const image = document.createElement('canvas') as HTMLCanvasElement & {
|
||||
complete: boolean;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
image.width = 4;
|
||||
image.height = 4;
|
||||
image.complete = true;
|
||||
image.naturalWidth = 4;
|
||||
image.naturalHeight = 4;
|
||||
extension._textFillImageCache.set('cached-image', image);
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(10, 24),
|
||||
spanStartPoint: Vector2.create(10, 24),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 0,
|
||||
centerAngle: 0,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('P', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: {
|
||||
type: 'picture',
|
||||
picture: {
|
||||
source: 'cached-image',
|
||||
mode: 'tile',
|
||||
scaleX: 0.5,
|
||||
scaleY: 0.5,
|
||||
offsetX: 1,
|
||||
offsetY: 1,
|
||||
opacity: 0.75,
|
||||
},
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext.createPattern).toHaveBeenCalled();
|
||||
|
||||
TestContext.fillText.mockClear();
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('N', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: { type: 'none' },
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext.fillText).not.toHaveBeenCalled();
|
||||
|
||||
extension.extensionOffset = {
|
||||
spanPointWithFont: Vector2.create(10, 24),
|
||||
spanStartPoint: Vector2.create(10, 24),
|
||||
centerPoint: Vector2.create(8, 8),
|
||||
renderConfig: {
|
||||
vertexAngle: 90,
|
||||
centerAngle: 90,
|
||||
},
|
||||
} as IExtensionConfig;
|
||||
extension.draw(TestContext, DEFAULT_SCALE, createGlyph('V', {
|
||||
ts: {
|
||||
fs: 12,
|
||||
cl: { rgb: '#111111' },
|
||||
textFill: {
|
||||
type: 'solid',
|
||||
color: '#ff0000',
|
||||
},
|
||||
} as IDocumentSkeletonGlyph['ts'],
|
||||
}));
|
||||
expect(TestContext.fillText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads picture text fills without forcing anonymous CORS mode', () => {
|
||||
const extension = new FontAndBaseLine() as unknown as ITestableFontAndBaseLine;
|
||||
const NativeImage = window.Image;
|
||||
class TestImage {
|
||||
complete = false;
|
||||
crossOrigin: string | null = null;
|
||||
src = '';
|
||||
}
|
||||
window.Image = TestImage as never;
|
||||
|
||||
try {
|
||||
extension._getTextFillImage('https://github.com/dream-num.png');
|
||||
const image = extension._textFillImageCache.get('https://github.com/dream-num.png') as TestImage;
|
||||
|
||||
expect(image.crossOrigin).toBeNull();
|
||||
expect(image.src).toBe('https://github.com/dream-num.png');
|
||||
} finally {
|
||||
window.Image = NativeImage;
|
||||
}
|
||||
});
|
||||
|
||||
it('notifies the document when a picture text fill finishes loading', () => {
|
||||
const extension = new FontAndBaseLine() as unknown as ITestableFontAndBaseLine;
|
||||
const onTextFillImageLoaded = vi.fn();
|
||||
extension.parent = { onTextFillImageLoaded };
|
||||
const NativeImage = window.Image;
|
||||
class TestImage {
|
||||
complete = false;
|
||||
onload: (() => void) | null = null;
|
||||
src = '';
|
||||
}
|
||||
window.Image = TestImage as never;
|
||||
|
||||
try {
|
||||
extension._getTextFillImage('https://example.com/text-fill.png');
|
||||
const image = extension._textFillImageCache.get('https://example.com/text-fill.png') as TestImage;
|
||||
|
||||
expect(image.onload).toBeTypeOf('function');
|
||||
image.onload?.();
|
||||
expect(onTextFillImageLoaded).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
window.Image = NativeImage;
|
||||
}
|
||||
});
|
||||
|
||||
it('notifies every document waiting for the same picture text fill', () => {
|
||||
const extension = new FontAndBaseLine() as unknown as ITestableFontAndBaseLine;
|
||||
const firstDocumentLoaded = vi.fn();
|
||||
const secondDocumentLoaded = vi.fn();
|
||||
const NativeImage = window.Image;
|
||||
class TestImage {
|
||||
complete = false;
|
||||
onload: (() => void) | null = null;
|
||||
src = '';
|
||||
}
|
||||
window.Image = TestImage as never;
|
||||
|
||||
try {
|
||||
extension.parent = { onTextFillImageLoaded: firstDocumentLoaded };
|
||||
extension._getTextFillImage('https://example.com/shared-text-fill.png');
|
||||
extension.parent = { onTextFillImageLoaded: secondDocumentLoaded };
|
||||
extension._getTextFillImage('https://example.com/shared-text-fill.png');
|
||||
|
||||
const image = extension._textFillImageCache.get('https://example.com/shared-text-fill.png') as TestImage;
|
||||
image.onload?.();
|
||||
|
||||
expect(firstDocumentLoaded).toHaveBeenCalledOnce();
|
||||
expect(secondDocumentLoaded).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
window.Image = NativeImage;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { BaselineOffset, BooleanNumber, TextDecoration } from '@univerjs/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Line } from '../line';
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
setLineDash: vi.fn(),
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function createGlyph() {
|
||||
return {
|
||||
content: 'A',
|
||||
left: 10,
|
||||
width: 12,
|
||||
ts: {
|
||||
cl: { rgb: '#333333' },
|
||||
ul: { s: BooleanNumber.TRUE, t: TextDecoration.SINGLE },
|
||||
st: { s: BooleanNumber.TRUE, t: TextDecoration.WAVY_DOUBLE },
|
||||
ol: { s: BooleanNumber.TRUE, t: TextDecoration.DASH_DOT_DOT_HEAVY, c: BooleanNumber.FALSE, cl: { rgb: '#00ff00' } },
|
||||
bbl: { s: BooleanNumber.TRUE, t: TextDecoration.DOUBLE },
|
||||
va: BaselineOffset.SUPERSCRIPT,
|
||||
},
|
||||
bBox: {
|
||||
sp: 2,
|
||||
spo: 1,
|
||||
sbo: 3,
|
||||
bd: 8,
|
||||
},
|
||||
parent: {
|
||||
parent: {
|
||||
asc: 6,
|
||||
dsc: 2,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('docs line extension', () => {
|
||||
it('draws underline/bottom-border/strikethrough/overline', () => {
|
||||
const line = new Line();
|
||||
(line as any).extensionOffset = {};
|
||||
|
||||
const ctx = createCtx();
|
||||
const glyph = createGlyph();
|
||||
line.draw(ctx, { scaleX: 1, scaleY: 1 } as any, glyph);
|
||||
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
expect(ctx.stroke).toHaveBeenCalled();
|
||||
expect(ctx.moveTo).toHaveBeenCalled();
|
||||
expect(ctx.lineTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles early return and baseline offset variants', () => {
|
||||
const line = new Line();
|
||||
(line as any).extensionOffset = {};
|
||||
|
||||
const ctx = createCtx();
|
||||
|
||||
const emptyGlyph = createGlyph();
|
||||
emptyGlyph.content = '\r';
|
||||
line.draw(ctx, { scaleX: 1, scaleY: 1 } as any, emptyGlyph);
|
||||
expect(ctx.beginPath).not.toHaveBeenCalled();
|
||||
|
||||
const subGlyph = createGlyph();
|
||||
subGlyph.ts.va = BaselineOffset.SUBSCRIPT;
|
||||
line.draw(ctx, { scaleX: 1, scaleY: 1 } as any, subGlyph);
|
||||
expect(ctx.beginPath).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('draws accounting underline across the cell view bound once', () => {
|
||||
const line = new Line();
|
||||
line.extensionOffset = {};
|
||||
|
||||
const ctx = createCtx();
|
||||
const glyph = createGlyph();
|
||||
glyph.ts.ul.t = TextDecoration.DOUBLE_ACCOUNTING;
|
||||
glyph.parent.parent.divides = [glyph.parent];
|
||||
glyph.parent.glyphGroup = [glyph, { ...glyph }];
|
||||
|
||||
line.draw(ctx, { scaleX: 1, scaleY: 1 }, glyph, undefined, {
|
||||
viewRanges: [],
|
||||
viewportKey: '',
|
||||
viewBound: { left: 0, top: 0, right: 40, bottom: 20 },
|
||||
});
|
||||
|
||||
expect(ctx.moveTo.mock.calls[0][0]).toBeCloseTo(0);
|
||||
expect(ctx.moveTo.mock.calls[0][1]).toBeCloseTo(7.2);
|
||||
expect(ctx.lineTo.mock.calls[0][0]).toBeCloseTo(40);
|
||||
expect(ctx.lineTo.mock.calls[0][1]).toBeCloseTo(7.2);
|
||||
expect(ctx.lineTo.mock.calls[1][0]).toBeCloseTo(40);
|
||||
expect(ctx.lineTo.mock.calls[1][1]).toBeCloseTo(9.2);
|
||||
});
|
||||
|
||||
it('covers private line helpers and line type switch', () => {
|
||||
const line = new Line() as any;
|
||||
line.extensionOffset = {};
|
||||
const ctx = createCtx();
|
||||
const glyph = createGlyph();
|
||||
|
||||
line._drawLine(ctx, glyph, { s: BooleanNumber.FALSE }, 10, 1);
|
||||
expect(ctx.beginPath).not.toHaveBeenCalled();
|
||||
|
||||
line._setLineType(ctx, TextDecoration.SINGLE, 5);
|
||||
expect(ctx.lineWidth).toBe(1);
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([0]);
|
||||
|
||||
line._setLineType(ctx, TextDecoration.DOTTED_HEAVY, 5);
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([2]);
|
||||
|
||||
line._setLineType(ctx, TextDecoration.DASH_LONG, 5);
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([6]);
|
||||
|
||||
line._setLineType(ctx, TextDecoration.DOT_DASH, 5);
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([2, 5, 2]);
|
||||
|
||||
line._setLineType(ctx, TextDecoration.DASH_DOT_DOT_HEAVY, 5);
|
||||
expect(ctx.setLineDash).toHaveBeenLastCalledWith([2, 2, 5, 2, 2]);
|
||||
|
||||
line._setLineType(ctx, TextDecoration.WAVY_HEAVY, 5);
|
||||
expect(ctx.lineWidth).toBe(2);
|
||||
|
||||
line._setLineType(ctx, 999, 7);
|
||||
expect(ctx.lineWidth).toBe(7);
|
||||
|
||||
line._drawLineTo(ctx, 0, 4, 2, TextDecoration.WAVE);
|
||||
const waveCalls = ctx.lineTo.mock.calls.length;
|
||||
line._drawLineTo(ctx, 0, 2, 3, TextDecoration.SINGLE);
|
||||
expect(ctx.lineTo.mock.calls.length).toBeGreaterThan(waveCalls);
|
||||
|
||||
expect(line._isWave(TextDecoration.WAVE)).toBe(true);
|
||||
expect(line._isWave(TextDecoration.SINGLE)).toBe(false);
|
||||
expect(line._isDouble(TextDecoration.WAVY_DOUBLE)).toBe(true);
|
||||
expect(line._isDouble(TextDecoration.DOUBLE_ACCOUNTING)).toBe(true);
|
||||
expect(line._isDouble(TextDecoration.DASH)).toBe(false);
|
||||
expect(line._isAccounting(TextDecoration.SINGLE_ACCOUNTING)).toBe(true);
|
||||
expect(line._isAccounting(TextDecoration.SINGLE)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears cache state', () => {
|
||||
const line = new Line() as any;
|
||||
line._preBackgroundColor = '#fff';
|
||||
line.clearCache();
|
||||
expect(line._preBackgroundColor).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
|
||||
import { getColorStyle } from '@univerjs/core';
|
||||
|
||||
export interface IBackgroundGlyphRun {
|
||||
glyph: IDocumentSkeletonGlyph;
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function collectBackgroundGlyphRuns(glyphGroup: IDocumentSkeletonGlyph[]): IBackgroundGlyphRun[] {
|
||||
const runs: IBackgroundGlyphRun[] = [];
|
||||
|
||||
let activeGlyph: IDocumentSkeletonGlyph | null = null;
|
||||
let activeColor = '';
|
||||
let activeLeft = 0;
|
||||
let activeRight = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (!activeGlyph) {
|
||||
return;
|
||||
}
|
||||
|
||||
runs.push({
|
||||
glyph: {
|
||||
...activeGlyph,
|
||||
width: activeRight - activeLeft,
|
||||
},
|
||||
left: activeLeft,
|
||||
width: activeRight - activeLeft,
|
||||
});
|
||||
|
||||
activeGlyph = null;
|
||||
activeColor = '';
|
||||
activeLeft = 0;
|
||||
activeRight = 0;
|
||||
};
|
||||
|
||||
for (const glyph of glyphGroup) {
|
||||
if (!glyph.content || glyph.content === '\r') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
const backgroundColor = glyph.ts?.bg ? getColorStyle(glyph.ts.bg) : '';
|
||||
if (!backgroundColor) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
const glyphLeft = glyph.left;
|
||||
const glyphRight = glyph.left + glyph.width;
|
||||
|
||||
if (activeGlyph && activeColor === backgroundColor) {
|
||||
activeRight = Math.max(activeRight, glyphRight);
|
||||
continue;
|
||||
}
|
||||
|
||||
flush();
|
||||
activeGlyph = glyph;
|
||||
activeColor = backgroundColor;
|
||||
activeLeft = glyphLeft;
|
||||
activeRight = glyphRight;
|
||||
}
|
||||
|
||||
flush();
|
||||
|
||||
return runs;
|
||||
}
|
||||
@@ -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 { IScale } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
|
||||
import type { UniverRenderingContext } from '../../../context';
|
||||
import { getColorStyle } from '@univerjs/core';
|
||||
import { Vector2 } from '../../../basics/vector2';
|
||||
import { DocumentsSpanAndLineExtensionRegistry } from '../../extension';
|
||||
import { docExtension } from '../doc-extension';
|
||||
|
||||
const UNIQUE_KEY = 'DefaultDocsBackgroundExtension';
|
||||
|
||||
const DOC_EXTENSION_Z_INDEX = 10;
|
||||
|
||||
export class Background extends docExtension {
|
||||
override uKey = UNIQUE_KEY;
|
||||
|
||||
override Z_INDEX = DOC_EXTENSION_Z_INDEX;
|
||||
|
||||
private _preBackgroundColor = '';
|
||||
|
||||
override draw(ctx: UniverRenderingContext, parentScale: IScale, span: IDocumentSkeletonGlyph) {
|
||||
const line = span.parent?.parent;
|
||||
if (line == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { contentHeight = 0 } = line;
|
||||
const { ts: textStyle, width, content } = span;
|
||||
if (textStyle?.bg == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { bg } = textStyle;
|
||||
|
||||
const background = getColorStyle(bg);
|
||||
|
||||
const { spanStartPoint = Vector2.create(0, 0) } = this.extensionOffset;
|
||||
|
||||
const DELTA = 1;
|
||||
|
||||
if (background && content !== '\r') {
|
||||
ctx.fillStyle = background;
|
||||
ctx.fillRect(spanStartPoint.x - 0.5, spanStartPoint.y + 1, width + 1, contentHeight + 2 * DELTA);
|
||||
}
|
||||
}
|
||||
|
||||
override clearCache() {
|
||||
this._preBackgroundColor = '';
|
||||
}
|
||||
}
|
||||
|
||||
DocumentsSpanAndLineExtensionRegistry.add(new Background());
|
||||
114
packages/engine-render/src/components/docs/extensions/border.ts
Normal file
114
packages/engine-render/src/components/docs/extensions/border.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { BorderStyleTypes, IBorderData, IBorderStyleData, IScale, Nullable } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
|
||||
import type { UniverRenderingContext } from '../../../context';
|
||||
import { getColorStyle } from '@univerjs/core';
|
||||
import { BORDER_TYPE, COLOR_BLACK_RGB, FIX_ONE_PIXEL_BLUR_OFFSET } from '../../../basics/const';
|
||||
import { drawLineByBorderType, getLineWidth, setLineType } from '../../../basics/draw';
|
||||
import { Vector2 } from '../../../basics/vector2';
|
||||
import { DocumentsSpanAndLineExtensionRegistry } from '../../extension';
|
||||
import { docExtension } from '../doc-extension';
|
||||
|
||||
const UNIQUE_KEY = 'DefaultDocsBorderExtension';
|
||||
|
||||
export class Border extends docExtension {
|
||||
override uKey = UNIQUE_KEY;
|
||||
|
||||
override Z_INDEX = 50;
|
||||
|
||||
private _preBorderStyle: Nullable<BorderStyleTypes>;
|
||||
|
||||
private _preBorderColor = '';
|
||||
|
||||
override draw(ctx: UniverRenderingContext, parentScale: IScale, glyph: IDocumentSkeletonGlyph) {
|
||||
const line = glyph.parent?.parent;
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { asc: maxLineAsc = 0, lineHeight = 0 } = line;
|
||||
|
||||
const { ts: textStyle, left, width: spanWidth } = glyph;
|
||||
if (!textStyle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { bd: borderData } = textStyle;
|
||||
if (!borderData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const precisionScale = this._getScale(ctx.getScale());
|
||||
|
||||
const borderCache = this._createBorderCache(borderData);
|
||||
|
||||
ctx.save();
|
||||
|
||||
ctx.translateWithPrecisionRatio(FIX_ONE_PIXEL_BLUR_OFFSET, FIX_ONE_PIXEL_BLUR_OFFSET);
|
||||
|
||||
const { spanStartPoint = Vector2.create(0, 0) } = this.extensionOffset;
|
||||
|
||||
for (const type of borderCache.keys()) {
|
||||
const borderCacheValue = borderCache.get(type);
|
||||
if (!borderCacheValue) {
|
||||
continue;
|
||||
}
|
||||
const { s: style, cl: colorStyle } = borderCacheValue;
|
||||
const color = getColorStyle(colorStyle) || COLOR_BLACK_RGB;
|
||||
|
||||
const lineWidth = getLineWidth(style);
|
||||
|
||||
if (style !== this._preBorderStyle) {
|
||||
setLineType(ctx, style);
|
||||
ctx.setLineWidthByPrecision(lineWidth);
|
||||
this._preBorderStyle = style;
|
||||
}
|
||||
|
||||
if (color !== this._preBorderColor) {
|
||||
ctx.strokeStyle = color || COLOR_BLACK_RGB;
|
||||
this._preBorderColor = color;
|
||||
}
|
||||
|
||||
drawLineByBorderType(ctx, type as BORDER_TYPE, (lineWidth - 1) / 2 / precisionScale, {
|
||||
startX: spanStartPoint.x,
|
||||
startY: spanStartPoint.y,
|
||||
endX: spanStartPoint.x + spanWidth,
|
||||
endY: spanStartPoint.y + lineHeight,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
override clearCache() {
|
||||
this._preBorderStyle = null;
|
||||
this._preBorderColor = '';
|
||||
}
|
||||
|
||||
private _createBorderCache(borderData: IBorderData) {
|
||||
const { t, b, l, r } = borderData;
|
||||
const borderCache = new Map<BORDER_TYPE, Nullable<IBorderStyleData>>();
|
||||
t && borderCache.set(BORDER_TYPE.TOP, t);
|
||||
b && borderCache.set(BORDER_TYPE.BOTTOM, b);
|
||||
l && borderCache.set(BORDER_TYPE.LEFT, l);
|
||||
r && borderCache.set(BORDER_TYPE.RIGHT, r);
|
||||
return borderCache;
|
||||
}
|
||||
}
|
||||
|
||||
DocumentsSpanAndLineExtensionRegistry.add(new Border());
|
||||
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocTextFill, IDocTextFillGradientStop, IScale, TabStopLeader } from '@univerjs/core';
|
||||
import type { IBoundRectNoAngle } from '../../../basics';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
|
||||
import type { UniverRenderingContext } from '../../../context';
|
||||
import type { IDrawInfo } from '../../extension';
|
||||
import { BaselineOffset } from '@univerjs/core';
|
||||
import { GlyphType } from '../../../basics';
|
||||
import { cjk } from '../../../basics/cjk-regexp';
|
||||
import { COLOR_BLACK_RGB } from '../../../basics/const';
|
||||
import { resolveGlowEffect, resolveOuterShadowEffect } from '../../../basics/drawing-effect';
|
||||
import { Vector2 } from '../../../basics/vector2';
|
||||
import { CheckboxShape, isCheckboxGlyph } from '../../../shape/checkbox';
|
||||
import { DocumentsSpanAndLineExtensionRegistry } from '../../extension';
|
||||
import { docExtension } from '../doc-extension';
|
||||
import { getColorStyleForCanvas } from '../layout/style/color';
|
||||
|
||||
const UNIQUE_KEY = 'DefaultDocsFontAndBaseLineExtension';
|
||||
|
||||
const DOC_EXTENSION_Z_INDEX = 20;
|
||||
|
||||
/**
|
||||
* Singleton
|
||||
*/
|
||||
export class FontAndBaseLine extends docExtension {
|
||||
override uKey = UNIQUE_KEY;
|
||||
|
||||
override Z_INDEX = DOC_EXTENSION_Z_INDEX;
|
||||
|
||||
private _preFontColor = '';
|
||||
|
||||
/**
|
||||
* ctx.font = val; then ctx.font is not exactly the same as val
|
||||
* that is because canvas would normalize the font string, remove default value and convert pt to px.
|
||||
* so we need a map to store actual value and set value
|
||||
*/
|
||||
actualFontMap: Record<string, string> = {};
|
||||
|
||||
private _textFillImageCache = new Map<string, HTMLImageElement>();
|
||||
|
||||
private _textFillImageLoadListeners = new Map<string, Set<() => void>>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
// invoked by document.ts
|
||||
override draw(ctx: UniverRenderingContext, _parentScale: IScale, glyph: IDocumentSkeletonGlyph, _?: IBoundRectNoAngle, _more?: IDrawInfo) {
|
||||
// _parentScale: IScale, _skeleton: T, _diffBounds?: V, _more?: IDrawInfo
|
||||
|
||||
const line = glyph.parent?.parent;
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ts: textStyle, content, fontStyle, bBox } = glyph;
|
||||
|
||||
const { spanPointWithFont = Vector2.create(0, 0) } = this.extensionOffset;
|
||||
|
||||
// I don't know what this code does. It affects the rendering of Rotate up. After commenting it out, it works normally.
|
||||
// if (more) {
|
||||
// if (more.viewBound) {
|
||||
// // ctx.fillText('', x, y), the 'y' is the baseline of a character, not the left top of the character
|
||||
// if (spanPointWithFont.x > more.viewBound.right || spanPointWithFont.y - glyph.bBox.aba > more.viewBound.bottom) {
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
if (content == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textStyle) {
|
||||
this._fillText(ctx, glyph, spanPointWithFont);
|
||||
return;
|
||||
}
|
||||
|
||||
const fontStringPxStr = fontStyle?.fontString || '';
|
||||
if (fontStringPxStr) {
|
||||
if (ctx.font !== this.actualFontMap[fontStringPxStr]) {
|
||||
ctx.font = fontStringPxStr;
|
||||
this.actualFontMap[fontStringPxStr] = ctx.font;
|
||||
}
|
||||
}
|
||||
|
||||
const { cl: colorStyle, va: baselineOffset, textFill, glow, outerShadow } = textStyle;
|
||||
const fontColor = getColorStyleForCanvas(colorStyle) || COLOR_BLACK_RGB;
|
||||
|
||||
if (baselineOffset === BaselineOffset.SUPERSCRIPT) {
|
||||
spanPointWithFont.y += -bBox.spo;
|
||||
} else if (baselineOffset === BaselineOffset.SUBSCRIPT) {
|
||||
spanPointWithFont.y += bBox.sbo;
|
||||
}
|
||||
|
||||
if (glyph.noteSeparator) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = fontColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveToByPrecision(spanPointWithFont.x, spanPointWithFont.y);
|
||||
ctx.lineToByPrecision(spanPointWithFont.x + glyph.width, spanPointWithFont.y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const drawText = () => {
|
||||
if (this._fillTextWithTextFill(ctx, glyph, spanPointWithFont, textFill, fontColor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fontColor && this._preFontColor !== fontColor) {
|
||||
ctx.fillStyle = fontColor;
|
||||
}
|
||||
|
||||
this._fillText(ctx, glyph, spanPointWithFont);
|
||||
};
|
||||
if (!glow && !outerShadow) {
|
||||
drawText();
|
||||
return;
|
||||
}
|
||||
|
||||
const effects = [resolveGlowEffect(glow), resolveOuterShadowEffect(outerShadow)].filter((effect) => effect != null);
|
||||
if (effects.length === 0) {
|
||||
drawText();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
for (const effect of effects) {
|
||||
ctx.shadowColor = effect.color;
|
||||
ctx.shadowBlur = effect.blurRadius;
|
||||
ctx.shadowOffsetX = effect.offsetX;
|
||||
ctx.shadowOffsetY = effect.offsetY;
|
||||
drawText();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _fillTextWithTextFill(
|
||||
ctx: UniverRenderingContext,
|
||||
glyph: IDocumentSkeletonGlyph,
|
||||
spanPointWithFont: Vector2,
|
||||
textFill: IDocTextFill | undefined,
|
||||
fallbackColor: string
|
||||
): boolean {
|
||||
if (!textFill || textFill.type === 'none') {
|
||||
return textFill?.type === 'none';
|
||||
}
|
||||
|
||||
const { content, glyphType } = glyph;
|
||||
if (content == null || glyphType === GlyphType.LIST) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { renderConfig } = this.extensionOffset;
|
||||
const { vertexAngle, centerAngle } = renderConfig ?? {};
|
||||
const VERTICAL_DEG = 90;
|
||||
if (vertexAngle === VERTICAL_DEG && centerAngle === VERTICAL_DEG) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bounds = this._getGlyphPaintBounds(glyph, spanPointWithFont);
|
||||
if (bounds.width <= 0 || bounds.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const color = this._colorWithOpacity(textFill.color || fallbackColor, textFill.opacity);
|
||||
|
||||
if (textFill.type === 'solid') {
|
||||
ctx.fillStyle = color;
|
||||
this._fillText(ctx, glyph, spanPointWithFont);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (textFill.type === 'gradient') {
|
||||
const gradient = this._createTextGradient(ctx, bounds, textFill, fallbackColor);
|
||||
if (!gradient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
this._fillText(ctx, glyph, spanPointWithFont);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (textFill.type === 'picture') {
|
||||
const pattern = this._createTextPicturePattern(ctx, bounds, textFill);
|
||||
if (!pattern) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ctx.fillStyle = pattern;
|
||||
this._fillText(ctx, glyph, spanPointWithFont);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private _getGlyphPaintBounds(glyph: IDocumentSkeletonGlyph, spanPointWithFont: Vector2) {
|
||||
const { width, bBox } = glyph;
|
||||
const textWidth = Math.max(width, bBox.width, 1);
|
||||
const textHeight = Math.max(bBox.aba + bBox.abd, 1);
|
||||
|
||||
return {
|
||||
left: spanPointWithFont.x,
|
||||
top: spanPointWithFont.y - bBox.aba,
|
||||
width: textWidth,
|
||||
height: textHeight,
|
||||
};
|
||||
}
|
||||
|
||||
private _createTextGradient(
|
||||
ctx: UniverRenderingContext,
|
||||
bounds: { left: number; top: number; width: number; height: number },
|
||||
textFill: IDocTextFill,
|
||||
fallbackColor: string
|
||||
): CanvasGradient | null {
|
||||
const gradient = textFill.gradient;
|
||||
const type = gradient?.type ?? 'linear';
|
||||
const angle = gradient?.angle ?? 0;
|
||||
const stops = this._normalizeGradientStops(gradient?.stops, textFill.color || fallbackColor);
|
||||
const centerX = bounds.left + bounds.width / 2;
|
||||
const centerY = bounds.top + bounds.height / 2;
|
||||
let canvasGradient: CanvasGradient;
|
||||
|
||||
if (type === 'radial') {
|
||||
const radius = Math.max(bounds.width, bounds.height) / 2;
|
||||
canvasGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
} else if (type === 'angular' && 'createConicGradient' in ((ctx as unknown as { _context?: CanvasRenderingContext2D })._context ?? ctx)) {
|
||||
const rawContext = ((ctx as unknown as { _context?: CanvasRenderingContext2D })._context ?? ctx) as CanvasRenderingContext2D & {
|
||||
createConicGradient: (startAngle: number, x: number, y: number) => CanvasGradient;
|
||||
};
|
||||
canvasGradient = rawContext.createConicGradient(((angle - 90) * Math.PI) / 180, centerX, centerY);
|
||||
} else if (type === 'diamond') {
|
||||
const radius = Math.max(bounds.width, bounds.height) / 2;
|
||||
canvasGradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
} else {
|
||||
const line = this._getLinearGradientLine(bounds, angle);
|
||||
canvasGradient = ctx.createLinearGradient(line.x0, line.y0, line.x1, line.y1);
|
||||
}
|
||||
|
||||
for (const stop of stops) {
|
||||
const opacity = (textFill.opacity ?? 1) * (stop.opacity ?? 1);
|
||||
canvasGradient.addColorStop(stop.offset, this._colorWithOpacity(stop.color, opacity));
|
||||
}
|
||||
|
||||
return canvasGradient;
|
||||
}
|
||||
|
||||
private _createTextPicturePattern(
|
||||
ctx: UniverRenderingContext,
|
||||
bounds: { left: number; top: number; width: number; height: number },
|
||||
textFill: IDocTextFill
|
||||
): CanvasPattern | null {
|
||||
const source = textFill.picture?.source;
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = this._getTextFillImage(source);
|
||||
if (!image || !image.complete || !(image.naturalWidth || image.width)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.ceil(bounds.width));
|
||||
canvas.height = Math.max(1, Math.ceil(bounds.height));
|
||||
const canvasContext = canvas.getContext('2d');
|
||||
if (!canvasContext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opacity = this._clamp(textFill.picture?.opacity ?? textFill.opacity ?? 1, 0, 1);
|
||||
canvasContext.globalAlpha = opacity;
|
||||
|
||||
if (textFill.picture?.mode === 'tile') {
|
||||
const scaleX = textFill.picture.scaleX ?? 1;
|
||||
const scaleY = textFill.picture.scaleY ?? 1;
|
||||
const cellWidth = Math.max(1, (image.naturalWidth || image.width) * scaleX);
|
||||
const cellHeight = Math.max(1, (image.naturalHeight || image.height) * scaleY);
|
||||
const offsetX = textFill.picture.offsetX ?? 0;
|
||||
const offsetY = textFill.picture.offsetY ?? 0;
|
||||
|
||||
for (let x = ((offsetX % cellWidth) + cellWidth) % cellWidth - cellWidth; x < canvas.width; x += cellWidth) {
|
||||
for (let y = ((offsetY % cellHeight) + cellHeight) % cellHeight - cellHeight; y < canvas.height; y += cellHeight) {
|
||||
canvasContext.drawImage(image, x, y, cellWidth, cellHeight);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
canvasContext.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const pattern = ctx.createPattern(canvas, 'no-repeat');
|
||||
pattern?.setTransform?.(new DOMMatrix().translate(bounds.left, bounds.top));
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private _getTextFillImage(source: string): HTMLImageElement | null {
|
||||
const onTextFillImageLoaded = (this.parent as { onTextFillImageLoaded?: () => void } | null)
|
||||
?.onTextFillImageLoaded;
|
||||
const cached = this._textFillImageCache.get(source);
|
||||
if (cached) {
|
||||
// The extension-level cache can serve multiple documents before one source finishes loading.
|
||||
if (!cached.complete && onTextFillImageLoaded) {
|
||||
this._textFillImageLoadListeners.get(source)?.add(onTextFillImageLoaded);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Do not force anonymous CORS: drawing does not read image pixels, while crossOrigin would reject remote
|
||||
// sources that browsers can otherwise display when the server omits Access-Control-Allow-Origin.
|
||||
const image = new Image();
|
||||
const listeners = new Set<() => void>();
|
||||
if (onTextFillImageLoaded) {
|
||||
listeners.add(onTextFillImageLoaded);
|
||||
}
|
||||
this._textFillImageLoadListeners.set(source, listeners);
|
||||
image.onload = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
const loadListeners = this._textFillImageLoadListeners.get(source);
|
||||
this._textFillImageLoadListeners.delete(source);
|
||||
loadListeners?.forEach((listener) => listener());
|
||||
};
|
||||
image.onerror = () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
this._textFillImageLoadListeners.delete(source);
|
||||
};
|
||||
image.src = source;
|
||||
this._textFillImageCache.set(source, image);
|
||||
|
||||
return image.complete ? image : null;
|
||||
}
|
||||
|
||||
private _normalizeGradientStops(stops: IDocTextFillGradientStop[] | undefined, fallbackColor: string) {
|
||||
const normalizedStops = stops && stops.length >= 2
|
||||
? stops
|
||||
: [
|
||||
{ offset: 0, color: fallbackColor },
|
||||
{ offset: 100, color: '#ffffff' },
|
||||
];
|
||||
|
||||
return normalizedStops.map((stop) => ({
|
||||
color: stop.color,
|
||||
offset: this._clamp(stop.offset > 1 ? stop.offset / 100 : stop.offset, 0, 1),
|
||||
opacity: stop.opacity,
|
||||
}));
|
||||
}
|
||||
|
||||
private _getLinearGradientLine(bounds: { left: number; top: number; width: number; height: number }, angle: number) {
|
||||
const angleRad = (angle * Math.PI) / 180;
|
||||
const centerX = bounds.left + bounds.width / 2;
|
||||
const centerY = bounds.top + bounds.height / 2;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const halfDiagonal = (Math.abs(bounds.width * cos) + Math.abs(bounds.height * sin)) / 2;
|
||||
|
||||
return {
|
||||
x0: centerX - cos * halfDiagonal,
|
||||
y0: centerY - sin * halfDiagonal,
|
||||
x1: centerX + cos * halfDiagonal,
|
||||
y1: centerY + sin * halfDiagonal,
|
||||
};
|
||||
}
|
||||
|
||||
private _colorWithOpacity(color: string, opacity?: number): string {
|
||||
if (opacity === undefined || opacity >= 1) {
|
||||
return color;
|
||||
}
|
||||
|
||||
const alpha = this._clamp(opacity, 0, 1);
|
||||
const hex = color.trim();
|
||||
if (/^#[0-9a-f]{3}$/i.test(hex)) {
|
||||
const [, r, g, b] = hex;
|
||||
return `rgba(${Number.parseInt(r + r, 16)}, ${Number.parseInt(g + g, 16)}, ${Number.parseInt(b + b, 16)}, ${alpha})`;
|
||||
}
|
||||
|
||||
if (/^#[0-9a-f]{6}$/i.test(hex)) {
|
||||
return `rgba(${Number.parseInt(hex.slice(1, 3), 16)}, ${Number.parseInt(hex.slice(3, 5), 16)}, ${Number.parseInt(hex.slice(5, 7), 16)}, ${alpha})`;
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
private _clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private _fillText(ctx: UniverRenderingContext, glyph: IDocumentSkeletonGlyph, spanPointWithFont: Vector2) {
|
||||
const { renderConfig, spanStartPoint, centerPoint } = this.extensionOffset;
|
||||
const { content, width, bBox } = glyph;
|
||||
const { aba, abd } = bBox;
|
||||
|
||||
if (content == null || spanStartPoint == null || centerPoint == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (glyph.glyphType === GlyphType.TAB && glyph.tabLeader != null) {
|
||||
const leader = this._getTabLeaderCharacter(glyph.tabLeader);
|
||||
if (leader) {
|
||||
const leaderWidth = ctx.measureText(leader).width;
|
||||
const count = leaderWidth > 0 ? Math.floor(width / leaderWidth) : 0;
|
||||
if (count > 0) {
|
||||
ctx.fillText(leader.repeat(count), spanPointWithFont.x, spanPointWithFont.y);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { vertexAngle, centerAngle } = renderConfig ?? {};
|
||||
|
||||
const VERTICAL_DEG = 90;
|
||||
|
||||
const isVertical = vertexAngle === VERTICAL_DEG && centerAngle === VERTICAL_DEG;
|
||||
|
||||
if (isVertical && !cjk.hasCJK(content)) {
|
||||
ctx.save();
|
||||
ctx.translate(spanStartPoint.x + centerPoint.x, spanStartPoint.y + centerPoint.y);
|
||||
ctx.rotate(Math.PI / 2);
|
||||
ctx.translate(-width / 2, (aba + abd) / 2 - abd);
|
||||
ctx.fillText(content, 0, 0);
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (isCheckboxGlyph(content) && glyph.glyphType === GlyphType.LIST) {
|
||||
const size = glyph.bBox.width;
|
||||
ctx.save();
|
||||
const left = spanPointWithFont.x;
|
||||
const top = spanPointWithFont.y - glyph.bBox.aba;
|
||||
const BORDER_WIDTH = 1;
|
||||
// The layout bbox already centers the custom shape on the marker's measured font metrics.
|
||||
ctx.translate(left - BORDER_WIDTH / 2, top - BORDER_WIDTH / 2);
|
||||
CheckboxShape.drawWith(ctx, {
|
||||
width: size,
|
||||
height: size,
|
||||
checked: content === '\u2611',
|
||||
});
|
||||
ctx.restore();
|
||||
} else {
|
||||
let x_offset = 0;
|
||||
let y_offset = 0;
|
||||
if (isVertical) {
|
||||
const fontHeight = glyph.bBox.aba - glyph.bBox.abd;
|
||||
x_offset = (glyph.width - glyph.bBox.width) / 2;
|
||||
y_offset = -(glyph.width - fontHeight) / 2;
|
||||
}
|
||||
ctx.fillText(content, spanPointWithFont.x + x_offset, spanPointWithFont.y + y_offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getTabLeaderCharacter(leader: TabStopLeader): string {
|
||||
switch (leader) {
|
||||
case 2:
|
||||
return '.';
|
||||
case 3:
|
||||
return '-';
|
||||
case 4:
|
||||
return '_';
|
||||
case 5:
|
||||
return '\u2022';
|
||||
case 6:
|
||||
return '\u00B7';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
override clearCache() {
|
||||
this._preFontColor = '';
|
||||
}
|
||||
}
|
||||
|
||||
DocumentsSpanAndLineExtensionRegistry.add(new FontAndBaseLine());
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './background';
|
||||
export * from './border';
|
||||
export * from './font-and-base-line';
|
||||
export * from './line';
|
||||
282
packages/engine-render/src/components/docs/extensions/line.ts
Normal file
282
packages/engine-render/src/components/docs/extensions/line.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IScale, ITextDecoration } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonGlyph } from '../../../basics/i-document-skeleton-cached';
|
||||
import type { IBoundRectNoAngle } from '../../../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../../../context';
|
||||
import type { IDrawInfo } from '../../extension';
|
||||
import { BaselineOffset, BooleanNumber, TextDecoration } from '@univerjs/core';
|
||||
import { COLOR_BLACK_RGB, DEFAULT_OFFSET_SPACING } from '../../../basics/const';
|
||||
import { calculateRectRotate } from '../../../basics/draw';
|
||||
import { degToRad, getScale } from '../../../basics/tools';
|
||||
import { Vector2 } from '../../../basics/vector2';
|
||||
import { DocumentsSpanAndLineExtensionRegistry } from '../../extension';
|
||||
import { docExtension } from '../doc-extension';
|
||||
import { getColorStyleForCanvas } from '../layout/style/color';
|
||||
|
||||
const UNIQUE_KEY = 'DefaultDocsLineExtension';
|
||||
|
||||
const DOC_EXTENSION_Z_INDEX = 40;
|
||||
export class Line extends docExtension {
|
||||
override uKey = UNIQUE_KEY;
|
||||
|
||||
override Z_INDEX = DOC_EXTENSION_Z_INDEX;
|
||||
|
||||
private _preBackgroundColor = '';
|
||||
|
||||
override draw(
|
||||
ctx: UniverRenderingContext,
|
||||
parentScale: IScale,
|
||||
glyph: IDocumentSkeletonGlyph,
|
||||
_diff?: IBoundRectNoAngle,
|
||||
more?: IDrawInfo
|
||||
) {
|
||||
const line = glyph.parent?.parent;
|
||||
const { ts: textStyle, bBox, content } = glyph;
|
||||
|
||||
if (line == null || textStyle == null || content === '\r') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { asc, dsc } = line;
|
||||
const { sp: strikeoutPosition, spo, sbo, bd } = bBox;
|
||||
const scale = getScale(parentScale);
|
||||
const DELTA = 0.5;
|
||||
const { ul: underline, st: strikethrough, ol: overline, va: baselineOffset, bbl: bottomBorderLine } = textStyle;
|
||||
|
||||
if (underline) {
|
||||
const startY = asc + dsc;
|
||||
this._drawLine(ctx, glyph, underline, startY, scale, 1, more?.viewBound);
|
||||
}
|
||||
|
||||
if (bottomBorderLine) {
|
||||
const startY = asc + dsc + 3;
|
||||
this._drawLine(ctx, glyph, bottomBorderLine, startY, scale, 2);
|
||||
}
|
||||
|
||||
if (strikethrough) {
|
||||
// strikethrough position is the middle of bounding box ascent and descent.
|
||||
let startY = asc + bd - strikeoutPosition - DELTA;
|
||||
|
||||
/**
|
||||
* --------- superscript strikethrough position -------
|
||||
* --------- superscript offset -----------------------
|
||||
* --------- baseline -----------------------
|
||||
* --------- subscript strikethrough position ---------
|
||||
* --------- subscript offset -----------------------
|
||||
*/
|
||||
if (baselineOffset === BaselineOffset.SUPERSCRIPT) {
|
||||
startY -= spo;
|
||||
} else if (baselineOffset === BaselineOffset.SUBSCRIPT) {
|
||||
startY += sbo;
|
||||
}
|
||||
|
||||
this._drawLine(ctx, glyph, strikethrough, startY, scale);
|
||||
}
|
||||
|
||||
if (overline) {
|
||||
const startY = -DEFAULT_OFFSET_SPACING - DELTA;
|
||||
|
||||
this._drawLine(ctx, glyph, overline, startY, scale);
|
||||
}
|
||||
}
|
||||
|
||||
override clearCache() {
|
||||
this._preBackgroundColor = '';
|
||||
}
|
||||
|
||||
private _drawLine(
|
||||
ctx: UniverRenderingContext,
|
||||
glyph: IDocumentSkeletonGlyph,
|
||||
line: ITextDecoration,
|
||||
startY: number,
|
||||
_scale: number,
|
||||
lineWidth = 1,
|
||||
viewBound?: IBoundRectNoAngle
|
||||
) {
|
||||
let { s: show, cl: colorStyle, t: lineType, c = BooleanNumber.TRUE } = line;
|
||||
|
||||
if (show !== BooleanNumber.TRUE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (c == null) {
|
||||
c = BooleanNumber.TRUE;
|
||||
}
|
||||
|
||||
const {
|
||||
originTranslate = Vector2.create(0, 0),
|
||||
alignOffset = Vector2.create(0, 0),
|
||||
renderConfig = {},
|
||||
} = this.extensionOffset;
|
||||
|
||||
const { left, width } = glyph;
|
||||
const isAccounting = this._isAccounting(lineType);
|
||||
if (isAccounting && !this._isFirstAccountingGlyph(glyph)) {
|
||||
return;
|
||||
}
|
||||
const lineLeft = isAccounting ? (viewBound?.left ?? left) : left;
|
||||
const lineRight = isAccounting ? (viewBound?.right ?? left + width) : left + width;
|
||||
const lineAlignOffset = isAccounting ? Vector2.create(0, alignOffset.y) : alignOffset;
|
||||
|
||||
const { centerAngle: centerAngleDeg = 0, vertexAngle: vertexAngleDeg = 0 } = renderConfig;
|
||||
|
||||
ctx.save();
|
||||
|
||||
// translate with precision is handled in moveToByPrecision and lineToByPrecision. NO NEED to do this again!
|
||||
// ctx.translateWithPrecision(FIX_ONE_PIXEL_BLUR_OFFSET, FIX_ONE_PIXEL_BLUR_OFFSET);
|
||||
|
||||
const color =
|
||||
(c === BooleanNumber.TRUE ? getColorStyleForCanvas(glyph.ts?.cl) : getColorStyleForCanvas(colorStyle)) || COLOR_BLACK_RGB;
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
this._setLineType(ctx, lineType ?? TextDecoration.SINGLE, lineWidth);
|
||||
|
||||
startY += this._isDouble(lineType) ? -0.8 : 0;
|
||||
|
||||
const centerAngle = degToRad(centerAngleDeg);
|
||||
const vertexAngle = degToRad(vertexAngleDeg);
|
||||
const start = calculateRectRotate(
|
||||
originTranslate.addByPoint(lineLeft, startY),
|
||||
Vector2.create(0, 0),
|
||||
centerAngle,
|
||||
vertexAngle,
|
||||
lineAlignOffset
|
||||
);
|
||||
const end = calculateRectRotate(
|
||||
originTranslate.addByPoint(lineRight, startY),
|
||||
Vector2.create(0, 0),
|
||||
centerAngle,
|
||||
vertexAngle,
|
||||
lineAlignOffset
|
||||
);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(start.x, start.y);
|
||||
|
||||
this._drawLineTo(ctx, start.x, end.x, end.y, lineType);
|
||||
if (this._isDouble(lineType)) {
|
||||
ctx.moveTo(start.x, start.y + 2);
|
||||
this._drawLineTo(ctx, start.x, end.x, end.y + 2, lineType);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _drawLineTo(ctx: UniverRenderingContext, startX: number, endX: number, endY: number, lineType?: TextDecoration) {
|
||||
if (this._isWave(lineType)) {
|
||||
for (let x = startX; x < endX; x++) {
|
||||
ctx.lineTo(x, endY + 0.8 * Math.sin(x * 1)); // y + amplitude * frequency
|
||||
}
|
||||
} else {
|
||||
ctx.lineTo(endX, endY);
|
||||
}
|
||||
}
|
||||
|
||||
private _setLineType(ctx: UniverRenderingContext, style: TextDecoration, lineWidth: number) {
|
||||
switch (style) {
|
||||
case TextDecoration.SINGLE:
|
||||
case TextDecoration.DOUBLE:
|
||||
case TextDecoration.SINGLE_ACCOUNTING:
|
||||
case TextDecoration.DOUBLE_ACCOUNTING:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([0]);
|
||||
return;
|
||||
case TextDecoration.DOTTED:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2]);
|
||||
return;
|
||||
case TextDecoration.DOTTED_HEAVY:
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([2]);
|
||||
return;
|
||||
case TextDecoration.DASH:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([3]);
|
||||
return;
|
||||
case TextDecoration.DASHED_HEAVY:
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([3]);
|
||||
return;
|
||||
case TextDecoration.DASH_LONG:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([6]);
|
||||
return;
|
||||
case TextDecoration.DASH_LONG_HEAVY:
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([6]);
|
||||
return;
|
||||
case TextDecoration.DOT_DASH:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 5, 2]);
|
||||
return;
|
||||
case TextDecoration.DASH_DOT_HEAVY:
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([2, 5, 2]);
|
||||
return;
|
||||
case TextDecoration.DOT_DOT_DASH:
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([2, 2, 5, 2, 2]);
|
||||
return;
|
||||
case TextDecoration.DASH_DOT_DOT_HEAVY:
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([2, 2, 5, 2, 2]);
|
||||
return;
|
||||
case TextDecoration.THICK:
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([0]);
|
||||
return;
|
||||
case TextDecoration.WAVE:
|
||||
case TextDecoration.WAVY_DOUBLE:
|
||||
ctx.lineWidth = 1;
|
||||
return;
|
||||
case TextDecoration.WAVY_HEAVY:
|
||||
ctx.lineWidth = 2;
|
||||
return;
|
||||
default:
|
||||
ctx.setLineDash([0]);
|
||||
ctx.lineWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private _isWave(lineType?: TextDecoration): boolean {
|
||||
return lineType === TextDecoration.WAVE || lineType === TextDecoration.WAVY_HEAVY || lineType === TextDecoration.WAVY_DOUBLE;
|
||||
}
|
||||
|
||||
private _isDouble(lineType?: TextDecoration): boolean {
|
||||
return lineType === TextDecoration.DOUBLE || lineType === TextDecoration.WAVY_DOUBLE || lineType === TextDecoration.DOUBLE_ACCOUNTING;
|
||||
}
|
||||
|
||||
private _isAccounting(lineType?: TextDecoration): boolean {
|
||||
return lineType === TextDecoration.SINGLE_ACCOUNTING || lineType === TextDecoration.DOUBLE_ACCOUNTING;
|
||||
}
|
||||
|
||||
private _isFirstAccountingGlyph(glyph: IDocumentSkeletonGlyph): boolean {
|
||||
const line = glyph.parent?.parent;
|
||||
for (const divide of line?.divides ?? []) {
|
||||
for (const candidate of divide.glyphGroup) {
|
||||
if (this._isAccounting(candidate.ts?.ul?.t)) {
|
||||
return candidate === glyph;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
DocumentsSpanAndLineExtensionRegistry.add(new Line());
|
||||
17
packages/engine-render/src/components/docs/horizon-bar.ts
Normal file
17
packages/engine-render/src/components/docs/horizon-bar.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class DocumentHorizonBar {}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData } from '@univerjs/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
measureDocumentNoWrapTextRangeWidth,
|
||||
measureDocumentNoWrapTextWidth,
|
||||
measureDocumentUnbreakableTextWidth,
|
||||
} from '../doc-no-wrap-measure';
|
||||
import { FontCache } from '../shaping-engine/font-cache';
|
||||
|
||||
function createDocument(dataStream: string): IDocumentData {
|
||||
return {
|
||||
id: 'doc-no-wrap-measure-test',
|
||||
body: {
|
||||
dataStream,
|
||||
textRuns: [],
|
||||
paragraphs: [],
|
||||
},
|
||||
documentStyle: {
|
||||
pageSize: {
|
||||
width: 4000,
|
||||
height: Infinity,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('measureDocumentNoWrapTextWidth', () => {
|
||||
it('uses docs CJK-Latin spacing for mixed no-wrap text', () => {
|
||||
const measureSpy = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: Array.from(text).reduce((total, char) => total + (/[\u2E80-\u9FFF\uF900-\uFAFF]/u.test(char) ? 20 : 10), 0),
|
||||
}) as never);
|
||||
|
||||
expect(measureDocumentNoWrapTextWidth(createDocument('A好B\r\n'))).toBe(50);
|
||||
expect(measureDocumentNoWrapTextWidth(createDocument('ABC\r\n'))).toBe(30);
|
||||
expect(measureDocumentNoWrapTextWidth(createDocument('你好\r\n'))).toBe(40);
|
||||
|
||||
measureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps trailing whitespace local to the current line', () => {
|
||||
const measureSpy = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 10,
|
||||
}) as never);
|
||||
|
||||
expect(measureDocumentNoWrapTextWidth(createDocument('A \r\nBBBB\r\n'))).toBe(40);
|
||||
|
||||
measureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('honors paragraph breaks that fall between text runs', () => {
|
||||
const measureSpy = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 10,
|
||||
}) as never);
|
||||
const document = createDocument('AA\rBBBB\r\n');
|
||||
document.body!.textRuns = [
|
||||
{ st: 0, ed: 2, ts: { fs: 20 } },
|
||||
{ st: 3, ed: 7, ts: { fs: 20 } },
|
||||
];
|
||||
|
||||
expect(measureDocumentNoWrapTextWidth(document)).toBe(40);
|
||||
|
||||
measureSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('measureDocumentUnbreakableTextWidth', () => {
|
||||
it('uses the docs line-break policy for words, spaces, and CJK text', () => {
|
||||
const measureSpy = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: Array.from(text).reduce((total, char) => total + (/[⺀-鿿豈-]/u.test(char) ? 20 : 10), 0),
|
||||
}) as never);
|
||||
|
||||
expect(measureDocumentUnbreakableTextWidth(createDocument('short longest 你好\r\n'))).toBe(70);
|
||||
|
||||
measureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('preserves text-run styles while measuring a break segment', () => {
|
||||
const measuredFonts = new Set<string>();
|
||||
const measureSpy = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string, font: string) => {
|
||||
measuredFonts.add(font);
|
||||
return { width: text.length * 10 } as never;
|
||||
});
|
||||
const document = createDocument('ABCD E\r\n');
|
||||
document.body!.textRuns = [
|
||||
{ st: 0, ed: 2, ts: { fs: 10 } },
|
||||
{ st: 2, ed: 4, ts: { fs: 20 } },
|
||||
];
|
||||
|
||||
expect(measureDocumentUnbreakableTextWidth(document)).toBe(40);
|
||||
expect(measuredFonts.size).toBeGreaterThan(1);
|
||||
|
||||
measureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('measures a styled document range without creating a temporary document', () => {
|
||||
const measureSpy = vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 10,
|
||||
}) as never);
|
||||
const document = createDocument('First Second\r\n');
|
||||
|
||||
expect(measureDocumentNoWrapTextRangeWidth(document, 6, 12)).toBe(60);
|
||||
|
||||
measureSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocSimpleSkeleton } from '../doc-simple-skeleton';
|
||||
import { FontCache } from '../shaping-engine/font-cache';
|
||||
|
||||
function measure(text: string) {
|
||||
return {
|
||||
width: text.length * 10,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('doc simple skeleton', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => measure(text));
|
||||
});
|
||||
|
||||
it('handles empty text and no-wrap text', () => {
|
||||
const empty = new DocSimpleSkeleton('', '12px Arial', true, 100, 100);
|
||||
const emptyLines = empty.calculate();
|
||||
expect(emptyLines).toEqual([
|
||||
{
|
||||
text: '',
|
||||
width: 0,
|
||||
height: 10,
|
||||
baseline: 8,
|
||||
},
|
||||
]);
|
||||
expect(empty.getTotalHeight()).toBe(10);
|
||||
expect(empty.getTotalWidth()).toBe(0);
|
||||
|
||||
const noWrap = new DocSimpleSkeleton('hello world', '12px Arial', false, 30, 200);
|
||||
const lines = noWrap.calculate();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0].text).toBe('hello world');
|
||||
expect(lines[0].width).toBe(110);
|
||||
expect(noWrap.getTotalHeight()).toBe(10);
|
||||
expect(noWrap.getTotalWidth()).toBe(110);
|
||||
});
|
||||
|
||||
it('wraps long text and uses cache behavior', () => {
|
||||
const skeleton = new DocSimpleSkeleton('supercalifragilistic', '12px Arial', true, 25, 100);
|
||||
const lines = skeleton.calculate();
|
||||
expect(lines.length).toBeGreaterThan(1);
|
||||
expect(lines.every((line) => line.width > 0)).toBe(true);
|
||||
expect(lines.every((line) => line.width <= 25 || line.text.length === 1)).toBe(true);
|
||||
|
||||
const callCountAfterFirst = (FontCache.getMeasureText as any).mock.calls.length;
|
||||
const second = skeleton.calculate();
|
||||
expect(second).toBe(lines);
|
||||
expect((FontCache.getMeasureText as any).mock.calls.length).toBe(callCountAfterFirst);
|
||||
});
|
||||
|
||||
it('recalculates after makeDirty in no-wrap mode', () => {
|
||||
const skeleton = new DocSimpleSkeleton('hello world', '12px Arial', false, 30, 200);
|
||||
const first = skeleton.calculate();
|
||||
const callCountAfterFirst = (FontCache.getMeasureText as any).mock.calls.length;
|
||||
const second = skeleton.calculate();
|
||||
expect(second).toBe(first);
|
||||
expect((FontCache.getMeasureText as any).mock.calls.length).toBe(callCountAfterFirst);
|
||||
|
||||
skeleton.makeDirty();
|
||||
const third = skeleton.calculate();
|
||||
expect(third).not.toBe(first);
|
||||
expect(third).toHaveLength(1);
|
||||
expect(third[0].text).toBe('hello world');
|
||||
expect((FontCache.getMeasureText as any).mock.calls.length).toBeGreaterThan(callCountAfterFirst);
|
||||
});
|
||||
|
||||
it('respects height limits when wrapping', () => {
|
||||
const skeleton = new DocSimpleSkeleton('a b c d e f g h i j k', '12px Arial', true, 20, 15);
|
||||
const lines = skeleton.calculate();
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
expect(skeleton.getTotalHeight()).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('content with \n', () => {
|
||||
const skeleton = new DocSimpleSkeleton('客户经理UM1\n客户经理UM2', '11pt Arial', true, 60, Infinity);
|
||||
const lines = skeleton.calculate();
|
||||
expect(lines.length).toBe(4);
|
||||
expect(lines[0].text).toBe('客户经理');
|
||||
expect(lines[1].text).toBe('UM1');
|
||||
expect(lines[2].text).toBe('客户经理');
|
||||
expect(lines[3].text).toBe('UM2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createDocumentModelWithStyle, DocumentFlavor, LocaleService, Univer } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DocumentViewModel } from '../../view-model/document-view-model';
|
||||
import { DocumentSkeleton } from '../doc-skeleton';
|
||||
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
|
||||
|
||||
function normalizeSkeleton(value: unknown): unknown {
|
||||
if (value instanceof Map) {
|
||||
return [...value].map(([key, item]) => [key, normalizeSkeleton(item)]);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeSkeleton);
|
||||
}
|
||||
|
||||
if (value != null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter(([key]) => key !== 'parent')
|
||||
.map(([key, item]) => [key, normalizeSkeleton(item)])
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
describe('document layout page patch', () => {
|
||||
it('survives structured clone and restores render parent links', () => {
|
||||
const univer = new Univer();
|
||||
const localeService = univer.__getInjector().get(LocaleService);
|
||||
const documentModel = createDocumentModelWithStyle(
|
||||
'Worker-safe page publication.\rSecond line.\r',
|
||||
{}
|
||||
);
|
||||
documentModel.updateDocumentStyle({ documentFlavor: DocumentFlavor.TRADITIONAL });
|
||||
documentModel.updateDocumentDataPageSize(240, 320);
|
||||
const skeleton = DocumentSkeleton.create(new DocumentViewModel(documentModel), localeService);
|
||||
skeleton.calculate();
|
||||
|
||||
const page = skeleton.getSkeletonData()?.pages[0];
|
||||
if (page == null) {
|
||||
throw new Error('Expected the document to produce a page.');
|
||||
}
|
||||
page.isNaturalPageOverflow = true;
|
||||
|
||||
const patch = structuredClone(serializeDocumentSkeletonPage(page));
|
||||
const hydrated = hydrateDocumentSkeletonPage(patch);
|
||||
const firstSection = hydrated.sections[0];
|
||||
const firstColumn = firstSection?.columns[0];
|
||||
const firstLine = firstColumn?.lines[0];
|
||||
const firstDivide = firstLine?.divides[0];
|
||||
const firstGlyph = firstDivide?.glyphGroup[0];
|
||||
|
||||
expect(JSON.stringify(patch)).not.toContain('"parent"');
|
||||
expect(normalizeSkeleton(hydrated)).toEqual(normalizeSkeleton(page));
|
||||
expect(hydrated.isNaturalPageOverflow).toBe(true);
|
||||
expect(firstSection?.parent).toBe(hydrated);
|
||||
expect(firstColumn?.parent).toBe(firstSection);
|
||||
expect(firstLine?.parent).toBe(firstColumn);
|
||||
expect(firstDivide?.parent).toBe(firstLine);
|
||||
expect(firstGlyph?.parent).toBe(firstDivide);
|
||||
|
||||
skeleton.dispose();
|
||||
univer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData, IDocumentNote } from '@univerjs/core';
|
||||
import { CustomRangeType, DocumentFlavor } from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createParagraphLayoutTestBed } from '../block/paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { DocumentSkeleton } from '../doc-skeleton';
|
||||
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
|
||||
import { resolveNoteReferences } from '../note-numbering';
|
||||
import { FontCache } from '../shaping-engine/font-cache';
|
||||
|
||||
function body(text: string) {
|
||||
const dataStream = `${text}\r\n`;
|
||||
return { dataStream, paragraphs: [...dataStream.matchAll(/\r/g)].map((match, i) => ({ startIndex: match.index!, paragraphId: `p-${i}` })), sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, i) => ({ startIndex: match.index!, sectionId: `s-${i}` })) };
|
||||
}
|
||||
|
||||
function snapshot(position: 'docEnd' | 'sectEnd', long = false) {
|
||||
const main = body('First \uFFFC foot \uFFFC\r\nSecond \uFFFC');
|
||||
const types = ['endnote', 'footnote', 'endnote'] as const;
|
||||
const notes: Record<string, IDocumentNote> = {};
|
||||
const customRanges = [...main.dataStream.matchAll(/\uFFFC/g)].map((match, i) => {
|
||||
const noteId = `note-${i}`;
|
||||
notes[noteId] = { noteId, type: types[i], body: body(long && i === 0 ? Array.from({ length: 60 }, (_, i) => `Long note paragraph ${i}`).join('\r') : `Note ${i}`) };
|
||||
return { rangeId: `ref-${i}`, rangeType: types[i] === 'endnote' ? CustomRangeType.ENDNOTE : CustomRangeType.FOOTNOTE, startIndex: match.index!, endIndex: match.index!, wholeEntity: true, properties: { noteId } };
|
||||
});
|
||||
return { documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 300, height: 180 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 }, body: { ...main, customRanges }, notes, noteSettings: { endnote: { position, numberFormat: 'lowerRoman' }, footnote: { startNumber: 3 } } } satisfies Partial<IDocumentData>;
|
||||
}
|
||||
|
||||
describe('endnote pagination', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({ width: text.length * 5, fontBoundingBoxAscent: 8, fontBoundingBoxDescent: 2, actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2 }) as TextMetrics);
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('numbers footnotes and endnotes independently', () => {
|
||||
const refs = [...resolveNoteReferences(snapshot('docEnd')).values()];
|
||||
expect(refs.map((ref) => ref.label)).toEqual(['i', '3', 'ii']);
|
||||
});
|
||||
|
||||
it.each([false, true])('places section endnotes before the next section and document endnotes after it (incremental: %s)', (incremental) => {
|
||||
for (const position of ['sectEnd', 'docEnd'] as const) {
|
||||
const bed = createParagraphLayoutTestBed('', snapshot(position));
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let i = 0; i < 1000 && !progress.complete; i++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const firstNote = pages.flatMap((page, pageIndex) => (page.notes ?? []).map((note) => ({ ...note, pageIndex }))).find((note) => note.noteId === 'note-0')!;
|
||||
const secondSectionPage = pages.findIndex((page) => page.sections.some((section) => section.columns.some((column) => column.lines.some((line) => line.paragraphIndex > 16))));
|
||||
expect(firstNote).toBeDefined();
|
||||
expect(pages.flatMap((page) => page.notes ?? []).filter((note) => !note.continued).map((note) => note.noteId).sort()).toEqual(['note-0', 'note-1', 'note-2']);
|
||||
if (position === 'sectEnd') {
|
||||
expect(firstNote.pageIndex).toBeLessThan(secondSectionPage);
|
||||
} else {
|
||||
expect(firstNote.pageIndex).toBeGreaterThanOrEqual(secondSectionPage);
|
||||
}
|
||||
for (const page of pages) {
|
||||
const restored = hydrateDocumentSkeletonPage(serializeDocumentSkeletonPage(page));
|
||||
expect(restored.notes?.map((note) => note.noteId)).toEqual(page.notes?.map((note) => note.noteId));
|
||||
}
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defers suppressed section endnotes while retaining their numbering and reference order', () => {
|
||||
const data = snapshot('sectEnd');
|
||||
Object.assign(data.body.sectionBreaks[0], { suppressEndnotes: true });
|
||||
const bed = createParagraphLayoutTestBed('', data);
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const endnotes = pages.flatMap((page, pageIndex) => (page.notes ?? [])
|
||||
.filter((note) => note.noteType === 'endnote')
|
||||
.map((note) => ({ noteId: note.noteId, pageIndex })));
|
||||
expect(endnotes.map((note) => note.noteId)).toEqual(['note-0', 'note-2']);
|
||||
expect(endnotes[0].pageIndex).toBe(endnotes[1].pageIndex);
|
||||
expect(endnotes[0].pageIndex).toBeGreaterThan(0);
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])('splits long endnotes and keeps all content above the footer (incremental: %s)', (incremental) => {
|
||||
const bed = createParagraphLayoutTestBed('', snapshot('docEnd', true));
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let i = 0; i < 1000 && !progress.complete; i++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const notes = pages.flatMap((page) => page.notes ?? []).filter((note) => note.noteId === 'note-0');
|
||||
expect(notes.length).toBeGreaterThan(1);
|
||||
const text = notes.flatMap((note) => note.page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.raw ?? glyph.content)))))).join('');
|
||||
for (let i = 0; i < 60; i++) {
|
||||
expect(text).toContain(`Long note paragraph ${i}`);
|
||||
}
|
||||
expect(notes[0].continued).toBe(false);
|
||||
expect(notes.slice(1).every((note) => note.continued)).toBe(true);
|
||||
for (const page of pages) {
|
||||
for (const note of page.notes ?? []) {
|
||||
expect(note.top + note.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 0.1);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,673 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentNote } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonPage } from '../../../../basics/i-document-skeleton-cached';
|
||||
import {
|
||||
BaselineOffset,
|
||||
BooleanNumber,
|
||||
CustomRangeType,
|
||||
DataStreamTreeTokenType,
|
||||
DocumentFlavor,
|
||||
} from '@univerjs/core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentSkeletonPageType, PageLayoutType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { Vector2 } from '../../../../basics/vector2';
|
||||
import { createParagraphLayoutTestBed } from '../block/paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { DocumentSkeleton } from '../doc-skeleton';
|
||||
import { hydrateDocumentSkeletonPage, serializeDocumentSkeletonPage } from '../document-layout-page-patch';
|
||||
import { layoutNoteBody } from '../footnote-layout';
|
||||
import { resolveNoteReferences } from '../note-numbering';
|
||||
import { FontCache } from '../shaping-engine/font-cache';
|
||||
|
||||
function createNoteTestBed(pageHeight = 600, noteOverrides: Partial<IDocumentNote> = {}) {
|
||||
const dataStream = `${Array.from({ length: 30 }, (_, i) => `Explanation ${i + 1} with enough text to wrap onto another line.\r`).join('')}\n`;
|
||||
const paragraphs = [...dataStream.matchAll(/\r/g)].map((match, i) => ({
|
||||
paragraphId: `note-paragraph-${i}`,
|
||||
startIndex: match.index!,
|
||||
}));
|
||||
return createParagraphLayoutTestBed('Reference\uFFFC', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: pageHeight } },
|
||||
body: {
|
||||
customRanges: [{
|
||||
rangeId: 'reference',
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: 9,
|
||||
endIndex: 9,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: 'note' },
|
||||
}],
|
||||
},
|
||||
noteSettings: { footnote: { numberFormat: 'upperRoman', startNumber: 8 } },
|
||||
notes: {
|
||||
note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream,
|
||||
paragraphs,
|
||||
sectionBreaks: [{ sectionId: 'note-body', startIndex: dataStream.length - 1 }],
|
||||
}, ...noteOverrides },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getPageGlyphs(page: IDocumentSkeletonPage) {
|
||||
return page.sections.flatMap((section) => section.columns.flatMap((column) =>
|
||||
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))));
|
||||
}
|
||||
|
||||
describe('footnote body layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text: string) => ({
|
||||
width: text.length * 5,
|
||||
fontBoundingBoxAscent: 8,
|
||||
fontBoundingBoxDescent: 2,
|
||||
actualBoundingBoxAscent: 8,
|
||||
actualBoundingBoxDescent: 2,
|
||||
}) as TextMetrics);
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each([false, true])('fills the remaining body space with table rows after an earlier footnote (incremental: %s)', (incremental) => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const prefix = `Reference\uFFFC\r${'Filler\r'.repeat(7)}`;
|
||||
const tableStream = `${T.TABLE_START}${Array.from({ length: 10 }, (_, index) => `${T.TABLE_ROW_START}${T.TABLE_CELL_START}Row ${index}\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}`).join('')}${T.TABLE_END}`;
|
||||
const dataStream = `${prefix}${tableStream}\rAfter\r\n`;
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 160 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].filter((match) => match.index !== prefix.length + tableStream.length).map((match, index) => ({ paragraphId: `p-${index}`, startIndex: match.index })),
|
||||
sectionBreaks: [...dataStream.matchAll(/\n/g)].map((match, index) => ({ sectionId: `s-${index}`, startIndex: match.index })),
|
||||
tables: [{ tableId: 'table', startIndex: prefix.length, endIndex: prefix.length + tableStream.length }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: 9, endIndex: 9, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
tableSource: { table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
size: { type: 0, width: { v: 200 } },
|
||||
cellMargin: { top: { v: 0 }, bottom: { v: 0 }, start: { v: 0 }, end: { v: 0 } },
|
||||
tableColumns: [{ size: { type: 0, width: { v: 200 } } }],
|
||||
tableRows: Array.from({ length: 10 }, () => ({ tableCells: [{}], trHeight: { val: { v: 0 }, hRule: 0 } })),
|
||||
} },
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: { dataStream: 'Explanation\r\n', paragraphs: [{ paragraphId: 'note-p', startIndex: 11 }], sectionBreaks: [{ sectionId: 'note-section', startIndex: 12 }] } } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 1000 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress.complete).toBe(true);
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages[0].notes?.map((note) => note.noteId)).toEqual(['note']);
|
||||
const firstTable = [...pages[0].skeTables.values()][0];
|
||||
expect(firstTable).toBeDefined();
|
||||
expect(firstTable.rows[0].index).toBe(0);
|
||||
expect(firstTable.rows.length).toBeLessThan(10);
|
||||
expect(pages[0].marginTop + firstTable.top + firstTable.height).toBeLessThanOrEqual(pages[0].noteDecorations![0].top + 0.01);
|
||||
const rows = pages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows));
|
||||
expect(rows.map((row) => row.index)).toEqual(Array.from({ length: 10 }, (_, index) => index));
|
||||
} finally {
|
||||
skeleton.dispose();
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 32].flatMap((repeatCount) => [false, true].flatMap((incremental) => ['keepLines', 'keepNext', 'widowControl'].map((constraint) => ({ repeatCount, incremental, constraint })))))('reflows page-local note markers for $constraint across $repeatCount paragraphs (incremental: $incremental)', ({ repeatCount, incremental, constraint }) => {
|
||||
const prefix = constraint === 'keepNext' ? 'Filler filler filler filler\rHeading \uFFFC\r' : 'Filler \uFFFC filler filler filler\r';
|
||||
const kept = constraint !== 'widowControl' ? 'One \uFFFC two three four five six seven eight nine ten.\r' : 'One two three four five six seven eight nine \uFFFC ten eleven twelve thirteen.\r';
|
||||
const dataStream = `${`${prefix}${kept}`.repeat(repeatCount)}\n`;
|
||||
const references = [...dataStream.matchAll(/\uFFFC/g)].map((match, index) => ({ index: match.index!, id: `note-${index}` }));
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 120, height: constraint === 'widowControl' ? 128 : 120 }, marginTop: 10, marginBottom: 10, marginLeft: 10, marginRight: 10 },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}`, paragraphStyle: constraint === 'keepNext'
|
||||
? { keepNext: index % 3 === 1 ? BooleanNumber.TRUE : BooleanNumber.FALSE, keepLines: index % 3 === 2 ? BooleanNumber.TRUE : BooleanNumber.FALSE }
|
||||
: { [constraint]: index % 2 === 1 ? BooleanNumber.TRUE : BooleanNumber.FALSE } })),
|
||||
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
|
||||
customRanges: references.map(({ index, id }) => ({ rangeId: `ref-${id}`, rangeType: CustomRangeType.FOOTNOTE, startIndex: index, endIndex: index, wholeEntity: true, properties: { noteId: id } })),
|
||||
},
|
||||
noteSettings: { footnote: { restart: 'eachPage', startNumber: 9 } },
|
||||
notes: Object.fromEntries(references.map(({ id }) => [id, { type: 'footnote' as const, noteId: id, body: { dataStream: 'Note\r\n', paragraphs: [{ startIndex: 4, paragraphId: 'note-text' }], sectionBreaks: [{ startIndex: 5, sectionId: 'note-section' }] } }])),
|
||||
});
|
||||
try {
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 20_000 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress).toMatchObject({ complete: true, cancelled: false });
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages.flatMap(getPageGlyphs).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(dataStream);
|
||||
const referenceWidth = pages.flatMap(getPageGlyphs).find((glyph) => glyph.noteId === 'note-0')!.bBox.width;
|
||||
for (const page of pages) {
|
||||
const refs = getPageGlyphs(page).filter((glyph) => glyph.noteId);
|
||||
expect(refs.map((glyph) => glyph.content)).toEqual(refs.map((_, index) => String(9 + index)));
|
||||
for (const glyph of refs) {
|
||||
if (glyph.content === '9') {
|
||||
expect(glyph.bBox.width).toBeCloseTo(referenceWidth);
|
||||
}
|
||||
const note = page.notes?.find((fragment) => fragment.noteId === glyph.noteId && !fragment.continued);
|
||||
expect(note).toBeDefined();
|
||||
expect(getPageGlyphs(note!.page).find((item) => item.count === 0)?.content).toBe(glyph.content);
|
||||
}
|
||||
for (const section of page.sections) {
|
||||
for (const column of section.columns) {
|
||||
for (const line of column.lines) {
|
||||
for (const divide of line.divides) {
|
||||
for (let index = 1; index < divide.glyphGroup.length; index++) {
|
||||
const previous = divide.glyphGroup[index - 1];
|
||||
expect(divide.glyphGroup[index].left).toBeCloseTo(previous.left + previous.width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (constraint === 'keepNext') {
|
||||
for (let index = 0; index < references.length; index += 2) {
|
||||
const headingPage = pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === references[index].id));
|
||||
const followingPage = pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === references[index + 1].id));
|
||||
expect(headingPage).toBe(followingPage);
|
||||
}
|
||||
}
|
||||
const paragraphEnds = [...dataStream.matchAll(/\r/g)].filter((_, index) => constraint === 'keepNext' ? index % 3 === 2 : index % 2 === 1).map((match) => match.index!);
|
||||
for (const end of paragraphEnds) {
|
||||
const counts = pages.map((page) => page.sections.flatMap((section) => section.columns.flatMap((column) => column.lines)).filter((line) => line.paragraphIndex === end).length).filter((count) => count > 0);
|
||||
if (constraint !== 'widowControl') {
|
||||
expect(counts).toHaveLength(1);
|
||||
} else {
|
||||
expect(counts.every((count) => count >= 2)).toBe(true);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('places a table-first footnote marker in its first cell without consuming an editable character', () => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const tableStream = `${T.TABLE_START}${T.TABLE_ROW_START}${T.TABLE_CELL_START}First\r\n${T.TABLE_CELL_END}${T.TABLE_CELL_START}Second\r\n${T.TABLE_CELL_END}${T.TABLE_ROW_END}${T.TABLE_END}`;
|
||||
const dataStream = `${tableStream}After\r\n`;
|
||||
const bed = createNoteTestBed(600, {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `note-${index}` })),
|
||||
sectionBreaks: [{ startIndex: dataStream.length - 1, sectionId: 'note-section' }],
|
||||
tables: [{ tableId: 'note-table', startIndex: 0, endIndex: tableStream.length }],
|
||||
},
|
||||
tableSource: {
|
||||
'note-table': {
|
||||
tableId: 'note-table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
position: { positionH: { relativeFrom: 0 }, positionV: { relativeFrom: 0 } },
|
||||
dist: { distT: 0, distB: 0, distL: 0, distR: 0 },
|
||||
size: { type: 0, width: { v: 300 } },
|
||||
tableRows: [{ tableCells: [{}, {}], trHeight: { val: { v: 0 }, hRule: 0 } }],
|
||||
tableColumns: [{ size: { type: 0, width: { v: 150 } } }, { size: { type: 0, width: { v: 150 } } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const pages = layoutNoteBody(bed.ctx, resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!, bed.sectionBreakConfig, {
|
||||
width: 320,
|
||||
firstPageHeight: 200,
|
||||
continuationPageHeight: 200,
|
||||
});
|
||||
const cells = pages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows.flatMap((row) => row.cells)));
|
||||
expect(cells).toHaveLength(2);
|
||||
const first = getPageGlyphs(cells[0]);
|
||||
expect(first[0]).toMatchObject({ content: 'VIII', raw: '', count: 0 });
|
||||
expect(first.slice(1).map((glyph) => glyph.raw).join('')).toBe('First\r\n');
|
||||
expect(first[1].left).toBeCloseTo(first[0].left + first[0].width, 5);
|
||||
expect(getPageGlyphs(cells[1]).map((glyph) => glyph.raw).join('')).toBe('Second\r\n');
|
||||
expect([...pages.flatMap(getPageGlyphs), ...cells.flatMap(getPageGlyphs)].filter((glyph) => glyph.content === 'VIII')).toHaveLength(1);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the reference font separate from editable footnote text', () => {
|
||||
const bed = createNoteTestBed(600, {
|
||||
referenceTextStyle: { fs: 8, va: BaselineOffset.SUPERSCRIPT },
|
||||
body: {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ startIndex: 11, paragraphId: 'note-text' }],
|
||||
sectionBreaks: [{ startIndex: 12, sectionId: 'note-section' }],
|
||||
textRuns: [{ st: 0, ed: 11, ts: { fs: 9.5 } }],
|
||||
},
|
||||
});
|
||||
const pages = layoutNoteBody(bed.ctx, resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!, bed.sectionBreakConfig, {
|
||||
width: 320,
|
||||
columnCount: 1,
|
||||
firstPageHeight: 100,
|
||||
continuationPageHeight: 100,
|
||||
});
|
||||
try {
|
||||
const [marker, text] = getPageGlyphs(pages[0]);
|
||||
expect(marker).toMatchObject({ count: 0, ts: { fs: 8, va: BaselineOffset.SUPERSCRIPT } });
|
||||
expect(marker.fontStyle?.originFontSize).toBe(8);
|
||||
expect(marker.fontStyle?.fontSize).toBeLessThan(8);
|
||||
expect(text.ts?.fs).toBe(9.5);
|
||||
expect(text.ts?.va).not.toBe(BaselineOffset.SUPERSCRIPT);
|
||||
expect(text.fontStyle?.fontSize).toBe(9.5);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 100])('lays out two notes in shared columns with a %i-length first note', (repeatCount) => {
|
||||
const noteText = 'Explanation with several words to wrap across lines.';
|
||||
const body = {
|
||||
dataStream: `${noteText}\r\n`,
|
||||
paragraphs: [{ paragraphId: 'p', startIndex: noteText.length }],
|
||||
sectionBreaks: [{ sectionId: 's', startIndex: noteText.length + 1 }],
|
||||
};
|
||||
const firstText = noteText.repeat(repeatCount);
|
||||
const firstBody = { dataStream: `${firstText}\r\n`, paragraphs: [{ paragraphId: 'first', startIndex: firstText.length }], sectionBreaks: [{ sectionId: 'first-section', startIndex: firstText.length + 1 }] };
|
||||
const bed = createParagraphLayoutTestBed('\uFFFC\uFFFC', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL },
|
||||
noteSettings: { footnote: { columnCount: 2 } },
|
||||
body: { customRanges: [0, 1].map((index) => ({
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
rangeId: `ref-${index}`,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: `note-${index}` },
|
||||
})) },
|
||||
notes: {
|
||||
'note-0': { type: 'footnote' as const, noteId: 'note-0', body: firstBody },
|
||||
'note-1': { type: 'footnote' as const, noteId: 'note-1', body: structuredClone(body) },
|
||||
},
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const notes = pages[0].notes!;
|
||||
if (repeatCount > 1) {
|
||||
const allNotes = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(allNotes.length).toBeGreaterThan(2);
|
||||
expect(allNotes[allNotes.length - 1].noteId).toBe('note-1');
|
||||
expect(allNotes[allNotes.length - 1].continued).toBe(false);
|
||||
for (const [id, expected] of [['note-0', firstBody.dataStream], ['note-1', body.dataStream]]) {
|
||||
const raw = allNotes.filter((note) => note.noteId === id).flatMap((note) =>
|
||||
note.page.sections.flatMap((section) => section.columns.flatMap((column) =>
|
||||
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw)))))).join('');
|
||||
expect(raw).toBe(expected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
expect(notes).toHaveLength(2);
|
||||
expect(notes[0].page.sections[0].columns[0].lines.length).toBeGreaterThan(0);
|
||||
expect(notes[1].page.sections[0].columns[1].lines.length).toBeGreaterThan(0);
|
||||
expect(notes[0].top).toBe(notes[1].top);
|
||||
const column = notes[1].page.sections[0].columns[1];
|
||||
const line = column.lines[0];
|
||||
const divide = line.divides[0];
|
||||
const glyph = divide.glyphGroup.find((item) => item.count > 0)!;
|
||||
const point = Vector2.create(notes[1].left + column.left + divide.left + divide.paddingLeft + glyph.left + 1, notes[1].top + line.top + 1);
|
||||
expect(skeleton.findNodeByCoord(point, PageLayoutType.VERTICAL, 0, 0)?.segmentId).toBe('note-1');
|
||||
for (const note of notes) {
|
||||
const glyphs = note.page.sections.flatMap((section) => section.columns.flatMap((column) =>
|
||||
column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))));
|
||||
expect(glyphs.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(body.dataStream);
|
||||
expect(note.page.st).toBe(0);
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4])('continues rich paragraphs through %i columns without changing note character offsets', (columnCount) => {
|
||||
const bed = createNoteTestBed();
|
||||
const snapshot = bed.dataModel.getSnapshot();
|
||||
const reference = resolveNoteReferences(snapshot).get(9)!;
|
||||
const pages = layoutNoteBody(bed.ctx, reference, bed.sectionBreakConfig, {
|
||||
width: 320,
|
||||
columnCount,
|
||||
firstPageHeight: 45,
|
||||
continuationPageHeight: 100,
|
||||
});
|
||||
expect(pages.length).toBeGreaterThan(2);
|
||||
expect(pages.every((page) => page.sections[0].columns.length === columnCount)).toBe(true);
|
||||
expect(pages.every((page) => page.type === DocumentSkeletonPageType.NOTE && page.segmentId === 'note')).toBe(true);
|
||||
expect(pages[0].height).toBeLessThanOrEqual(47);
|
||||
expect(pages.slice(1).every((page) => page.height <= 102)).toBe(true);
|
||||
const glyphs = pages.flatMap((page) => page.sections.flatMap((section) => section.columns.flatMap(
|
||||
(column) => column.lines.flatMap((line) => line.divides.flatMap((divide) => divide.glyphGroup))
|
||||
)));
|
||||
expect(glyphs[0]).toMatchObject({ content: 'VIII', raw: '', count: 0 });
|
||||
expect(glyphs.filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(snapshot.notes!.note.body.dataStream);
|
||||
expect(pages[0].st).toBe(0);
|
||||
for (let index = 1; index < pages.length; index++) {
|
||||
expect(pages[index].st).toBe(pages[index - 1].ed + 1);
|
||||
}
|
||||
expect(snapshot.notes!.note.body.dataStream.startsWith('Explanation')).toBe(true);
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0 },
|
||||
{ prefixCount: 11, rowCount: 1, referenceRow: 0 },
|
||||
{ prefixCount: 14, rowCount: 1, referenceRow: 0 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 0 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 5 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 19 },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 0, noteParagraphs: 30 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraph: 10 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraph: 19 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, columnCount: 2, referenceColumn: 1 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, noteParagraphs: 30 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19] },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], restartEachPage: true },
|
||||
{ prefixCount: 0, rowCount: 20, referenceRow: 0, referenceRows: [0, 4, 8, 12, 16, 19], restartEachPage: true, cantSplit: true, noteParagraphs: 1 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], restartEachPage: true, startNumber: 9 },
|
||||
{ prefixCount: 7, rowCount: 12, referenceRow: 0, referenceRows: [0, 3, 6, 9, 11], restartEachPage: true, cantSplit: true, noteParagraphs: 1, exactRowHeight: 30 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, referenceParagraphs: [0, 10, 19], referenceColumns: [0, 1], columnCount: 2, restartEachPage: true, noteParagraphs: 1 },
|
||||
{ prefixCount: 0, rowCount: 1, referenceRow: 0, cellParagraphs: 20, columnCount: 2, referenceColumns: [0, 1] },
|
||||
].flatMap((sample, caseIndex) => [false, true].map((incremental) => ({ ...sample, incremental, caseIndex }))))('keeps table references with their notes (case $caseIndex, incremental: $incremental)', ({ prefixCount, rowCount, referenceRow, incremental, noteParagraphs = 4, cellParagraphs = 1, referenceParagraph = 0, columnCount = 1, referenceColumn = 0, referenceParagraphs = [referenceParagraph], referenceColumns = [referenceColumn], restartEachPage = false, startNumber = 1, cantSplit = false, referenceRows = [referenceRow], exactRowHeight = 0 }) => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const cellText = (row: number, column: number) => `${Array.from({ length: cellParagraphs }, (_, paragraph) =>
|
||||
`Cell${referenceRows.includes(row) && referenceColumns.includes(column) && referenceParagraphs.includes(paragraph) ? '\uFFFC' : ''}${T.PARAGRAPH}`).join('')}${T.SECTION_BREAK}`;
|
||||
const tableStream = `${T.TABLE_START}${Array.from({ length: rowCount }, (_, index) => `${T.TABLE_ROW_START}${Array.from({ length: columnCount }, (_, column) => `${T.TABLE_CELL_START}${cellText(index, column)}${T.TABLE_CELL_END}`).join('')}${T.TABLE_ROW_END}`).join('')}${T.TABLE_END}`;
|
||||
const prefix = 'Intro\r'.repeat(prefixCount);
|
||||
const dataStream = `${prefix}${tableStream}\r\n`;
|
||||
const noteStream = `${'Explanation\r'.repeat(noteParagraphs)}\n`;
|
||||
const references = [...dataStream.matchAll(/\uFFFC/g)].map((match, index) => ({ index: match.index!, id: `note-${index}` }));
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 200 } },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}` })),
|
||||
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
|
||||
tables: [{ tableId: 'table', startIndex: prefix.length, endIndex: prefix.length + tableStream.length }],
|
||||
customRanges: references.map(({ index, id }) => ({ rangeId: `ref-${id}`, rangeType: CustomRangeType.FOOTNOTE, startIndex: index, endIndex: index, wholeEntity: true, properties: { noteId: id } })),
|
||||
},
|
||||
tableSource: {
|
||||
table: {
|
||||
tableId: 'table',
|
||||
align: 0,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0,
|
||||
size: { type: 0, width: { v: 300 } },
|
||||
tableRows: Array.from({ length: rowCount }, () => ({ cantSplit: cantSplit ? 1 : 0, tableCells: Array.from({ length: columnCount }, () => ({ size: { type: 0, width: { v: 300 / columnCount } } })), trHeight: { val: { v: exactRowHeight }, hRule: exactRowHeight > 0 ? 2 : 0 } })),
|
||||
tableColumns: Array.from({ length: columnCount }, () => ({ size: { type: 0, width: { v: 300 / columnCount } } })),
|
||||
},
|
||||
},
|
||||
noteSettings: { footnote: restartEachPage ? { restart: 'eachPage', startNumber } : undefined },
|
||||
notes: Object.fromEntries(references.map(({ id }) => [id, { type: 'footnote' as const, noteId: id, body: {
|
||||
dataStream: noteStream,
|
||||
paragraphs: [...noteStream.matchAll(/\r/g)].map((match, index) => ({ paragraphId: `note-${index}`, startIndex: match.index! })),
|
||||
sectionBreaks: [{ sectionId: 'note-section', startIndex: noteStream.length - 1 }],
|
||||
} }])),
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
if (incremental) {
|
||||
const generation = skeleton.startIncrementalLayout();
|
||||
let progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
for (let step = 0; step < 20_000 && !progress.complete; step++) {
|
||||
progress = skeleton.stepIncrementalLayout(generation, 0);
|
||||
}
|
||||
expect(progress).toMatchObject({ complete: true, cancelled: false });
|
||||
} else {
|
||||
skeleton.calculate();
|
||||
}
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
if (prefixCount === 0 && rowCount === 1 && cellParagraphs === 1) {
|
||||
expect(pages).toHaveLength(1);
|
||||
} else {
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
}
|
||||
const tablePages = pages.filter((page) => page.skeTables.size > 0);
|
||||
if (restartEachPage) {
|
||||
const markerWidths = new Map<string, number>();
|
||||
for (const page of tablePages) {
|
||||
const refs = [...page.skeTables.values()].flatMap((table) => table.rows.flatMap((row) => row.cells.flatMap(getPageGlyphs))).filter((glyph) => glyph.noteId);
|
||||
expect(refs.map((glyph) => glyph.content)).toEqual(refs.map((_, index) => String(index + startNumber)));
|
||||
for (const glyph of refs) {
|
||||
const previousWidth = markerWidths.get(glyph.content);
|
||||
if (previousWidth != null) {
|
||||
expect(glyph.width).toBeCloseTo(previousWidth);
|
||||
}
|
||||
markerWidths.set(glyph.content, glyph.width);
|
||||
const note = page.notes?.find((fragment) => fragment.noteId === glyph.noteId && !fragment.continued);
|
||||
expect(note).toBeDefined();
|
||||
expect(getPageGlyphs(note!.page).find((item) => item.count === 0)?.content).toBe(glyph.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = tablePages.flatMap((page) => [...page.skeTables.values()].flatMap((table) => table.rows));
|
||||
expect(new Set(rows.map((row) => row.index)).size).toBe(rowCount);
|
||||
for (let column = 0; column < columnCount; column++) {
|
||||
const text = rows.flatMap((row) => getPageGlyphs(row.cells[column])).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('');
|
||||
expect(text).toBe(Array.from({ length: rowCount }, (_, row) => cellText(row, column)).join(''));
|
||||
}
|
||||
const page = tablePages.find((page) => [...page.skeTables.values()].some((table) =>
|
||||
table.rows.some((row) => row.cells.some((cell) => getPageGlyphs(cell).some((glyph) => glyph.noteId === 'note-0')))))!;
|
||||
expect(page.notes!.some((note) => note.noteId === 'note-0')).toBe(true);
|
||||
const fragments = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(fragments[0].continued).toBe(false);
|
||||
for (const { id } of references) {
|
||||
const noteFragments = fragments.filter((fragment) => fragment.noteId === id);
|
||||
expect(noteFragments[0].continued).toBe(false);
|
||||
expect(noteFragments.slice(1).every((fragment) => fragment.continued)).toBe(true);
|
||||
expect(noteFragments.flatMap((fragment) => getPageGlyphs(fragment.page)).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(noteStream);
|
||||
}
|
||||
const table = [...page.skeTables.values()][0];
|
||||
expect(page.marginTop + table.top + table.height).toBeLessThanOrEqual(page.noteDecorations![0].top + 0.01);
|
||||
for (const tablePage of tablePages) {
|
||||
if (tablePage.footnoteHeight) {
|
||||
const bottom = Math.max(...[...tablePage.skeTables.values()].map((table) => table.top + table.height));
|
||||
expect(tablePage.marginTop + bottom).toBeLessThanOrEqual(tablePage.noteDecorations![0].top + 0.01);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['pageBottom', 'beneathText'] as const)('positions a short note at %s without changing its text', (position) => {
|
||||
const body = {
|
||||
dataStream: 'Explanation\r\n',
|
||||
paragraphs: [{ paragraphId: 'np', startIndex: 11 }],
|
||||
sectionBreaks: [{ sectionId: 'ns', startIndex: 12 }],
|
||||
};
|
||||
const bed = createNoteTestBed(600, { body });
|
||||
bed.dataModel.getSnapshot().noteSettings = { footnote: { position } };
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages).toHaveLength(1);
|
||||
const page = pages[0];
|
||||
const note = page.notes![0];
|
||||
const separator = page.noteDecorations![0];
|
||||
const bodyLine = page.sections[0].columns[0].lines[0];
|
||||
const bodyBottom = page.marginTop + bodyLine.top + bodyLine.lineHeight;
|
||||
expect(note.top).toBeCloseTo(separator.top + separator.page.height);
|
||||
if (position === 'beneathText') {
|
||||
expect(separator.top).toBeCloseTo(bodyBottom);
|
||||
expect(note.top + note.page.height).toBeLessThan(page.pageHeight - page.marginBottom - 100);
|
||||
} else {
|
||||
expect(note.top + note.page.height).toBeCloseTo(page.pageHeight - page.marginBottom);
|
||||
expect(separator.top).toBeGreaterThan(bodyBottom + 100);
|
||||
}
|
||||
expect(getPageGlyphs(note.page).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(body.dataStream);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('reserves custom separator and continuation notice paragraphs and publishes them to the Worker consumer', () => {
|
||||
const bed = createNoteTestBed(200);
|
||||
const makeBody = (text: string) => ({
|
||||
dataStream: `${text}\r\n`,
|
||||
paragraphs: [{ paragraphId: 'decoration', startIndex: text.length, paragraphStyle: { spaceBelow: { v: 17 } } }],
|
||||
sectionBreaks: [{ sectionId: 'decoration', startIndex: text.length + 1 }],
|
||||
});
|
||||
bed.dataModel.getSnapshot().noteSettings = { footnote: {
|
||||
separator: makeBody('Notes'),
|
||||
continuationSeparator: makeBody('Notes continued'),
|
||||
continuationNotice: makeBody('Continued on the following page'),
|
||||
} };
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
for (let index = 0; index < pages.length; index++) {
|
||||
const page = pages[index];
|
||||
const decorations = page.noteDecorations!;
|
||||
const separator = decorations[0];
|
||||
expect(separator.kind).toBe(index === 0 ? 'separator' : 'continuationSeparator');
|
||||
expect(getPageGlyphs(separator.page).map((glyph) => glyph.raw).join('')).toBe(index === 0 ? 'Notes\r\n' : 'Notes continued\r\n');
|
||||
expect(page.notes![0].top).toBeCloseTo(separator.top + separator.page.height);
|
||||
const lastLine = separator.page.sections[0].columns[0].lines.slice(-1)[0];
|
||||
expect(separator.page.height).toBeCloseTo(lastLine.top + lastLine.lineHeight + 17);
|
||||
const notice = decorations.find((decoration) => decoration.kind === 'continuationNotice');
|
||||
expect(Boolean(notice)).toBe(index < pages.length - 1);
|
||||
if (notice) {
|
||||
const noteBottom = Math.max(...page.notes!.map((note) => note.top + note.page.height));
|
||||
expect(notice.top).toBeCloseTo(noteBottom);
|
||||
expect(notice.top + notice.page.height).toBeLessThanOrEqual(page.pageHeight - page.marginBottom + 0.01);
|
||||
}
|
||||
const patch = serializeDocumentSkeletonPage(page, true);
|
||||
expect(() => JSON.stringify(patch)).not.toThrow();
|
||||
const restored = hydrateDocumentSkeletonPage(patch, undefined, bed.dataModel.getSnapshot());
|
||||
const restoredPage = restored.noteDecorations![0].page;
|
||||
expect(restoredPage.sections[0].parent).toBe(restoredPage);
|
||||
expect(getPageGlyphs(restoredPage).map((glyph) => glyph.raw)).toEqual(getPageGlyphs(separator.page).map((glyph) => glyph.raw));
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([10, 11, 12, 13, 14, 15])('does not carry a speculative note when its reference line moves after %i paragraphs', (prefixCount) => {
|
||||
const prefix = 'Intro\r'.repeat(prefixCount);
|
||||
const dataStream = `${prefix}\uFFFC ${'A paragraph that wraps over several lines. '.repeat(8)}\r\n`;
|
||||
const noteStream = 'A short explanation.\r\n';
|
||||
const bed = createParagraphLayoutTestBed('', {
|
||||
documentStyle: { documentFlavor: DocumentFlavor.TRADITIONAL, pageSize: { width: 400, height: 200 } },
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [...dataStream.matchAll(/\r/g)].map((match, index) => ({ startIndex: match.index!, paragraphId: `p-${index}` })),
|
||||
sectionBreaks: [{ sectionId: 'main', startIndex: dataStream.length - 1 }],
|
||||
customRanges: [{ rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, startIndex: prefix.length, endIndex: prefix.length, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: {
|
||||
dataStream: noteStream,
|
||||
paragraphs: [{ paragraphId: 'note-text', startIndex: noteStream.length - 2 }],
|
||||
sectionBreaks: [{ sectionId: 'note-section', startIndex: noteStream.length - 1 }],
|
||||
} } },
|
||||
});
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
const fragments = pages.flatMap((page) => page.notes ?? []);
|
||||
expect(fragments).toHaveLength(1);
|
||||
expect(fragments.flatMap((fragment) => getPageGlyphs(fragment.page)).filter((glyph) => glyph.count > 0).map((glyph) => glyph.raw).join('')).toBe(noteStream);
|
||||
expect(pages.find((page) => getPageGlyphs(page).some((glyph) => glyph.noteId === 'note'))?.notes).toHaveLength(1);
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses short and full-width default separator marks on first and continued pages', () => {
|
||||
const bed = createNoteTestBed(200);
|
||||
const skeleton = DocumentSkeleton.create(bed.viewModel, bed.ctx.docsConfig.localeService);
|
||||
try {
|
||||
skeleton.calculate();
|
||||
const pages = skeleton.getSkeletonData()!.pages;
|
||||
expect(pages.length).toBeGreaterThan(1);
|
||||
for (const [index, page] of pages.entries()) {
|
||||
const separator = page.noteDecorations![0];
|
||||
const marker = getPageGlyphs(separator.page).find((glyph) => glyph.noteSeparator)!;
|
||||
expect(marker.raw).toBe('\uFFFC');
|
||||
expect(marker.count).toBe(1);
|
||||
expect(marker.width).toBe(index === 0 ? 192 : page.pageWidth - page.marginLeft - page.marginRight);
|
||||
}
|
||||
} finally {
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes note fragments without parent cycles and restores their local hit-test hierarchy', () => {
|
||||
const bed = createNoteTestBed();
|
||||
const reference = resolveNoteReferences(bed.dataModel.getSnapshot()).get(9)!;
|
||||
const pages = layoutNoteBody(bed.ctx, reference, bed.sectionBreakConfig, {
|
||||
width: 180,
|
||||
firstPageHeight: 100,
|
||||
continuationPageHeight: 100,
|
||||
});
|
||||
bed.curPage.notes = [{
|
||||
noteId: 'note',
|
||||
referenceIndex: 9,
|
||||
continued: false,
|
||||
left: 20,
|
||||
top: 400,
|
||||
page: pages[0],
|
||||
}];
|
||||
const patch = serializeDocumentSkeletonPage(bed.curPage, true);
|
||||
expect(() => JSON.stringify(patch)).not.toThrow();
|
||||
const restored = hydrateDocumentSkeletonPage(patch, undefined, bed.dataModel.getSnapshot());
|
||||
const notePage = restored.notes![0].page;
|
||||
const section = notePage.sections[0];
|
||||
const column = section.columns[0];
|
||||
const line = column.lines[0];
|
||||
const divide = line.divides[0];
|
||||
expect(section.parent).toBe(notePage);
|
||||
expect(column.parent).toBe(section);
|
||||
expect(line.parent).toBe(column);
|
||||
expect(divide.glyphGroup[0].parent).toBe(divide);
|
||||
expect(notePage.st).toBe(0);
|
||||
expect(restored.notes![0].top).toBe(400);
|
||||
bed.viewModel.dispose();
|
||||
bed.dataModel.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData, IDocumentNote, IFootnoteProperties } from '@univerjs/core';
|
||||
import { CustomRangeType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatNoteNumber, resolveNoteReferences } from '../note-numbering';
|
||||
|
||||
function createSnapshot(restart: IFootnoteProperties['restart']): Pick<IDocumentData, 'body' | 'notes' | 'noteSettings'> {
|
||||
const notes: Record<string, IDocumentNote> = {};
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const noteId = `note-${index}`;
|
||||
notes[noteId] = { type: 'footnote' as const, noteId, body: { dataStream: 'Explanation\r\n' } };
|
||||
}
|
||||
notes['note-1'].customMark = '†';
|
||||
return {
|
||||
body: {
|
||||
dataStream: '\uFFFC\uFFFC\uFFFC\r\n\uFFFC\uFFFC\r\n',
|
||||
customRanges: [0, 1, 2, 5, 6].map((offset, index) => ({
|
||||
rangeId: `reference-${index}`,
|
||||
rangeType: CustomRangeType.FOOTNOTE,
|
||||
startIndex: offset,
|
||||
endIndex: offset,
|
||||
wholeEntity: true,
|
||||
properties: { noteId: `note-${index}` },
|
||||
})),
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section-1', startIndex: 4 },
|
||||
{ sectionId: 'section-2', startIndex: 8 },
|
||||
],
|
||||
},
|
||||
notes,
|
||||
noteSettings: { footnote: { restart, startNumber: 3, numberFormat: 'lowerRoman' } },
|
||||
};
|
||||
}
|
||||
|
||||
describe('footnote numbering', () => {
|
||||
it('keeps custom marks out of the automatic sequence without changing persisted text', () => {
|
||||
const snapshot = createSnapshot('continuous');
|
||||
const original = JSON.stringify(snapshot);
|
||||
const references = resolveNoteReferences(snapshot);
|
||||
expect([...references.values()].map((reference) => reference.label)).toEqual(['iii', '†', 'iv', 'v', 'vi']);
|
||||
expect(JSON.stringify(snapshot)).toBe(original);
|
||||
});
|
||||
|
||||
it('restarts at section boundaries and inherits the document number format', () => {
|
||||
const snapshot = createSnapshot('eachSect');
|
||||
snapshot.body!.sectionBreaks![1].noteProperties = { footnote: { startNumber: 9 } };
|
||||
expect([...resolveNoteReferences(snapshot).values()].map((reference) => reference.label))
|
||||
.toEqual(['iii', '†', 'iv', 'ix', 'x']);
|
||||
});
|
||||
|
||||
it('renumbers after a reference moves to a new physical page without rewriting the reference', () => {
|
||||
const snapshot = createSnapshot('eachPage');
|
||||
const pages = new Map([['note-0', 0], ['note-1', 1], ['note-2', 1], ['note-3', 2], ['note-4', 2]]);
|
||||
expect([...resolveNoteReferences(snapshot, pages).values()].map((reference) => reference.label))
|
||||
.toEqual(['iii', '†', 'iii', 'iii', 'iv']);
|
||||
pages.set('note-4', 3);
|
||||
const references = resolveNoteReferences(snapshot, pages);
|
||||
expect(references.get(6)).toMatchObject({ noteId: 'note-4', label: 'iii' });
|
||||
expect(snapshot.body!.customRanges![4].startIndex).toBe(6);
|
||||
});
|
||||
|
||||
it('extends the symbol cycle and supports enclosed and full-width numbers', () => {
|
||||
expect([1, 6, 7, 13].map((number) => formatNoteNumber(number, 'chicago'))).toEqual(['*', '¶', '**', '***']);
|
||||
expect([1, 20, 21, 35, 36, 50].map((number) => formatNoteNumber(number, 'decimalEnclosedCircle')))
|
||||
.toEqual(['①', '⑳', '㉑', '㉟', '㊱', '㊿']);
|
||||
expect(formatNoteNumber(108, 'decimalFullWidth')).toBe('108');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 { dealWithBlockError } from '../block-error';
|
||||
|
||||
describe('block-error', () => {
|
||||
it('returns an empty array', () => {
|
||||
expect(dealWithBlockError()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ColumnLayoutType, ColumnResponsiveType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateColumnGroupLayout } from '../column';
|
||||
|
||||
describe('column group layout', () => {
|
||||
it('compresses multiple columns without looping on floating-point residue', () => {
|
||||
const layout = calculateColumnGroupLayout({
|
||||
columnGroupId: 'column-group-1',
|
||||
columns: [
|
||||
{ columnId: 'column-1', widthRatio: 1, minWidth: { v: 50 } },
|
||||
{ columnId: 'column-2', widthRatio: 1 },
|
||||
{ columnId: 'column-3', widthRatio: 1 },
|
||||
],
|
||||
gap: { v: 0 },
|
||||
layout: ColumnLayoutType.FIXED,
|
||||
responsive: ColumnResponsiveType.SHRINK,
|
||||
}, 100, [0, 0, 0]);
|
||||
|
||||
expect(layout.columns[0].width).toBe(50);
|
||||
expect(layout.columns[1].width).toBeCloseTo(25);
|
||||
expect(layout.columns[2].width).toBeCloseTo(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentSkeletonPage } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import { BooleanNumber, ColumnLayoutType, ColumnResponsiveType, DashStyleType, DataStreamTreeNodeType, DataStreamTreeTokenType, DocumentBlockRangeType, TableAlignmentType, TableRowHeightRule, TableSizeType, TableTextWrapType, VerticalAlignmentType } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DataStreamTreeNode } from '../../../view-model/data-stream-tree-node';
|
||||
import { updateBlockIndex } from '../../tools';
|
||||
import { createSectionLayoutTestBed } from '../paragraph/__tests__/create-paragraph-layout-test-bed';
|
||||
import { dealWithSection } from '../section';
|
||||
|
||||
function getLastPage(pages: IDocumentSkeletonPage[]) {
|
||||
return pages[pages.length - 1];
|
||||
}
|
||||
|
||||
describe('section', () => {
|
||||
describe('dealWithSection', () => {
|
||||
it('processes a single paragraph', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello']);
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(1);
|
||||
expect(result.renderedBlockIdMap).toBeInstanceOf(Map);
|
||||
const lastPage = getLastPage(result.pages);
|
||||
expect(lastPage.sections.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('processes multiple paragraphs', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello', 'World']);
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(1);
|
||||
const lastPage = getLastPage(result.pages);
|
||||
const lines = lastPage.sections[0].columns[0].lines;
|
||||
expect(lines.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('skips paragraphs before layoutAnchor', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello', 'World']);
|
||||
|
||||
// layoutAnchor points to the end of the first paragraph.
|
||||
const layoutAnchor = sectionNode.children[0].endIndex;
|
||||
const result = dealWithSection(
|
||||
ctx,
|
||||
viewModel,
|
||||
sectionNode,
|
||||
curPage,
|
||||
sectionBreakConfig,
|
||||
layoutAnchor
|
||||
);
|
||||
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(1);
|
||||
const lastPage = getLastPage(result.pages);
|
||||
const lines = lastPage.sections[0].columns[0].lines;
|
||||
// Should only contain lines from the second paragraph onwards.
|
||||
expect(lines.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('stops early when ctx.isDirty is true', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello', 'World']);
|
||||
|
||||
ctx.isDirty = true;
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(1);
|
||||
const lastPage = getLastPage(result.pages);
|
||||
const lines = lastPage.sections[0].columns[0].lines;
|
||||
// Only first paragraph is processed before dirty break.
|
||||
expect(lines.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('rolls back pages when dirty and layoutStartPointer is set', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello']);
|
||||
|
||||
// paragraphIndex in lines is set to the paragraphNode's endIndex.
|
||||
const paragraphEndIndex = sectionNode.children[0].endIndex;
|
||||
ctx.isDirty = true;
|
||||
ctx.layoutStartPointer[''] = paragraphEndIndex;
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(1);
|
||||
const lastPage = getLastPage(result.pages);
|
||||
const lines = lastPage.sections[0].columns[0].lines;
|
||||
// After rollback, lines at or after the dirty paragraphIndex should be removed.
|
||||
const rolledBackLines = lines.filter((line) => line.paragraphIndex >= paragraphEndIndex);
|
||||
expect(rolledBackLines.length).toBe(0);
|
||||
});
|
||||
|
||||
it('opens a new page when paragraphsOpenNewPage contains the paragraph endIndex', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello', 'World']);
|
||||
|
||||
// Mark the second paragraph to open a new page.
|
||||
const paragraphEndIndex = sectionNode.children[1].endIndex;
|
||||
ctx.paragraphsOpenNewPage.add(paragraphEndIndex);
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
// Should have two pages because the second paragraph starts on a new page.
|
||||
expect(result.pages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(ctx.paragraphsOpenNewPage.has(paragraphEndIndex)).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses an empty rollback page when opening the anchor paragraph', () => {
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello']);
|
||||
const paragraphEndIndex = sectionNode.children[0].endIndex;
|
||||
ctx.paragraphsOpenNewPage.add(paragraphEndIndex);
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
expect(result.pages).toHaveLength(1);
|
||||
expect(result.pages[0].pageNumber).toBe(curPage.pageNumber);
|
||||
expect(ctx.paragraphsOpenNewPage.has(paragraphEndIndex)).toBe(false);
|
||||
});
|
||||
|
||||
it('lays out column group blocks into page skeleton columns', () => {
|
||||
const dataStream = [
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_START,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
'Left',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
'Right',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_END,
|
||||
DataStreamTreeTokenType.SECTION_BREAK,
|
||||
].join('');
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['unused'], {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [
|
||||
{
|
||||
startIndex: 6,
|
||||
paragraphId: 'left',
|
||||
paragraphStyle: {
|
||||
borderBottom: {
|
||||
color: { rgb: '#336699' },
|
||||
dashStyle: DashStyleType.SOLID,
|
||||
padding: 4,
|
||||
width: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ startIndex: 14, paragraphId: 'right' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_1014', startIndex: dataStream.length - 1 }],
|
||||
columnGroups: [{
|
||||
startIndex: 0,
|
||||
endIndex: dataStream.length - 1,
|
||||
columnGroupId: 'cg-1',
|
||||
gap: { v: 12 },
|
||||
layout: ColumnLayoutType.FIXED,
|
||||
responsive: ColumnResponsiveType.SHRINK,
|
||||
columns: [
|
||||
{ columnId: 'left-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
{ columnId: 'right-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
const page = result.pages[0];
|
||||
const columnGroup = page.skeColumnGroups.get('cg-1');
|
||||
|
||||
expect(columnGroup).toBeTruthy();
|
||||
if (columnGroup == null) {
|
||||
throw new Error('Expected column group skeleton to be created');
|
||||
}
|
||||
|
||||
expect(columnGroup).toMatchObject({
|
||||
columnGroupId: 'cg-1',
|
||||
left: 0,
|
||||
width: 266.66666666666663,
|
||||
columns: [
|
||||
{ columnId: 'left-col', left: 0, width: 127.33333333333331 },
|
||||
{ columnId: 'right-col', left: 139.33333333333331, width: 127.33333333333331 },
|
||||
],
|
||||
});
|
||||
expect(columnGroup.height).toBeGreaterThan(0);
|
||||
expect(columnGroup.columns.map(({ page: columnPage }) => ({
|
||||
marginLeft: columnPage.marginLeft,
|
||||
marginRight: columnPage.marginRight,
|
||||
}))).toEqual([
|
||||
{ marginLeft: 8, marginRight: 8 },
|
||||
{ marginLeft: 8, marginRight: 8 },
|
||||
]);
|
||||
expect(columnGroup.columns[0].page.sections[0].columns[0].lines.length).toBeGreaterThan(0);
|
||||
expect(columnGroup.columns[0].page.sections[0].columns[0].lines[0].borderBottom).toMatchObject({
|
||||
color: { rgb: '#336699' },
|
||||
padding: 4,
|
||||
width: 2,
|
||||
});
|
||||
expect(page.sections[0].columns[0].lines.at(-1)).toMatchObject({
|
||||
paragraphIndex: columnGroup.ed,
|
||||
lineHeight: columnGroup.height,
|
||||
});
|
||||
});
|
||||
|
||||
it('reserves visible height for empty column group blocks', () => {
|
||||
const dataStream = [
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_START,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
' ',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
' ',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_END,
|
||||
DataStreamTreeTokenType.SECTION_BREAK,
|
||||
].join('');
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['unused'], {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [
|
||||
{ startIndex: 3, paragraphId: 'left' },
|
||||
{ startIndex: 7, paragraphId: 'right' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_1015', startIndex: dataStream.length - 1 }],
|
||||
columnGroups: [{
|
||||
startIndex: 0,
|
||||
endIndex: dataStream.length - 1,
|
||||
columnGroupId: 'cg-empty',
|
||||
gap: { v: 12 },
|
||||
layout: ColumnLayoutType.FIXED,
|
||||
responsive: ColumnResponsiveType.SHRINK,
|
||||
columns: [
|
||||
{ columnId: 'left-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
{ columnId: 'right-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
const page = result.pages[0];
|
||||
const columnGroup = page.skeColumnGroups.get('cg-empty');
|
||||
|
||||
expect(columnGroup?.height).toBe(72);
|
||||
expect(columnGroup?.columns.map((column) => column.height)).toEqual([72, 72]);
|
||||
expect(columnGroup?.columns.map((column) => column.page.sections[0].columns[0].lines.length)).toEqual([1, 1]);
|
||||
expect(columnGroup?.columns.map((column) =>
|
||||
column.page.sections[0].columns[0].lines[0].divides.flatMap((divide) => divide.glyphGroup.map((glyph) => glyph.content))
|
||||
)).toEqual([[' ', DataStreamTreeTokenType.PARAGRAPH], [' ', DataStreamTreeTokenType.PARAGRAPH]]);
|
||||
expect(page.sections[0].columns[0].lines.at(-1)).toMatchObject({
|
||||
lineHeight: 72,
|
||||
paragraphIndex: columnGroup?.ed,
|
||||
});
|
||||
});
|
||||
|
||||
it('pushes following paragraph lines below an empty column group block', () => {
|
||||
const dataStream = [
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_START,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_END,
|
||||
'After',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.SECTION_BREAK,
|
||||
].join('');
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['unused'], {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [
|
||||
{ startIndex: 2, paragraphId: 'left' },
|
||||
{ startIndex: 5, paragraphId: 'right' },
|
||||
{ startIndex: 13, paragraphId: 'after' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_1016', startIndex: dataStream.length - 1 }],
|
||||
columnGroups: [{
|
||||
startIndex: 0,
|
||||
endIndex: 7,
|
||||
columnGroupId: 'cg-empty-flow',
|
||||
gap: { v: 12 },
|
||||
layout: ColumnLayoutType.FIXED,
|
||||
responsive: ColumnResponsiveType.SHRINK,
|
||||
columns: [
|
||||
{ columnId: 'left-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
{ columnId: 'right-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
updateBlockIndex(result.pages);
|
||||
const page = result.pages[0];
|
||||
const columnGroup = page.skeColumnGroups.get('cg-empty-flow');
|
||||
const lines = page.sections[0].columns[0].lines;
|
||||
const columnGroupLine = lines.find((line) => line.paragraphIndex === columnGroup?.ed);
|
||||
const followingParagraphLine = lines.find((line) => line.paragraphIndex === 13);
|
||||
|
||||
expect(columnGroup?.height).toBe(72);
|
||||
expect(columnGroupLine).toMatchObject({
|
||||
top: columnGroup?.top,
|
||||
lineHeight: 72,
|
||||
st: 0,
|
||||
ed: 7,
|
||||
});
|
||||
expect(followingParagraphLine).toMatchObject({
|
||||
st: 8,
|
||||
ed: 14,
|
||||
});
|
||||
expect(followingParagraphLine?.top).toBeGreaterThanOrEqual(columnGroupLine!.top + columnGroupLine!.lineHeight);
|
||||
});
|
||||
|
||||
it('lays out tables inside column content pages', () => {
|
||||
const T = DataStreamTreeTokenType;
|
||||
const dataStream = [
|
||||
T.COLUMN_GROUP_START,
|
||||
T.COLUMN_START,
|
||||
T.TABLE_START,
|
||||
T.TABLE_ROW_START,
|
||||
T.TABLE_CELL_START,
|
||||
'H',
|
||||
T.PARAGRAPH,
|
||||
T.SECTION_BREAK,
|
||||
T.TABLE_CELL_END,
|
||||
T.TABLE_ROW_END,
|
||||
T.TABLE_END,
|
||||
T.PARAGRAPH,
|
||||
T.COLUMN_END,
|
||||
T.COLUMN_START,
|
||||
T.PARAGRAPH,
|
||||
T.COLUMN_END,
|
||||
T.COLUMN_GROUP_END,
|
||||
T.SECTION_BREAK,
|
||||
].join('');
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['unused'], {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [
|
||||
{ startIndex: 6, paragraphId: 'table-cell-para' },
|
||||
{ startIndex: 11, paragraphId: 'after-table-para' },
|
||||
{ startIndex: 14, paragraphId: 'right-column-para' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ sectionId: 'section_fixture_1017', startIndex: 7 },
|
||||
{ sectionId: 'section_fixture_1018', startIndex: dataStream.length - 1 },
|
||||
],
|
||||
tables: [{ startIndex: 2, endIndex: 11, tableId: 'table-in-column' }],
|
||||
columnGroups: [{
|
||||
startIndex: 0,
|
||||
endIndex: 16,
|
||||
columnGroupId: 'cg-table',
|
||||
gap: { v: 12 },
|
||||
layout: ColumnLayoutType.FIXED,
|
||||
responsive: ColumnResponsiveType.SHRINK,
|
||||
columns: [
|
||||
{ columnId: 'left-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
{ columnId: 'right-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
tableSource: {
|
||||
'table-in-column': {
|
||||
tableId: 'table-in-column',
|
||||
align: TableAlignmentType.START,
|
||||
indent: { v: 0 },
|
||||
textWrap: TableTextWrapType.NONE,
|
||||
position: {} as never,
|
||||
dist: {} as never,
|
||||
size: { type: TableSizeType.SPECIFIED, width: { v: 120 } },
|
||||
tableColumns: [{ size: { type: TableSizeType.SPECIFIED, width: { v: 120 } } }],
|
||||
tableRows: [{
|
||||
repeatHeaderRow: BooleanNumber.FALSE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.AT_LEAST,
|
||||
val: { v: 30 },
|
||||
},
|
||||
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
const columnGroup = result.pages[0].skeColumnGroups.get('cg-table');
|
||||
const table = columnGroup?.columns[0].page.skeTables.get('table-in-column');
|
||||
|
||||
expect(columnGroup).toBeTruthy();
|
||||
expect(table).toBeTruthy();
|
||||
expect(table?.rows).toHaveLength(1);
|
||||
expect(table?.left).toBe(0);
|
||||
expect(columnGroup?.height).toBeGreaterThanOrEqual(table?.height ?? 0);
|
||||
});
|
||||
|
||||
it('adds trailing block range spacing to the column page when the block range is the last column element', () => {
|
||||
const dataStream = [
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_START,
|
||||
DataStreamTreeTokenType.COLUMN_START,
|
||||
'Column block range line one',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
'Column block range line two',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
'Column block range line three',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
'Column block range line four',
|
||||
DataStreamTreeTokenType.PARAGRAPH,
|
||||
DataStreamTreeTokenType.COLUMN_END,
|
||||
DataStreamTreeTokenType.COLUMN_GROUP_END,
|
||||
DataStreamTreeTokenType.SECTION_BREAK,
|
||||
].join('');
|
||||
const { ctx, viewModel, sectionNode, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['unused'], {
|
||||
body: {
|
||||
dataStream,
|
||||
paragraphs: [
|
||||
{ startIndex: 30, paragraphId: 'column-block-1' },
|
||||
{ startIndex: 58, paragraphId: 'column-block-2' },
|
||||
{ startIndex: 88, paragraphId: 'column-block-3' },
|
||||
{ startIndex: 117, paragraphId: 'column-block-4' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_1019', startIndex: dataStream.length - 1 }],
|
||||
blockRanges: [{
|
||||
blockId: 'callout-column-1',
|
||||
blockType: DocumentBlockRangeType.CALLOUT,
|
||||
startIndex: 1,
|
||||
endIndex: dataStream.length - 3,
|
||||
}],
|
||||
columnGroups: [{
|
||||
startIndex: 0,
|
||||
endIndex: dataStream.length - 1,
|
||||
columnGroupId: 'cg-block-range',
|
||||
gap: { v: 12 },
|
||||
layout: ColumnLayoutType.FIXED,
|
||||
responsive: ColumnResponsiveType.SHRINK,
|
||||
columns: [
|
||||
{ columnId: 'left-col', widthRatio: 1, minWidth: { v: 40 } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
});
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, sectionNode, curPage, sectionBreakConfig, null);
|
||||
const columnPage = result.pages[0].skeColumnGroups.get('cg-block-range')?.columns[0].page;
|
||||
const lastLine = columnPage?.sections[0].columns[0].lines.at(-1);
|
||||
|
||||
expect(lastLine?.paragraphIndex).toBeGreaterThan(1);
|
||||
expect(lastLine?.paragraphIndex).toBeLessThan(dataStream.length - 3);
|
||||
expect(columnPage?.height).toBeGreaterThan((lastLine?.top ?? 0) + (lastLine?.lineHeight ?? 0));
|
||||
});
|
||||
|
||||
it('handles an empty section', () => {
|
||||
const { ctx, viewModel, curPage, sectionBreakConfig } =
|
||||
createSectionLayoutTestBed(['Hello']);
|
||||
|
||||
const emptySectionNode = new DataStreamTreeNode(DataStreamTreeNodeType.SECTION_BREAK, '');
|
||||
emptySectionNode.startIndex = 0;
|
||||
emptySectionNode.endIndex = 0;
|
||||
emptySectionNode.children = [];
|
||||
|
||||
const result = dealWithSection(ctx, viewModel, emptySectionNode, curPage, sectionBreakConfig, null);
|
||||
|
||||
expect(result.pages.length).toBe(0);
|
||||
expect(result.renderedBlockIdMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,871 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ITable } from '@univerjs/core';
|
||||
import type { IParagraphList } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import type { DataStreamTreeNode } from '../../../view-model/data-stream-tree-node';
|
||||
import type { ILayoutContext } from '../../tools';
|
||||
import {
|
||||
BooleanNumber,
|
||||
DocumentFlavor,
|
||||
TableAlignmentType,
|
||||
TableRowHeightRule,
|
||||
TableSizeType,
|
||||
VerticalAlignmentType,
|
||||
} from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { BreakType, DocumentSkeletonPageType } from '../../../../../basics/i-document-skeleton-cached';
|
||||
import { getDocumentCompatibilityPolicy } from '../../../document-compatibility';
|
||||
import {
|
||||
cachePrecomputedSlicedTableSkeletons,
|
||||
cachePrecomputedTableSkeleton,
|
||||
createTableSkeleton,
|
||||
createTableSkeletons,
|
||||
getNullTableSkeleton,
|
||||
getTableIdAndSliceIndex,
|
||||
getTableLeft,
|
||||
getTableSliceId,
|
||||
rollbackListCache,
|
||||
startTableSkeletonBuild,
|
||||
startTableSkeletonsBuild,
|
||||
stepTableSkeletonBuild,
|
||||
stepTableSkeletonsBuild,
|
||||
} from '../table';
|
||||
|
||||
const createSkeletonCellPagesMock = vi.fn();
|
||||
const createNullCellPageMock = vi.fn();
|
||||
|
||||
vi.mock('../../model/page', () => ({
|
||||
createSkeletonCellPages: (...args: unknown[]) => createSkeletonCellPagesMock(...args),
|
||||
createNullCellPage: (...args: unknown[]) => createNullCellPageMock(...args),
|
||||
startSkeletonCellPagesBuild: (...args: unknown[]) => ({
|
||||
args,
|
||||
complete: false,
|
||||
requiresSyncFallback: false,
|
||||
pages: [],
|
||||
}),
|
||||
stepSkeletonCellPagesBuild: (state: { args: unknown[]; complete: boolean; pages: unknown[] }) => {
|
||||
state.pages = createSkeletonCellPagesMock(...state.args);
|
||||
state.complete = true;
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
function createMockTable(overrides: Partial<ITable> = {}): ITable {
|
||||
return {
|
||||
tableId: 'test-table',
|
||||
tableRows: [],
|
||||
tableColumns: [],
|
||||
align: TableAlignmentType.START,
|
||||
indent: { v: 0 },
|
||||
textWrap: 0 as unknown as ITable['textWrap'],
|
||||
position: {} as unknown as ITable['position'],
|
||||
dist: {} as unknown as ITable['dist'],
|
||||
size: { type: TableSizeType.UNSPECIFIED, width: { v: 100 } },
|
||||
...overrides,
|
||||
} as ITable;
|
||||
}
|
||||
|
||||
function createRowNode(startIndex: number, endIndex: number, cellCount: number) {
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
children: new Array(cellCount).fill(0).map((_, i) => ({
|
||||
startIndex: startIndex + i * 5,
|
||||
endIndex: startIndex + i * 5 + 4,
|
||||
children: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createContextAndTable() {
|
||||
const tableSource = {
|
||||
tableId: 'table-1',
|
||||
align: TableAlignmentType.START,
|
||||
indent: { v: 8 },
|
||||
tableRows: [
|
||||
{
|
||||
repeatHeaderRow: BooleanNumber.TRUE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.AUTO,
|
||||
val: { v: 16 },
|
||||
},
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.CENTER },
|
||||
],
|
||||
},
|
||||
{
|
||||
repeatHeaderRow: BooleanNumber.FALSE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.AT_LEAST,
|
||||
val: { v: 28 },
|
||||
},
|
||||
cantSplit: BooleanNumber.TRUE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.BOTTOM },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
const viewModel = {
|
||||
getTableByStartIndex: vi.fn(() => ({ tableSource })),
|
||||
} as any;
|
||||
|
||||
const tableNode = {
|
||||
startIndex: 0,
|
||||
endIndex: 80,
|
||||
children: [
|
||||
createRowNode(1, 20, 2),
|
||||
createRowNode(21, 40, 2),
|
||||
],
|
||||
} as any;
|
||||
|
||||
const curPage = {
|
||||
pageWidth: 400,
|
||||
pageHeight: 300,
|
||||
marginTop: 20,
|
||||
marginBottom: 20,
|
||||
marginLeft: 10,
|
||||
marginRight: 10,
|
||||
} as any;
|
||||
|
||||
return {
|
||||
ctx: {} as any,
|
||||
sectionBreakConfig: {} as any,
|
||||
tableSource,
|
||||
viewModel,
|
||||
tableNode,
|
||||
curPage,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCellPage(width: number, height: number) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
pageWidth: width,
|
||||
pageHeight: height,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
originMarginTop: 1,
|
||||
originMarginBottom: 1,
|
||||
sections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function useDocumentFlavor(sectionBreakConfig: Record<string, unknown>, documentFlavor: DocumentFlavor) {
|
||||
sectionBreakConfig.documentCompatibilityPolicy = getDocumentCompatibilityPolicy(documentFlavor);
|
||||
}
|
||||
|
||||
describe('table utilities', () => {
|
||||
describe('getTableLeft', () => {
|
||||
it('centers a table within the supplied column width', () => {
|
||||
expect(getTableLeft(243, 243, TableAlignmentType.CENTER)).toBe(0);
|
||||
expect(261 + getTableLeft(243, 120, TableAlignmentType.CENTER)).toBe(322.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTableSliceId', () => {
|
||||
it('concatenates tableId and sliceIndex with delimiter', () => {
|
||||
expect(getTableSliceId('table1', 0)).toBe('table1#-#0');
|
||||
expect(getTableSliceId('table1', 2)).toBe('table1#-#2');
|
||||
expect(getTableSliceId('my-table', 99)).toBe('my-table#-#99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTableIdAndSliceIndex', () => {
|
||||
it('parses sliced table id', () => {
|
||||
expect(getTableIdAndSliceIndex('table1#-#0')).toEqual({
|
||||
tableId: 'table1',
|
||||
sliceIndex: 0,
|
||||
});
|
||||
expect(getTableIdAndSliceIndex('table1#-#2')).toEqual({
|
||||
tableId: 'table1',
|
||||
sliceIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns sliceIndex 0 for unsliced table id', () => {
|
||||
expect(getTableIdAndSliceIndex('table1')).toEqual({
|
||||
tableId: 'table1',
|
||||
sliceIndex: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNullTableSkeleton', () => {
|
||||
it('returns a skeleton with zero dimensions and given bounds', () => {
|
||||
const table = createMockTable();
|
||||
const skeleton = getNullTableSkeleton(10, 50, table);
|
||||
|
||||
expect(skeleton.rows).toEqual([]);
|
||||
expect(skeleton.width).toBe(0);
|
||||
expect(skeleton.height).toBe(0);
|
||||
expect(skeleton.top).toBe(0);
|
||||
expect(skeleton.left).toBe(0);
|
||||
expect(skeleton.st).toBe(10);
|
||||
expect(skeleton.ed).toBe(50);
|
||||
expect(skeleton.tableId).toBe('test-table');
|
||||
expect(skeleton.tableSource).toBe(table);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rollbackListCache', () => {
|
||||
it('removes paragraph lists whose startIndex is inside the table range', () => {
|
||||
const paragraphList1: IParagraphList = {
|
||||
bullet: {} as unknown as IParagraphList['bullet'],
|
||||
paragraph: { startIndex: 5 } as unknown as IParagraphList['paragraph'],
|
||||
};
|
||||
const paragraphList2: IParagraphList = {
|
||||
bullet: {} as unknown as IParagraphList['bullet'],
|
||||
paragraph: { startIndex: 15 } as unknown as IParagraphList['paragraph'],
|
||||
};
|
||||
const paragraphList3: IParagraphList = {
|
||||
bullet: {} as unknown as IParagraphList['bullet'],
|
||||
paragraph: { startIndex: 25 } as unknown as IParagraphList['paragraph'],
|
||||
};
|
||||
|
||||
const listLevel = new Map<string, IParagraphList[][]>([
|
||||
['list1', [[paragraphList1, paragraphList2, paragraphList3]]],
|
||||
]);
|
||||
|
||||
const tableNode = {
|
||||
startIndex: 10,
|
||||
endIndex: 20,
|
||||
} as DataStreamTreeNode;
|
||||
|
||||
rollbackListCache(listLevel, tableNode);
|
||||
|
||||
const result = listLevel.get('list1')![0];
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].paragraph.startIndex).toBe(5);
|
||||
});
|
||||
|
||||
it('does not remove paragraph lists outside the table range', () => {
|
||||
const paragraphList1: IParagraphList = {
|
||||
bullet: {} as unknown as IParagraphList['bullet'],
|
||||
paragraph: { startIndex: 1 } as unknown as IParagraphList['paragraph'],
|
||||
};
|
||||
const paragraphList2: IParagraphList = {
|
||||
bullet: {} as unknown as IParagraphList['bullet'],
|
||||
paragraph: { startIndex: 2 } as unknown as IParagraphList['paragraph'],
|
||||
};
|
||||
|
||||
const listLevel = new Map<string, IParagraphList[][]>([
|
||||
['list1', [[paragraphList1, paragraphList2]]],
|
||||
]);
|
||||
|
||||
const tableNode = {
|
||||
startIndex: 10,
|
||||
endIndex: 20,
|
||||
} as DataStreamTreeNode;
|
||||
|
||||
rollbackListCache(listLevel, tableNode);
|
||||
|
||||
const result = listLevel.get('list1')![0];
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty listLevel', () => {
|
||||
const listLevel = new Map<string, IParagraphList[][]>();
|
||||
const tableNode = {
|
||||
startIndex: 10,
|
||||
endIndex: 20,
|
||||
} as DataStreamTreeNode;
|
||||
|
||||
expect(() => rollbackListCache(listLevel, tableNode)).not.toThrow();
|
||||
});
|
||||
|
||||
it('skips sparse paragraph list levels', () => {
|
||||
const paragraphList: IParagraphList = {
|
||||
bullet: {} as unknown as IParagraphList['bullet'],
|
||||
paragraph: { startIndex: 25 } as unknown as IParagraphList['paragraph'],
|
||||
};
|
||||
const paragraphLists = [] as unknown as IParagraphList[][];
|
||||
paragraphLists[2] = [paragraphList];
|
||||
const listLevel = new Map<string, IParagraphList[][]>([
|
||||
['list1', paragraphLists],
|
||||
]);
|
||||
const tableNode = {
|
||||
startIndex: 10,
|
||||
endIndex: 20,
|
||||
} as DataStreamTreeNode;
|
||||
|
||||
expect(() => rollbackListCache(listLevel, tableNode)).not.toThrow();
|
||||
expect(listLevel.get('list1')![2]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs table layout', () => {
|
||||
beforeEach(() => {
|
||||
createSkeletonCellPagesMock.mockReset();
|
||||
createNullCellPageMock.mockReset();
|
||||
|
||||
createNullCellPageMock.mockImplementation(() => ({
|
||||
page: makeCellPage(60, 10),
|
||||
}));
|
||||
|
||||
createSkeletonCellPagesMock.mockImplementation(
|
||||
(_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, _table: unknown, row: number, col: number, availableHeight?: number) => {
|
||||
const baseHeight = row === 0 ? 20 : 24;
|
||||
if (row === 1 && col === 0 && typeof availableHeight === 'number' && availableHeight < 40) {
|
||||
return [makeCellPage(60, baseHeight), makeCellPage(60, 16)];
|
||||
}
|
||||
return [makeCellPage(60, baseHeight)];
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('creates table skeleton and applies row/cell alignment data', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
expect(skeleton).toBeTruthy();
|
||||
expect(skeleton?.rows.length).toBe(2);
|
||||
expect(skeleton?.width).toBeGreaterThan(0);
|
||||
expect(skeleton?.height).toBeGreaterThan(0);
|
||||
expect(skeleton?.left).toBeGreaterThanOrEqual(0);
|
||||
expect(skeleton?.rows[0].cells[0].marginTop).toBeGreaterThanOrEqual(1);
|
||||
expect(skeleton?.rows[0].cells[1].marginTop).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('builds a table one source cell at a time', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
const state = startTableSkeletonBuild(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(stepTableSkeletonBuild(state!)).toBe(false);
|
||||
expect(state).toMatchObject({ rowIndex: 0, currentColumnIndex: 1, complete: false });
|
||||
expect(state?.tableSkeleton.rows).toHaveLength(1);
|
||||
|
||||
expect(stepTableSkeletonBuild(state!)).toBe(false);
|
||||
expect(state).toMatchObject({ rowIndex: 1, currentColumnIndex: 0, complete: false });
|
||||
|
||||
while (!stepTableSkeletonBuild(state!)) {
|
||||
// Continue until all source cells are complete.
|
||||
}
|
||||
expect(state).toMatchObject({ rowIndex: 2, complete: true });
|
||||
expect(state?.tableSkeleton.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reuses incrementally precomputed rows when a table is split', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
const state = startTableSkeletonBuild(ctx, curPage, viewModel, tableNode, sectionBreakConfig)!;
|
||||
while (!stepTableSkeletonBuild(state)) {
|
||||
// Build all source rows through the resumable path before pagination consumes them.
|
||||
}
|
||||
cachePrecomputedTableSkeleton(ctx, tableNode.startIndex, state.tableSkeleton);
|
||||
const callsBeforePagination = createSkeletonCellPagesMock.mock.calls.length;
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
|
||||
|
||||
expect(result.skeTables.flatMap((table) => table.rows).filter((row) => !row.isRepeatRow)).toHaveLength(2);
|
||||
expect(createSkeletonCellPagesMock.mock.calls.length).toBe(callsBeforePagination);
|
||||
});
|
||||
|
||||
it('builds exact paginated cell pages incrementally before the synchronous consumer needs them', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
const state = startTableSkeletonsBuild(
|
||||
ctx,
|
||||
curPage,
|
||||
viewModel,
|
||||
tableNode,
|
||||
sectionBreakConfig,
|
||||
90
|
||||
)!;
|
||||
|
||||
expect(stepTableSkeletonsBuild(state)).toBe(false);
|
||||
expect(state).toMatchObject({ rowIndex: 0, columnIndex: 1, complete: false });
|
||||
while (!stepTableSkeletonsBuild(state)) {
|
||||
// Continue through each cell paragraph until exact pagination is ready.
|
||||
}
|
||||
expect(state.result).not.toBeNull();
|
||||
|
||||
cachePrecomputedSlicedTableSkeletons(ctx, tableNode.startIndex, 90, state.result!);
|
||||
const callsBeforeConsume = createSkeletonCellPagesMock.mock.calls.length;
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
|
||||
|
||||
expect(result).toBe(state.result);
|
||||
expect(createSkeletonCellPagesMock.mock.calls.length).toBe(callsBeforeConsume);
|
||||
});
|
||||
|
||||
it('defers split-table work only after the normal layout path provides its exact context', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
type DeferredRequest = Parameters<NonNullable<ILayoutContext['deferSlicedTableLayout']>>[0];
|
||||
let request: DeferredRequest | undefined;
|
||||
ctx.deferSlicedTableLayout = (value: DeferredRequest) => {
|
||||
request = value;
|
||||
return true;
|
||||
};
|
||||
|
||||
const provisional = createTableSkeletons(
|
||||
ctx,
|
||||
curPage,
|
||||
viewModel,
|
||||
tableNode,
|
||||
sectionBreakConfig,
|
||||
90
|
||||
);
|
||||
|
||||
expect(provisional).toEqual({ skeTables: [], fromCurrentPage: false });
|
||||
expect(request).toMatchObject({
|
||||
curPage,
|
||||
viewModel,
|
||||
tableNode,
|
||||
sectionBreakConfig,
|
||||
availableHeight: 90,
|
||||
});
|
||||
|
||||
ctx.deferSlicedTableLayout = undefined;
|
||||
const state = startTableSkeletonsBuild(
|
||||
ctx,
|
||||
request!.curPage,
|
||||
request!.viewModel,
|
||||
request!.tableNode,
|
||||
request!.sectionBreakConfig,
|
||||
request!.availableHeight
|
||||
)!;
|
||||
while (!stepTableSkeletonsBuild(state)) {
|
||||
// Resume the exact deferred request one cell paragraph at a time.
|
||||
}
|
||||
cachePrecomputedSlicedTableSkeletons(
|
||||
ctx,
|
||||
tableNode.startIndex,
|
||||
request!.availableHeight,
|
||||
state.result!
|
||||
);
|
||||
|
||||
expect(createTableSkeletons(
|
||||
ctx,
|
||||
curPage,
|
||||
viewModel,
|
||||
tableNode,
|
||||
sectionBreakConfig,
|
||||
90
|
||||
)).toBe(state.result);
|
||||
});
|
||||
|
||||
it('uses every nested cell page when estimating a table inside a cell', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
curPage.type = DocumentSkeletonPageType.CELL;
|
||||
tableSource.tableRows = [{
|
||||
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
|
||||
}];
|
||||
tableNode.children = [createRowNode(1, 20, 1) as any];
|
||||
createSkeletonCellPagesMock.mockReturnValue([
|
||||
makeCellPage(60, 20),
|
||||
makeCellPage(60, 30),
|
||||
]);
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(skeleton?.height).toBe(54);
|
||||
expect(skeleton?.rows[0].height).toBe(54);
|
||||
});
|
||||
|
||||
it('does not count top-level cell continuation pages before the table paginator runs', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows = [{
|
||||
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
|
||||
}];
|
||||
tableNode.children = [createRowNode(1, 20, 1) as any];
|
||||
createSkeletonCellPagesMock.mockReturnValue([
|
||||
makeCellPage(60, 20),
|
||||
makeCellPage(60, 30),
|
||||
]);
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(skeleton?.height).toBe(22);
|
||||
expect(skeleton?.rows[0].height).toBe(22);
|
||||
});
|
||||
|
||||
it('keeps covered merged cells as non-rendering layout placeholders', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
createSkeletonCellPagesMock.mockImplementation(
|
||||
(_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, table: any, row: number, col: number) => {
|
||||
const columnSpan = Math.max(1, table.tableRows[row].tableCells[col].columnSpan ?? 1);
|
||||
return [makeCellPage(60 * columnSpan, 20)];
|
||||
}
|
||||
);
|
||||
tableSource.tableRows[0].tableCells = [
|
||||
{ columnSpan: 2, vAlign: VerticalAlignmentType.TOP },
|
||||
{ rowSpan: 0, columnSpan: 0, vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
];
|
||||
tableNode.children[0] = createRowNode(1, 30, 3) as any;
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(skeleton).not.toBeNull();
|
||||
const cells = skeleton!.rows[0].cells;
|
||||
expect(cells).toHaveLength(3);
|
||||
expect((cells[1] as any).isMergedCellCovered).toBe(true);
|
||||
expect(cells[2].left).toBe(120);
|
||||
expect(createSkeletonCellPagesMock.mock.calls.some((call) => call[5] === 0 && call[6] === 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not shift cells after a same-row horizontally covered merged cell', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
createSkeletonCellPagesMock.mockImplementation(
|
||||
(_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, table: any, row: number, col: number) => {
|
||||
const columnSpan = Math.max(1, table.tableRows[row].tableCells[col].columnSpan ?? 1);
|
||||
return [makeCellPage(60 * columnSpan, 20)];
|
||||
}
|
||||
);
|
||||
tableSource.tableRows[0].tableCells = [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ columnSpan: 2, vAlign: VerticalAlignmentType.TOP },
|
||||
{ rowSpan: 0, columnSpan: 0, vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
];
|
||||
tableNode.children[0] = createRowNode(1, 30, 4) as any;
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(skeleton?.rows[0].cells[3].left).toBe(180);
|
||||
});
|
||||
|
||||
it('expands merged master cell height across spanned rows', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[0].tableCells = [
|
||||
{ rowSpan: 2, columnSpan: 2, vAlign: VerticalAlignmentType.TOP },
|
||||
{ rowSpan: 0, columnSpan: 0, vAlign: VerticalAlignmentType.TOP },
|
||||
];
|
||||
tableSource.tableRows[1].tableCells = [
|
||||
{ rowSpan: 0, columnSpan: 0, vAlign: VerticalAlignmentType.TOP },
|
||||
{ rowSpan: 0, columnSpan: 0, vAlign: VerticalAlignmentType.TOP },
|
||||
];
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(skeleton?.rows[0].cells[0].pageHeight).toBe(
|
||||
skeleton!.rows[0].height + skeleton!.rows[1].height
|
||||
);
|
||||
});
|
||||
|
||||
it('treats at-least row height as a minimum so wrapped cell content remains visible', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[0].trHeight = {
|
||||
hRule: TableRowHeightRule.AT_LEAST,
|
||||
val: { v: 18 },
|
||||
};
|
||||
createSkeletonCellPagesMock.mockImplementation(
|
||||
(_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, _table: unknown, row: number) =>
|
||||
[makeCellPage(60, row === 0 ? 48 : 20)]
|
||||
);
|
||||
|
||||
const skeleton = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(skeleton?.rows[0].height).toBe(50);
|
||||
expect(skeleton?.rows[0].cells[0].pageHeight).toBe(50);
|
||||
});
|
||||
|
||||
it('creates sliced tables when available height is limited', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
|
||||
expect(result.skeTables.length).toBeGreaterThan(0);
|
||||
expect(typeof result.fromCurrentPage).toBe('boolean');
|
||||
expect(result.skeTables[0].rows.length).toBeGreaterThan(0);
|
||||
|
||||
if (result.skeTables.length > 1) {
|
||||
expect(result.skeTables[1].tableId).toContain('#-#');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a table on the current page when it only slightly overflows available height', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
useDocumentFlavor(sectionBreakConfig, DocumentFlavor.TRADITIONAL);
|
||||
tableSource.tableRows = [
|
||||
{
|
||||
repeatHeaderRow: BooleanNumber.FALSE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.AUTO,
|
||||
val: { v: 16 },
|
||||
},
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
],
|
||||
},
|
||||
];
|
||||
tableNode.children = [createRowNode(1, 20, 2) as any];
|
||||
createSkeletonCellPagesMock.mockImplementation(() => [makeCellPage(60, 115)]);
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 109);
|
||||
|
||||
expect(result.skeTables[0].height).toBe(117);
|
||||
expect(result.fromCurrentPage).toBe(true);
|
||||
});
|
||||
|
||||
it('opens a new table slice in modern documents when it overflows available height', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
useDocumentFlavor(sectionBreakConfig, DocumentFlavor.MODERN);
|
||||
tableSource.tableRows = [
|
||||
{
|
||||
repeatHeaderRow: BooleanNumber.FALSE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.AUTO,
|
||||
val: { v: 16 },
|
||||
},
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
],
|
||||
},
|
||||
];
|
||||
tableNode.children = [createRowNode(1, 20, 2) as any];
|
||||
createSkeletonCellPagesMock.mockImplementation(() => [makeCellPage(60, 115)]);
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 109);
|
||||
|
||||
expect(result.skeTables[0].height).toBe(117);
|
||||
expect(result.fromCurrentPage).toBe(false);
|
||||
});
|
||||
|
||||
it('DOCX golden e2e lays out splittable auto rows against the remaining page height', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[0].repeatHeaderRow = BooleanNumber.FALSE;
|
||||
tableSource.tableRows[0].trHeight = {
|
||||
hRule: TableRowHeightRule.EXACT,
|
||||
val: { v: 140 },
|
||||
};
|
||||
tableSource.tableRows[1].trHeight = {
|
||||
hRule: TableRowHeightRule.AUTO,
|
||||
val: { v: 0 },
|
||||
};
|
||||
tableSource.tableRows[1].cantSplit = BooleanNumber.FALSE;
|
||||
createSkeletonCellPagesMock.mockImplementation(
|
||||
(_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, _table: unknown, row: number, _col: number) =>
|
||||
row === 0 ? [makeCellPage(60, 20)] : [makeCellPage(60, 80)]
|
||||
);
|
||||
|
||||
createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 240);
|
||||
|
||||
const secondRowCall = createSkeletonCellPagesMock.mock.calls.find((call) => call[5] === 1 && call[6] === 0);
|
||||
expect(secondRowCall?.[7]).toBe(100);
|
||||
});
|
||||
|
||||
it('top-aligns every continuation fragment of a vertically centered split row', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[0].tableCells = [
|
||||
{ vAlign: VerticalAlignmentType.CENTER },
|
||||
{ vAlign: VerticalAlignmentType.CENTER },
|
||||
];
|
||||
tableSource.tableRows[0].cantSplit = BooleanNumber.FALSE;
|
||||
tableNode.children = [createRowNode(1, 20, 2) as any];
|
||||
createSkeletonCellPagesMock.mockImplementation(() => [makeCellPage(60, 20), makeCellPage(60, 20)]);
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
|
||||
const fragments = result.skeTables.flatMap((table) => table.rows).filter((row) => row.index === 0);
|
||||
|
||||
expect(fragments).toHaveLength(2);
|
||||
expect(fragments.flatMap((row) => row.cells).every((cell) => cell.marginTop === cell.originMarginTop)).toBe(true);
|
||||
});
|
||||
|
||||
it('propagates an explicit cell page boundary through an ancestor table measured with infinite height', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows = [{
|
||||
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
|
||||
}];
|
||||
tableNode.children = [createRowNode(1, 20, 1) as any];
|
||||
createSkeletonCellPagesMock.mockReturnValue([
|
||||
makeCellPage(60, 20),
|
||||
{ ...makeCellPage(60, 20), breakType: BreakType.PAGE, isExplicitPageBreak: true },
|
||||
]);
|
||||
|
||||
const result = createTableSkeletons(
|
||||
ctx,
|
||||
curPage,
|
||||
viewModel,
|
||||
tableNode,
|
||||
sectionBreakConfig,
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
|
||||
expect(result.skeTables).toHaveLength(2);
|
||||
expect(result.skeTables.map((table) => table.rows.length)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('does not propagate a natural cell continuation through an ancestor table measured with infinite height', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows = [{
|
||||
trHeight: { hRule: TableRowHeightRule.AUTO, val: { v: 0 } },
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [{ vAlign: VerticalAlignmentType.TOP }],
|
||||
}];
|
||||
tableNode.children = [createRowNode(1, 20, 1) as any];
|
||||
createSkeletonCellPagesMock.mockReturnValue([
|
||||
makeCellPage(60, 20),
|
||||
{ ...makeCellPage(60, 20), breakType: BreakType.PAGE, isNaturalPageOverflow: true },
|
||||
]);
|
||||
|
||||
const result = createTableSkeletons(
|
||||
ctx,
|
||||
curPage,
|
||||
viewModel,
|
||||
tableNode,
|
||||
sectionBreakConfig,
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
|
||||
expect(result.skeTables).toHaveLength(1);
|
||||
expect(result.skeTables[0].rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('DOCX golden e2e lays out splittable at-least rows against the remaining page height', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[0].repeatHeaderRow = BooleanNumber.FALSE;
|
||||
tableSource.tableRows[0].trHeight = {
|
||||
hRule: TableRowHeightRule.AT_LEAST,
|
||||
val: { v: 20 },
|
||||
};
|
||||
tableSource.tableRows[0].cantSplit = BooleanNumber.FALSE;
|
||||
|
||||
createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 90);
|
||||
|
||||
const firstRowCall = createSkeletonCellPagesMock.mock.calls.find((call) => call[5] === 0 && call[6] === 0);
|
||||
expect(firstRowCall?.[7]).toBe(90);
|
||||
});
|
||||
|
||||
it('uses the declared height for exact rows even when content is taller', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[0].trHeight = {
|
||||
hRule: TableRowHeightRule.EXACT,
|
||||
val: { v: 40 },
|
||||
};
|
||||
createSkeletonCellPagesMock.mockReturnValue([makeCellPage(60, 90)]);
|
||||
|
||||
const result = createTableSkeleton(ctx, curPage, viewModel, tableNode, sectionBreakConfig);
|
||||
|
||||
expect(result?.rows[0].height).toBe(40);
|
||||
});
|
||||
|
||||
it('keeps a splittable auto row on the current page when its first slice slightly overflows', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
useDocumentFlavor(sectionBreakConfig, DocumentFlavor.TRADITIONAL);
|
||||
tableSource.tableRows[0].repeatHeaderRow = BooleanNumber.FALSE;
|
||||
tableSource.tableRows[0].trHeight = {
|
||||
hRule: TableRowHeightRule.EXACT,
|
||||
val: { v: 140 },
|
||||
};
|
||||
tableSource.tableRows[1].trHeight = {
|
||||
hRule: TableRowHeightRule.AUTO,
|
||||
val: { v: 0 },
|
||||
};
|
||||
tableSource.tableRows[1].cantSplit = BooleanNumber.FALSE;
|
||||
createSkeletonCellPagesMock.mockImplementation(
|
||||
(_ctx: unknown, _viewModel: unknown, _cellNode: unknown, _section: unknown, _table: unknown, row: number) =>
|
||||
row === 0 ? [makeCellPage(60, 20)] : [makeCellPage(60, 100.5)]
|
||||
);
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 240);
|
||||
|
||||
expect(result.skeTables[0].rows.map((row) => row.index)).toEqual([0, 1]);
|
||||
expect(result.skeTables[1]?.rows.map((row) => row.index) ?? []).not.toContain(1);
|
||||
});
|
||||
|
||||
it('DOCX golden e2e repeats multiple leading header rows on sliced table pages', () => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
tableSource.tableRows[1].repeatHeaderRow = BooleanNumber.TRUE;
|
||||
tableSource.tableRows.push(
|
||||
{
|
||||
repeatHeaderRow: BooleanNumber.FALSE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.EXACT,
|
||||
val: { v: 70 },
|
||||
},
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
],
|
||||
},
|
||||
{
|
||||
repeatHeaderRow: BooleanNumber.FALSE,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.EXACT,
|
||||
val: { v: 40 },
|
||||
},
|
||||
cantSplit: BooleanNumber.FALSE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
],
|
||||
}
|
||||
);
|
||||
tableNode.children.push(createRowNode(41, 60, 2) as any, createRowNode(61, 80, 2) as any);
|
||||
|
||||
const result = createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 110);
|
||||
|
||||
expect(result.skeTables.length).toBeGreaterThan(1);
|
||||
expect(result.skeTables[1].rows[0]).toMatchObject({ index: 0, isRepeatRow: true });
|
||||
expect(result.skeTables[1].rows[1]).toMatchObject({ index: 1, isRepeatRow: true });
|
||||
expect(result.skeTables[1].rows[2]).toMatchObject({ index: 2, isRepeatRow: false });
|
||||
});
|
||||
|
||||
it('DOCX golden e2e paginates an all-header table like the same table without repeat headers', () => {
|
||||
const paginate = (repeatHeaderRow: BooleanNumber) => {
|
||||
const { ctx, curPage, viewModel, tableNode, sectionBreakConfig, tableSource } = createContextAndTable();
|
||||
const row = {
|
||||
repeatHeaderRow,
|
||||
trHeight: {
|
||||
hRule: TableRowHeightRule.AT_LEAST,
|
||||
val: { v: 60 },
|
||||
},
|
||||
cantSplit: BooleanNumber.TRUE,
|
||||
tableCells: [
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
{ vAlign: VerticalAlignmentType.TOP },
|
||||
],
|
||||
};
|
||||
tableSource.tableRows = new Array(9).fill(null).map(() => ({ ...row }));
|
||||
tableNode.children = new Array(9).fill(null).map((_, index) => createRowNode(index * 20 + 1, index * 20 + 20, 2));
|
||||
|
||||
return createTableSkeletons(ctx, curPage, viewModel, tableNode, sectionBreakConfig, 100).skeTables.map(
|
||||
(table) => table.rows.filter((tableRow) => !tableRow.isRepeatRow).map((tableRow) => tableRow.index)
|
||||
);
|
||||
};
|
||||
|
||||
expect(paginate(BooleanNumber.TRUE)).toEqual(paginate(BooleanNumber.FALSE));
|
||||
});
|
||||
|
||||
it('returns an empty slice result when the table is missing', () => {
|
||||
const { ctx, curPage, tableNode, sectionBreakConfig } = createContextAndTable();
|
||||
const noTableViewModel = {
|
||||
getTableByStartIndex: vi.fn(() => null),
|
||||
};
|
||||
|
||||
expect(createTableSkeleton(ctx, curPage, noTableViewModel as any, tableNode, sectionBreakConfig)).toBeNull();
|
||||
const sliced = createTableSkeletons(ctx, curPage, noTableViewModel as any, tableNode, sectionBreakConfig, 100);
|
||||
expect(sliced.skeTables).toEqual([]);
|
||||
expect(sliced.fromCurrentPage).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export function dealWithBlockError() {
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IColumn, IColumnGroup, Nullable } from '@univerjs/core';
|
||||
import type { IDocumentSkeletonColumnGroup, IDocumentSkeletonColumnGroupColumn, IDocumentSkeletonLine, IDocumentSkeletonPage } from '../../../../basics/i-document-skeleton-cached';
|
||||
import type { ISectionBreakConfig } from '../../../../basics/interfaces';
|
||||
import type { DataStreamTreeNode } from '../../view-model/data-stream-tree-node';
|
||||
import type { DocumentViewModel } from '../../view-model/document-view-model';
|
||||
import type { ILayoutContext } from '../tools';
|
||||
import { ColumnResponsiveType, DataStreamTreeNodeType } from '@univerjs/core';
|
||||
import { DocumentSkeletonPageType, LineType } from '../../../../basics/i-document-skeleton-cached';
|
||||
import { getDocumentCompatibilityPolicy } from '../../document-compatibility';
|
||||
import { applyTrailingBlockRangeSpaceBelow, createSkeletonPage } from '../model/page';
|
||||
import { getLastNotFullColumnInfo, updateBlockIndex, updateInlineDrawingCoordsAndBorder } from '../tools';
|
||||
import { dealWidthParagraph } from './paragraph/paragraph-layout';
|
||||
|
||||
interface IColumnGroupLayoutColumn {
|
||||
columnId: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface IColumnGroupLayout {
|
||||
mode: 'horizontal' | 'stack';
|
||||
width: number;
|
||||
height: number;
|
||||
columns: IColumnGroupLayoutColumn[];
|
||||
}
|
||||
|
||||
const EMPTY_COLUMN_GROUP_MIN_HEIGHT = 72;
|
||||
const COLUMN_GROUP_HORIZONTAL_PADDING = 8;
|
||||
|
||||
export function createColumnGroupSkeleton(
|
||||
ctx: ILayoutContext,
|
||||
curPage: IDocumentSkeletonPage,
|
||||
viewModel: DocumentViewModel,
|
||||
columnGroupNode: DataStreamTreeNode,
|
||||
sectionBreakConfig: ISectionBreakConfig
|
||||
): Nullable<IDocumentSkeletonColumnGroup> {
|
||||
const columnGroupSource = viewModel.getColumnGroupByStartIndex(columnGroupNode.startIndex)?.columnGroupSource;
|
||||
if (columnGroupSource == null) {
|
||||
console.warn('Column group not found when creating column group skeleton');
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostColumn = getLastNotFullColumnInfo(curPage)?.column;
|
||||
if (hostColumn == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnPages = columnGroupNode.children.map((columnNode, index) => {
|
||||
const sourceColumn = columnGroupSource.columns[index];
|
||||
const width = Math.max(0, getInitialColumnWidth(columnGroupSource, sourceColumn, hostColumn.width));
|
||||
|
||||
return createColumnContentPage(ctx, viewModel, columnNode, sectionBreakConfig, width);
|
||||
});
|
||||
const columnHeights = columnPages.map((page) => Math.max(page.height, EMPTY_COLUMN_GROUP_MIN_HEIGHT));
|
||||
const layout = calculateColumnGroupLayout(columnGroupSource, hostColumn.width, columnHeights);
|
||||
const columns = layout.columns.map((layoutColumn, index): IDocumentSkeletonColumnGroupColumn => {
|
||||
const page = columnPages[index];
|
||||
page.pageHeight = columnHeights[index];
|
||||
|
||||
return {
|
||||
columnId: layoutColumn.columnId,
|
||||
left: layoutColumn.left,
|
||||
top: layoutColumn.top,
|
||||
width: layoutColumn.width,
|
||||
height: columnHeights[index],
|
||||
st: columnGroupNode.children[index].startIndex,
|
||||
ed: columnGroupNode.children[index].endIndex,
|
||||
page,
|
||||
};
|
||||
});
|
||||
|
||||
const columnGroupSkeleton: IDocumentSkeletonColumnGroup = {
|
||||
columns,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
top: getNextBlockTop(hostColumn.lines),
|
||||
left: hostColumn.left,
|
||||
st: columnGroupNode.startIndex,
|
||||
ed: columnGroupNode.endIndex,
|
||||
columnGroupId: columnGroupSource.columnGroupId,
|
||||
columnGroupSource,
|
||||
};
|
||||
|
||||
columns.forEach((column) => {
|
||||
column.parent = columnGroupSkeleton;
|
||||
column.page.parent = column as never;
|
||||
});
|
||||
|
||||
return columnGroupSkeleton;
|
||||
}
|
||||
|
||||
export function appendColumnGroupBlockLine(page: IDocumentSkeletonPage, columnGroup: IDocumentSkeletonColumnGroup): boolean {
|
||||
const columnInfo = getLastNotFullColumnInfo(page);
|
||||
if (columnInfo == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { column } = columnInfo;
|
||||
const line = createColumnGroupBlockLine(columnGroup, column.lines.length);
|
||||
line.parent = column;
|
||||
column.lines.push(line);
|
||||
|
||||
page.skeColumnGroups.set(columnGroup.columnGroupId, columnGroup);
|
||||
columnGroup.parent = page;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createColumnContentPage(
|
||||
ctx: ILayoutContext,
|
||||
viewModel: DocumentViewModel,
|
||||
columnNode: DataStreamTreeNode,
|
||||
sectionBreakConfig: ISectionBreakConfig,
|
||||
width: number
|
||||
): IDocumentSkeletonPage {
|
||||
const columnSectionBreakConfig: ISectionBreakConfig = {
|
||||
...sectionBreakConfig,
|
||||
pageSize: {
|
||||
width,
|
||||
height: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
marginLeft: COLUMN_GROUP_HORIZONTAL_PADDING,
|
||||
marginRight: COLUMN_GROUP_HORIZONTAL_PADDING,
|
||||
columnProperties: [],
|
||||
};
|
||||
const page = createSkeletonPage(ctx, columnSectionBreakConfig, ctx.skeletonResourceReference);
|
||||
page.type = DocumentSkeletonPageType.CELL;
|
||||
|
||||
const paragraphNodes = getColumnParagraphNodes(columnNode);
|
||||
for (let index = 0; index < paragraphNodes.length; index++) {
|
||||
const paragraphNode = paragraphNodes[index];
|
||||
const nextParagraphNode = paragraphNodes[index + 1]?.nodeType === DataStreamTreeNodeType.PARAGRAPH
|
||||
? paragraphNodes[index + 1]
|
||||
: undefined;
|
||||
dealWidthParagraph(ctx, viewModel, paragraphNode, page, columnSectionBreakConfig, nextParagraphNode);
|
||||
}
|
||||
|
||||
updateBlockIndex(
|
||||
[page],
|
||||
columnNode.startIndex,
|
||||
sectionBreakConfig.documentCompatibilityPolicy ?? getDocumentCompatibilityPolicy()
|
||||
);
|
||||
updateInlineDrawingCoordsAndBorder(ctx, [page]);
|
||||
applyTrailingBlockRangeSpaceBelow([page], ctx.dataModel?.getBody?.(), columnNode.endIndex);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
function getColumnParagraphNodes(columnNode: DataStreamTreeNode): DataStreamTreeNode[] {
|
||||
const firstChild = columnNode.children[0];
|
||||
if (firstChild?.nodeType === DataStreamTreeNodeType.SECTION_BREAK) {
|
||||
return firstChild.children;
|
||||
}
|
||||
|
||||
return columnNode.children;
|
||||
}
|
||||
|
||||
function createColumnGroupBlockLine(columnGroup: IDocumentSkeletonColumnGroup, lineIndex: number): IDocumentSkeletonLine {
|
||||
const top = columnGroup.top;
|
||||
|
||||
return {
|
||||
paragraphIndex: columnGroup.ed,
|
||||
type: LineType.BLOCK,
|
||||
divides: [],
|
||||
divideLen: 0,
|
||||
lineHeight: columnGroup.height,
|
||||
contentHeight: columnGroup.height,
|
||||
top,
|
||||
asc: 0,
|
||||
dsc: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
spaceBelowApply: 0,
|
||||
st: columnGroup.st,
|
||||
ed: columnGroup.ed,
|
||||
lineIndex,
|
||||
paragraphStart: true,
|
||||
isBehindTable: false,
|
||||
tableId: '',
|
||||
};
|
||||
}
|
||||
|
||||
function getNextBlockTop(lines: IDocumentSkeletonLine[]) {
|
||||
const lastLine = lines[lines.length - 1];
|
||||
if (lastLine == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return lastLine.top + lastLine.lineHeight;
|
||||
}
|
||||
|
||||
export function calculateColumnGroupLayout(source: IColumnGroup, availableWidth: number, columnHeights: number[]): IColumnGroupLayout {
|
||||
const width = Math.max(0, availableWidth);
|
||||
const gap = Math.max(0, source.gap?.v ?? 0);
|
||||
const columns = source.columns;
|
||||
if (columns.length === 0) {
|
||||
return {
|
||||
mode: 'horizontal',
|
||||
width,
|
||||
height: 0,
|
||||
columns: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldStack(columns, width, gap, source.responsive)) {
|
||||
let top = 0;
|
||||
|
||||
return {
|
||||
mode: 'stack',
|
||||
width,
|
||||
height: sumHeights(columnHeights),
|
||||
columns: columns.map((column, index) => {
|
||||
const layoutColumn = {
|
||||
columnId: column.columnId,
|
||||
left: 0,
|
||||
top,
|
||||
width,
|
||||
};
|
||||
top += Math.max(0, columnHeights[index] ?? 0);
|
||||
|
||||
return layoutColumn;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const contentWidth = Math.max(0, width - gap * (columns.length - 1));
|
||||
const widths = allocateHorizontalWidths(columns, contentWidth);
|
||||
let left = 0;
|
||||
|
||||
return {
|
||||
mode: 'horizontal',
|
||||
width,
|
||||
height: Math.max(0, ...columnHeights),
|
||||
columns: columns.map((column, index) => {
|
||||
const layoutColumn = {
|
||||
columnId: column.columnId,
|
||||
left,
|
||||
top: 0,
|
||||
width: widths[index],
|
||||
};
|
||||
left += widths[index] + gap;
|
||||
|
||||
return layoutColumn;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function shouldStack(columns: IColumn[], availableWidth: number, gap: number, responsive: ColumnResponsiveType): boolean {
|
||||
if (responsive !== ColumnResponsiveType.STACK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const totalMinWidth = columns.reduce((sum, column) => sum + getMinWidth(column), 0) + gap * Math.max(0, columns.length - 1);
|
||||
|
||||
return totalMinWidth > availableWidth;
|
||||
}
|
||||
|
||||
function getInitialColumnWidth(source: IColumnGroup, column: IColumn | undefined, availableWidth: number): number {
|
||||
if (column == null) {
|
||||
return availableWidth;
|
||||
}
|
||||
|
||||
const contentWidth = Math.max(0, availableWidth - Math.max(0, source.gap?.v ?? 0) * Math.max(0, source.columns.length - 1));
|
||||
const ratioSum = source.columns.reduce((sum, item) => sum + Math.max(0, item.widthRatio || 0), 0) || source.columns.length;
|
||||
|
||||
return Math.max(getMinWidth(column), contentWidth * (Math.max(0, column.widthRatio || 0) || 1) / ratioSum);
|
||||
}
|
||||
|
||||
function allocateHorizontalWidths(columns: IColumn[], contentWidth: number): number[] {
|
||||
const ratioSum = columns.reduce((sum, column) => sum + Math.max(0, column.widthRatio || 0), 0) || columns.length;
|
||||
const idealWidths = columns.map((column) => contentWidth * (Math.max(0, column.widthRatio || 0) || 1) / ratioSum);
|
||||
const minWidths = columns.map(getMinWidth);
|
||||
const widths = idealWidths.map((width, index) => Math.max(width, minWidths[index]));
|
||||
const overflow = widths.reduce((sum, width) => sum + width, 0) - contentWidth;
|
||||
|
||||
if (overflow <= 0) {
|
||||
return widths;
|
||||
}
|
||||
|
||||
return compressToFit(widths, minWidths, overflow);
|
||||
}
|
||||
|
||||
function compressToFit(widths: number[], minWidths: number[], overflow: number): number[] {
|
||||
const nextWidths = [...widths];
|
||||
const flexibleIndexes = nextWidths
|
||||
.map((width, index) => ({ index, shrink: Math.max(0, width - minWidths[index]) }))
|
||||
.filter((item) => item.shrink > 0);
|
||||
const totalShrink = flexibleIndexes.reduce((sum, item) => sum + item.shrink, 0);
|
||||
const appliedOverflow = Math.min(overflow, totalShrink);
|
||||
if (appliedOverflow <= 0 || totalShrink <= 0) {
|
||||
return nextWidths;
|
||||
}
|
||||
for (const item of flexibleIndexes) {
|
||||
nextWidths[item.index] -= appliedOverflow * item.shrink / totalShrink;
|
||||
}
|
||||
|
||||
return nextWidths;
|
||||
}
|
||||
|
||||
function getMinWidth(column: IColumn): number {
|
||||
return Math.max(0, column.minWidth?.v ?? 0);
|
||||
}
|
||||
|
||||
function sumHeights(heights: number[]): number {
|
||||
return heights.reduce((sum, height) => sum + Math.max(0, height), 0);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user