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:
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector } from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DrawingImageClipService } from '../drawing-image-clip.service';
|
||||
|
||||
describe('DrawingImageClipService', () => {
|
||||
let service: DrawingImageClipService;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = new Injector();
|
||||
injector.add([DrawingImageClipService]);
|
||||
service = injector.get(DrawingImageClipService);
|
||||
});
|
||||
|
||||
it('delegates image clipping to the registered shape engine', () => {
|
||||
const bounds = { left: 0, top: 0, width: 20, height: 10 };
|
||||
const delegate = vi.fn(() => bounds);
|
||||
const disposable = service.registerClipDelegate(delegate);
|
||||
|
||||
expect(service.applyShapeClip({} as never, 'rect', 20, 10, { adj: 1 })).toBe(bounds);
|
||||
expect(delegate).toHaveBeenCalledWith({}, 'rect', 20, 10, { adj: 1 });
|
||||
|
||||
disposable.dispose();
|
||||
expect(service.applyShapeClip({} as never, 'rect', 20, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes whether shape clipping is available in the current runtime', () => {
|
||||
const values: boolean[] = [];
|
||||
service.canUseShapeClip$.subscribe((value) => values.push(value));
|
||||
|
||||
service.setCanUseShapeClip(true);
|
||||
|
||||
expect(values).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('clears registered clipping behavior when disposed', () => {
|
||||
const delegate = vi.fn(() => ({ left: 0, top: 0, width: 20, height: 10 }));
|
||||
service.registerClipDelegate(delegate);
|
||||
|
||||
service.dispose();
|
||||
|
||||
expect(service.applyShapeClip({} as never, 'rect', 20, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES 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 { IDrawingSearch } from '@univerjs/core';
|
||||
import type { IGalleryProps } from '@univerjs/design';
|
||||
import type { IDocFloatDomData, IImageData } from '@univerjs/drawing';
|
||||
import type { BaseObject, Scene } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
DrawingTypeEnum,
|
||||
IImageIoService,
|
||||
ImageSourceType,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
IURLImageService,
|
||||
PositionedObjectLayoutType,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
getDrawingShapeKeyByDrawingSearch,
|
||||
IDrawingManagerService,
|
||||
} from '@univerjs/drawing';
|
||||
import { DRAWING_OBJECT_LAYER_INDEX, Image, Rect } from '@univerjs/engine-render';
|
||||
import { IGalleryService } from '@univerjs/ui';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DrawingImageClipService } from '../drawing-image-clip.service';
|
||||
import { DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX, DrawingRenderService } from '../drawing-render.service';
|
||||
|
||||
const BASE_TRANSFORM = {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
angle: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
};
|
||||
|
||||
const SECOND_TRANSFORM = {
|
||||
left: 50,
|
||||
top: 60,
|
||||
width: 70,
|
||||
height: 80,
|
||||
angle: 15,
|
||||
flipX: true,
|
||||
flipY: false,
|
||||
skewX: 1,
|
||||
skewY: 2,
|
||||
};
|
||||
|
||||
function imageParam(overrides: Partial<IImageData> = {}): IImageData {
|
||||
return {
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
drawingId: 'drawing-1',
|
||||
unitId: 'book-1',
|
||||
subUnitId: 'sheet-1',
|
||||
source: 'image-source',
|
||||
imageSourceType: ImageSourceType.BASE64,
|
||||
transform: BASE_TRANSFORM,
|
||||
...overrides,
|
||||
} as IImageData;
|
||||
}
|
||||
|
||||
function floatDomParam(overrides: Partial<IDocFloatDomData> = {}): IDocFloatDomData {
|
||||
return {
|
||||
drawingType: DrawingTypeEnum.DRAWING_DOM,
|
||||
drawingId: 'float-1',
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
transform: BASE_TRANSFORM,
|
||||
...overrides,
|
||||
} as IDocFloatDomData;
|
||||
}
|
||||
|
||||
function drawingKey(param: IDrawingSearch, index?: number) {
|
||||
return getDrawingShapeKeyByDrawingSearch(param, index);
|
||||
}
|
||||
|
||||
function cacheKey(source: string, imageSourceType: ImageSourceType) {
|
||||
return `${source}:${imageSourceType}`;
|
||||
}
|
||||
|
||||
class TestDrawingManagerService {
|
||||
visible = true;
|
||||
editable = true;
|
||||
orders = ['drawing-1'];
|
||||
visibleReads: boolean[] = [];
|
||||
readonly drawings = new Map<string, unknown>();
|
||||
|
||||
getDrawingVisible() {
|
||||
return this.visibleReads.length > 0 ? this.visibleReads.shift()! : this.visible;
|
||||
}
|
||||
|
||||
getDrawingEditable() {
|
||||
return this.editable;
|
||||
}
|
||||
|
||||
getDrawingOrder() {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
getDrawingByParam(param: IDrawingSearch | null | undefined) {
|
||||
if (param == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.drawings.get(drawingKey(param)) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class TestImageIoService {
|
||||
imageUrl = 'resolved-image-url';
|
||||
imageError: unknown;
|
||||
beforeResolveImage: (() => void) | undefined;
|
||||
readonly imageRequests: string[] = [];
|
||||
readonly cache = new Map<string, HTMLImageElement>();
|
||||
readonly cacheWrites: Array<{ source: string; imageSourceType: ImageSourceType; image: HTMLImageElement | null }> = [];
|
||||
readonly change$ = new Subject<number>();
|
||||
|
||||
setWaitCount() {
|
||||
// not used by DrawingRenderService
|
||||
}
|
||||
|
||||
async saveImage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getImage(imageId: string) {
|
||||
this.imageRequests.push(imageId);
|
||||
this.beforeResolveImage?.();
|
||||
if (this.imageError) {
|
||||
throw this.imageError;
|
||||
}
|
||||
return this.imageUrl;
|
||||
}
|
||||
|
||||
getImageSourceCache(source: string, imageSourceType: ImageSourceType) {
|
||||
return this.cache.get(cacheKey(source, imageSourceType)) ?? null;
|
||||
}
|
||||
|
||||
addImageSourceCache(source: string, imageSourceType: ImageSourceType, image: HTMLImageElement | null) {
|
||||
this.cacheWrites.push({ source, imageSourceType, image });
|
||||
if (image) {
|
||||
this.cache.set(cacheKey(source, imageSourceType), image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TestUrlImageService {
|
||||
imageUrl = 'downloaded-image-url';
|
||||
imageError: unknown;
|
||||
readonly requests: string[] = [];
|
||||
|
||||
async getImage(url: string) {
|
||||
this.requests.push(url);
|
||||
if (this.imageError) {
|
||||
throw this.imageError;
|
||||
}
|
||||
return this.imageUrl;
|
||||
}
|
||||
|
||||
async downloadImage() {
|
||||
return new Blob();
|
||||
}
|
||||
|
||||
registerURLImageDownloader() {
|
||||
return toDisposable(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
class TestGalleryService {
|
||||
gallery$ = new Subject<IGalleryProps>();
|
||||
lastOpen: IGalleryProps | undefined;
|
||||
closeCount = 0;
|
||||
|
||||
open(params: IGalleryProps) {
|
||||
this.lastOpen = params;
|
||||
return toDisposable(() => {});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
unitType = UniverInstanceType.UNIVER_DOC;
|
||||
activeSheetId = 'sheet-1';
|
||||
|
||||
getUnitType() {
|
||||
return this.unitType;
|
||||
}
|
||||
|
||||
getCurrentUnitOfType() {
|
||||
return {
|
||||
getActiveSheet: () => ({
|
||||
getSheetId: () => this.activeSheetId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface IRecordedScene {
|
||||
readonly objects: Map<string, BaseObject | ITransformRecorder>;
|
||||
readonly addedObjects: Array<{ object: BaseObject; layerIndex: number }>;
|
||||
readonly transformerObjects: BaseObject[];
|
||||
readonly getObjectKeys: string[];
|
||||
getObject(key: string): BaseObject | ITransformRecorder | null;
|
||||
getObjectIncludeInGroup(key: string): BaseObject | ITransformRecorder | null;
|
||||
addObject(object: BaseObject, layerIndex: number): Scene;
|
||||
attachTransformerTo(object: BaseObject): Scene;
|
||||
}
|
||||
|
||||
interface ITransformRecorder {
|
||||
readonly oKey: string;
|
||||
readonly transformCalls: unknown[];
|
||||
transformByState(state: unknown): void;
|
||||
}
|
||||
|
||||
function createTransformRecorder(oKey: string): ITransformRecorder {
|
||||
return {
|
||||
oKey,
|
||||
transformCalls: [],
|
||||
transformByState(state: unknown) {
|
||||
this.transformCalls.push(state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createScene(): IRecordedScene {
|
||||
const scene: IRecordedScene = {
|
||||
objects: new Map(),
|
||||
addedObjects: [],
|
||||
transformerObjects: [],
|
||||
getObjectKeys: [],
|
||||
getObject(key: string) {
|
||||
this.getObjectKeys.push(key);
|
||||
return this.objects.get(key) ?? null;
|
||||
},
|
||||
getObjectIncludeInGroup(key: string) {
|
||||
return this.objects.get(key) ?? null;
|
||||
},
|
||||
addObject(object: BaseObject, layerIndex: number) {
|
||||
this.objects.set(object.oKey, object);
|
||||
this.addedObjects.push({ object, layerIndex });
|
||||
return this as unknown as Scene;
|
||||
},
|
||||
attachTransformerTo(object: BaseObject) {
|
||||
this.transformerObjects.push(object);
|
||||
return this as unknown as Scene;
|
||||
},
|
||||
};
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const injector = new Injector();
|
||||
|
||||
injector.add([IDrawingManagerService, { useClass: TestDrawingManagerService as never }]);
|
||||
injector.add([IImageIoService, { useClass: TestImageIoService as never }]);
|
||||
injector.add([IGalleryService, { useClass: TestGalleryService as never }]);
|
||||
injector.add([IURLImageService, { useClass: TestUrlImageService as never }]);
|
||||
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
|
||||
injector.add([DrawingImageClipService]);
|
||||
injector.add([DrawingRenderService]);
|
||||
|
||||
return {
|
||||
service: injector.get(DrawingRenderService),
|
||||
drawingManagerService: injector.get(IDrawingManagerService) as unknown as TestDrawingManagerService,
|
||||
imageIoService: injector.get(IImageIoService) as unknown as TestImageIoService,
|
||||
urlImageService: injector.get(IURLImageService) as unknown as TestUrlImageService,
|
||||
galleryService: injector.get(IGalleryService) as unknown as TestGalleryService,
|
||||
univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService,
|
||||
drawingImageClipService: injector.get(DrawingImageClipService),
|
||||
scene: createScene(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DrawingRenderService', () => {
|
||||
it('skips image rendering when the drawing is not renderable in the current context', async () => {
|
||||
const hiddenCase = createHarness();
|
||||
hiddenCase.drawingManagerService.visible = false;
|
||||
expect(await hiddenCase.service.renderImages(imageParam(), hiddenCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(hiddenCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const nonImageCase = createHarness();
|
||||
expect(await nonImageCase.service.renderImages(imageParam({ drawingType: DrawingTypeEnum.DRAWING_SHAPE }), nonImageCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(nonImageCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const inactiveSheetCase = createHarness();
|
||||
inactiveSheetCase.univerInstanceService.unitType = UniverInstanceType.UNIVER_SHEET;
|
||||
inactiveSheetCase.univerInstanceService.activeSheetId = 'sheet-2';
|
||||
expect(await inactiveSheetCase.service.renderImages(imageParam(), inactiveSheetCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(inactiveSheetCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const inactivePrintingSheetCase = createHarness();
|
||||
inactivePrintingSheetCase.univerInstanceService.unitType = UniverInstanceType.UNIVER_SHEET;
|
||||
inactivePrintingSheetCase.univerInstanceService.activeSheetId = 'sheet-2';
|
||||
expect(await inactivePrintingSheetCase.service.renderImages(
|
||||
imageParam(),
|
||||
inactivePrintingSheetCase.scene as unknown as Scene,
|
||||
{ allowInactiveSheet: true }
|
||||
)).toHaveLength(1);
|
||||
|
||||
const noTransformCase = createHarness();
|
||||
expect(await noTransformCase.service.renderImages(imageParam({ transform: null }), noTransformCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(noTransformCase.scene.getObjectKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds a base64 image with drawing metadata, cache, and transformer state', async () => {
|
||||
const { service, scene, drawingManagerService, imageIoService, drawingImageClipService } = createHarness();
|
||||
drawingManagerService.orders = ['background', 'drawing-1'];
|
||||
const srcRect = { left: 0.1, top: 0.2, right: 0.3, bottom: 0.4 };
|
||||
|
||||
const result = await service.renderImages(imageParam({
|
||||
hidden: true,
|
||||
prstGeom: 'roundRect',
|
||||
adjustValues: { adj: 0.25 },
|
||||
srcRect,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
const key = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' });
|
||||
const image = scene.objects.get(key) as Image;
|
||||
|
||||
expect(result).toEqual([image]);
|
||||
expect(image).toBeInstanceOf(Image);
|
||||
expect(image.left).toBe(10);
|
||||
expect(image.top).toBe(20);
|
||||
expect(image.width).toBe(30);
|
||||
expect(image.height).toBe(40);
|
||||
expect(image.zIndex).toBe(1);
|
||||
expect(image.visible).toBe(false);
|
||||
expect(image.printable).toBe(true);
|
||||
expect(image.getProps().url).toBe('image-source');
|
||||
expect(image.getClipService()).toBe(drawingImageClipService);
|
||||
expect(image.prstGeom).toBe('roundRect');
|
||||
expect(image.prstGeomAdjValues).toEqual({ adj: 0.25 });
|
||||
expect(image.srcRect).toEqual(srcRect);
|
||||
expect(scene.addedObjects.map(({ object, layerIndex }) => [object.oKey, layerIndex])).toEqual([[key, DRAWING_OBJECT_LAYER_INDEX]]);
|
||||
expect(scene.transformerObjects).toEqual([image]);
|
||||
expect(imageIoService.cacheWrites).toEqual([{ source: 'image-source', imageSourceType: ImageSourceType.BASE64, image: image.getNative() }]);
|
||||
});
|
||||
|
||||
it('syncs hidden state when an existing image shape is refreshed', async () => {
|
||||
const { service, scene } = createHarness();
|
||||
const key = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' });
|
||||
const image = new Image(key, { ...BASE_TRANSFORM, url: 'existing-image' });
|
||||
scene.objects.set(key, image);
|
||||
|
||||
await service.renderImages(imageParam({ hidden: true, transform: SECOND_TRANSFORM }), scene as unknown as Scene);
|
||||
|
||||
expect(image.visible).toBe(false);
|
||||
expect(image.left).toBe(50);
|
||||
expect(image.top).toBe(60);
|
||||
expect(image.width).toBe(70);
|
||||
expect(image.height).toBe(80);
|
||||
|
||||
await service.renderImages(imageParam({ hidden: false, transform: BASE_TRANSFORM }), scene as unknown as Scene);
|
||||
|
||||
expect(image.visible).toBe(true);
|
||||
});
|
||||
|
||||
it('uses cached native images instead of resolving the source again', async () => {
|
||||
const { service, scene, imageIoService } = createHarness();
|
||||
const nativeImage = document.createElement('img');
|
||||
Object.defineProperty(nativeImage, 'complete', { value: true });
|
||||
Object.defineProperty(nativeImage, 'naturalWidth', { value: 1 });
|
||||
Object.defineProperty(nativeImage, 'naturalHeight', { value: 1 });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), nativeImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(image.getNative()).toBe(nativeImage);
|
||||
expect(imageIoService.imageRequests).toEqual([]);
|
||||
expect(imageIoService.cacheWrites).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads stored and URL images through their respective services', async () => {
|
||||
const uuidCase = createHarness();
|
||||
uuidCase.imageIoService.imageUrl = 'stored-image-url';
|
||||
await uuidCase.service.renderImages(imageParam({
|
||||
source: 'stored-image-id',
|
||||
imageSourceType: ImageSourceType.UUID,
|
||||
}), uuidCase.scene as unknown as Scene);
|
||||
const storedImage = uuidCase.scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(storedImage.getProps().url).toBe('stored-image-url');
|
||||
expect(uuidCase.imageIoService.imageRequests).toEqual(['stored-image-id']);
|
||||
expect(uuidCase.imageIoService.cacheWrites).toEqual([]);
|
||||
|
||||
const urlCase = createHarness();
|
||||
urlCase.urlImageService.imageUrl = 'downloaded-url-image';
|
||||
await urlCase.service.renderImages(imageParam({
|
||||
source: 'https://example.test/image.png',
|
||||
imageSourceType: ImageSourceType.URL,
|
||||
}), urlCase.scene as unknown as Scene);
|
||||
const urlImage = urlCase.scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(urlImage.getProps().url).toBe('downloaded-url-image');
|
||||
expect(urlCase.urlImageService.requests).toEqual(['https://example.test/image.png']);
|
||||
expect(urlCase.imageIoService.cacheWrites).toEqual([{ source: 'https://example.test/image.png', imageSourceType: ImageSourceType.URL, image: urlImage.getNative() }]);
|
||||
});
|
||||
|
||||
it('keeps URL drawings visible with their original source when downloading fails', async () => {
|
||||
const { service, scene, urlImageService } = createHarness();
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
urlImageService.imageError = new Error('network unavailable');
|
||||
|
||||
await service.renderImages(imageParam({
|
||||
source: 'https://example.test/offline.png',
|
||||
imageSourceType: ImageSourceType.URL,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
const image = scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(image.getProps().url).toBe('https://example.test/offline.png');
|
||||
expect(errorSpy).toHaveBeenCalledWith(urlImageService.imageError);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not duplicate an image that appears while its source is loading', async () => {
|
||||
const { service, scene, imageIoService } = createHarness();
|
||||
const key = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' });
|
||||
const existingObject = createTransformRecorder(key);
|
||||
imageIoService.beforeResolveImage = () => scene.objects.set(key, existingObject);
|
||||
|
||||
const result = await service.renderImages(imageParam({
|
||||
source: 'stored-image-id',
|
||||
imageSourceType: ImageSourceType.UUID,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(scene.addedObjects).toEqual([]);
|
||||
expect(scene.objects.get(key)).toBe(existingObject);
|
||||
});
|
||||
|
||||
it('updates existing image objects for each transform instead of recreating them', async () => {
|
||||
const { service, scene } = createHarness();
|
||||
const firstKey = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' }, 0);
|
||||
const secondKey = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' }, 1);
|
||||
const firstObject = createTransformRecorder(firstKey);
|
||||
const secondObject = createTransformRecorder(secondKey);
|
||||
scene.objects.set(firstKey, firstObject);
|
||||
scene.objects.set(secondKey, secondObject);
|
||||
|
||||
const result = await service.renderImages(imageParam({
|
||||
isMultiTransform: 1,
|
||||
transforms: [BASE_TRANSFORM, SECOND_TRANSFORM],
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(firstObject.transformCalls).toEqual([BASE_TRANSFORM]);
|
||||
expect(secondObject.transformCalls).toEqual([SECOND_TRANSFORM]);
|
||||
expect(scene.addedObjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips float DOM rendering when the drawing cannot produce a scene object', () => {
|
||||
const hiddenCase = createHarness();
|
||||
hiddenCase.drawingManagerService.visible = false;
|
||||
expect(hiddenCase.service.renderFloatDom(floatDomParam(), hiddenCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(hiddenCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const nonDomCase = createHarness();
|
||||
expect(nonDomCase.service.renderFloatDom(floatDomParam({ drawingType: DrawingTypeEnum.DRAWING_IMAGE }), nonDomCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(nonDomCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const noTransformCase = createHarness();
|
||||
expect(noTransformCase.service.renderFloatDom(floatDomParam({ transform: null }), noTransformCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(noTransformCase.scene.getObjectKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds float DOM placeholders for every transform and respects transform permissions', () => {
|
||||
const { service, scene, drawingManagerService } = createHarness();
|
||||
drawingManagerService.orders = ['float-1', 'later'];
|
||||
|
||||
const result = service.renderFloatDom(floatDomParam({
|
||||
isMultiTransform: 1,
|
||||
transforms: [BASE_TRANSFORM, SECOND_TRANSFORM],
|
||||
allowTransform: false,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
const firstKey = drawingKey({ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'float-1' }, 0);
|
||||
const secondKey = drawingKey({ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'float-1' }, 1);
|
||||
const firstRect = scene.objects.get(firstKey) as Rect;
|
||||
const secondRect = scene.objects.get(secondKey) as Rect;
|
||||
|
||||
expect(result).toEqual([firstRect, secondRect]);
|
||||
expect(firstRect).toBeInstanceOf(Rect);
|
||||
expect(firstRect.left).toBe(10);
|
||||
expect(firstRect.top).toBe(20);
|
||||
expect(firstRect.zIndex).toBe(0);
|
||||
expect(firstRect.printable).toBe(false);
|
||||
expect(secondRect.left).toBe(50);
|
||||
expect(secondRect.top).toBe(60);
|
||||
expect(scene.addedObjects.map(({ object, layerIndex }) => [object.oKey, layerIndex])).toEqual([
|
||||
[firstKey, DRAWING_OBJECT_LAYER_INDEX],
|
||||
[secondKey, DRAWING_OBJECT_LAYER_INDEX],
|
||||
]);
|
||||
expect(scene.transformerObjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates existing float DOM placeholders and does not add stale placeholders after visibility changes', () => {
|
||||
const updateCase = createHarness();
|
||||
const key = drawingKey({ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'float-1' });
|
||||
const existingObject = createTransformRecorder(key);
|
||||
updateCase.scene.objects.set(key, existingObject);
|
||||
|
||||
const updateResult = updateCase.service.renderFloatDom(floatDomParam(), updateCase.scene as unknown as Scene);
|
||||
|
||||
expect(updateResult).toEqual([]);
|
||||
expect(existingObject.transformCalls).toEqual([BASE_TRANSFORM]);
|
||||
expect(updateCase.scene.addedObjects).toEqual([]);
|
||||
|
||||
const staleCase = createHarness();
|
||||
staleCase.drawingManagerService.visibleReads = [true, false];
|
||||
expect(staleCase.service.renderFloatDom(floatDomParam(), staleCase.scene as unknown as Scene)).toEqual([]);
|
||||
expect(staleCase.scene.addedObjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders drawings fetched from the drawing manager and ignores unsupported records', async () => {
|
||||
const imageCase = createHarness();
|
||||
const search = { unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' };
|
||||
imageCase.drawingManagerService.drawings.set(drawingKey(search), imageParam());
|
||||
|
||||
const imageResult = await imageCase.service.renderDrawing(search, imageCase.scene as unknown as Scene);
|
||||
|
||||
expect(imageResult).toEqual([imageCase.scene.objects.get(drawingKey(search))]);
|
||||
|
||||
const missingCase = createHarness();
|
||||
expect(missingCase.service.renderDrawing(search, missingCase.scene as unknown as Scene)).toBeUndefined();
|
||||
|
||||
const unsupportedCase = createHarness();
|
||||
unsupportedCase.drawingManagerService.drawings.set(drawingKey(search), { ...imageParam(), drawingType: DrawingTypeEnum.DRAWING_SHAPE });
|
||||
expect(unsupportedCase.service.renderDrawing(search, unsupportedCase.scene as unknown as Scene)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('opens preview images in the gallery and closes the gallery when the preview is dismissed', () => {
|
||||
const { service, galleryService } = createHarness();
|
||||
|
||||
service.previewImage('drawing-1', 'preview-src', 640, 480);
|
||||
galleryService.lastOpen?.onOpenChange?.(true);
|
||||
galleryService.lastOpen?.onOpenChange?.(false);
|
||||
|
||||
expect(galleryService.lastOpen?.images).toEqual(['preview-src']);
|
||||
expect(galleryService.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('renders incomplete cached images through url loading so onload can repaint', async () => {
|
||||
const { imageIoService, scene, service } = createHarness();
|
||||
const incompleteImage = document.createElement('img');
|
||||
Object.defineProperty(incompleteImage, 'complete', { value: false });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), incompleteImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.addedObjects[0]?.object as Image;
|
||||
expect(image.getNative()).not.toBe(incompleteImage);
|
||||
expect(imageIoService.cacheWrites).toEqual([{ source: 'image-source', imageSourceType: ImageSourceType.BASE64, image: image.getNative() }]);
|
||||
});
|
||||
|
||||
it('uses completed cached images directly', async () => {
|
||||
const { imageIoService, scene, service } = createHarness();
|
||||
const completeImage = document.createElement('img');
|
||||
Object.defineProperty(completeImage, 'complete', { value: true });
|
||||
Object.defineProperty(completeImage, 'naturalWidth', { value: 1 });
|
||||
Object.defineProperty(completeImage, 'naturalHeight', { value: 1 });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), completeImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.addedObjects[0]?.object as Image;
|
||||
expect(image.getNative()).toBe(completeImage);
|
||||
expect(imageIoService.cacheWrites).toEqual([]);
|
||||
});
|
||||
|
||||
it('reloads completed cached images without natural dimensions', async () => {
|
||||
const { imageIoService, scene, service } = createHarness();
|
||||
const brokenCompleteImage = document.createElement('img');
|
||||
Object.defineProperty(brokenCompleteImage, 'complete', { value: true });
|
||||
Object.defineProperty(brokenCompleteImage, 'naturalWidth', { value: 0 });
|
||||
Object.defineProperty(brokenCompleteImage, 'naturalHeight', { value: 0 });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), brokenCompleteImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.addedObjects[0]?.object as Image;
|
||||
expect(image.getNative()).not.toBe(brokenCompleteImage);
|
||||
expect(imageIoService.cacheWrites).toEqual([{ source: 'image-source', imageSourceType: ImageSourceType.BASE64, image: image.getNative() }]);
|
||||
});
|
||||
|
||||
it('renders docs behind-text images between page background and document text', async () => {
|
||||
const { scene, service } = createHarness();
|
||||
|
||||
await service.renderImages(imageParam({ behindText: true } as never), scene as unknown as Scene);
|
||||
|
||||
expect(scene.addedObjects[0]?.layerIndex).toBe(DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
|
||||
});
|
||||
|
||||
it('renders Word behindDoc wrap-none images between page background and document text', async () => {
|
||||
const { scene, service } = createHarness();
|
||||
|
||||
await service.renderImages(imageParam({
|
||||
behindDoc: BooleanNumber.TRUE,
|
||||
layoutType: PositionedObjectLayoutType.WRAP_NONE,
|
||||
} as never), scene as unknown as Scene);
|
||||
|
||||
expect(scene.addedObjects[0]?.layerIndex).toBe(DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
|
||||
});
|
||||
|
||||
it('keeps normal images on the regular drawing layer', async () => {
|
||||
const { scene, service } = createHarness();
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
expect(scene.addedObjects[0]?.layerIndex).toBe(DRAWING_OBJECT_LAYER_INDEX);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user