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

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

View File

@@ -0,0 +1,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 });
});
});

View File

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

View 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();
});
});

View 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]);
});
});

View 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');
});
});

View 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();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
declare module 'jest-canvas-mock/lib/index.js' {
export function setupJestCanvasMock(window?: Window): void;
}

View 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([]);
});
});

View 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;
},
};
}

View 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();
});
});

View 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);
});
});

View 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();
});
});

View 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);
});
});

View 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();
});
});

File diff suppressed because it is too large Load Diff