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:
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
Reference in New Issue
Block a user