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,41 @@
# @univerjs/sheets-drawing
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-drawing?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-drawing)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-drawing?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-drawing)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-drawing?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-drawing)
`@univerjs/sheets-drawing` connects the shared drawing model to Univer Sheets so worksheets can host drawing objects.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-drawing` | `UniverSheetsDrawing` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-drawing
# or
npm install @univerjs/sheets-drawing
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverSheetsDrawingPlugin } from '@univerjs/sheets-drawing';
univer.registerPlugin(UniverSheetsDrawingPlugin);
```
## Integration Notes
Use this package with `@univerjs/sheets-drawing-ui` when users need drawing interactions in the sheet UI.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-drawing)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,88 @@
{
"name": "@univerjs/sheets-drawing",
"version": "1.0.0-rc.0",
"private": false,
"description": "Drawing model integration for Univer Sheets.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"sheets",
"drawing",
"spreadsheet",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/sheets": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,125 @@
/**
* 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 { Dependency, IWorkbookData, Workbook } from '@univerjs/core';
import {
ICommandService,
ILogService,
Inject,
Injector,
IUndoRedoService,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
UniverSheetsPlugin,
} from '@univerjs/sheets';
import enUS from '@univerjs/sheets/locale/en-US';
import { UniverSheetsDrawingPlugin } from '../plugin';
const TEST_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet1', 'sheet2'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {},
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
rowCount: 20,
columnCount: 20,
cellData: {},
},
},
};
export interface ISheetsDrawingTestBed {
univer: Univer;
get: Injector['get'];
injector: Injector;
workbook: Workbook;
commandService: ICommandService;
}
export function createSheetsDrawingTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ISheetsDrawingTestBed {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.get(IUndoRedoService);
([
[IRenderManagerService, { useClass: RenderManagerService }],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
dependencies?.forEach((dependency) => this._injector.add(dependency));
}
}
univer.registerPlugin(TestPlugin);
univer.registerPlugin(UniverSheetsPlugin);
univer.registerPlugin(UniverDrawingPlugin);
univer.registerPlugin(UniverSheetsDrawingPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
Tools.deepClone(workbookData ?? TEST_WORKBOOK_DATA)
);
get(IUniverInstanceService).focusUnit(workbook.getUnitId());
get(ILogService).setLogLevel(LogLevel.SILENT);
const localeService = get(LocaleService);
localeService.load({ enUS });
localeService.setLocale(LocaleType.EN_US);
return {
univer,
get,
injector,
workbook,
commandService: get(ICommandService),
};
}

View File

@@ -0,0 +1,103 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ITransformState, Nullable } from '@univerjs/core';
import type { SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ISheetSkeletonManagerParam } from '@univerjs/sheets';
import type { ISheetDrawingPosition } from '../services/sheet-drawing.service';
import { convertPositionSheetOverGridToAbsolute } from '@univerjs/sheets';
export function drawingPositionToTransform(position: ISheetDrawingPosition, sheetSkeletonParam: Nullable<ISheetSkeletonManagerParam>): Nullable<ITransformState> {
if (!sheetSkeletonParam) return;
const { unitId, sheetId, skeleton } = sheetSkeletonParam;
const { from, to, flipY = false, flipX = false, angle = 0, skewX = 0, skewY = 0 } = position;
const absolutePosition = convertPositionSheetOverGridToAbsolute(unitId, sheetId, { from, to }, skeleton);
let { left, top, width, height } = absolutePosition;
const sheetWidth = skeleton.rowHeaderWidth + skeleton.columnTotalWidth;
const sheetHeight = skeleton.columnHeaderHeight + skeleton.rowTotalHeight;
if ((left + width) > sheetWidth) {
left = sheetWidth - width;
}
if ((top + height) > sheetHeight) {
top = sheetHeight - height;
}
return {
flipY,
flipX,
angle,
skewX,
skewY,
left,
top,
width,
height,
};
}
// use transform and originSize convert to ISheetDrawingPosition
export function transformToDrawingPosition(transform: ITransformState, skeleton: SpreadsheetSkeleton): ISheetDrawingPosition {
const { left = 0, top = 0, width = 0, height = 0, flipY = false, flipX = false, angle = 0, skewX = 0, skewY = 0 } = transform;
const startSelectionCell = skeleton.getCellIndexAndOffsetByPosition(left, top);
const endSelectionCell = skeleton.getCellIndexAndOffsetByPosition(left + width, top + height);
return {
flipY,
flipX,
angle,
skewX,
skewY,
from: startSelectionCell,
to: endSelectionCell,
};
}
/**
* In excel, the basic drawing with rotate bound use major axis switch, axis-aligned bound will bu used.That means the position bound of drawing element will save as nearly axis-aligned rectangle.
* Here is the rule to convert transform to axis-aligned position:
* [-45°, 45°): use the original bound
* [45°, 135°): rotate the bound 90° clockwise,and the left, top, bottom,right will use the rotated bound.
* [135°, 225°): use the original bound
* [225°, 315°): rotate the bound 90° counterclockwise, and the left, top, bottom, right will use the rotated bound.
* @return The axis-aligned position of the drawing element.
*/
export function transformToAxisAlignPosition(transform: ITransformState, skeleton: SpreadsheetSkeleton): ISheetDrawingPosition {
const { left = 0, top = 0, width = 0, height = 0, angle = 0 } = transform;
const norm = ((angle % 360) + 360) % 360;
const useSwappedAxis =
(norm >= 45 && norm < 135) ||
(norm >= 225 && norm < 315);
if (!useSwappedAxis) {
return transformToDrawingPosition(transform, skeleton);
}
const rotatedTransform = {
...transform,
left: left + width / 2 - height / 2,
top: top + height / 2 - width / 2,
width: height,
height: width,
};
return transformToDrawingPosition(rotatedTransform, skeleton);
}

View File

@@ -0,0 +1,792 @@
/**
* 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 { Injector, Univer, Workbook } from '@univerjs/core';
import type { ISheetDrawing } from '../../../services/sheet-drawing.service';
import {
ArrangeTypeEnum,
DrawingTypeEnum,
ICommandService,
ImageSourceType,
IUndoRedoService,
IUniverInstanceService,
RedoCommand,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import { DRAWING_COPY_CONTEXT_KEY, IDrawingManagerService } from '@univerjs/drawing';
import { CopySheetCommand, RemoveSheetCommand, SetWorksheetActivateCommand, SheetInterceptorService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSheetsDrawingTestBed } from '../../../__tests__/create-sheets-drawing-test-bed';
import { resolveSheetDrawingRotateEnabled } from '../../../common/rotate-enabled';
import { ISheetDrawingService } from '../../../services/sheet-drawing.service';
import { DrawingApplyType, SetDrawingApplyMutation } from '../../mutations/set-drawing-apply.mutation';
import { InsertSheetDrawingCommand } from '../insert-sheet-drawing.command';
import { RemoveSheetDrawingCommand } from '../remove-sheet-drawing.command';
import { SetDrawingArrangeCommand } from '../set-drawing-arrange.command';
import { SetSheetDrawingCommand } from '../set-sheet-drawing.command';
function createSheetDrawing(drawingId: string, subUnitId = 'sheet1'): ISheetDrawing {
return {
unitId: 'test',
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: `https://example.com/${drawingId}.png`,
sheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: {
row: 1,
rowOffset: 0,
column: 1,
columnOffset: 0,
},
to: {
row: 4,
rowOffset: 0,
column: 3,
columnOffset: 0,
},
},
axisAlignSheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: {
row: 1,
rowOffset: 0,
column: 1,
columnOffset: 0,
},
to: {
row: 4,
rowOffset: 0,
column: 3,
columnOffset: 0,
},
},
};
}
function createSheetDrawingTransform(angle = 0) {
return {
left: 10,
top: 20,
width: 30,
height: 40,
angle,
};
}
function withoutAngle<T extends { angle?: number }>(value: T): Omit<T, 'angle'> {
const { angle: _angle, ...rest } = value;
return rest;
}
function createGroupSheetDrawing(drawingId: string, angle = 0): ISheetDrawing {
const drawing = createSheetDrawing(drawingId);
return {
...drawing,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: createSheetDrawingTransform(angle),
groupBaseBound: createSheetDrawingTransform(angle),
sheetTransform: {
...drawing.sheetTransform,
angle,
},
axisAlignSheetTransform: {
...drawing.axisAlignSheetTransform,
angle,
},
} as ISheetDrawing;
}
describe('sheet drawing integration', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
beforeEach(() => {
const testBed = createSheetsDrawingTestBed();
univer = testBed.univer;
get = testBed.get;
commandService = testBed.commandService;
});
afterEach(() => {
univer.dispose();
});
it('inserts a drawing through the real command pipeline and syncs both drawing services', async () => {
const drawing = createSheetDrawing('drawing-1');
expect(await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [drawing],
})).toBe(true);
const sheetDrawingService = get(ISheetDrawingService);
const drawingManagerService = get(IDrawingManagerService);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-1' })).toMatchObject({
drawingId: 'drawing-1',
subUnitId: 'sheet1',
});
expect(drawingManagerService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-1' })).toMatchObject({
drawingId: 'drawing-1',
subUnitId: 'sheet1',
});
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-1']);
});
it('removes an inserted drawing through the real command pipeline', async () => {
const drawing = createSheetDrawing('drawing-2');
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [drawing],
});
expect(await commandService.executeCommand(RemoveSheetDrawingCommand.id, {
unitId: 'test',
drawings: [{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'drawing-2',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
}],
})).toBe(true);
const sheetDrawingService = get(ISheetDrawingService);
const drawingManagerService = get(IDrawingManagerService);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-2' })).toBeUndefined();
expect(drawingManagerService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-2' })).toBeUndefined();
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual([]);
});
it('skips remove sheet drawing when no drawing objects are resolved', async () => {
expect(await commandService.executeCommand(RemoveSheetDrawingCommand.id, {
unitId: 'test',
drawings: [],
})).toBe(false);
});
it('removes and restores a grouped drawing graph including chart children', async () => {
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [
createGroupSheetDrawing('group-delete'),
{
...createSheetDrawing('image-child'),
groupId: 'group-delete',
transform: createSheetDrawingTransform(),
},
{
...createSheetDrawing('chart-child'),
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: 'group-delete',
transform: createSheetDrawingTransform(),
} as ISheetDrawing,
],
});
expect(await commandService.executeCommand(RemoveSheetDrawingCommand.id, {
unitId: 'test',
drawings: [{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'group-delete',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
}],
})).toBe(true);
const sheetDrawingService = get(ISheetDrawingService);
const drawingManagerService = get(IDrawingManagerService);
const drawingSearches = ['group-delete', 'image-child', 'chart-child'].map((drawingId) => ({ unitId: 'test', subUnitId: 'sheet1', drawingId }));
drawingSearches.forEach((drawingSearch) => {
expect(sheetDrawingService.getDrawingByParam(drawingSearch)).toBeUndefined();
expect(drawingManagerService.getDrawingByParam(drawingSearch)).toBeUndefined();
});
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual([]);
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-delete' })).toMatchObject({
drawingType: DrawingTypeEnum.DRAWING_GROUP,
});
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'image-child' })).toMatchObject({
groupId: 'group-delete',
});
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'chart-child' })).toMatchObject({
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: 'group-delete',
});
});
it('updates an existing drawing through the real command pipeline', async () => {
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [createSheetDrawing('drawing-update')],
});
expect(await commandService.executeCommand(SetSheetDrawingCommand.id, {
unitId: 'test',
drawings: [{
...createSheetDrawing('drawing-update'),
source: 'https://example.com/updated.png',
sheetTransform: {
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
from: {
row: 2,
rowOffset: 1,
column: 2,
columnOffset: 1,
},
to: {
row: 6,
rowOffset: 0,
column: 5,
columnOffset: 0,
},
},
}],
})).toBe(true);
const sheetDrawingService = get(ISheetDrawingService);
const drawingManagerService = get(IDrawingManagerService);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-update' })).toMatchObject({
source: 'https://example.com/updated.png',
sheetTransform: {
from: {
row: 2,
column: 2,
},
to: {
row: 6,
column: 5,
},
},
});
expect(drawingManagerService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-update' })).toMatchObject({
source: 'https://example.com/updated.png',
});
});
it('preserves angle updates for groups containing charts while applying move and resize fields', async () => {
const image = {
...createSheetDrawing('image-child'),
groupId: 'group-with-chart',
transform: createSheetDrawingTransform(),
};
const chart = {
...createSheetDrawing('chart-child'),
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: 'group-with-chart',
transform: createSheetDrawingTransform(),
} as ISheetDrawing;
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [
createGroupSheetDrawing('group-with-chart', 30),
image,
chart,
],
});
const sheetDrawingService = get(ISheetDrawingService);
const restoredGroup = sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-with-chart' })!;
expect(Object.prototype.hasOwnProperty.call(restoredGroup.transform ?? {}, 'rotateEnabled')).toBe(false);
expect(restoredGroup.transform?.angle).toBe(30);
expect(resolveSheetDrawingRotateEnabled(restoredGroup, sheetDrawingService)).toBe(false);
expect(await commandService.executeCommand(SetSheetDrawingCommand.id, {
unitId: 'test',
drawings: [{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'group-with-chart',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
left: 15,
top: 25,
width: 35,
height: 45,
angle: 60,
},
sheetTransform: {
...createSheetDrawing('group-with-chart').sheetTransform,
angle: 60,
},
axisAlignSheetTransform: {
...createSheetDrawing('group-with-chart').axisAlignSheetTransform,
angle: 60,
},
}],
})).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-with-chart' })).toMatchObject({
transform: {
left: 15,
top: 25,
width: 35,
height: 45,
angle: 30,
},
sheetTransform: expect.objectContaining({ angle: 30 }),
axisAlignSheetTransform: expect.objectContaining({ angle: 30 }),
});
expect(await commandService.executeCommand(SetSheetDrawingCommand.id, {
unitId: 'test',
drawings: [{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'group-with-chart',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
angle: 60,
},
}],
})).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-with-chart' })).toMatchObject({
transform: {
left: 15,
top: 25,
width: 35,
height: 45,
angle: 30,
},
sheetTransform: expect.objectContaining({ angle: 30 }),
axisAlignSheetTransform: expect.objectContaining({ angle: 30 }),
});
});
it('drops incoming angles for non-rotatable drawings when current angle is absent', async () => {
const drawing = createSheetDrawing('chart-without-angle');
const chart = {
...drawing,
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: withoutAngle(createSheetDrawingTransform()),
sheetTransform: withoutAngle(drawing.sheetTransform),
axisAlignSheetTransform: withoutAngle(drawing.axisAlignSheetTransform),
} as ISheetDrawing;
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [chart],
});
expect(await commandService.executeCommand(SetSheetDrawingCommand.id, {
unitId: 'test',
drawings: [{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'chart-without-angle',
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: createSheetDrawingTransform(45),
sheetTransform: {
...drawing.sheetTransform,
angle: 45,
},
axisAlignSheetTransform: {
...drawing.axisAlignSheetTransform,
angle: 45,
},
}],
})).toBe(true);
const restoredChart = get(ISheetDrawingService).getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'chart-without-angle',
})!;
expect(Object.prototype.hasOwnProperty.call(restoredChart.transform ?? {}, 'angle')).toBe(false);
expect(Object.prototype.hasOwnProperty.call(restoredChart.sheetTransform, 'angle')).toBe(false);
expect(Object.prototype.hasOwnProperty.call(restoredChart.axisAlignSheetTransform, 'angle')).toBe(false);
});
it('keeps angle updates unchanged when the current drawing is missing', () => {
const commandServiceMock = { syncExecuteCommand: vi.fn(() => true) };
const undoRedoServiceMock = { pushUndoRedo: vi.fn() };
const sheetDrawingServiceMock = {
getDrawingByParam: vi.fn(() => undefined),
getBatchUpdateOp: vi.fn((drawings: ISheetDrawing[]) => ({
unitId: 'test',
subUnitId: 'sheet1',
undo: null,
redo: null,
objects: drawings.map(({ unitId, subUnitId, drawingId }) => ({ unitId, subUnitId, drawingId })),
})),
};
const sheetInterceptorServiceMock = {
onCommandExecute: vi.fn(() => ({ redos: [], undos: [] })),
};
const drawing = {
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'missing-chart',
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: { angle: 45 },
};
const accessor = {
get: vi.fn((token) => {
if (token === ICommandService) return commandServiceMock;
if (token === IUndoRedoService) return undoRedoServiceMock;
if (token === ISheetDrawingService) return sheetDrawingServiceMock;
if (token === SheetInterceptorService) return sheetInterceptorServiceMock;
throw new Error('unexpected dependency');
}),
};
expect(SetSheetDrawingCommand.handler(accessor as never, {
unitId: 'test',
drawings: [drawing],
})).toBe(true);
expect(sheetDrawingServiceMock.getBatchUpdateOp).toHaveBeenCalledWith([drawing]);
expect(sheetInterceptorServiceMock.onCommandExecute).toHaveBeenCalledWith({
id: SetSheetDrawingCommand.id,
params: {
unitId: 'test',
drawings: [drawing],
},
});
});
it('arranges drawing order through the real command pipeline', async () => {
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [
createSheetDrawing('drawing-a'),
createSheetDrawing('drawing-b'),
createSheetDrawing('drawing-c'),
],
});
const sheetDrawingService = get(ISheetDrawingService);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-c', 'drawing-b', 'drawing-a']);
expect(await commandService.executeCommand(SetDrawingArrangeCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawingIds: ['drawing-c'],
arrangeType: ArrangeTypeEnum.front,
})).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-b', 'drawing-a', 'drawing-c']);
expect(await commandService.executeCommand(SetDrawingArrangeCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawingIds: ['drawing-c'],
arrangeType: ArrangeTypeEnum.backward,
})).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-b', 'drawing-c', 'drawing-a']);
expect(await commandService.executeCommand(SetDrawingArrangeCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawingIds: ['drawing-b'],
arrangeType: ArrangeTypeEnum.forward,
})).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-c', 'drawing-b', 'drawing-a']);
expect(await commandService.executeCommand(SetDrawingArrangeCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawingIds: ['drawing-a'],
arrangeType: ArrangeTypeEnum.back,
})).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-a', 'drawing-c', 'drawing-b']);
});
it('moves one drawing to a clamped zero-based order index with undo', async () => {
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [
createSheetDrawing('drawing-a'),
createSheetDrawing('drawing-b'),
createSheetDrawing('drawing-c'),
],
});
const sheetDrawingService = get(ISheetDrawingService);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-c', 'drawing-b', 'drawing-a']);
expect(await commandService.executeCommand(SetDrawingArrangeCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawingIds: ['drawing-b'],
zOrder: 99,
})).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-c', 'drawing-a', 'drawing-b']);
expect(commandService.syncExecuteCommand(UndoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-c', 'drawing-b', 'drawing-a']);
expect(commandService.syncExecuteCommand(RedoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual(['drawing-c', 'drawing-a', 'drawing-b']);
});
it('copies sheet drawings when a worksheet is duplicated', async () => {
await commandService.executeCommand(SetWorksheetActivateCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
});
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [createSheetDrawing('drawing-3')],
});
expect(commandService.syncExecuteCommand(CopySheetCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBe(true);
const workbook = get(IUniverInstanceService).getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)!;
let copiedSheetId = '';
const sheets = workbook.getSheets();
for (const sheet of sheets) {
const sheetId = sheet.getSheetId();
if (sheetId !== 'sheet1' && sheetId !== 'sheet2') {
copiedSheetId = sheetId;
break;
}
}
expect(copiedSheetId).not.toBe('');
const copiedDrawings = Object.values(get(ISheetDrawingService).getDrawingData('test', copiedSheetId));
expect(copiedDrawings).toHaveLength(1);
expect(copiedDrawings[0]).toMatchObject({
unitId: 'test',
subUnitId: copiedSheetId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
});
expect(copiedDrawings[0].drawingId).not.toBe('drawing-3');
expect(get(IDrawingManagerService).getDrawingOrder('test', copiedSheetId)).toHaveLength(1);
});
it('copies grouped drawing records with the same group id map', async () => {
await commandService.executeCommand(SetWorksheetActivateCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
});
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [
createGroupSheetDrawing('group-copy'),
{
...createSheetDrawing('image-child'),
groupId: 'group-copy',
transform: createSheetDrawingTransform(),
},
{
...createSheetDrawing('chart-child'),
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: 'group-copy',
transform: createSheetDrawingTransform(),
} as ISheetDrawing,
],
});
expect(commandService.syncExecuteCommand(CopySheetCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBe(true);
const workbook = get(IUniverInstanceService).getUnit<Workbook>('test', UniverInstanceType.UNIVER_SHEET)!;
const copiedSheetId = workbook.getSheets()
.map((sheet) => sheet.getSheetId())
.find((sheetId) => sheetId !== 'sheet1' && sheetId !== 'sheet2')!;
const copiedDrawings = Object.values(get(ISheetDrawingService).getDrawingData('test', copiedSheetId));
const copiedGroup = copiedDrawings.find((drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_GROUP);
const copiedImage = copiedDrawings.find((drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE);
const copiedChart = copiedDrawings.find((drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_CHART);
expect(copiedDrawings.map((drawing) => drawing.drawingType).sort()).toEqual([
DrawingTypeEnum.DRAWING_CHART,
DrawingTypeEnum.DRAWING_IMAGE,
DrawingTypeEnum.DRAWING_GROUP,
].sort());
expect(copiedGroup?.drawingId).not.toBe('group-copy');
expect(copiedImage?.drawingId).not.toBe('image-child');
expect(copiedChart?.drawingId).not.toBe('chart-child');
expect(copiedImage?.groupId).toBe(copiedGroup?.drawingId);
expect(copiedChart?.groupId).toBe(copiedGroup?.drawingId);
});
it('includes grouped chart children in remove sheet drawing record mutations', async () => {
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [
createGroupSheetDrawing('group-remove'),
{
...createSheetDrawing('image-child'),
groupId: 'group-remove',
transform: createSheetDrawingTransform(),
},
{
...createSheetDrawing('chart-child'),
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: 'group-remove',
transform: createSheetDrawingTransform(),
} as ISheetDrawing,
],
});
const intercepted = get(SheetInterceptorService).onCommandExecute({
id: RemoveSheetCommand.id,
params: { unitId: 'test', subUnitId: 'sheet1' },
});
const removeMutation = intercepted.redos.find((mutation) => mutation.id === SetDrawingApplyMutation.id);
const removeObjects = (removeMutation?.params as { objects?: Array<{ drawingId: string }>; type?: DrawingApplyType } | undefined)?.objects ?? [];
expect((removeMutation?.params as { type?: DrawingApplyType } | undefined)?.type).toBe(DrawingApplyType.REMOVE);
expect(removeObjects.map((object) => object.drawingId)).toEqual(['chart-child', 'image-child', 'group-remove']);
});
it('copies drawing records that are missing from drawing order after ordered records', () => {
const sheetDrawingService = get(ISheetDrawingService);
sheetDrawingService.registerDrawingData('test', {
sheet1: {
data: {
ordered: createSheetDrawing('ordered'),
unordered: createSheetDrawing('unordered'),
},
order: ['ordered'],
},
});
const copyContext = new Map<string, unknown>();
const intercepted = get(SheetInterceptorService).onCommandExecute({
id: CopySheetCommand.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
targetSubUnitId: 'copied-sheet',
copyContext,
},
});
const insertMutation = intercepted.redos.find((mutation) => mutation.id === SetDrawingApplyMutation.id);
const copiedObjects = (insertMutation?.params as { objects?: Array<{ drawingId: string; subUnitId: string }> } | undefined)?.objects ?? [];
const copyPlan = copyContext.get(DRAWING_COPY_CONTEXT_KEY) as { drawings: Array<ISheetDrawing & { source?: string }> };
expect(copyPlan.drawings.map((drawing) => drawing.source)).toEqual([
'https://example.com/ordered.png',
'https://example.com/unordered.png',
]);
expect(copiedObjects.map((drawing) => drawing.drawingId)).toEqual(copyPlan.drawings.map((drawing) => drawing.drawingId));
expect(copiedObjects.map((drawing) => drawing.subUnitId)).toEqual(['copied-sheet', 'copied-sheet']);
});
it('does not create remove sheet mutations when batch remove resolves no objects', async () => {
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [createSheetDrawing('empty-remove-guard')],
});
const sheetDrawingService = get(ISheetDrawingService);
const getBatchRemoveOpSpy = vi.spyOn(sheetDrawingService, 'getBatchRemoveOp').mockReturnValue({
unitId: 'test',
subUnitId: 'sheet1',
undo: null,
redo: null,
objects: [],
});
expect(get(SheetInterceptorService).onCommandExecute({
id: RemoveSheetCommand.id,
params: { unitId: 'test', subUnitId: 'sheet1' },
})).toMatchObject({ redos: [], undos: [] });
getBatchRemoveOpSpy.mockRestore();
});
it('does not create sheet lifecycle mutations when the source sheet has no drawings', () => {
const sheetInterceptorService = get(SheetInterceptorService);
expect(sheetInterceptorService.onCommandExecute({
id: RemoveSheetCommand.id,
params: { unitId: 'test', subUnitId: 'sheet2' },
})).toMatchObject({ redos: [], undos: [] });
expect(sheetInterceptorService.onCommandExecute({
id: CopySheetCommand.id,
params: {
unitId: 'test',
subUnitId: 'sheet2',
},
})).toMatchObject({ redos: [], undos: [] });
expect(sheetInterceptorService.onCommandExecute({
id: CopySheetCommand.id,
params: {
unitId: 'test',
subUnitId: 'sheet2',
targetSubUnitId: 'copied-empty-sheet',
copyContext: new Map<string, unknown>(),
},
})).toMatchObject({ redos: [], undos: [] });
});
it('removes and restores sheet drawings when a worksheet is deleted and undone', async () => {
await commandService.executeCommand(SetWorksheetActivateCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
});
await commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [createSheetDrawing('drawing-4')],
});
expect(commandService.syncExecuteCommand(RemoveSheetCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBe(true);
const sheetDrawingService = get(ISheetDrawingService);
const drawingManagerService = get(IDrawingManagerService);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-4' })).toBeUndefined();
expect(drawingManagerService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-4' })).toBeUndefined();
expect(sheetDrawingService.getDrawingOrder('test', 'sheet1')).toEqual([]);
expect(drawingManagerService.getDrawingOrder('test', 'sheet1')).toEqual([]);
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-4' })).toMatchObject({
drawingId: 'drawing-4',
});
expect(drawingManagerService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-4' })).toMatchObject({
drawingId: 'drawing-4',
});
});
});

View File

@@ -0,0 +1,103 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { IDrawingJsonUndo1 } from '@univerjs/drawing';
import type { ISheetDrawing } from '../../services/sheet-drawing.service';
import {
CommandType,
ICommandService,
IUndoRedoService,
sequenceExecute,
} from '@univerjs/core';
import { SheetInterceptorService } from '@univerjs/sheets';
import { ISheetDrawingService } from '../../services/sheet-drawing.service';
import { DrawingApplyType, SetDrawingApplyMutation } from '../mutations/set-drawing-apply.mutation';
import { ClearSheetDrawingTransformerOperation } from '../operations/clear-drawing-transformer.operation';
export interface IInsertSheetDrawingCommandParams {
unitId: string;
drawings: ISheetDrawing[];
}
export const InsertSheetDrawingCommand: ICommand = {
id: 'sheet.command.insert-sheet-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IInsertSheetDrawingCommandParams) => {
if (!params) return false;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
const sheetInterceptorService = accessor.get(SheetInterceptorService);
const drawings = params.drawings;
const jsonOp = sheetDrawingService.getBatchAddOp(drawings) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
const intercepted = sheetInterceptorService.onCommandExecute({ id: InsertSheetDrawingCommand.id, params });
const redoMutations = [
...(intercepted.preRedos ?? []),
{
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
op: redo,
objects,
type: DrawingApplyType.INSERT,
},
},
{
id: ClearSheetDrawingTransformerOperation.id,
params: [unitId],
},
...intercepted.redos,
];
const undoMutations = [
...(intercepted.preUndos ?? []),
{
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
op: undo,
objects,
type: DrawingApplyType.REMOVE,
},
},
{
id: ClearSheetDrawingTransformerOperation.id,
params: [unitId],
},
...intercepted.undos,
];
const result = sequenceExecute(redoMutations, commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations,
redoMutations,
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,113 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DrawingTypeEnum, IAccessor, ICommand } from '@univerjs/core';
import type { IDrawingJsonUndo1 } from '@univerjs/drawing';
import {
CommandType,
ICommandService,
IUndoRedoService,
sequenceExecute,
} from '@univerjs/core';
import { SheetInterceptorService } from '@univerjs/sheets';
import { ISheetDrawingService } from '../../services/sheet-drawing.service';
import { DrawingApplyType, SetDrawingApplyMutation } from '../mutations/set-drawing-apply.mutation';
import { ClearSheetDrawingTransformerOperation } from '../operations/clear-drawing-transformer.operation';
export interface IRemoveSheetDrawingCommandParam {
unitId: string;
subUnitId: string;
drawingId: string;
drawingType: DrawingTypeEnum;
}
export interface IRemoveSheetDrawingCommandParams {
unitId: string;
drawings: IRemoveSheetDrawingCommandParam[];
}
export const RemoveSheetDrawingCommand: ICommand = {
id: 'sheet.command.remove-sheet-image',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: IRemoveSheetDrawingCommandParams) => {
if (!params) return false;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const sheetInterceptorService = accessor.get(SheetInterceptorService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
const { drawings } = params;
const jsonOp = sheetDrawingService.getBatchRemoveOp(drawings) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
if (Array.isArray(objects) && objects.length === 0) {
return false;
}
const intercepted = sheetInterceptorService.onCommandExecute({ id: RemoveSheetDrawingCommand.id, params });
const redoMutations = [
...(intercepted.preRedos ?? []),
{
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
op: redo,
objects,
type: DrawingApplyType.REMOVE,
},
},
{
id: ClearSheetDrawingTransformerOperation.id,
params: [unitId],
},
...intercepted.redos,
];
const undoMutations = [
...(intercepted.preUndos ?? []),
{
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
op: undo,
objects,
type: DrawingApplyType.INSERT,
},
},
{
id: ClearSheetDrawingTransformerOperation.id,
params: [unitId],
},
...intercepted.undos,
];
const result = sequenceExecute(redoMutations, commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations,
redoMutations,
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,85 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand, Nullable } from '@univerjs/core';
import type { IDrawingJsonUndo1, IDrawingOrderMapParam } from '@univerjs/drawing';
import {
ArrangeTypeEnum,
CommandType,
ICommandService,
IUndoRedoService,
} from '@univerjs/core';
import { ISheetDrawingService } from '../../services/sheet-drawing.service';
import { DrawingApplyType, SetDrawingApplyMutation } from '../mutations/set-drawing-apply.mutation';
export type ISetDrawingArrangeCommandParams = IDrawingOrderMapParam & (
{ arrangeType: ArrangeTypeEnum; zOrder?: never }
| { arrangeType?: never; zOrder: number }
);
export const SetDrawingArrangeCommand: ICommand = {
id: 'sheet.command.set-drawing-arrange',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params?: ISetDrawingArrangeCommandParams) => {
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
if (!params) return false;
const sheetDrawingService = accessor.get(ISheetDrawingService);
const { unitId, subUnitId, drawingIds } = params;
const drawingOrderMapParam = { unitId, subUnitId, drawingIds } as IDrawingOrderMapParam;
let jsonOp: Nullable<IDrawingJsonUndo1>;
if (params.zOrder !== undefined) {
jsonOp = sheetDrawingService.getDrawingOrderOp(drawingOrderMapParam, params.zOrder) as Nullable<IDrawingJsonUndo1>;
} else if (params.arrangeType === ArrangeTypeEnum.forward) {
jsonOp = sheetDrawingService.getForwardDrawingsOp(drawingOrderMapParam) as IDrawingJsonUndo1;
} else if (params.arrangeType === ArrangeTypeEnum.backward) {
jsonOp = sheetDrawingService.getBackwardDrawingOp(drawingOrderMapParam) as IDrawingJsonUndo1;
} else if (params.arrangeType === ArrangeTypeEnum.front) {
jsonOp = sheetDrawingService.getFrontDrawingsOp(drawingOrderMapParam) as IDrawingJsonUndo1;
} else if (params.arrangeType === ArrangeTypeEnum.back) {
jsonOp = sheetDrawingService.getBackDrawingsOp(drawingOrderMapParam) as IDrawingJsonUndo1;
}
if (jsonOp == null) {
return false;
}
const { objects, redo, undo } = jsonOp;
const result = commandService.syncExecuteCommand(SetDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.ARRANGE });
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects, type: DrawingApplyType.ARRANGE } },
],
redoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type: DrawingApplyType.ARRANGE } },
],
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,89 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { ISheetDrawingPlacementInput } from '../../services/sheet-drawing-placement';
import { CommandType, ICommandService, IUndoRedoService, sequenceExecute } from '@univerjs/core';
import { SheetSkeletonService } from '@univerjs/sheets';
import { applySheetDrawingPlacement } from '../../services/sheet-drawing-placement';
import { ISheetDrawingService, SheetDrawingAnchorType } from '../../services/sheet-drawing.service';
import { DrawingApplyType, SetDrawingApplyMutation } from '../mutations/set-drawing-apply.mutation';
import { ClearSheetDrawingTransformerOperation } from '../operations/clear-drawing-transformer.operation';
export interface ISetSheetDrawingPlacementCommandParams {
unitId: string;
subUnitId: string;
drawings: Array<{
drawingId: string;
placement: ISheetDrawingPlacementInput;
}>;
}
export const SetSheetDrawingPlacementCommand: ICommand<ISetSheetDrawingPlacementCommandParams> = {
id: 'sheet.command.set-drawing-placement',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params?.drawings.length) {
return false;
}
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const drawingService = accessor.get(ISheetDrawingService);
const skeleton = params.drawings.every(({ placement }) => placement.kind === SheetDrawingAnchorType.None)
? undefined
: accessor.get(SheetSkeletonService).ensureSkeleton(params.unitId, params.subUnitId);
const updatedDrawings = [];
for (const { drawingId, placement } of params.drawings) {
const drawing = drawingService.getDrawingByParam({
unitId: params.unitId,
subUnitId: params.subUnitId,
drawingId,
});
if (!drawing) {
return false;
}
updatedDrawings.push(applySheetDrawingPlacement(drawing, placement, skeleton));
}
const drawingOp = drawingService.getBatchUpdateOp(updatedDrawings);
const { unitId, subUnitId, undo, redo, objects } = drawingOp;
const redoMutations = [
{
id: SetDrawingApplyMutation.id,
params: { unitId, subUnitId, op: redo, objects, type: DrawingApplyType.UPDATE },
},
{ id: ClearSheetDrawingTransformerOperation.id, params: [unitId] },
];
const undoMutations = [
{
id: SetDrawingApplyMutation.id,
params: { unitId, subUnitId, op: undo, objects, type: DrawingApplyType.UPDATE },
},
{ id: ClearSheetDrawingTransformerOperation.id, params: [unitId] },
];
const result = sequenceExecute(redoMutations, commandService);
if (!result.result) {
return false;
}
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations,
redoMutations,
});
return true;
},
};

View File

@@ -0,0 +1,165 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { IDrawingJsonUndo1 } from '@univerjs/drawing';
import type { ISheetDrawing } from '../../services/sheet-drawing.service';
import {
CommandType,
ICommandService,
IUndoRedoService,
sequenceExecute,
} from '@univerjs/core';
import { SheetInterceptorService } from '@univerjs/sheets';
import { resolveSheetDrawingRotateEnabled } from '../../common/rotate-enabled';
import { ISheetDrawingService } from '../../services/sheet-drawing.service';
import { DrawingApplyType, SetDrawingApplyMutation } from '../mutations/set-drawing-apply.mutation';
import { ClearSheetDrawingTransformerOperation } from '../operations/clear-drawing-transformer.operation';
export interface ISetDrawingCommandParams {
unitId: string;
drawings: Partial<ISheetDrawing>[];
}
function hasIncomingAngle(drawing: Partial<ISheetDrawing>): boolean {
return drawing.transform?.angle !== undefined || drawing.sheetTransform?.angle !== undefined || drawing.axisAlignSheetTransform?.angle !== undefined;
}
type NullableAngleState = { angle?: number } | null | undefined | void;
function preserveAngle<T extends { angle?: number }>(incoming: T, current: NullableAngleState): T {
const currentAngle = (current as { angle?: number } | null | undefined)?.angle;
const next = { ...((current ?? {}) as object), ...incoming } as T;
if (currentAngle === undefined) {
delete next.angle;
} else {
next.angle = currentAngle;
}
return next;
}
function normalizeNonRotatableAngleUpdate(drawing: Partial<ISheetDrawing>, sheetDrawingService: ISheetDrawingService): Partial<ISheetDrawing> {
if (!drawing.drawingId || !drawing.unitId || !drawing.subUnitId || !hasIncomingAngle(drawing)) {
return drawing;
}
const current = sheetDrawingService.getDrawingByParam({
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
});
if (!current) {
return drawing;
}
const resolveTarget = {
...current,
...drawing,
drawingType: drawing.drawingType ?? current.drawingType,
transform: {
...current.transform,
...drawing.transform,
},
} as ISheetDrawing;
if (resolveSheetDrawingRotateEnabled(resolveTarget, sheetDrawingService)) {
return drawing;
}
const normalized = { ...drawing };
if (drawing.transform?.angle !== undefined) {
normalized.transform = preserveAngle(drawing.transform, current.transform);
}
if (drawing.sheetTransform?.angle !== undefined) {
normalized.sheetTransform = preserveAngle(drawing.sheetTransform, current.sheetTransform);
}
if (drawing.axisAlignSheetTransform?.angle !== undefined) {
normalized.axisAlignSheetTransform = preserveAngle(drawing.axisAlignSheetTransform, current.axisAlignSheetTransform);
}
return normalized;
}
export const SetSheetDrawingCommand: ICommand<ISetDrawingCommandParams> = {
id: 'sheet.command.set-sheet-image',
type: CommandType.COMMAND,
handler: (accessor, params) => {
if (!params) return false;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
const sheetInterceptorService = accessor.get(SheetInterceptorService);
const { drawings } = params;
const normalizedDrawings = drawings.map((drawing) => normalizeNonRotatableAngleUpdate(drawing, sheetDrawingService));
const normalizedParams = { ...params, drawings: normalizedDrawings };
const jsonOp = sheetDrawingService.getBatchUpdateOp(normalizedDrawings as ISheetDrawing[]) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
const intercepted = sheetInterceptorService.onCommandExecute({ id: SetSheetDrawingCommand.id, params: normalizedParams });
const redoMutations = [
...(intercepted.preRedos ?? []),
{
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
op: redo,
objects,
type: DrawingApplyType.UPDATE,
},
},
{
id: ClearSheetDrawingTransformerOperation.id,
params: [unitId],
},
...intercepted.redos,
];
const undoMutations = [
...(intercepted.preUndos ?? []),
{
id: SetDrawingApplyMutation.id,
params: {
unitId,
subUnitId,
op: undo,
objects,
type: DrawingApplyType.UPDATE,
},
},
{
id: ClearSheetDrawingTransformerOperation.id,
params: [unitId],
},
...intercepted.undos,
];
const result = sequenceExecute(redoMutations, commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations,
redoMutations,
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,77 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingSearch, IMutation } from '@univerjs/core';
import type { IDrawingGroupUpdateParam, IDrawingJson1Type, IDrawingOrderMapParam } from '@univerjs/drawing';
import { CommandType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { ISheetDrawingService } from '../../services/sheet-drawing.service';
export enum DrawingApplyType {
INSERT,
REMOVE,
UPDATE,
ARRANGE,
GROUP,
UNGROUP,
}
export interface ISetDrawingApplyMutationParams extends IDrawingJson1Type {
type: DrawingApplyType;
}
export const SetDrawingApplyMutation: IMutation<ISetDrawingApplyMutationParams> = {
id: 'sheet.mutation.set-drawing-apply',
type: CommandType.MUTATION,
handler: (accessor, params) => {
const drawingManagerService = accessor.get(IDrawingManagerService);
const sheetDrawingService = accessor.get(ISheetDrawingService);
const { op, unitId, subUnitId, type, objects } = params;
drawingManagerService.applyJson1(unitId, subUnitId, op);
sheetDrawingService.applyJson1(unitId, subUnitId, op);
switch (type) {
case DrawingApplyType.INSERT:
drawingManagerService.addNotification(objects as IDrawingSearch[]);
sheetDrawingService.addNotification(objects as IDrawingSearch[]);
break;
case DrawingApplyType.REMOVE:
drawingManagerService.removeNotification(objects as IDrawingSearch[]);
sheetDrawingService.removeNotification(objects as IDrawingSearch[]);
break;
case DrawingApplyType.UPDATE:
drawingManagerService.updateNotification(objects as IDrawingSearch[]);
sheetDrawingService.updateNotification(objects as IDrawingSearch[]);
break;
case DrawingApplyType.ARRANGE:
drawingManagerService.orderNotification(objects as IDrawingOrderMapParam);
sheetDrawingService.orderNotification(objects as IDrawingOrderMapParam);
break;
case DrawingApplyType.GROUP:
drawingManagerService.groupUpdateNotification(objects as IDrawingGroupUpdateParam[]);
sheetDrawingService.groupUpdateNotification(objects as IDrawingGroupUpdateParam[]);
break;
case DrawingApplyType.UNGROUP:
drawingManagerService.ungroupUpdateNotification(objects as IDrawingGroupUpdateParam[]);
sheetDrawingService.ungroupUpdateNotification(objects as IDrawingGroupUpdateParam[]);
break;
}
return true;
},
};

View File

@@ -0,0 +1,33 @@
/**
* 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 { IMutation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
export const ClearSheetDrawingTransformerOperation: IMutation<string[]> = {
id: 'sheet.operation.clear-drawing-transformer',
type: CommandType.MUTATION,
handler: (accessor, params) => {
const renderManagerService = accessor.get(IRenderManagerService);
params.forEach((unitId) => {
renderManagerService.getRenderUnitById(unitId)?.scene.getTransformer()?.debounceRefreshControls();
});
return true;
},
};

View File

@@ -0,0 +1,87 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { ISheetDrawing } from '../../services/sheet-drawing.service';
import { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { resolveSheetDrawingRotateEnabled } from '../rotate-enabled';
const unitId = 'unit-1';
const subUnitId = 'sheet-1';
function createDrawing(drawingId: string, drawingType = DrawingTypeEnum.DRAWING_IMAGE, groupId?: string, rotateEnabled?: boolean): IDrawingParam {
const transform: IDrawingParam['transform'] = {
left: 0,
top: 0,
width: 10,
height: 10,
};
if (rotateEnabled !== undefined) {
transform.rotateEnabled = rotateEnabled;
}
return {
unitId,
subUnitId,
drawingId,
drawingType,
groupId,
transform,
};
}
function createGroup(drawingId: string, groupId?: string, rotateEnabled?: boolean): IDrawingParam {
return createDrawing(drawingId, DrawingTypeEnum.DRAWING_GROUP, groupId, rotateEnabled);
}
function createDrawingService(drawings: readonly IDrawingParam[] = []) {
return {
getDrawingsByGroup: (drawing: IDrawingParam) => drawings.filter((child) =>
child.groupId === drawing.drawingId &&
child.unitId === drawing.unitId &&
child.subUnitId === drawing.subUnitId
) as ISheetDrawing[],
};
}
describe('sheet drawing rotate-enabled helpers', () => {
it('keeps regular drawings rotatable by default', () => {
expect(resolveSheetDrawingRotateEnabled(createDrawing('shape'), createDrawingService())).toBe(true);
});
it('treats chart drawings as non-rotatable without persisted rotate flags', () => {
const chart = createDrawing('chart', DrawingTypeEnum.DRAWING_CHART);
expect(Object.prototype.hasOwnProperty.call(chart.transform ?? {}, 'rotateEnabled')).toBe(false);
expect(resolveSheetDrawingRotateEnabled(chart, createDrawingService())).toBe(false);
});
it('treats old chart children as non-rotatable', () => {
const root = createGroup('root');
const chart = createDrawing('chart', DrawingTypeEnum.DRAWING_CHART, 'root');
expect(resolveSheetDrawingRotateEnabled(root, createDrawingService([chart]))).toBe(false);
});
it('recomputes stale nested group rotate flags from current children', () => {
const root = createGroup('root');
const nested = createGroup('nested', 'root', false);
const child = createDrawing('shape', DrawingTypeEnum.DRAWING_IMAGE, 'nested');
expect(resolveSheetDrawingRotateEnabled(root, createDrawingService([nested, child]))).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DrawingType, IDrawingParam } from '@univerjs/core';
import type { IUnitDrawingService } from '@univerjs/drawing';
import type { ISheetDrawing } from '../services/sheet-drawing.service';
import { DrawingTypeEnum } from '@univerjs/core';
import { resolveDrawingRotateEnabled } from '@univerjs/drawing';
export function isKnownSheetNonRotatableDrawingType(drawingType: DrawingType): boolean {
return drawingType === DrawingTypeEnum.DRAWING_CHART;
}
export function resolveSheetDrawingRotateEnabled(
drawing: IDrawingParam,
drawingService: Pick<IUnitDrawingService<ISheetDrawing>, 'getDrawingsByGroup'>,
getChildren?: (drawing: IDrawingParam) => readonly IDrawingParam[] | null | undefined
): boolean {
return resolveDrawingRotateEnabled(drawing, {
getChildren: (current) => getChildren?.(current) ?? drawingService.getDrawingsByGroup(current),
isKnownNonRotatableType: isKnownSheetNonRotatableDrawingType,
});
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const SHEETS_DRAWING_PLUGIN_CONFIG_KEY = 'sheets-drawing.config';
export const configSymbol = Symbol(SHEETS_DRAWING_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsDrawingConfig {
}
export const defaultPluginConfig: IUniverSheetsDrawingConfig = {};

View File

@@ -0,0 +1,446 @@
/**
* 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 { DrawingTypeEnum } from '@univerjs/core';
import {
ClearSheetDrawingTransformerOperation,
DrawingApplyType,
SetDrawingApplyMutation,
SheetDrawingAnchorType,
} from '@univerjs/sheets-drawing';
import { describe, expect, it, vi } from 'vitest';
import { SheetDrawingTransformAffectedController } from '../sheet-drawing-transform-affected.controller';
const UNIT_ID = 'unit-1';
const SUB_UNIT_ID = 'sheet-1';
function createSkeleton(options: { rowHeights?: number[]; colWidths?: number[] } = {}) {
const rowHeights = options.rowHeights ?? [];
const colWidths = options.colWidths ?? [];
const rowHeightAt = (row: number) => rowHeights[row] ?? 20;
const colWidthAt = (column: number) => colWidths[column] ?? 10;
const sumRows = (end: number) => Array.from({ length: Math.max(end, 0) }, (_, row) => rowHeightAt(row)).reduce((sum, height) => sum + height, 0);
const sumCols = (end: number) => Array.from({ length: Math.max(end, 0) }, (_, column) => colWidthAt(column)).reduce((sum, width) => sum + width, 0);
return {
rowHeaderWidth: 0,
columnHeaderHeight: 0,
rowTotalHeight: 2000,
columnTotalWidth: 1000,
makeDirty: vi.fn(),
calculate: vi.fn(),
getNoMergeCellWithCoordByIndex: vi.fn((row: number, column: number) => {
const startY = sumRows(row);
const startX = sumCols(column);
return {
startX,
endX: startX + colWidthAt(column),
startY,
endY: startY + rowHeightAt(row),
};
}),
getCellIndexAndOffsetByPosition: vi.fn((left: number, top: number) => {
let column = 0;
let colStart = 0;
while (left >= colStart + colWidthAt(column)) {
colStart += colWidthAt(column);
column++;
}
let row = 0;
let rowStart = 0;
while (top >= rowStart + rowHeightAt(row)) {
rowStart += rowHeightAt(row);
row++;
}
return {
column,
row,
columnOffset: left - colStart,
rowOffset: top - rowStart,
};
}),
};
}
function transformFromPosition(sheetTransform: any, skeleton: any) {
const fromCell = skeleton.getNoMergeCellWithCoordByIndex(sheetTransform.from.row, sheetTransform.from.column);
const toCell = skeleton.getNoMergeCellWithCoordByIndex(sheetTransform.to.row, sheetTransform.to.column);
const left = fromCell.startX + sheetTransform.from.columnOffset;
const top = fromCell.startY + sheetTransform.from.rowOffset;
return {
left,
top,
width: toCell.startX + sheetTransform.to.columnOffset - left,
height: toCell.startY + sheetTransform.to.rowOffset - top,
};
}
function createDrawing(overrides: Record<string, any> = {}, skeleton = createSkeleton()) {
const sheetTransform = overrides.sheetTransform ?? {
from: { row: 1, column: 1, rowOffset: 2, columnOffset: 2 },
to: { row: 3, column: 3, rowOffset: 6, columnOffset: 6 },
};
const transform = overrides.transform ?? transformFromPosition(sheetTransform, skeleton);
return {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
drawingId: overrides.drawingId ?? 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
anchorType: overrides.anchorType ?? SheetDrawingAnchorType.Both,
sheetTransform,
axisAlignSheetTransform: sheetTransform,
transform,
...overrides,
};
}
function createController(options: { drawingData?: Record<string, any>; skeleton?: any } = {}) {
const skeleton = options.skeleton ?? createSkeleton();
const drawingData = options.drawingData ?? {};
const sheetSkeletonParam = { unitId: UNIT_ID, sheetId: SUB_UNIT_ID, skeleton };
const sheetDrawingService = {
getDrawingData: vi.fn(() => drawingData),
getBatchUpdateOp: vi.fn((drawings) => ({ undo: 'update-undo', redo: 'update-redo', objects: drawings })),
getBatchRemoveOp: vi.fn((drawings) => ({ undo: 'remove-undo', redo: 'remove-redo', objects: drawings })),
refreshTransform: vi.fn(),
};
const drawingManagerService = {
getDrawingData: vi.fn(() => drawingData),
refreshTransform: vi.fn(),
};
const controller = Object.create(SheetDrawingTransformAffectedController.prototype) as any;
controller._sheetSkeletonService = {
getSkeletonParam: vi.fn(() => sheetSkeletonParam),
getSkeleton: vi.fn(() => skeleton),
};
controller._sheetDrawingService = sheetDrawingService;
controller._drawingManagerService = drawingManagerService;
controller._commandService = { syncExecuteCommand: vi.fn() };
controller._transformPlanService = {
transform: vi.fn(() => ({ preRedos: [], redos: [], preUndos: [], undos: [] })),
};
controller._getUnitIdAndSubUnitId = vi.fn(() => ({ unitId: UNIT_ID, subUnitId: SUB_UNIT_ID }));
return { controller, skeleton, sheetDrawingService, drawingManagerService };
}
describe('SheetDrawingTransformAffectedController', () => {
it('finalizes complete drawing candidates through extensions in mutation order', () => {
const skeleton = createSkeleton();
const drawing = createDrawing({ drawingId: 'extended' }, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: { [drawing.drawingId]: drawing },
});
controller._transformPlanService.transform.mockImplementation((plan: any) => {
const candidate = plan.updates.get(drawing.drawingId);
expect(candidate).toMatchObject({
drawingId: drawing.drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
anchorType: SheetDrawingAnchorType.Both,
});
plan.updates.set(drawing.drawingId, {
...candidate,
transform: { ...candidate.transform, left: 77, top: 88 },
});
return {
preRedos: [{ id: 'feature.pre-redo', params: {} }],
redos: [{ id: 'feature.redo', params: {} }],
preUndos: [{ id: 'feature.pre-undo', params: {} }],
undos: [{ id: 'feature.undo', params: {} }],
};
});
const result = controller._moveColInterceptor({
range: { startRow: 0, endRow: 9, startColumn: 0, endColumn: 0 },
}, 'insert');
expect(result.redos.map(({ id }: any) => id)).toEqual([
'feature.pre-redo',
SetDrawingApplyMutation.id,
'feature.redo',
ClearSheetDrawingTransformerOperation.id,
]);
expect(result.undos.map(({ id }: any) => id)).toEqual([
'feature.pre-undo',
SetDrawingApplyMutation.id,
'feature.undo',
ClearSheetDrawingTransformerOperation.id,
]);
expect(sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0].transform).toMatchObject({ left: 77, top: 88 });
});
it('removes drawings covered by deleted rows and shifts drawings below into undoable mutations', () => {
const skeleton = createSkeleton();
const deleted = createDrawing({
drawingId: 'deleted',
sheetTransform: {
from: { row: 2, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 2, rowOffset: 10, columnOffset: 5 },
},
}, skeleton);
const shifted = createDrawing({
drawingId: 'shifted',
sheetTransform: {
from: { row: 5, column: 1, rowOffset: 2, columnOffset: 2 },
to: { row: 6, column: 2, rowOffset: 6, columnOffset: 5 },
},
}, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: { deleted, shifted },
});
const result = controller._moveRowInterceptor({
range: { startRow: 2, endRow: 3, startColumn: 0, endColumn: 9 },
}, 'remove');
expect(sheetDrawingService.getBatchUpdateOp).toHaveBeenCalledWith([
expect.objectContaining({
drawingId: 'shifted',
transform: expect.objectContaining({ top: 62, height: 24 }),
sheetTransform: expect.objectContaining({
from: expect.objectContaining({ row: 3, rowOffset: 2 }),
to: expect.objectContaining({ row: 4, rowOffset: 6 }),
}),
}),
]);
expect(sheetDrawingService.getBatchRemoveOp).toHaveBeenCalledWith([
{ unitId: UNIT_ID, subUnitId: SUB_UNIT_ID, drawingId: 'deleted' },
]);
expect(result.redos).toEqual([
{ id: SetDrawingApplyMutation.id, params: { unitId: UNIT_ID, subUnitId: SUB_UNIT_ID, op: 'update-redo', objects: sheetDrawingService.getBatchUpdateOp.mock.calls[0][0], type: DrawingApplyType.UPDATE } },
{ id: SetDrawingApplyMutation.id, params: { unitId: UNIT_ID, subUnitId: SUB_UNIT_ID, op: 'remove-redo', objects: sheetDrawingService.getBatchRemoveOp.mock.calls[0][0], type: DrawingApplyType.REMOVE } },
{ id: ClearSheetDrawingTransformerOperation.id, params: [UNIT_ID] },
]);
expect(result.undos[1].params.type).toBe(DrawingApplyType.INSERT);
});
it('moves drawings to the right when columns are inserted before them', () => {
const skeleton = createSkeleton();
const drawing = createDrawing({
drawingId: 'image-after-inserted-cols',
sheetTransform: {
from: { row: 1, column: 3, rowOffset: 2, columnOffset: 2 },
to: { row: 2, column: 4, rowOffset: 8, columnOffset: 5 },
},
}, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: { [drawing.drawingId]: drawing },
});
const result = controller._moveColInterceptor({
range: { startRow: 0, endRow: 9, startColumn: 1, endColumn: 2 },
}, 'insert');
const updatedDrawing = sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0];
expect(updatedDrawing).toMatchObject({
drawingId: 'image-after-inserted-cols',
transform: { left: 52, top: 22, width: 13, height: 26 },
sheetTransform: {
from: { row: 1, column: 5, rowOffset: 2, columnOffset: 2 },
to: { row: 2, column: 6, rowOffset: 8, columnOffset: 5 },
},
});
expect(result.redos).toContainEqual({ id: ClearSheetDrawingTransformerOperation.id, params: [UNIT_ID] });
});
it('shrinks a both-anchored drawing when deleted rows cover its top anchor', () => {
const skeleton = createSkeleton();
const drawing = createDrawing({
drawingId: 'row-delete-top-anchor',
sheetTransform: {
from: { row: 2, column: 1, rowOffset: 5, columnOffset: 0 },
to: { row: 6, column: 2, rowOffset: 5, columnOffset: 5 },
},
}, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: { [drawing.drawingId]: drawing },
});
controller._moveRowInterceptor({
range: { startRow: 2, endRow: 4, startColumn: 0, endColumn: 9 },
}, 'remove');
const updatedDrawing = sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0];
expect(updatedDrawing).toMatchObject({
transform: { top: 40, height: 25 },
sheetTransform: {
from: { row: 2, rowOffset: 0 },
to: { row: 3, rowOffset: 5 },
},
});
});
it('shrinks a both-anchored drawing when deleted columns cover its left anchor', () => {
const skeleton = createSkeleton();
const drawing = createDrawing({
drawingId: 'column-delete-left-anchor',
sheetTransform: {
from: { row: 1, column: 2, rowOffset: 0, columnOffset: 5 },
to: { row: 2, column: 6, rowOffset: 5, columnOffset: 5 },
},
}, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: { [drawing.drawingId]: drawing },
});
controller._moveColInterceptor({
range: { startRow: 0, endRow: 9, startColumn: 2, endColumn: 4 },
}, 'remove');
const updatedDrawing = sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0];
expect(updatedDrawing).toMatchObject({
transform: { left: 20, width: 15 },
sheetTransform: {
from: { column: 2, columnOffset: 0 },
to: { column: 3, columnOffset: 5 },
},
});
});
it('updates both-anchored drawings when hidden rows cut through their top edge', () => {
const skeleton = createSkeleton();
const drawing = createDrawing({
drawingId: 'row-hidden-top-edge',
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 5, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 5, columnOffset: 0 },
},
}, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: { [drawing.drawingId]: drawing },
});
const result = controller._getDrawingUndoForRowVisible(UNIT_ID, SUB_UNIT_ID, [
{ startRow: 1, endRow: 2, startColumn: 0, endColumn: 9 },
]);
const updatedDrawing = sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0];
expect(updatedDrawing).toMatchObject({
drawingId: 'row-hidden-top-edge',
transform: { left: 10, top: 20, width: 20, height: 20 },
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 2, column: 3, rowOffset: 0, columnOffset: 0 },
},
});
expect(result.redos).toEqual([
{ id: SetDrawingApplyMutation.id, params: { unitId: UNIT_ID, subUnitId: SUB_UNIT_ID, op: 'update-redo', objects: [updatedDrawing], type: DrawingApplyType.UPDATE } },
{ id: ClearSheetDrawingTransformerOperation.id, params: [UNIT_ID] },
]);
});
it('recalculates drawing transforms after row height changes without changing anchors', () => {
const resizedSkeleton = createSkeleton({ rowHeights: [20, 20, 40, 20] });
const sheetTransform = {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 3, rowOffset: 0, columnOffset: 0 },
};
const drawing = createDrawing({
drawingId: 'resized-by-row-height',
sheetTransform,
transform: { left: 10, top: 20, width: 20, height: 40 },
}, resizedSkeleton);
const { controller, sheetDrawingService } = createController({
skeleton: resizedSkeleton,
drawingData: { [drawing.drawingId]: drawing },
});
const result = controller._getDrawingUndoForRowAndColSize(UNIT_ID, SUB_UNIT_ID, [
{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 9 },
]);
const updatedDrawing = sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0];
expect(updatedDrawing).toMatchObject({
drawingId: 'resized-by-row-height',
sheetTransform,
transform: { left: 10, top: 20, width: 20, height: 60 },
});
expect(result.undos).toEqual([
{ id: SetDrawingApplyMutation.id, params: { unitId: UNIT_ID, subUnitId: SUB_UNIT_ID, op: 'update-undo', objects: [updatedDrawing], type: DrawingApplyType.UPDATE } },
{ id: ClearSheetDrawingTransformerOperation.id, params: [UNIT_ID] },
]);
});
it('moves only fully contained both-anchored drawings when a range is moved', () => {
const skeleton = createSkeleton();
const contained = createDrawing({
drawingId: 'contained',
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 2, columnOffset: 2 },
to: { row: 2, column: 2, rowOffset: 6, columnOffset: 6 },
},
}, skeleton);
const positionOnly = createDrawing({
drawingId: 'position-only',
anchorType: SheetDrawingAnchorType.Position,
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 2, column: 2, rowOffset: 0, columnOffset: 0 },
},
}, skeleton);
const partlyOutside = createDrawing({
drawingId: 'partly-outside',
sheetTransform: {
from: { row: 0, column: 0, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 3, rowOffset: 0, columnOffset: 0 },
},
}, skeleton);
const { controller, sheetDrawingService } = createController({
skeleton,
drawingData: {
contained,
[positionOnly.drawingId]: positionOnly,
[partlyOutside.drawingId]: partlyOutside,
},
});
const result = controller._moveRangeInterceptor(
UNIT_ID,
SUB_UNIT_ID,
{ startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 },
{ startRow: 3, endRow: 4, startColumn: 4, endColumn: 5 }
);
const updatedDrawing = sheetDrawingService.getBatchUpdateOp.mock.calls[0][0][0];
expect(sheetDrawingService.getBatchUpdateOp).toHaveBeenCalledTimes(1);
expect(sheetDrawingService.getBatchUpdateOp.mock.calls[0][0]).toHaveLength(1);
expect(updatedDrawing).toMatchObject({
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
drawingId: 'contained',
transform: { left: 42, top: 62, width: 14, height: 24 },
sheetTransform: {
from: { row: 3, column: 4, rowOffset: 2, columnOffset: 2 },
to: { row: 4, column: 5, rowOffset: 6, columnOffset: 6 },
},
});
expect(result.redos).toEqual([
{ id: SetDrawingApplyMutation.id, params: { unitId: UNIT_ID, subUnitId: SUB_UNIT_ID, op: 'update-redo', objects: [updatedDrawing], type: DrawingApplyType.UPDATE } },
{ id: ClearSheetDrawingTransformerOperation.id, params: [UNIT_ID] },
]);
});
});

View File

@@ -0,0 +1,474 @@
/**
* 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 {
ISheetDrawingPlacement,
} from '../../services/sheet-drawing-placement';
import type { ISheetDrawing } from '../../services/sheet-drawing.service';
import { Direction, DrawingTypeEnum, ImageSourceType, RANGE_TYPE, RedoCommandId, UndoCommandId } from '@univerjs/core';
import {
DeleteRangeMoveLeftCommand,
DeleteRangeMoveUpCommand,
InsertColCommand,
InsertRangeMoveDownCommand,
InsertRangeMoveRightCommand,
InsertRowCommand,
MoveColsCommand,
MoveRangeCommand,
MoveRowsCommand,
RemoveColCommand,
RemoveRowCommand,
SetColHiddenCommand,
SetColWidthCommand,
SetRowHeightCommand,
SetRowHiddenCommand,
SetWorksheetRowAutoHeightMutation,
SheetSkeletonService,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingTestBed } from '../../__tests__/create-sheets-drawing-test-bed';
import { drawingPositionToTransform } from '../../basics/transform-position';
import { InsertSheetDrawingCommand } from '../../commands/commands/insert-sheet-drawing.command';
import { SetSheetDrawingPlacementCommand } from '../../commands/commands/set-sheet-drawing-placement.command';
import {
applySheetDrawingPlacement,
getSheetDrawingPlacement,
normalizeSheetDrawingPlacement,
} from '../../services/sheet-drawing-placement';
import { ISheetDrawingService, SheetDrawingAnchorType } from '../../services/sheet-drawing.service';
describe('sheet drawing transforms without UI plugins', () => {
let testBed: ReturnType<typeof createSheetsDrawingTestBed>;
beforeEach(() => {
testBed = createSheetsDrawingTestBed();
});
afterEach(() => testBed.univer.dispose());
async function insertDrawing() {
const sheetTransform = {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 4, column: 3, rowOffset: 0, columnOffset: 0 },
};
const skeleton = testBed.get(SheetSkeletonService).getSkeletonParam('test', 'sheet1');
const drawing: ISheetDrawing = {
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: 'https://example.com/drawing.png',
anchorType: SheetDrawingAnchorType.Both,
sheetTransform,
axisAlignSheetTransform: sheetTransform,
transform: drawingPositionToTransform(sheetTransform, skeleton)!,
};
expect(await testBed.commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [drawing],
})).toBe(true);
return testBed.get(ISheetDrawingService);
}
function getDrawing(service: ISheetDrawingService) {
return service.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-1' })!;
}
it('inserts rows and restores the exact drawing through undo and redo', async () => {
const service = await insertDrawing();
const before = structuredClone(getDrawing(service));
expect(await testBed.commandService.executeCommand(InsertRowCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 },
direction: Direction.DOWN,
})).toBe(true);
const after = structuredClone(getDrawing(service));
expect(after.transform?.height).toBeGreaterThan(before.transform?.height ?? 0);
expect(testBed.commandService.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(before);
expect(testBed.commandService.syncExecuteCommand(RedoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(after);
});
it.each([
['insert row', InsertRowCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 },
direction: Direction.DOWN,
}],
['delete row', RemoveRowCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 },
}],
['insert column', InsertColCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 },
direction: Direction.RIGHT,
}],
['delete column', RemoveColCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 },
}],
['resize row', SetRowHeightCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
value: 60,
ranges: [{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 }],
}],
['resize column', SetColWidthCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
value: 120,
ranges: [{ startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 }],
}],
['hide row', SetRowHiddenCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
ranges: [{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 }],
}],
['hide column', SetColHiddenCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
ranges: [{ startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 }],
}],
])('%s preserves the three placement contracts in headless mode', async (_name, commandId, params) => {
const skeleton = testBed.get(SheetSkeletonService).ensureSkeleton('test', 'sheet1');
if (!skeleton) {
throw new Error('Expected the Sheet skeleton to exist.');
}
const base = {
unitId: 'test',
subUnitId: 'sheet1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: 'https://example.com/anchor.png',
sheetTransform: {
from: { row: 0, column: 0, rowOffset: 0, columnOffset: 0 },
to: { row: 0, column: 0, rowOffset: 1, columnOffset: 1 },
},
axisAlignSheetTransform: {
from: { row: 0, column: 0, rowOffset: 0, columnOffset: 0 },
to: { row: 0, column: 0, rowOffset: 1, columnOffset: 1 },
},
transform: { left: 0, top: 0, width: 1, height: 1 },
};
const placements: ISheetDrawingPlacement[] = [
{
kind: SheetDrawingAnchorType.Position,
from: { row: 3, column: 3, rowOffset: 4, columnOffset: 6 },
width: 240,
height: 120,
},
{
kind: SheetDrawingAnchorType.Both,
from: { row: 1, column: 1, rowOffset: 4, columnOffset: 6 },
to: { row: 8, column: 6, rowOffset: 0, columnOffset: 0 },
},
{
kind: SheetDrawingAnchorType.None,
left: 640,
top: 96,
width: 240,
height: 120,
},
];
const drawings = placements.map((placement, index) => applySheetDrawingPlacement({
...base,
drawingId: `placement-${index}`,
}, placement, placement.kind === SheetDrawingAnchorType.None ? undefined : skeleton));
expect(await testBed.commandService.executeCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings,
})).toBe(true);
const service = testBed.get(ISheetDrawingService);
const readDrawings = () => drawings.map((drawing) => {
const current = service.getDrawingByParam(drawing);
if (!current) {
throw new Error(`Expected drawing "${drawing.drawingId}" to exist.`);
}
return structuredClone(current);
});
const before = readDrawings();
expect(await testBed.commandService.executeCommand(commandId, params)).toBe(true);
const after = readDrawings();
expect(after[0].transform).not.toEqual(before[0].transform);
expect(after[2].transform).toEqual(before[2].transform);
expect(getSheetDrawingPlacement(after[2])).toEqual(getSheetDrawingPlacement(before[2]));
expect(after[0].transform?.width).toBe(before[0].transform?.width);
expect(after[0].transform?.height).toBe(before[0].transform?.height);
if (commandId === SetRowHiddenCommand.id || commandId === SetColHiddenCommand.id) {
expect(after[1]).not.toEqual(before[1]);
} else {
expect(after[1].transform).not.toEqual(before[1].transform);
expect([
after[1].transform?.width !== before[1].transform?.width,
after[1].transform?.height !== before[1].transform?.height,
]).toContain(true);
}
expect(after.map(getSheetDrawingPlacement).map(({ kind }) => kind)).toEqual([
SheetDrawingAnchorType.Position,
SheetDrawingAnchorType.Both,
SheetDrawingAnchorType.None,
]);
expect(await testBed.commandService.executeCommand(UndoCommandId)).toBe(true);
expect(readDrawings()).toEqual(before);
expect(await testBed.commandService.executeCommand(RedoCommandId)).toBe(true);
expect(readDrawings()).toEqual(after);
});
it('resizes both-anchored drawings when row height changes', async () => {
const service = await insertDrawing();
const before = structuredClone(getDrawing(service));
expect(testBed.commandService.syncExecuteCommand(SetRowHeightCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
ranges: [{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 }],
value: 40,
})).toBe(true);
const after = structuredClone(getDrawing(service));
expect(after.transform?.height).toBeGreaterThan(before.transform?.height ?? 0);
expect(testBed.commandService.syncExecuteCommand(UndoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(before);
expect(testBed.commandService.syncExecuteCommand(RedoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(after);
});
it('updates placement through a command and round-trips through undo and redo', async () => {
const service = await insertDrawing();
const before = structuredClone(getDrawing(service));
const placement: ISheetDrawingPlacement = {
kind: SheetDrawingAnchorType.None,
left: 640,
top: 96,
width: 240,
height: 120,
};
expect(await testBed.commandService.executeCommand(SetSheetDrawingPlacementCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
drawings: [{ drawingId: 'drawing-1', placement }],
})).toBe(true);
const after = structuredClone(getDrawing(service));
expect(getSheetDrawingPlacement(after)).toEqual(placement);
expect(await testBed.commandService.executeCommand(UndoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(before);
expect(await testBed.commandService.executeCommand(RedoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(after);
});
it('requires a loaded Sheet for bounds inference and preserves rotation state', () => {
expect(() => normalizeSheetDrawingPlacement({
kind: SheetDrawingAnchorType.Position,
bounds: { left: 120, top: 80, width: 240, height: 120 },
})).toThrow('SHEET_DRAWING_PLACEMENT_SKELETON_REQUIRED');
const skeleton = testBed.get(SheetSkeletonService).ensureSkeleton('test', 'sheet1');
if (!skeleton) {
throw new Error('Expected the Sheet skeleton to exist.');
}
const source: ISheetDrawing = {
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'rotated-drawing',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
transform: {
left: 120,
top: 80,
width: 240,
height: 120,
angle: 37,
flipX: true,
flipY: false,
skewX: 4,
skewY: 2,
},
sheetTransform: {
from: { row: 2, column: 2, rowOffset: 4, columnOffset: 6 },
to: { row: 8, column: 6, rowOffset: 0, columnOffset: 0 },
angle: 37,
flipX: true,
flipY: false,
skewX: 4,
skewY: 2,
},
axisAlignSheetTransform: {
from: { row: 2, column: 2, rowOffset: 4, columnOffset: 6 },
to: { row: 8, column: 6, rowOffset: 0, columnOffset: 0 },
},
};
const placements: ISheetDrawingPlacement[] = [
{
kind: SheetDrawingAnchorType.Position,
from: { row: 3, column: 3, rowOffset: 8, columnOffset: 8 },
width: 320,
height: 180,
},
{
kind: SheetDrawingAnchorType.Both,
from: { row: 3, column: 3, rowOffset: 8, columnOffset: 8 },
to: { row: 10, column: 8, rowOffset: 0, columnOffset: 0 },
},
{
kind: SheetDrawingAnchorType.None,
left: 640,
top: 96,
width: 320,
height: 180,
},
];
for (const placement of placements) {
const result = applySheetDrawingPlacement(
source,
placement,
placement.kind === SheetDrawingAnchorType.None ? undefined : skeleton
);
expect(result.transform).toEqual(expect.objectContaining({
angle: 37,
flipX: true,
flipY: false,
skewX: 4,
skewY: 2,
}));
expect(result.sheetTransform).toEqual(expect.objectContaining({
angle: 37,
flipX: true,
flipY: false,
skewX: 4,
skewY: 2,
}));
}
});
it.each([
['remove row', RemoveRowCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 },
}],
['insert column', InsertColCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
direction: Direction.RIGHT,
range: { startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 },
}],
['remove column', RemoveColCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 },
}],
['insert cells down', InsertRangeMoveDownCommand.id, {
range: { startRow: 2, endRow: 2, startColumn: 1, endColumn: 3 },
}],
['insert cells right', InsertRangeMoveRightCommand.id, {
range: { startRow: 1, endRow: 4, startColumn: 2, endColumn: 2 },
}],
['delete cells up', DeleteRangeMoveUpCommand.id, {
range: { startRow: 2, endRow: 2, startColumn: 1, endColumn: 3 },
}],
['delete cells left', DeleteRangeMoveLeftCommand.id, {
range: { startRow: 1, endRow: 4, startColumn: 2, endColumn: 2 },
}],
['set column width', SetColWidthCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
value: 120,
ranges: [{ startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 }],
}],
['hide row', SetRowHiddenCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
ranges: [{ startRow: 2, endRow: 2, startColumn: 0, endColumn: 19 }],
}],
['hide column', SetColHiddenCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
ranges: [{ startRow: 0, endRow: 19, startColumn: 2, endColumn: 2 }],
}],
['move rows', MoveRowsCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 1, endRow: 4, startColumn: 0, endColumn: 19, rangeType: RANGE_TYPE.ROW },
fromRange: { startRow: 1, endRow: 4, startColumn: 0, endColumn: 19 },
toRange: { startRow: 8, endRow: 11, startColumn: 0, endColumn: 19 },
}],
['move columns', MoveColsCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, endRow: 19, startColumn: 1, endColumn: 3, rangeType: RANGE_TYPE.COLUMN },
fromRange: { startRow: 0, endRow: 19, startColumn: 1, endColumn: 3 },
toRange: { startRow: 0, endRow: 19, startColumn: 7, endColumn: 9 },
}],
['move range', MoveRangeCommand.id, {
fromUnitId: 'test',
fromSubUnitId: 'sheet1',
toUnitId: 'test',
toSubUnitId: 'sheet1',
fromRange: { startRow: 1, endRow: 4, startColumn: 1, endColumn: 3 },
toRange: { startRow: 8, endRow: 11, startColumn: 7, endColumn: 9 },
}],
])('%s changes drawing geometry and round-trips exactly', async (_name, commandId, params) => {
const service = await insertDrawing();
const before = structuredClone(getDrawing(service));
expect(await testBed.commandService.executeCommand(commandId, params)).toBe(true);
const after = structuredClone(getDrawing(service));
expect(after).not.toEqual(before);
expect(await testBed.commandService.executeCommand(UndoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(before);
expect(await testBed.commandService.executeCommand(RedoCommandId)).toBe(true);
expect(getDrawing(service)).toEqual(after);
});
it('refreshes direct auto-height mutations and their inverse without UI', async () => {
const service = await insertDrawing();
const before = structuredClone(getDrawing(service));
const worksheet = testBed.workbook.getSheetBySheetId('sheet1')!;
const originalHeight = worksheet.getRowHeight(2);
expect(testBed.commandService.syncExecuteCommand(SetWorksheetRowAutoHeightMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
rowsAutoHeightInfo: [{ row: 2, autoHeight: originalHeight + 30 }],
})).toBe(true);
await Promise.resolve();
expect(getDrawing(service).transform?.height).toBeGreaterThan(before.transform?.height ?? 0);
expect(testBed.commandService.syncExecuteCommand(SetWorksheetRowAutoHeightMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
rowsAutoHeightInfo: [{ row: 2, autoHeight: originalHeight }],
})).toBe(true);
await Promise.resolve();
expect(getDrawing(service)).toEqual(before);
});
});

View File

@@ -0,0 +1,236 @@
/**
* 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 { Workbook } from '@univerjs/core';
import type { IDrawingJsonUndo1, IDrawingSubunitMap } from '@univerjs/drawing';
import type { ICopySheetCommandInterceptorParams, IRemoveSheetCommandParams } from '@univerjs/sheets';
import type { ISheetDrawing } from '../services/sheet-drawing.service';
import { Disposable, ICommandService, Inject, IResourceManagerService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { getOrCreateDrawingCopyPlan, IDrawingManagerService } from '@univerjs/drawing';
import { CopySheetCommand, RemoveSheetCommand, SheetInterceptorService } from '@univerjs/sheets';
import { InsertSheetDrawingCommand } from '../commands/commands/insert-sheet-drawing.command';
import { RemoveSheetDrawingCommand } from '../commands/commands/remove-sheet-drawing.command';
import { SetDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command';
import { SetSheetDrawingPlacementCommand } from '../commands/commands/set-sheet-drawing-placement.command';
import { SetSheetDrawingCommand } from '../commands/commands/set-sheet-drawing.command';
import { DrawingApplyType, SetDrawingApplyMutation } from '../commands/mutations/set-drawing-apply.mutation';
import { ClearSheetDrawingTransformerOperation } from '../commands/operations/clear-drawing-transformer.operation';
import { ISheetDrawingService } from '../services/sheet-drawing.service';
export const SHEET_DRAWING_PLUGIN = 'SHEET_DRAWING_PLUGIN';
function getDrawingsInOrder(drawingData: Record<string, ISheetDrawing>, drawingOrder: string[]): ISheetDrawing[] {
const visited = new Set<string>();
const drawings: ISheetDrawing[] = [];
drawingOrder.forEach((drawingId) => {
const drawing = drawingData[drawingId];
if (drawing) {
visited.add(drawingId);
drawings.push(drawing);
}
});
Object.values(drawingData).forEach((drawing) => {
if (!visited.has(drawing.drawingId)) {
drawings.push(drawing);
}
});
return drawings;
}
export class SheetsDrawingLoadController extends Disposable {
constructor(
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(IUniverInstanceService) private _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@ISheetDrawingService private readonly _sheetDrawingService: ISheetDrawingService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IResourceManagerService private _resourceManagerService: IResourceManagerService
) {
super();
this._initCommands();
this._initSnapshot();
this._initSheetChange();
this.disposeWithMe(this._commandService.registerCommand(SetDrawingApplyMutation));
}
private _initCommands() {
[
SetSheetDrawingCommand,
InsertSheetDrawingCommand,
RemoveSheetDrawingCommand,
SetDrawingArrangeCommand,
SetSheetDrawingPlacementCommand,
ClearSheetDrawingTransformerOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _initSnapshot() {
const toJson = (unitId: string, model?: IDrawingSubunitMap<ISheetDrawing>) => {
const map = model || this._sheetDrawingService.getDrawingDataForUnit(unitId);
if (map) {
return JSON.stringify(map);
}
return '';
};
const parseJson = (json: string): IDrawingSubunitMap<ISheetDrawing> => {
if (!json) {
return {};
}
try {
return JSON.parse(json);
} catch {
return {};
}
};
this.disposeWithMe(
this._resourceManagerService.registerPluginResource<IDrawingSubunitMap<ISheetDrawing>>({
pluginName: SHEET_DRAWING_PLUGIN,
businesses: [UniverInstanceType.UNIVER_SHEET],
toJson: (unitId, model) => toJson(unitId, model),
parseJson: (json) => parseJson(json),
onUnLoad: (unitId) => {
this._sheetDrawingService.removeDrawingDataForUnit(unitId);
this._drawingManagerService.removeDrawingDataForUnit(unitId);
},
onLoad: (unitId, value) => {
this._sheetDrawingService.registerDrawingData(unitId, value);
this._drawingManagerService.registerDrawingData(unitId, value);
},
})
);
}
// eslint-disable-next-line max-lines-per-function
private _initSheetChange() {
this.disposeWithMe(
this._sheetInterceptorService.interceptCommand({
// eslint-disable-next-line max-lines-per-function
getMutations: (commandInfo) => {
if (commandInfo.id === RemoveSheetCommand.id) {
const params = commandInfo.params as IRemoveSheetCommandParams;
const unitId = params.unitId || this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getUnitId();
const subUnitId = params.subUnitId || this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()?.getSheetId();
if (!unitId || !subUnitId) {
return { redos: [], undos: [] };
}
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const drawings = Object.values(drawingData);
if (drawings.length === 0) {
return { redos: [], undos: [] };
}
const jsonOp = this._sheetDrawingService.getBatchRemoveOp(drawings) as IDrawingJsonUndo1;
const { unitId: jsonOpUnitId, subUnitId: jsonOpSubUnitId, undo, redo, objects } = jsonOp;
if (Array.isArray(objects) && objects.length === 0) {
return { redos: [], undos: [] };
}
return {
redos: [
{
id: SetDrawingApplyMutation.id,
params: {
op: redo,
unitId: jsonOpUnitId,
subUnitId: jsonOpSubUnitId,
objects,
type: DrawingApplyType.REMOVE,
},
},
],
undos: [
{
id: SetDrawingApplyMutation.id,
params: {
op: undo,
unitId: jsonOpUnitId,
subUnitId: jsonOpSubUnitId,
objects,
type: DrawingApplyType.INSERT,
},
},
],
};
} else if (commandInfo.id === CopySheetCommand.id) {
const params = commandInfo.params as ICopySheetCommandInterceptorParams;
const { unitId, subUnitId, targetSubUnitId, copyContext } = params;
if (!unitId || !subUnitId || !targetSubUnitId) {
return { redos: [], undos: [] };
}
const drawingData = this._sheetDrawingService.getDrawingData(unitId, subUnitId);
const sourceDrawings = getDrawingsInOrder(drawingData, this._sheetDrawingService.getDrawingOrder(unitId, subUnitId));
const copyPlan = getOrCreateDrawingCopyPlan(copyContext, sourceDrawings, {
unitId,
sourceSubUnitId: subUnitId,
targetSubUnitId,
});
const drawings = copyPlan.drawings;
if (drawings.length === 0) {
return { redos: [], undos: [] };
}
const jsonOp = this._sheetDrawingService.getBatchAddOp(drawings) as IDrawingJsonUndo1;
const { unitId: jsonOpUnitId, subUnitId: jsonOpSubUnitId, undo, redo, objects } = jsonOp;
return {
redos: [
{
id: SetDrawingApplyMutation.id,
params: {
op: redo,
unitId: jsonOpUnitId,
subUnitId: jsonOpSubUnitId,
objects,
type: DrawingApplyType.INSERT,
},
},
],
undos: [
{
id: SetDrawingApplyMutation.id,
params: {
op: undo,
unitId: jsonOpUnitId,
subUnitId: jsonOpSubUnitId,
objects,
type: DrawingApplyType.REMOVE,
},
},
],
};
}
return { redos: [], undos: [] };
},
})
);
}
}

View File

@@ -0,0 +1,189 @@
/**
* 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 { Injector, Univer } from '@univerjs/core';
import type { ISheetImage } from '@univerjs/sheets-drawing';
import { DrawingTypeEnum, ICommandService, ImageSourceType } from '@univerjs/core';
import { SheetSkeletonService } from '@univerjs/sheets';
import { ISheetDrawingService } from '@univerjs/sheets-drawing';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSheetsDrawingTestBed } from '../../__tests__/create-sheets-drawing-test-bed';
import { InsertSheetDrawingCommand } from '../../commands/commands/insert-sheet-drawing.command';
import { FOverGridImage } from '../f-over-grid-image';
describe('FOverGridImage', () => {
let univer: Univer;
let injector: Injector;
beforeEach(() => {
const testBed = createSheetsDrawingTestBed();
univer = testBed.univer;
injector = testBed.injector;
});
afterEach(() => {
univer.dispose();
});
it('creates a builder from the image unit and sheet ids', () => {
const image: ISheetImage = {
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: 'https://example.com/drawing-1.png',
sheetTransform: {
from: {
row: 1,
rowOffset: 0,
column: 1,
columnOffset: 0,
},
to: {
row: 4,
rowOffset: 0,
column: 3,
columnOffset: 0,
},
},
axisAlignSheetTransform: {
from: {
row: 1,
rowOffset: 0,
column: 1,
columnOffset: 0,
},
to: {
row: 4,
rowOffset: 0,
column: 3,
columnOffset: 0,
},
},
};
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
injector.get(SheetSkeletonService).ensureSkeleton('test', 'sheet1');
const builder = injector.createInstance(FOverGridImage, image).toBuilder();
expect(builder.getSource()).toBe('https://example.com/drawing-1.png');
expect(builder.getSourceType()).toBe(ImageSourceType.URL);
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('FOverGridImageBuilder but get 0'));
warnSpy.mockRestore();
});
it('updates image data and removes the image through facade methods', async () => {
const image = createImage();
const commandService = injector.get(ICommandService);
const sheetDrawingService = injector.get(ISheetDrawingService);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings: [image] })).toBe(true);
const fImage = injector.createInstance(FOverGridImage, image);
expect(fImage.setSource('https://example.com/updated.png', ImageSourceType.URL)).toBe(true);
expect(getStoredImage('drawing-1').source).toBe('https://example.com/updated.png');
expect(await fImage.setPositionAsync(4, 5, 6, 7)).toBe(true);
expect(getStoredImage('drawing-1').sheetTransform.from).toEqual(expect.objectContaining({
row: 4,
column: 5,
rowOffset: 6,
columnOffset: 7,
}));
expect(await fImage.setSizeAsync(160, 90)).toBe(true);
expect(getStoredImage('drawing-1').transform).toEqual(expect.objectContaining({
width: 160,
height: 90,
}));
expect(fImage.setCrop(1, 2, 3, 4)).toBe(true);
expect(getStoredImage('drawing-1').srcRect).toEqual({ top: 1, left: 2, bottom: 3, right: 4 });
expect(fImage.setRotate(30)).toBe(true);
expect(getStoredImage('drawing-1').sheetTransform.angle).toBe(30);
expect(fImage.setForward()).toBe(true);
expect(fImage.setBackward()).toBe(true);
expect(fImage.setFront()).toBe(true);
expect(fImage.setBack()).toBe(true);
expect(fImage.remove()).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'drawing-1' })).toBeUndefined();
});
function getStoredImage(drawingId: string): ISheetImage {
const image = injector.get(ISheetDrawingService).getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
}) as ISheetImage | undefined;
if (!image) {
throw new Error(`Image ${drawingId} was not stored`);
}
return image;
}
});
function createImage(): ISheetImage {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source: 'https://example.com/drawing-1.png',
transform: {
left: 20,
top: 30,
width: 100,
height: 60,
},
sheetTransform: {
from: {
row: 1,
rowOffset: 0,
column: 1,
columnOffset: 0,
},
to: {
row: 4,
rowOffset: 0,
column: 3,
columnOffset: 0,
},
},
axisAlignSheetTransform: {
from: {
row: 1,
rowOffset: 0,
column: 1,
columnOffset: 0,
},
to: {
row: 4,
rowOffset: 0,
column: 3,
columnOffset: 0,
},
},
};
}

View File

@@ -0,0 +1,143 @@
/**
* 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 { Injector, Univer } from '@univerjs/core';
import type { ISheetImage } from '@univerjs/sheets-drawing';
import { ICommandService } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { SetDrawingSelectedOperation } from '@univerjs/drawing';
import { ISheetDrawingService } from '@univerjs/sheets-drawing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createSheetsDrawingTestBed } from '../../__tests__/create-sheets-drawing-test-bed';
import '@univerjs/sheets/facade';
import '../index';
describe('sheets drawing facade image lifecycle', () => {
let univer: Univer;
let injector: Injector;
let univerAPI: FUniver;
beforeEach(() => {
const testBed = createSheetsDrawingTestBed();
univer = testBed.univer;
injector = testBed.injector;
univerAPI = FUniver.newAPI(injector);
});
afterEach(() => {
univer.dispose();
});
it('keeps the sheet unchanged when a before-insert listener cancels the image', async () => {
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const disposable = univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageInsert, (event) => {
if (event.insertImageParams[0].source.includes('blocked')) {
event.cancel = true;
}
});
const blocked = await worksheet.newOverGridImage()
.setSource('https://example.com/blocked.png', univerAPI.Enum.ImageSourceType.URL)
.setWidth(40)
.setHeight(40)
.buildAsync();
expect(worksheet.insertImages([blocked])).toBe(worksheet);
expect(worksheet.getImageById(blocked.drawingId)).toBeNull();
disposable.dispose();
});
it('emits image lifecycle events around insert, change, select, and remove operations', async () => {
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const commandService = injector.get(ICommandService);
const events: string[] = [];
const disposables = [
univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageInsert, (event) => {
events.push(`before-insert:${event.insertImageParams[0].source}`);
}),
univerAPI.addEvent(univerAPI.Event.OverGridImageInserted, (event) => {
events.push(`inserted:${event.images[0].getId()}`);
}),
univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageChange, (event) => {
events.push(`before-change:${event.images[0].changeParam.source}`);
}),
univerAPI.addEvent(univerAPI.Event.OverGridImageChanged, (event) => {
events.push(`changed:${event.images[0].toBuilder().getSource()}`);
}),
univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageSelect, (event) => {
events.push(`before-select:${event.selectedImages[0].getId()}:${event.oldSelectedImages.length}`);
}),
univerAPI.addEvent(univerAPI.Event.OverGridImageSelected, (event) => {
events.push(`selected:${event.selectedImages[0].getId()}`);
}),
univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageRemove, (event) => {
events.push(`before-remove:${event.images[0].getId()}`);
}),
univerAPI.addEvent(univerAPI.Event.OverGridImageRemoved, (event) => {
events.push(`removed:${event.removeImageParams[0].drawingId}`);
}),
];
const image = await worksheet.newOverGridImage()
.setSource('https://example.com/image.png', univerAPI.Enum.ImageSourceType.URL)
.setWidth(40)
.setHeight(30)
.buildAsync();
expect(worksheet.insertImages([image])).toBe(worksheet);
expect(getStoredImage(injector, image.drawingId).source).toBe('https://example.com/image.png');
const imageFacade = worksheet.getImageById(image.drawingId)!;
expect(imageFacade.setSource('https://example.com/updated.png', univerAPI.Enum.ImageSourceType.URL)).toBe(true);
expect(commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: image.drawingId,
drawingType: image.drawingType,
}])).toBe(true);
expect(imageFacade.remove()).toBe(true);
expect(events).toEqual([
'before-insert:https://example.com/image.png',
`inserted:${image.drawingId}`,
'before-change:https://example.com/updated.png',
'changed:https://example.com/updated.png',
`before-select:${image.drawingId}:0`,
`selected:${image.drawingId}`,
`before-remove:${image.drawingId}`,
`removed:${image.drawingId}`,
]);
for (const disposable of disposables) {
disposable.dispose();
}
});
});
function getStoredImage(injector: Injector, drawingId: string): ISheetImage {
const image = injector.get(ISheetDrawingService).getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
}) as ISheetImage | undefined;
if (!image) {
throw new Error(`Image ${drawingId} was not stored`);
}
return image;
}

View File

@@ -0,0 +1,421 @@
/**
* 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 {
Injector,
Workbook,
} from '@univerjs/core';
import type { FWorkbook } from '@univerjs/sheets/facade';
import type { ISheetDrawingPlacement } from '../../services/sheet-drawing-placement';
import type { ISheetDrawing } from '../../services/sheet-drawing.service';
import {
DrawingTypeEnum,
ICommandService,
ImageSourceType,
IUniverInstanceService,
RedoCommand,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { SheetSkeletonService, UniverSheetsPlugin } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createSheetsDrawingTestBed } from '../../__tests__/create-sheets-drawing-test-bed';
import { InsertSheetDrawingCommand } from '../../commands/commands/insert-sheet-drawing.command';
import { resolveSheetDrawingRotateEnabled } from '../../common/rotate-enabled';
import { getSheetDrawingPlacement } from '../../services/sheet-drawing-placement';
import { ISheetDrawingService, SheetDrawingAnchorType } from '../../services/sheet-drawing.service';
import { FWorksheetDrawingMixin } from '../f-worksheet';
describe('FWorksheetDrawingMixin group drawings', () => {
let univer: Univer;
let injector: Injector;
beforeEach(() => {
const testBed = createSheetsDrawingTestBed();
univer = testBed.univer;
injector = testBed.injector;
});
afterEach(() => {
univer.dispose();
});
it('uses non-drawing worksheet APIs before drawing services are registered', () => {
univer.dispose();
univer = new Univer();
univer.registerPlugin(UniverSheetsPlugin);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, {
id: 'test',
sheetOrder: ['sheet1'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {},
},
},
});
expect(createFacade(univer.__getInjector()).getSheetName()).toBe('Sheet1');
});
it('groups drawings and exposes parent and child relationships', () => {
const drawings = [
createDrawing('drawing-1', 10),
createDrawing('drawing-2', 80),
];
const commandService = injector.get(ICommandService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const fWorksheet = createFacade(injector);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings })).toBe(true);
const groupId = fWorksheet.groupDrawings(['drawing-1', 'drawing-2'], 'group-1');
const groupDrawing = sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' });
expect(groupId).toBe('group-1');
expect(groupDrawing?.drawingType).toBe(DrawingTypeEnum.DRAWING_GROUP);
expect(Object.prototype.hasOwnProperty.call(groupDrawing?.transform ?? {}, 'rotateEnabled')).toBe(false);
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(true);
expect(fWorksheet.getDrawingParentGroup('drawing-1')?.drawingId).toBe('group-1');
expect(fWorksheet.getDrawingGroupChildren('group-1').map((drawing: ISheetDrawing) => drawing.drawingId))
.toEqual(['drawing-1', 'drawing-2']);
});
it('groups chart drawings through the facade and disables group rotation', () => {
const drawings = [
createDrawing('drawing-1', 10),
createDrawing('chart-1', 80, DrawingTypeEnum.DRAWING_CHART),
];
const commandService = injector.get(ICommandService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const fWorksheet = createFacade(injector);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings })).toBe(true);
const groupId = fWorksheet.groupDrawings(['drawing-1', 'chart-1'], 'group-1');
const groupDrawing = sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' });
expect(groupId).toBe('group-1');
expect(groupDrawing?.drawingType).toBe(DrawingTypeEnum.DRAWING_GROUP);
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(true);
expect(fWorksheet.isDrawingGrouped('chart-1')).toBe(true);
expect(fWorksheet.getDrawingGroupChildren('group-1').map((drawing: ISheetDrawing) => drawing.drawingId).sort())
.toEqual(['chart-1', 'drawing-1']);
expect(resolveSheetDrawingRotateEnabled(groupDrawing!, sheetDrawingService)).toBe(false);
});
it('supports undo and redo for group and ungroup facade operations', async () => {
const drawings = [
createDrawing('drawing-1', 10),
createDrawing('drawing-2', 80),
];
const commandService = injector.get(ICommandService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const fWorksheet = createFacade(injector);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings })).toBe(true);
expect(fWorksheet.groupDrawings(['drawing-1', 'drawing-2'], 'group-1')).toBe('group-1');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' })).toBeUndefined();
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(false);
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' })?.drawingType)
.toBe(DrawingTypeEnum.DRAWING_GROUP);
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(true);
expect(fWorksheet.ungroupDrawings(['group-1'])).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' })).toBeUndefined();
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' })?.drawingType)
.toBe(DrawingTypeEnum.DRAWING_GROUP);
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(true);
});
it('does not group with duplicate drawing ids or an existing group id', () => {
const drawings = [
createDrawing('drawing-1', 10),
createDrawing('drawing-2', 80),
];
const commandService = injector.get(ICommandService);
const fWorksheet = createFacade(injector);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings })).toBe(true);
expect(fWorksheet.groupDrawings(['drawing-1', 'drawing-1'], 'group-1')).toBeNull();
expect(fWorksheet.groupDrawings(['drawing-1', 'drawing-2'], 'drawing-1')).toBeNull();
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(false);
expect(fWorksheet.isDrawingGrouped('drawing-2')).toBe(false);
});
it('ungroups drawings and clears parent relationships', () => {
const drawings = [
createDrawing('drawing-1', 10),
createDrawing('drawing-2', 80),
];
const commandService = injector.get(ICommandService);
const sheetDrawingService = injector.get(ISheetDrawingService);
const fWorksheet = createFacade(injector);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings })).toBe(true);
expect(fWorksheet.groupDrawings(['drawing-1', 'drawing-2'], 'group-1')).toBe('group-1');
expect(fWorksheet.ungroupDrawings(['group-1'])).toBe(true);
expect(sheetDrawingService.getDrawingByParam({ unitId: 'test', subUnitId: 'sheet1', drawingId: 'group-1' })).toBeUndefined();
expect(fWorksheet.isDrawingGrouped('drawing-1')).toBe(false);
expect(fWorksheet.isDrawingGrouped('drawing-2')).toBe(false);
});
it('returns nested group children when recursive is enabled', () => {
const drawings = [
createDrawing('drawing-1', 10),
createDrawing('drawing-2', 80),
createDrawing('drawing-3', 150),
];
const commandService = injector.get(ICommandService);
const fWorksheet = createFacade(injector);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings })).toBe(true);
expect(fWorksheet.groupDrawings(['drawing-1', 'drawing-2'], 'group-1')).toBe('group-1');
expect(fWorksheet.groupDrawings(['group-1', 'drawing-3'], 'group-2')).toBe('group-2');
expect(fWorksheet.getDrawingGroupChildren('group-2').map((drawing: ISheetDrawing) => drawing.drawingId))
.toEqual(['drawing-3', 'group-1']);
const recursiveChildren = fWorksheet.getDrawingGroupChildren('group-2', true).map((drawing: ISheetDrawing) => drawing.drawingId);
expect(recursiveChildren).toHaveLength(4);
expect(recursiveChildren).toEqual(expect.arrayContaining(['drawing-1', 'drawing-2', 'drawing-3', 'group-1']));
});
it('inserts, reads, updates, and deletes over-grid images through the worksheet facade', async () => {
const sheetDrawingService = injector.get(ISheetDrawingService);
const fWorksheet = createFacade(injector);
const image = await fWorksheet.newOverGridImage()
.setSource('https://example.com/image.png', ImageSourceType.URL)
.setColumn(2)
.setRow(3)
.setColumnOffset(4)
.setRowOffset(5)
.setWidth(120)
.setHeight(80)
.setCropTop(1)
.setCropLeft(2)
.setCropBottom(3)
.setCropRight(4)
.setRotate(15)
.buildAsync();
expect(fWorksheet.insertImages([image])).toBe(fWorksheet);
const images = fWorksheet.getImages();
expect(images).toHaveLength(1);
expect(images[0].getId()).toBe(image.drawingId);
expect(fWorksheet.getImageById(image.drawingId)?.toBuilder().getSource()).toBe('https://example.com/image.png');
const updatedImage = {
...image,
source: 'https://example.com/updated.png',
};
expect(fWorksheet.updateImages([updatedImage])).toBe(fWorksheet);
expect((sheetDrawingService.getDrawingByParam({
unitId: 'test',
subUnitId: 'sheet1',
drawingId: image.drawingId,
}) as { source: string }).source).toBe('https://example.com/updated.png');
expect(fWorksheet.deleteImages(images)).toBe(fWorksheet);
expect(fWorksheet.getImageById(image.drawingId)).toBeNull();
expect(fWorksheet.getImages()).toEqual([]);
});
it('builds and round-trips an Absolute image placement without a Sheet skeleton', async () => {
const fWorksheet = createFacade(injector);
const ensureSkeleton = vi.spyOn(injector.get(SheetSkeletonService), 'ensureSkeleton')
.mockImplementation(() => {
throw new Error('SKELETON_MUST_NOT_BE_READ');
});
const placement: ISheetDrawingPlacement = {
kind: SheetDrawingAnchorType.None,
left: 640,
top: 96,
width: 320,
height: 180,
};
const image = await fWorksheet.newOverGridImage()
.setSource('https://example.com/absolute.png', ImageSourceType.URL)
.setPlacement(placement)
.buildAsync();
expect(ensureSkeleton).not.toHaveBeenCalled();
expect(image.transform).toEqual(expect.objectContaining({
left: placement.left,
top: placement.top,
width: placement.width,
height: placement.height,
}));
fWorksheet.insertImages([image]);
expect(fWorksheet.getDrawingPlacement(image.drawingId)).toEqual(placement);
expect(fWorksheet.getImageById(image.drawingId)?.getPlacement()).toEqual(placement);
const moved: ISheetDrawingPlacement = {
...placement,
left: 720,
top: 128,
};
expect(fWorksheet.setDrawingPlacement(image.drawingId, moved)).toBe(true);
expect(fWorksheet.getDrawingPlacement(image.drawingId)).toEqual(moved);
});
it('infers Position and Both markers from model bounds and returns the headless host layout', () => {
const fWorksheet = createFacade(injector);
const commandService = injector.get(ICommandService);
const drawing = createDrawing('layout-drawing', 120);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [drawing],
})).toBe(true);
const bounds = { left: 120, top: 80, width: 320, height: 160 };
const position = fWorksheet.resolveDrawingPlacement({
kind: SheetDrawingAnchorType.Position,
bounds,
});
const both = fWorksheet.resolveDrawingPlacement({
kind: SheetDrawingAnchorType.Both,
bounds,
});
expect(position).toEqual(expect.objectContaining({
kind: SheetDrawingAnchorType.Position,
width: bounds.width,
height: bounds.height,
}));
expect(position).toHaveProperty('from');
expect(both).toEqual(expect.objectContaining({
kind: SheetDrawingAnchorType.Both,
}));
expect(both).toHaveProperty('from');
expect(both).toHaveProperty('to');
expect(fWorksheet.setDrawingPlacement(drawing.drawingId, {
kind: SheetDrawingAnchorType.Both,
bounds,
})).toBe(true);
expect(fWorksheet.getDrawingPlacement(drawing.drawingId)).toEqual(both);
const layout = fWorksheet.getDrawingLayout();
expect(layout.gridBounds.width).toBeGreaterThan(0);
expect(layout.gridBounds.height).toBeGreaterThan(0);
expect(layout.dataBounds.width).toBeGreaterThan(0);
expect(layout.dataBounds.height).toBeGreaterThan(0);
expect(layout.drawings).toEqual([
expect.objectContaining({
drawingId: drawing.drawingId,
bounds,
placement: both,
}),
]);
});
it('rejects invalid bounds and reversed TwoCell markers without mutating the drawing', () => {
const fWorksheet = createFacade(injector);
const commandService = injector.get(ICommandService);
const drawing = createDrawing('validated-drawing', 120);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, {
unitId: 'test',
drawings: [drawing],
})).toBe(true);
expect(() => fWorksheet.setDrawingPlacement(drawing.drawingId, {
kind: SheetDrawingAnchorType.Position,
bounds: { left: 120, top: 80, width: 0, height: 160 },
})).toThrow('SHEET_DRAWING_PLACEMENT_EXTENT_INVALID');
expect(() => fWorksheet.setDrawingPlacement(drawing.drawingId, {
kind: SheetDrawingAnchorType.Both,
from: { row: 8, column: 6, rowOffset: 0, columnOffset: 0 },
to: { row: 2, column: 2, rowOffset: 0, columnOffset: 0 },
})).toThrow('SHEET_DRAWING_PLACEMENT_EXTENT_INVALID');
expect(fWorksheet.getDrawingPlacement(drawing.drawingId)).toEqual(getSheetDrawingPlacement(drawing));
});
it('returns only images from sheet drawing data and exposes active images from the drawing selection', () => {
const commandService = injector.get(ICommandService);
const fWorksheet = createFacade(injector);
const image = createDrawing('image-1', 10, DrawingTypeEnum.DRAWING_IMAGE);
const shape = createDrawing('shape-1', 80, DrawingTypeEnum.DRAWING_SHAPE);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings: [image, shape] })).toBe(true);
expect(fWorksheet.getImages().map((drawing) => drawing.getId())).toEqual(['image-1']);
injector.get(ISheetDrawingService).focusDrawing([{
unitId: 'test',
subUnitId: 'sheet1',
drawingId: 'image-1',
}]);
expect(fWorksheet.getActiveImages().map((drawing) => drawing.getId())).toEqual(['image-1']);
});
it('keeps grouping APIs inert when requested drawings or groups do not exist', () => {
const commandService = injector.get(ICommandService);
const fWorksheet = createFacade(injector);
const image = createDrawing('drawing-1', 10);
expect(commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: 'test', drawings: [image] })).toBe(true);
expect(fWorksheet.groupDrawings(['drawing-1', 'missing-drawing'], 'group-1')).toBeNull();
expect(fWorksheet.ungroupDrawings(['missing-group'])).toBe(false);
expect(fWorksheet.getDrawingGroupChildren('missing-group', true)).toEqual([]);
expect(fWorksheet.getDrawingParentGroup('drawing-1')).toBeNull();
});
});
function createFacade(injector: Injector): FWorksheetDrawingMixin {
const workbook = injector.get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getSheetBySheetId('sheet1')!;
return injector.createInstance(FWorksheetDrawingMixin, { getId: () => 'test' } as FWorkbook, workbook, worksheet);
}
function createDrawing(drawingId: string, left: number, drawingType = DrawingTypeEnum.DRAWING_SHAPE): ISheetDrawing {
return {
unitId: 'test',
subUnitId: 'sheet1',
drawingId,
drawingType,
transform: {
left,
top: 20,
width: 40,
height: 30,
},
sheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 3, rowOffset: 0, columnOffset: 0 },
},
axisAlignSheetTransform: {
from: { row: 1, column: 1, rowOffset: 0, columnOffset: 0 },
to: { row: 3, column: 3, rowOffset: 0, columnOffset: 0 },
},
};
}

View File

@@ -0,0 +1,42 @@
/**
* 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 { DrawingTypeEnum } from '@univerjs/core';
import { FEnum } from '@univerjs/core/facade';
import { SheetDrawingAnchorType } from '@univerjs/sheets-drawing';
/**
* @ignore
*/
export interface IFSheetsDrawingEnumMixin {
/** Please refer to {@link DrawingTypeEnum}. */
DrawingType: typeof DrawingTypeEnum;
/** Please refer to {@link SheetDrawingAnchorType}. */
SheetDrawingAnchorType: typeof SheetDrawingAnchorType;
}
export class FSheetsDrawingEnumMixin extends FEnum implements IFSheetsDrawingEnumMixin {
override get DrawingType(): typeof DrawingTypeEnum { return DrawingTypeEnum; };
override get SheetDrawingAnchorType(): typeof SheetDrawingAnchorType { return SheetDrawingAnchorType; };
}
FEnum.extend(FSheetsDrawingEnumMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEnum extends IFSheetsDrawingEnumMixin { }
}

View File

@@ -0,0 +1,452 @@
/**
* 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 { IEventBase } from '@univerjs/core/facade';
import type { ISheetImage } from '@univerjs/sheets-drawing';
import type { FWorkbook } from '@univerjs/sheets/facade';
import type { FOverGridImage } from './f-over-grid-image';
import { FEventName } from '@univerjs/core/facade';
/**
* @ignore
*/
interface IFSheetsDrawingEventNameMixin {
/**
* Triggered before float dom insertion.
* @see {@link IBeforeFloatDomAddParam}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeFloatDomAdd, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* // Cancel the insertion operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeFloatDomAdd: 'BeforeFloatDomAdd';
/**
* Triggered after float dom insertion.
* @see {@link IFloatDomAddedParam}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.FloatDomAdded, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly FloatDomAdded: 'FloatDomAdded';
/**
* Triggered before float dom update.
* @see {@link IBeforeFloatDomUpdateParam}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeFloatDomUpdate, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* // Cancel the update operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeFloatDomUpdate: 'BeforeFloatDomUpdate';
/**
* Triggered after float dom update.
* @see {@link IFloatDomUpdatedParam}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.FloatDomUpdated, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly FloatDomUpdated: 'FloatDomUpdated';
/**
* Triggered before float dom deletion.
* @see {@link IBeforeFloatDomDeleteParam}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeFloatDomDelete, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* // Cancel the deletion operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeFloatDomDelete: 'BeforeFloatDomDelete';
/**
* Triggered after float dom deletion.
* @see {@link IFloatDomDeletedParam}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.FloatDomDeleted, (params) => {
* console.log(params);
* // do something
* const { workbook, drawings } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly FloatDomDeleted: 'FloatDomDeleted';
/**
* Triggered before floating image insertion.
* @see {@link IBeforeOverGridImageInsertEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageInsert, (params) => {
* console.log(params);
* // do something
* const { workbook, insertImageParams } = params;
* // Cancel the insertion operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeOverGridImageInsert: 'BeforeOverGridImageInsert';
/**
* Triggered after floating image insertion.
* @see {@link IOverGridImageInsertedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.OverGridImageInserted, (params) => {
* console.log(params);
* // do something
* const { workbook, images } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly OverGridImageInserted: 'OverGridImageInserted';
/**
* Triggered before floating image removal.
* @see {@link IBeforeOverGridImageRemoveEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageRemove, (params) => {
* console.log(params);
* // do something
* const { workbook, images } = params;
* // Cancel the removal operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeOverGridImageRemove: 'BeforeOverGridImageRemove';
/**
* Triggered after floating image removal.
* @see {@link IOverGridImageRemovedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.OverGridImageRemoved, (params) => {
* console.log(params);
* // do something
* const { workbook, removeImageParams } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly OverGridImageRemoved: 'OverGridImageRemoved';
/**
* Triggered before floating image change.
* @see {@link IBeforeOverGridImageChangeEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageChange, (params) => {
* console.log(params);
* // do something
* const { workbook, images } = params;
* // Cancel the image change
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeOverGridImageChange: 'BeforeOverGridImageChange';
/**
* Triggered after floating image change.
* @see {@link IOverGridImageChangedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.OverGridImageChanged, (params) => {
* console.log(params);
* // do something
* const { workbook, images } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly OverGridImageChanged: 'OverGridImageChanged';
/**
* Triggered before floating image selection.
* @see {@link IBeforeOverGridImageSelectEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeOverGridImageSelect, (params) => {
* console.log(params);
* // do something
* const { workbook, selectedImages, oldSelectedImages } = params;
* // cancel the selection operation
* params.cancel = true;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeOverGridImageSelect: 'BeforeOverGridImageSelect';
/**
* Triggered after floating image selection.
* @see {@link IOverGridImageSelectedEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.OverGridImageSelected, (params) => {
* console.log(params);
* // do something
* const { workbook, selectedImages } = params;
* })
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly OverGridImageSelected: 'OverGridImageSelected';
}
export class FSheetsDrawingEventNameMixin extends FEventName implements IFSheetsDrawingEventNameMixin {
override get BeforeFloatDomAdd(): 'BeforeFloatDomAdd' {
return 'BeforeFloatDomAdd' as const;
}
override get FloatDomAdded(): 'FloatDomAdded' {
return 'FloatDomAdded' as const;
}
override get BeforeFloatDomUpdate(): 'BeforeFloatDomUpdate' {
return 'BeforeFloatDomUpdate' as const;
}
override get FloatDomUpdated(): 'FloatDomUpdated' {
return 'FloatDomUpdated' as const;
}
override get BeforeFloatDomDelete(): 'BeforeFloatDomDelete' {
return 'BeforeFloatDomDelete' as const;
}
override get FloatDomDeleted(): 'FloatDomDeleted' {
return 'FloatDomDeleted' as const;
}
override get BeforeOverGridImageChange(): 'BeforeOverGridImageChange' {
return 'BeforeOverGridImageChange' as const;
}
override get OverGridImageChanged(): 'OverGridImageChanged' {
return 'OverGridImageChanged' as const;
}
override get BeforeOverGridImageInsert(): 'BeforeOverGridImageInsert' {
return 'BeforeOverGridImageInsert' as const;
}
override get OverGridImageInserted(): 'OverGridImageInserted' {
return 'OverGridImageInserted' as const;
}
override get BeforeOverGridImageRemove(): 'BeforeOverGridImageRemove' {
return 'BeforeOverGridImageRemove' as const;
}
override get OverGridImageRemoved(): 'OverGridImageRemoved' {
return 'OverGridImageRemoved' as const;
}
override get BeforeOverGridImageSelect(): 'BeforeOverGridImageSelect' {
return 'BeforeOverGridImageSelect' as const;
}
override get OverGridImageSelected(): 'OverGridImageSelected' {
return 'OverGridImageSelected' as const;
}
}
export interface IBeforeOverGridImageChangeParamObject {
/**
* Parameters of the image to be updated. {@link ISheetImage}
*/
changeParam: Partial<ISheetImage>;
/**
* Instance of the image to be updated. {@link FOverGridImage}
*/
image: FOverGridImage;
}
export interface IBeforeOverGridImageChangeEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of parameters of the images to be updated, there may be multiple images to be updated. {@link IBeforeOverGridImageChangeParamObject}
*/
images: IBeforeOverGridImageChangeParamObject[];
}
export interface IOverGridImageChangedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Instances of the images after the update. {@link FOverGridImage}
*/
images: FOverGridImage[];
}
export interface IBeforeOverGridImageInsertEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of parameters of the images to be inserted. {@link ISheetImage}
*/
insertImageParams: ISheetImage[];
}
export interface IOverGridImageInsertedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of instances of the images after insertion. {@link FOverGridImage}
*/
images: FOverGridImage[];
}
export interface IBeforeOverGridImageRemoveEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of instances of the images to be removed. {@link FOverGridImage}
*/
images: FOverGridImage[];
}
export interface IOverGridImageRemovedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* Collection of parameters of the images after removal. {@link ISheetImage}
*/
removeImageParams: IDrawingSearch[];
}
export interface IBeforeOverGridImageSelectEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* The images before selection. {@link FOverGridImage}
*/
oldSelectedImages: FOverGridImage[];
selectedImages: FOverGridImage[];
}
export interface IOverGridImageSelectedEventParams extends IEventBase {
/**
* The workbook instance currently being operated on. {@link FWorkbook}
*/
workbook: FWorkbook;
/**
* The selected images. {@link FOverGridImage}
*/
selectedImages: FOverGridImage[];
}
interface ISheetsDrawingEventParamConfig {
BeforeOverGridImageChange: IBeforeOverGridImageChangeEventParams;
OverGridImageChanged: IOverGridImageChangedEventParams;
BeforeOverGridImageInsert: IBeforeOverGridImageInsertEventParams;
OverGridImageInserted: IOverGridImageInsertedEventParams;
BeforeOverGridImageRemove: IBeforeOverGridImageRemoveEventParams;
OverGridImageRemoved: IOverGridImageRemovedEventParams;
BeforeOverGridImageSelect: IBeforeOverGridImageSelectEventParams;
OverGridImageSelected: IOverGridImageSelectedEventParams;
}
FEventName.extend(FSheetsDrawingEventNameMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEventName extends IFSheetsDrawingEventNameMixin { }
interface IEventParamConfig extends ISheetsDrawingEventParamConfig { }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
/**
* 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, Injector } from '@univerjs/core';
import type { IInsertSheetDrawingCommandParams, IRemoveSheetDrawingCommandParams, ISetDrawingCommandParams, ISheetImage } from '@univerjs/sheets-drawing';
import type {
IBeforeOverGridImageChangeEventParams,
IBeforeOverGridImageChangeParamObject,
IBeforeOverGridImageInsertEventParams,
IBeforeOverGridImageRemoveEventParams,
IBeforeOverGridImageSelectEventParams,
IOverGridImageChangedEventParams,
IOverGridImageInsertedEventParams,
IOverGridImageRemovedEventParams,
IOverGridImageSelectedEventParams,
} from './f-event';
import { CanceledError, ICommandService } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { InsertSheetDrawingCommand, RemoveSheetDrawingCommand, SetSheetDrawingCommand } from '@univerjs/sheets-drawing';
import { FOverGridImage } from './f-over-grid-image';
/**
* @ignore
*/
export class FUniverSheetsDrawingMixin extends FUniver {
/**
* @ignore
*/
// eslint-disable-next-line max-lines-per-function
override _initialize(injector: Injector): void {
const commandService = injector.get(ICommandService);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeOverGridImageInsert,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== InsertSheetDrawingCommand.id) return;
const params = commandInfo.params as IInsertSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const eventParams: IBeforeOverGridImageInsertEventParams = {
workbook,
insertImageParams: drawings as ISheetImage[],
};
this.fireEvent(this.Event.BeforeOverGridImageInsert, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeOverGridImageRemove,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== RemoveSheetDrawingCommand.id) return;
const params = commandInfo.params as IRemoveSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const drawingManagerService = injector.get(IDrawingManagerService);
const { drawings } = params;
const willRemoveDrawings = drawings.map((drawing) => {
return drawingManagerService.getDrawingByParam(drawing);
}) as ISheetImage[];
const eventParams: IBeforeOverGridImageRemoveEventParams = {
workbook,
images: this._createFOverGridImage(willRemoveDrawings),
};
this.fireEvent(this.Event.BeforeOverGridImageRemove, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeOverGridImageChange,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== SetSheetDrawingCommand.id) return;
const params = commandInfo.params as ISetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const drawingManagerService = injector.get(IDrawingManagerService);
const images: IBeforeOverGridImageChangeParamObject[] = [];
drawings.forEach((drawing) => {
const image = drawingManagerService.getDrawingByParam(drawing as IDrawingSearch) as ISheetImage;
if (image == null) {
return;
}
images.push({
changeParam: drawing,
image: this._injector.createInstance(FOverGridImage, image),
});
});
const eventParams: IBeforeOverGridImageChangeEventParams = {
workbook,
images,
};
this.fireEvent(this.Event.BeforeOverGridImageChange, eventParams);
if (eventParams.cancel) {
drawingManagerService.updateNotification(drawings as IDrawingSearch[]);
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeOverGridImageSelect,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== SetDrawingSelectedOperation.id) return;
const drawings = commandInfo.params as IDrawingSearch[];
const workbook = this.getActiveWorkbook();
if (workbook == null) {
return;
}
const drawingManagerService = injector.get(IDrawingManagerService);
const oldSelectedDrawings = drawingManagerService.getFocusDrawings() as ISheetImage[];
const selectedDrawings = drawings.map((drawing) => {
return drawingManagerService.getDrawingByParam(drawing);
}) as ISheetImage[];
const eventParams: IBeforeOverGridImageSelectEventParams = {
workbook,
selectedImages: this._createFOverGridImage(selectedDrawings),
oldSelectedImages: this._createFOverGridImage(oldSelectedDrawings),
};
this.fireEvent(this.Event.BeforeOverGridImageSelect, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.OverGridImageInserted,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== InsertSheetDrawingCommand.id) return;
const params = commandInfo.params as IInsertSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const eventParams: IOverGridImageInsertedEventParams = {
workbook,
images: this._createFOverGridImage(drawings as ISheetImage[]),
};
this.fireEvent(this.Event.OverGridImageInserted, eventParams);
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.OverGridImageRemoved,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== RemoveSheetDrawingCommand.id) return;
const params = commandInfo.params as IRemoveSheetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const eventParams: IOverGridImageRemovedEventParams = {
workbook,
removeImageParams: drawings,
};
this.fireEvent(this.Event.OverGridImageRemoved, eventParams);
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.OverGridImageChanged,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== SetSheetDrawingCommand.id) return;
const params = commandInfo.params as ISetDrawingCommandParams;
const workbook = this.getActiveWorkbook();
if (workbook == null || params == null) {
return;
}
const { drawings } = params;
const drawingManagerService = injector.get(IDrawingManagerService);
const images = drawings.map((drawing) => {
return this._injector.createInstance(FOverGridImage, drawingManagerService.getDrawingByParam(drawing as IDrawingSearch) as ISheetImage);
});
const eventParams: IOverGridImageChangedEventParams = {
workbook,
images,
};
this.fireEvent(this.Event.OverGridImageChanged, eventParams);
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.OverGridImageSelected,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== SetDrawingSelectedOperation.id) return;
const drawings = commandInfo.params as IDrawingSearch[];
const workbook = this.getActiveWorkbook();
if (workbook == null) {
return;
}
const drawingManagerService = injector.get(IDrawingManagerService);
const selectedDrawings = drawings.map((drawing) => {
return drawingManagerService.getDrawingByParam(drawing);
}) as ISheetImage[];
const eventParams: IOverGridImageSelectedEventParams = {
workbook,
selectedImages: this._createFOverGridImage(selectedDrawings as ISheetImage[]),
};
this.fireEvent(this.Event.OverGridImageSelected, eventParams);
})
)
);
}
private _createFOverGridImage(drawings: ISheetImage[]): FOverGridImage[] {
return drawings.map((drawing) => {
return this._injector.createInstance(FOverGridImage, drawing);
});
}
}
FUniver.extend(FUniverSheetsDrawingMixin);

View File

@@ -0,0 +1,923 @@
/**
* 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 { IDrawingParam, IGroupBaseBound } from '@univerjs/core';
import type { IFBlobSource } from '@univerjs/core/facade';
import type { IDrawingGroupUpdateParam, IDrawingJsonUndo1 } from '@univerjs/drawing';
import type { ISheetDrawing, ISheetDrawingPlacement, ISheetDrawingPlacementInput, ISheetImage } from '@univerjs/sheets-drawing';
import { DrawingTypeEnum, generateRandomId, ImageSourceType, IUndoRedoService } from '@univerjs/core';
import { isGroupableDrawingType } from '@univerjs/drawing';
import { getGroupState, transformObjectOutOfGroup } from '@univerjs/engine-render';
import { SheetSkeletonService } from '@univerjs/sheets';
import { DrawingApplyType, getSheetDrawingPlacement, InsertSheetDrawingCommand, ISheetDrawingService, normalizeSheetDrawingPlacement, RemoveSheetDrawingCommand, SetDrawingApplyMutation, SetSheetDrawingCommand, SetSheetDrawingPlacementCommand, SheetDrawingAnchorType } from '@univerjs/sheets-drawing';
import { FWorksheet } from '@univerjs/sheets/facade';
import { FOverGridImage, FOverGridImageBuilder } from './f-over-grid-image';
export interface ISheetDrawingLayoutObject {
drawingId: string;
drawingType: DrawingTypeEnum;
bounds: IGroupBaseBound;
placement: ISheetDrawingPlacement;
groupId?: string;
}
export interface ISheetDrawingLayout {
/** Entire Sheet grid in model coordinates. */
gridBounds: IGroupBaseBound;
/** Cell content range in model coordinates. */
dataBounds: IGroupBaseBound;
/** Drawings ordered from back to front. */
drawings: ISheetDrawingLayoutObject[];
}
/**
* @ignore
*/
export interface IFWorksheetDrawingMixin {
/**
* Insert an image to the sheet
* @param {string} url - The image url
* @returns {Promise<boolean>} true if the image is inserted successfully
* @example
* ```ts
* // Insert an image to the sheet, default position is A1
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const result = await fWorksheet.insertImage('https://avatars.githubusercontent.com/u/61444807?s=48&v=4');
* console.log(result);
* ```
*/
insertImage(url: string): Promise<boolean>;
/**
* @param {string} url - The image url
* @param {number} column - The column index to insert the image
* @param {number} row - The row index to insert the image
* @returns {Promise<boolean>} True if the image is inserted successfully
* @example
* ```ts
* // Insert an image to the sheet, position is F6
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const result = await fWorksheet.insertImage('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', 5, 5);
* console.log(result);
* ```
*/
insertImage(url: string, column: number, row: number): Promise<boolean>;
/**
* @param {string} url - The image url
* @param {number} column - The column index to insert the image
* @param {number} row - The row index to insert the image
* @param {number} offsetX - The column offset, pixel unit
* @param {number} offsetY - The row offset, pixel unit
* @returns {Promise<boolean>} True if the image is inserted successfully
* @example
* ```ts
* // Insert an image to the sheet, position is F6, offset is 10px
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const result = await fWorksheet.insertImage('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', 5, 5, 10, 10);
* console.log(result);
* ```
*/
insertImage(url: string, column: number, row: number, offsetX: number, offsetY: number): Promise<boolean>;
insertImage(url: IFBlobSource): Promise<boolean>;
insertImage(url: IFBlobSource, column: number, row: number): Promise<boolean>;
insertImage(url: IFBlobSource, column: number, row: number, offsetX: number, offsetY: number): Promise<boolean>;
insertImage(url: IFBlobSource | string, column: number, row: number, offsetX: number, offsetY: number): Promise<boolean>;
/**
* Insert images to the sheet
* @param {ISheetImage[]} sheetImages - The images to insert
* @returns {FWorksheet} The FWorksheet instance for chaining
* @example
* ```ts
* // create a new image builder and set image source.
* // then build `ISheetImage` and insert it into the sheet, position is start from F6 cell, width is 500px, height is 300px
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const image = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(5)
* .setRow(5)
* .setWidth(500)
* .setHeight(300)
* .buildAsync();
* fWorksheet.insertImages([image]);
*
* // update the image width to 100px and height to 50px
* setTimeout(async () => {
* const imageBuilder = fWorksheet.getImageById(image.drawingId).toBuilder();
* const newImage = await imageBuilder.setWidth(100).setHeight(50).buildAsync();
* fWorksheet.updateImages([newImage]);
* }, 4000);
* ```
*/
insertImages(sheetImages: ISheetImage[]): FWorksheet;
/**
* Get all images of the sheet.
* @returns {FOverGridImage[]} The FOverGridImage instances
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const images = fWorksheet.getImages();
* images.forEach((image) => {
* console.log(image, image.getId());
* });
* ```
*/
getImages(): FOverGridImage[];
/**
* Get image by drawing id
* @param {string} id - The drawing id of the image
* @returns {FOverGridImage | null} The FOverGridImage instance
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const image = fWorksheet.getImageById('xxxx');
* console.log(image);
* ```
*/
getImageById(id: string): FOverGridImage | null;
/**
* Delete images from the sheet
* @param {FOverGridImage[]} sheetImages - The images to delete
* @returns {FWorksheet} The FWorksheet instance for chaining
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const image = fWorksheet.getImages()[0];
*
* // Delete the first image of the sheet
* fWorksheet.deleteImages([image]);
* ```
*/
deleteImages(sheetImages: FOverGridImage[]): FWorksheet;
/**
* Update images to the sheet
* @param {ISheetImage[]} sheetImages - The images to update
* @returns {FWorksheet} The FWorksheet instance for chaining
* @example
* ```ts
* // create a new image builder and set image source.
* // then build `ISheetImage` and insert it into the sheet, position is start from F6 cell, width is 500px, height is 300px
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const image = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(5)
* .setRow(5)
* .setWidth(500)
* .setHeight(300)
* .buildAsync();
* fWorksheet.insertImages([image]);
*
* // update the image width to 100px and height to 50px after 4 seconds
* setTimeout(async () => {
* const imageBuilder = fWorksheet.getImageById(image.drawingId).toBuilder();
* const newImage = await imageBuilder.setWidth(100).setHeight(50).buildAsync();
* fWorksheet.updateImages([newImage]);
* }, 4000);
* ```
*/
updateImages(sheetImages: ISheetImage[]): FWorksheet;
/**
* Get the placement of any drawing on this sheet.
*
* Image, Shape, Chart, and Group use the same placement contract.
* @param {string} drawingId Drawing id.
* @returns {ISheetDrawingPlacement | null} The placement, or `null` when the drawing does not exist.
* @example
* ```ts
* const sheet = univerAPI.getActiveWorkbook().getActiveSheet();
* const placement = sheet.getDrawingPlacement('drawing-id');
* if (placement?.kind === univerAPI.Enum.SheetDrawingAnchorType.Both) {
* console.log(placement.from, placement.to);
* }
* ```
*/
getDrawingPlacement(drawingId: string): ISheetDrawingPlacement | null;
/**
* Set the placement of any drawing on this sheet through the drawing command.
*
* @param {string} drawingId Drawing id.
* @param {ISheetDrawingPlacementInput} placement Exact markers or model-space bounds with an explicit anchor type.
* @returns {boolean} `true` when the command succeeds.
* @example OneCell: move with cells, keep pixel size
* ```ts
* const sheet = univerAPI.getActiveWorkbook().getActiveSheet();
* const drawingId = sheet.getImages()[0]?.getId();
* if (!drawingId) throw new Error('No drawing found.');
* const changed = sheet.setDrawingPlacement(drawingId, {
* kind: univerAPI.Enum.SheetDrawingAnchorType.Position,
* from: { row: 2, column: 2, rowOffset: 8, columnOffset: 8 },
* width: 240,
* height: 120,
* });
* console.log(changed);
* ```
* @example TwoCell: move and resize with both cell markers
* ```ts
* const sheet = univerAPI.getActiveWorkbook().getActiveSheet();
* const drawingId = sheet.getImages()[0]?.getId();
* if (!drawingId) throw new Error('No drawing found.');
* sheet.setDrawingPlacement(drawingId, {
* kind: univerAPI.Enum.SheetDrawingAnchorType.Both,
* from: { row: 2, column: 2, rowOffset: 8, columnOffset: 8 },
* to: { row: 8, column: 6, rowOffset: 0, columnOffset: 0 },
* });
* ```
* @example Absolute: do not move or resize after row or column changes
* ```ts
* const sheet = univerAPI.getActiveWorkbook().getActiveSheet();
* const drawingId = sheet.getImages()[0]?.getId();
* if (!drawingId) throw new Error('No drawing found.');
* sheet.setDrawingPlacement(drawingId, {
* kind: univerAPI.Enum.SheetDrawingAnchorType.None,
* left: 640,
* top: 96,
* width: 240,
* height: 120,
* });
* ```
*/
setDrawingPlacement(drawingId: string, placement: ISheetDrawingPlacementInput): boolean;
/**
* Resolve exact markers or model-space bounds to a normalized Placement.
*
* Bounds inference is usually preferable when positioning from an existing
* transform. Exact markers are useful when the caller must preserve a
* user-selected cell and offset. `Position` maps to OOXML OneCell,
* `Both` maps to TwoCell, and `None` maps to Absolute.
* @param {ISheetDrawingPlacementInput} placement Exact markers or bounds with an explicit anchor type.
* @returns {ISheetDrawingPlacement} The normalized Placement.
* @example Infer Position and Both placements from bounds in Node/headless
* ```ts
* const sheet = univerAPI.getActiveWorkbook().getActiveSheet();
* const positionPlacement = sheet.resolveDrawingPlacement({
* kind: univerAPI.Enum.SheetDrawingAnchorType.Position,
* bounds: { left: 120, top: 80, width: 320, height: 160 },
* });
* const bothPlacement = sheet.resolveDrawingPlacement({
* kind: univerAPI.Enum.SheetDrawingAnchorType.Both,
* bounds: { left: 120, top: 80, width: 320, height: 160 },
* });
* console.log(positionPlacement.from);
* console.log(bothPlacement.from, bothPlacement.to);
* ```
*/
resolveDrawingPlacement(placement: ISheetDrawingPlacementInput): ISheetDrawingPlacement;
/**
* Get the Sheet host layout in model coordinates.
*
* This API is available in Node/headless and does not include viewport,
* scroll, zoom, frozen-pane clipping, or screen coordinates.
* @returns {ISheetDrawingLayout} Grid, data, and ordered Drawing bounds.
* @example
* ```ts
* const sheet = univerAPI.getActiveWorkbook().getActiveSheet();
* const layout = sheet.getDrawingLayout();
* console.log(layout.gridBounds, layout.dataBounds);
* for (const drawing of layout.drawings) {
* console.log(drawing.drawingId, drawing.bounds);
* }
* ```
*/
getDrawingLayout(): ISheetDrawingLayout;
/**
* Get the current selected images.
* @returns {FOverGridImage[]} The FOverGridImage instances
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const images = fWorksheet.getActiveImages();
* images.forEach((image) => {
* console.log(image, image.getId());
* });
* ```
*/
getActiveImages(): FOverGridImage[];
/**
* Create a new over grid image builder.
* @returns {FOverGridImageBuilder} The FOverGridImageBuilder instance
* @example
* ```ts
* // create a new image builder and set image source.
* // then build `ISheetImage` and insert it into the sheet, position is start from F6 cell, width is 500px, height is 300px
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const image = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(5)
* .setRow(5)
* .setWidth(500)
* .setHeight(300)
* .buildAsync();
* fWorksheet.insertImages([image]);
* ```
*/
newOverGridImage(): FOverGridImageBuilder;
/**
* Group drawings on the current sheet.
* @param {string[]} drawingIds - The drawing ids to group. At least two drawings are required.
* @param {string} [groupId] - Optional group drawing id. If omitted, a new id will be generated.
* @returns {string | null} The group id if the operation succeeds, otherwise null.
* @example
* ```ts
* // Group two over-grid images on the active sheet.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
* const image1 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(1)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* const image2 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(3)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* fWorksheet.insertImages([image1, image2]);
* const groupId = fWorksheet.groupDrawings([image1.drawingId, image2.drawingId]);
* console.log(groupId);
* ```
*/
groupDrawings(drawingIds: string[], groupId?: string): string | null;
/**
* Ungroup drawing groups on the current sheet.
* @param {string[]} groupIds - The group drawing ids to ungroup.
* @returns {boolean} true if the operation succeeds, otherwise false.
* @example
* ```ts
* // Group two images, then ungroup the generated group.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
* const image1 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(1)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* const image2 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(3)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* fWorksheet.insertImages([image1, image2]);
* const groupId = fWorksheet.groupDrawings([image1.drawingId, image2.drawingId]);
* if (groupId) {
* const result = fWorksheet.ungroupDrawings([groupId]);
* console.log(result);
* }
* ```
*/
ungroupDrawings(groupIds: string[]): boolean;
/**
* Get children of a drawing group on the current sheet.
* @param {string} groupId - The group drawing id.
* @param {boolean} [recursive] - Whether to return all descendants.
* @returns {ISheetDrawing[]} The child drawings.
* @example
* ```ts
* // Get the direct children of a drawing group.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
* const image1 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(1)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* const image2 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(3)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* fWorksheet.insertImages([image1, image2]);
* const groupId = fWorksheet.groupDrawings([image1.drawingId, image2.drawingId]);
* if (groupId) {
* const children = fWorksheet.getDrawingGroupChildren(groupId);
* console.log(children.map((drawing) => drawing.drawingId));
* }
* ```
*/
getDrawingGroupChildren(groupId: string, recursive?: boolean): ISheetDrawing[];
/**
* Get the parent group of a drawing on the current sheet.
* @param {string} drawingId - The child drawing id.
* @returns {ISheetDrawing | null} The parent group drawing, or null if the drawing is not grouped.
* @example
* ```ts
* // Get the parent group of a drawing.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
* const image1 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(1)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* const image2 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(3)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* fWorksheet.insertImages([image1, image2]);
* fWorksheet.groupDrawings([image1.drawingId, image2.drawingId]);
* const parentGroup = fWorksheet.getDrawingParentGroup(image1.drawingId);
* console.log(parentGroup?.drawingId);
* ```
*/
getDrawingParentGroup(drawingId: string): ISheetDrawing | null;
/**
* Returns whether a drawing is inside a group on the current sheet.
* @param {string} drawingId - The drawing id.
* @returns {boolean} true if the drawing has a parent group.
* @example
* ```ts
* // Check whether a drawing is inside a group.
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getActiveSheet();
* const image1 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(1)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* const image2 = await fWorksheet.newOverGridImage()
* .setSource('https://avatars.githubusercontent.com/u/61444807?s=48&v=4', univerAPI.Enum.ImageSourceType.URL)
* .setColumn(3)
* .setRow(1)
* .setWidth(100)
* .setHeight(100)
* .buildAsync();
* fWorksheet.insertImages([image1, image2]);
* fWorksheet.groupDrawings([image1.drawingId, image2.drawingId]);
* const isGrouped = fWorksheet.isDrawingGrouped(image1.drawingId);
* console.log(isGrouped);
* ```
*/
isDrawingGrouped(drawingId: string): boolean;
}
export class FWorksheetDrawingMixin extends FWorksheet implements IFWorksheetDrawingMixin {
private get _sheetDrawingService(): ISheetDrawingService {
return this._injector.get(ISheetDrawingService);
}
private get _sheetSkeletonService(): SheetSkeletonService {
return this._injector.get(SheetSkeletonService);
}
private get _undoRedoService(): IUndoRedoService {
return this._injector.get(IUndoRedoService);
}
override async insertImage(url: IFBlobSource | string, column?: number, row?: number, offsetX?: number, offsetY?: number): Promise<boolean> {
const imageBuilder = this.newOverGridImage();
if (typeof url === 'string') {
imageBuilder.setSource(url);
} else {
const blobSource = url.getBlob();
const base64 = await blobSource.getDataAsString();
imageBuilder.setSource(base64, ImageSourceType.BASE64);
}
if (column !== undefined) {
imageBuilder.setColumn(column);
} else {
imageBuilder.setColumn(0);
}
if (row !== undefined) {
imageBuilder.setRow(row);
} else {
imageBuilder.setRow(0);
}
if (offsetX !== undefined) {
imageBuilder.setColumnOffset(offsetX);
} else {
imageBuilder.setColumnOffset(0);
}
if (offsetY !== undefined) {
imageBuilder.setRowOffset(offsetY);
} else {
imageBuilder.setRowOffset(0);
}
const param = await imageBuilder.buildAsync();
// param.drawingId
return this._commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: this._fWorkbook.getId(), drawings: [param] });
}
override insertImages(sheetImages: ISheetImage[]): FWorksheet {
const param = sheetImages.map((image) => {
image.unitId = this._fWorkbook.getId();
image.subUnitId = this.getSheetId();
return image;
});
this._commandService.syncExecuteCommand(InsertSheetDrawingCommand.id, { unitId: this._fWorkbook.getId(), drawings: param });
return this;
}
override deleteImages(sheetImages: FOverGridImage[]): FWorksheet {
const drawings = sheetImages.map((image) => {
return {
unitId: this._fWorkbook.getId(),
drawingId: image.getId(),
subUnitId: this.getSheetId(),
drawingType: image.getType(),
};
});
this._commandService.syncExecuteCommand(RemoveSheetDrawingCommand.id, { unitId: this._fWorkbook.getId(), drawings });
return this;
}
override getImages(): FOverGridImage[] {
const sheetDrawingService = this._sheetDrawingService;
const drawingData = sheetDrawingService.getDrawingData(this._fWorkbook.getId(), this.getSheetId());
const images: FOverGridImage[] = [];
for (const drawingId in drawingData) {
const drawing = drawingData[drawingId];
if (drawing.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
continue;
}
images.push(this._injector.createInstance(FOverGridImage, drawing as ISheetImage));
}
return images;
}
override getImageById(id: string): FOverGridImage | null {
const sheetDrawingService = this._sheetDrawingService;
const drawing = sheetDrawingService.getDrawingByParam({ unitId: this._fWorkbook.getId(), subUnitId: this.getSheetId(), drawingId: id });
if (drawing && drawing.drawingType === DrawingTypeEnum.DRAWING_IMAGE) {
return this._injector.createInstance(FOverGridImage, drawing as ISheetImage);
}
return null;
}
override getActiveImages(): FOverGridImage[] {
const sheetDrawingService = this._sheetDrawingService;
const drawingData = sheetDrawingService.getFocusDrawings();
const images: FOverGridImage[] = [];
for (const drawingId in drawingData) {
const drawing = drawingData[drawingId];
images.push(this._injector.createInstance(FOverGridImage, drawing as ISheetImage));
}
return images;
}
override updateImages(sheetImages: ISheetImage[]): FWorksheet {
this._commandService.syncExecuteCommand(SetSheetDrawingCommand.id, { unitId: this._fWorkbook.getId(), drawings: sheetImages });
return this;
}
override getDrawingPlacement(drawingId: string): ISheetDrawingPlacement | null {
const drawing = this._sheetDrawingService.getDrawingByParam({
unitId: this._fWorkbook.getId(),
subUnitId: this.getSheetId(),
drawingId,
});
return drawing ? getSheetDrawingPlacement(drawing) : null;
}
override setDrawingPlacement(drawingId: string, placement: ISheetDrawingPlacementInput): boolean {
return this._commandService.syncExecuteCommand(SetSheetDrawingPlacementCommand.id, {
unitId: this._fWorkbook.getId(),
subUnitId: this.getSheetId(),
drawings: [{ drawingId, placement }],
});
}
override resolveDrawingPlacement(placement: ISheetDrawingPlacementInput): ISheetDrawingPlacement {
const skeleton = placement.kind === SheetDrawingAnchorType.None
? undefined
: this._sheetSkeletonService.ensureSkeleton(this._fWorkbook.getId(), this.getSheetId());
if (placement.kind !== SheetDrawingAnchorType.None && !skeleton) {
throw new Error('SHEET_DRAWING_PLACEMENT_SKELETON_REQUIRED');
}
return normalizeSheetDrawingPlacement(placement, skeleton);
}
override getDrawingLayout(): ISheetDrawingLayout {
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
const skeleton = this._sheetSkeletonService.ensureSkeleton(unitId, subUnitId);
if (!skeleton) {
throw new Error('SHEET_DRAWING_PLACEMENT_SKELETON_REQUIRED');
}
const { startRow, endRow, startColumn, endColumn } = this._worksheet.getDataRealRange();
const startCell = skeleton.getCellWithCoordByIndex(startRow, startColumn, false);
const endCell = skeleton.getCellWithCoordByIndex(endRow, endColumn, false);
const drawingService = this._sheetDrawingService;
const drawingData = drawingService.getDrawingData(unitId, subUnitId);
const orderedIds = drawingService.getDrawingOrder(unitId, subUnitId);
const drawingIds = [...orderedIds, ...Object.keys(drawingData).filter((drawingId) => !orderedIds.includes(drawingId))];
const drawings: ISheetDrawingLayoutObject[] = [];
for (const drawingId of drawingIds) {
const drawing = drawingData[drawingId];
const bounds = drawing && getDrawingBounds(drawing);
if (!drawing || !bounds) {
continue;
}
drawings.push({
drawingId,
drawingType: drawing.drawingType,
bounds,
placement: getSheetDrawingPlacement(drawing),
groupId: drawing.groupId,
});
}
return {
gridBounds: {
left: 0,
top: 0,
width: skeleton.columnTotalWidth,
height: skeleton.rowTotalHeight,
},
dataBounds: {
left: startCell.startX,
top: startCell.startY,
width: endCell.endX - startCell.startX,
height: endCell.endY - startCell.startY,
},
drawings,
};
}
override newOverGridImage(): FOverGridImageBuilder {
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
return this._injector.createInstance(FOverGridImageBuilder, unitId, subUnitId);
}
override groupDrawings(drawingIds: string[], groupId = generateRandomId(10)): string | null {
const uniqueDrawingIds = Array.from(new Set(drawingIds));
if (uniqueDrawingIds.length < 2) return null;
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
const sheetDrawingService = this._sheetDrawingService;
if (sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId: groupId })) return null;
const drawings = uniqueDrawingIds.map((drawingId) => sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId }));
if (drawings.some((drawing) => !drawing)) return null;
if (drawings.some((drawing) => !isGroupableDrawingType(drawing!.drawingType))) return null;
const validDrawings = drawings as ISheetDrawing[];
const groupTransform = getGroupState(0, 0, validDrawings.map((drawing) => drawing.transform || {}));
const groupParam = {
unitId,
subUnitId,
drawingId: groupId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: groupTransform,
groupBaseBound: {
left: groupTransform.left,
top: groupTransform.top,
width: groupTransform.width,
height: groupTransform.height,
},
} as IDrawingParam;
const children = validDrawings.map((drawing) => ({
...drawing,
groupId,
})) as IDrawingParam[];
const result = this._applyGroupDrawingOperation([{
parent: groupParam,
children,
}], DrawingApplyType.GROUP);
return result ? groupId : null;
}
override ungroupDrawings(groupIds: string[]): boolean {
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
const sheetDrawingService = this._sheetDrawingService;
const groupParams: IDrawingGroupUpdateParam[] = [];
for (const groupId of groupIds) {
const groupDrawing = sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId: groupId });
if (!groupDrawing || groupDrawing.drawingType !== DrawingTypeEnum.DRAWING_GROUP) continue;
const groupTransform = groupDrawing.transform || { width: 0, height: 0 };
const children = sheetDrawingService.getDrawingsByGroup({ unitId, subUnitId, drawingId: groupId })
.map((drawing) => {
const newTransform = transformObjectOutOfGroup(
drawing.transform || {},
groupTransform,
groupTransform.width || 0,
groupTransform.height || 0,
groupDrawing.groupBaseBound
);
return {
...drawing,
transform: {
...drawing.transform,
...newTransform,
},
groupId: undefined,
};
});
if (children.length > 0) {
groupParams.push({
parent: groupDrawing,
children,
});
}
}
return this._applyGroupDrawingOperation(groupParams, DrawingApplyType.UNGROUP);
}
override getDrawingGroupChildren(groupId: string, recursive = false): ISheetDrawing[] {
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
const sheetDrawingService = this._sheetDrawingService;
if (!recursive) {
return sheetDrawingService.getDrawingsByGroup({ unitId, subUnitId, drawingId: groupId }) as ISheetDrawing[];
}
const nested = sheetDrawingService.getDrawingsByGroupNested({ unitId, subUnitId, drawingId: groupId });
if (!nested) return [];
return [
...(nested.flatChildren || []),
...nested.groups.filter((group) => group.drawingId !== groupId),
] as ISheetDrawing[];
}
override getDrawingParentGroup(drawingId: string): ISheetDrawing | null {
const unitId = this._fWorkbook.getId();
const subUnitId = this.getSheetId();
const sheetDrawingService = this._sheetDrawingService;
const drawing = sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (!drawing?.groupId) return null;
const groupDrawing = sheetDrawingService.getDrawingByParam({ unitId, subUnitId, drawingId: drawing.groupId });
if (!groupDrawing || groupDrawing.drawingType !== DrawingTypeEnum.DRAWING_GROUP) return null;
return groupDrawing;
}
override isDrawingGrouped(drawingId: string): boolean {
return this.getDrawingParentGroup(drawingId) !== null;
}
private _applyGroupDrawingOperation(groupParams: IDrawingGroupUpdateParam[], type: DrawingApplyType.GROUP | DrawingApplyType.UNGROUP): boolean {
if (groupParams.length === 0) return false;
const sheetDrawingService = this._sheetDrawingService;
const commandService = this._commandService;
const undoRedoService = this._undoRedoService;
const jsonOp = (type === DrawingApplyType.GROUP
? sheetDrawingService.getGroupDrawingOp(groupParams)
: sheetDrawingService.getUngroupDrawingOp(groupParams)) as IDrawingJsonUndo1;
const { unitId, subUnitId, undo, redo, objects } = jsonOp;
const result = commandService.syncExecuteCommand(SetDrawingApplyMutation.id, { op: redo, unitId, subUnitId, objects, type });
if (result) {
const inverseType = type === DrawingApplyType.GROUP ? DrawingApplyType.UNGROUP : DrawingApplyType.GROUP;
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: undo, unitId, subUnitId, objects: this._invertGroupOperationObjects(objects as IDrawingGroupUpdateParam[], type), type: inverseType } },
],
redoMutations: [
{ id: SetDrawingApplyMutation.id, params: { op: redo, unitId, subUnitId, objects, type } },
],
});
return true;
}
return false;
}
private _invertGroupOperationObjects(groupParams: IDrawingGroupUpdateParam[], type: DrawingApplyType.GROUP | DrawingApplyType.UNGROUP): IDrawingGroupUpdateParam[] {
return type === DrawingApplyType.GROUP
? groupParams.map((groupParam) => {
const { parent, children } = groupParam;
const groupTransform = parent.transform || { width: 0, height: 0 };
return {
parent: {
...parent,
transform: {
left: 0,
top: 0,
},
},
children: children.map((child) => ({
...child,
transform: transformObjectOutOfGroup(
child.transform || {},
groupTransform,
groupTransform.width || 0,
groupTransform.height || 0,
parent.groupBaseBound
),
groupId: undefined,
})),
};
})
: groupParams.map((groupParam) => {
const { parent, children } = groupParam;
const groupTransform = getGroupState(0, 0, children.map((child) => child.transform || {}));
return {
parent: {
...parent,
transform: groupTransform,
},
children: children.map((child) => ({
...child,
groupId: parent.drawingId,
})),
};
});
}
}
function getDrawingBounds(drawing: ISheetDrawing): IGroupBaseBound | null {
const { left, top, width, height } = drawing.transform ?? {};
if (
typeof left !== 'number' ||
typeof top !== 'number' ||
typeof width !== 'number' ||
typeof height !== 'number' ||
!Number.isFinite(left) ||
!Number.isFinite(top) ||
!Number.isFinite(width) ||
!Number.isFinite(height)
) {
return null;
}
return { left, top, width, height };
}
FWorksheet.extend(FWorksheetDrawingMixin);
declare module '@univerjs/sheets/facade' {
interface FWorksheet extends IFWorksheetDrawingMixin { }
}

View File

@@ -0,0 +1,27 @@
/**
* 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 './f-over-grid-image';
import './f-worksheet';
import './f-enum';
import './f-event';
import './f-univer';
export type * from './f-enum';
export type * from './f-event';
export { FOverGridImage, type FOverGridImageBuilder, type IFOverGridImage } from './f-over-grid-image';
export type * from './f-univer';
export type * from './f-worksheet';

View File

@@ -0,0 +1,66 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
drawingPositionToTransform,
transformToAxisAlignPosition,
transformToDrawingPosition,
} from './basics/transform-position';
export { InsertSheetDrawingCommand } from './commands/commands/insert-sheet-drawing.command';
export type { IInsertSheetDrawingCommandParams } from './commands/commands/insert-sheet-drawing.command';
export { RemoveSheetDrawingCommand } from './commands/commands/remove-sheet-drawing.command';
export type { IRemoveSheetDrawingCommandParam, IRemoveSheetDrawingCommandParams } from './commands/commands/remove-sheet-drawing.command';
export { SetDrawingArrangeCommand } from './commands/commands/set-drawing-arrange.command';
export type { ISetDrawingArrangeCommandParams } from './commands/commands/set-drawing-arrange.command';
export { SetSheetDrawingPlacementCommand } from './commands/commands/set-sheet-drawing-placement.command';
export type { ISetSheetDrawingPlacementCommandParams } from './commands/commands/set-sheet-drawing-placement.command';
export { SetSheetDrawingCommand } from './commands/commands/set-sheet-drawing.command';
export type { ISetDrawingCommandParams } from './commands/commands/set-sheet-drawing.command';
export { DrawingApplyType, SetDrawingApplyMutation } from './commands/mutations/set-drawing-apply.mutation';
export type { ISetDrawingApplyMutationParams } from './commands/mutations/set-drawing-apply.mutation';
export { ClearSheetDrawingTransformerOperation } from './commands/operations/clear-drawing-transformer.operation';
export { isKnownSheetNonRotatableDrawingType, resolveSheetDrawingRotateEnabled } from './common/rotate-enabled';
export type { IUniverSheetsDrawingConfig } from './config/config';
export { SHEET_DRAWING_PLUGIN } from './controllers/sheet-drawing.controller';
export { UniverSheetsDrawingPlugin } from './plugin';
export {
applySheetDrawingPlacement,
getSheetDrawingPlacement,
normalizeSheetDrawingPlacement,
} from './services/sheet-drawing-placement';
export type {
ISheetDrawingAbsolutePlacement,
ISheetDrawingOneCellPlacement,
ISheetDrawingPlacement,
ISheetDrawingPlacementByBounds,
ISheetDrawingPlacementInput,
ISheetDrawingTwoCellPlacement,
} from './services/sheet-drawing-placement';
export { SheetDrawingTransformPlanService } from './services/sheet-drawing-transform-plan.service';
export type {
ISheetDrawingTransformExtension,
ISheetDrawingTransformExtensionResult,
ISheetDrawingTransformPlan,
} from './services/sheet-drawing-transform-plan.service';
export { ISheetDrawingService, SheetDrawingAnchorType, SheetDrawingService } from './services/sheet-drawing.service';
export type {
IFloatDomData,
ISheetDrawing,
ISheetDrawingPosition,
ISheetFloatDom,
ISheetImage,
ISheetShape,
} from './services/sheet-drawing.service';

View File

@@ -0,0 +1,63 @@
/**
* 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 { Dependency } from '@univerjs/core';
import type { IUniverSheetsDrawingConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_DRAWING_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetDrawingTransformAffectedController } from './controllers/sheet-drawing-transform-affected.controller';
import { SHEET_DRAWING_PLUGIN, SheetsDrawingLoadController } from './controllers/sheet-drawing.controller';
import { SheetDrawingTransformPlanService } from './services/sheet-drawing-transform-plan.service';
import { ISheetDrawingService, SheetDrawingService } from './services/sheet-drawing.service';
@DependentOn(UniverDrawingPlugin, UniverSheetsPlugin)
export class UniverSheetsDrawingPlugin extends Plugin {
static override pluginName = SHEET_DRAWING_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsDrawingConfig> = defaultPluginConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_DRAWING_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[SheetsDrawingLoadController],
[SheetDrawingTransformPlanService],
[SheetDrawingTransformAffectedController],
[ISheetDrawingService, { useClass: SheetDrawingService }],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
this._injector.get(SheetsDrawingLoadController);
this._injector.get(SheetDrawingTransformAffectedController);
}
}

View File

@@ -0,0 +1,66 @@
/**
* 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 { ISheetDrawingTransformPlan } from '../sheet-drawing-transform-plan.service';
import { describe, expect, it, vi } from 'vitest';
import { SheetDrawingTransformPlanService } from '../sheet-drawing-transform-plan.service';
describe('SheetDrawingTransformPlanService', () => {
it('runs extensions by priority and aggregates feature mutations', () => {
const service = new SheetDrawingTransformPlanService();
const calls: string[] = [];
service.register({
priority: 0,
transform: () => {
calls.push('low');
return {
redos: [{ id: 'low-redo', params: {} }],
undos: [{ id: 'low-undo', params: {} }],
};
},
});
service.register({
priority: 100,
transform: () => {
calls.push('high');
return {
preRedos: [{ id: 'high-pre-redo', params: {} }],
preUndos: [{ id: 'high-pre-undo', params: {} }],
};
},
});
const result = service.transform({ mode: 'command' } as ISheetDrawingTransformPlan);
expect(calls).toEqual(['high', 'low']);
expect(result.preRedos).toEqual([{ id: 'high-pre-redo', params: {} }]);
expect(result.redos).toEqual([{ id: 'low-redo', params: {} }]);
expect(result.preUndos).toEqual([{ id: 'high-pre-undo', params: {} }]);
expect(result.undos).toEqual([{ id: 'low-undo', params: {} }]);
});
it('stops running an extension after disposal', () => {
const service = new SheetDrawingTransformPlanService();
const transform = vi.fn(() => ({}));
const disposable = service.register({ transform });
disposable.dispose();
service.transform({ mode: 'command' } as ISheetDrawingTransformPlan);
expect(transform).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { ISheetDrawingService, SheetDrawingService } from '../sheet-drawing.service';
describe('SheetDrawingService', () => {
let service: ISheetDrawingService;
beforeEach(() => {
const injector = new Injector();
injector.add([ISheetDrawingService, { useClass: SheetDrawingService }]);
service = injector.get(ISheetDrawingService);
});
it('initializes sheet drawing data and notifies renderers about existing drawings', () => {
const added: unknown[] = [];
service.add$.subscribe((items) => added.push(items));
service.registerDrawingData('book-1', {
sheet1: {
data: {
image1: { drawingId: 'image1' },
},
order: ['image1'],
},
} as never);
service.initializeNotification('book-1');
expect(service.getDrawingData('book-1', 'sheet1')?.image1.drawingId).toBe('image1');
expect(added).toEqual([[{ drawingId: 'image1', unitId: 'book-1', subUnitId: 'sheet1' }]]);
});
});

View File

@@ -0,0 +1,352 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IGroupBaseBound, ITransformState } from '@univerjs/core';
import type { SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ICellOverGridPosition } from '@univerjs/sheets';
import type {
ISheetDrawing,
ISheetDrawingPosition,
ISheetFloatDom,
ISheetImage,
} from './sheet-drawing.service';
import { convertPositionCellToSheetOverGrid, convertPositionSheetOverGridToAbsolute } from '@univerjs/sheets';
import { transformToAxisAlignPosition, transformToDrawingPosition } from '../basics/transform-position';
import {
SheetDrawingAnchorType,
} from './sheet-drawing.service';
/**
* Anchor a drawing to one cell while keeping a fixed pixel extent.
*/
export interface ISheetDrawingOneCellPlacement {
/** Placement discriminator. */
kind: SheetDrawingAnchorType.Position;
/** Zero-based anchor cell and pixel offsets from its top-left corner. */
from: ICellOverGridPosition;
/** Drawing width in pixels. */
width: number;
/** Drawing height in pixels. */
height: number;
}
/**
* Anchor a drawing between two cell markers.
*/
export interface ISheetDrawingTwoCellPlacement {
/** Placement discriminator. */
kind: SheetDrawingAnchorType.Both;
/** Zero-based start cell and pixel offsets. */
from: ICellOverGridPosition;
/** Zero-based end cell and pixel offsets. */
to: ICellOverGridPosition;
}
/**
* Position a drawing in the Sheet canvas pixel coordinate system.
*/
export interface ISheetDrawingAbsolutePlacement {
/** Placement discriminator. */
kind: SheetDrawingAnchorType.None;
/** Horizontal pixel offset from the Sheet canvas origin. */
left: number;
/** Vertical pixel offset from the Sheet canvas origin. */
top: number;
/** Drawing width in pixels. */
width: number;
/** Drawing height in pixels. */
height: number;
}
/**
* Explicit Sheet drawing placement.
*/
export type ISheetDrawingPlacement =
| ISheetDrawingOneCellPlacement
| ISheetDrawingTwoCellPlacement
| ISheetDrawingAbsolutePlacement;
/**
* Infer cell markers from model-space bounds after the anchor semantics have
* been selected explicitly.
*/
export interface ISheetDrawingPlacementByBounds {
/** Position, Both, or None semantics. */
kind: SheetDrawingAnchorType;
/** Bounds in the Sheet model coordinate system. */
bounds: IGroupBaseBound;
}
/**
* Exact Placement or model-space bounds to normalize into one.
*/
export type ISheetDrawingPlacementInput = ISheetDrawingPlacement | ISheetDrawingPlacementByBounds;
export function getSheetDrawingPlacement(drawing: ISheetDrawing): ISheetDrawingPlacement {
const anchorType = drawing.anchorType ?? SheetDrawingAnchorType.Position;
if (anchorType === SheetDrawingAnchorType.None) {
return {
kind: SheetDrawingAnchorType.None,
left: drawing.transform?.left ?? 0,
top: drawing.transform?.top ?? 0,
width: drawing.transform?.width ?? 0,
height: drawing.transform?.height ?? 0,
};
}
if (anchorType === SheetDrawingAnchorType.Both) {
return {
kind: SheetDrawingAnchorType.Both,
from: { ...drawing.sheetTransform.from },
to: { ...drawing.sheetTransform.to },
};
}
return {
kind: SheetDrawingAnchorType.Position,
from: { ...drawing.sheetTransform.from },
width: drawing.transform?.width ?? 0,
height: drawing.transform?.height ?? 0,
};
}
export function applySheetDrawingPlacement(
drawing: ISheetImage,
input: ISheetDrawingPlacementInput,
skeleton?: SpreadsheetSkeleton
): ISheetImage;
export function applySheetDrawingPlacement(
drawing: ISheetFloatDom,
input: ISheetDrawingPlacementInput,
skeleton?: SpreadsheetSkeleton
): ISheetFloatDom;
export function applySheetDrawingPlacement(
drawing: ISheetDrawing,
input: ISheetDrawingPlacementInput,
skeleton?: SpreadsheetSkeleton
): ISheetDrawing;
export function applySheetDrawingPlacement(
drawing: ISheetDrawing,
input: ISheetDrawingPlacementInput,
skeleton?: SpreadsheetSkeleton
): ISheetDrawing {
const placement = normalizeSheetDrawingPlacement(input, skeleton);
if (placement.kind === SheetDrawingAnchorType.None) {
const transform = withExistingTransform(drawing.transform, placement);
const sheetTransform = absoluteSheetTransform(placement, drawing.sheetTransform);
return {
...drawing,
anchorType: SheetDrawingAnchorType.None,
transform,
sheetTransform,
axisAlignSheetTransform: sheetTransform,
};
}
if (!skeleton) {
throw new Error('SHEET_DRAWING_PLACEMENT_SKELETON_REQUIRED');
}
if (placement.kind === SheetDrawingAnchorType.Position) {
const converted = convertPositionCellToSheetOverGrid(
drawing.unitId,
drawing.subUnitId,
placement.from,
placement.width,
placement.height,
skeleton
);
const sheetTransform = withExistingSheetTransform(drawing.sheetTransform, converted.sheetTransform);
const transform = withExistingTransform(drawing.transform, converted.transform);
return {
...drawing,
anchorType: SheetDrawingAnchorType.Position,
sheetTransform,
transform,
axisAlignSheetTransform: transformToAxisAlignPosition(transform, skeleton),
};
}
const sheetTransform = withExistingSheetTransform(drawing.sheetTransform, {
from: placement.from,
to: placement.to,
});
const bounds = convertPositionSheetOverGridToAbsolute(
drawing.unitId,
drawing.subUnitId,
sheetTransform,
skeleton
);
const transform = withExistingTransform(drawing.transform, bounds);
return {
...drawing,
anchorType: SheetDrawingAnchorType.Both,
sheetTransform,
transform,
axisAlignSheetTransform: transformToAxisAlignPosition(transform, skeleton),
};
}
/**
* Normalize exact markers or model-space bounds to the authoritative
* Position, Both, or None Placement.
*/
export function normalizeSheetDrawingPlacement(
input: ISheetDrawingPlacementInput,
skeleton?: SpreadsheetSkeleton
): ISheetDrawingPlacement {
if (!('bounds' in input)) {
validatePlacement(input);
if (input.kind === SheetDrawingAnchorType.Both && skeleton) {
validateBounds(convertPositionSheetOverGridToAbsolute(
skeleton.worksheet.getUnitId(),
skeleton.worksheet.getSheetId(),
{
from: input.from,
to: input.to,
},
skeleton
));
}
return input;
}
validateBounds(input.bounds);
if (input.kind === SheetDrawingAnchorType.None) {
return {
kind: SheetDrawingAnchorType.None,
...input.bounds,
};
}
if (!skeleton) {
throw new Error('SHEET_DRAWING_PLACEMENT_SKELETON_REQUIRED');
}
const sheetTransform = transformToDrawingPosition(input.bounds, skeleton);
if (input.kind === SheetDrawingAnchorType.Position) {
return {
kind: SheetDrawingAnchorType.Position,
from: sheetTransform.from,
width: input.bounds.width,
height: input.bounds.height,
};
}
if (input.kind === SheetDrawingAnchorType.Both) {
return {
kind: SheetDrawingAnchorType.Both,
from: sheetTransform.from,
to: sheetTransform.to,
};
}
throw new Error('SHEET_DRAWING_PLACEMENT_KIND_INVALID');
}
function withExistingSheetTransform(
current: ISheetDrawingPosition,
placement: Pick<ISheetDrawingPosition, 'from' | 'to'>
): ISheetDrawingPosition {
return {
...current,
from: { ...placement.from },
to: { ...placement.to },
};
}
function withExistingTransform(
current: ITransformState | null | undefined | void,
bounds: Pick<ITransformState, 'left' | 'top' | 'width' | 'height'>
): ITransformState {
return {
...current,
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
};
}
function absoluteSheetTransform(
placement: ISheetDrawingAbsolutePlacement,
current: ISheetDrawingPosition
): ISheetDrawingPosition {
return {
...current,
from: {
column: 0,
columnOffset: placement.left,
row: 0,
rowOffset: placement.top,
},
to: {
column: 0,
columnOffset: placement.left + placement.width,
row: 0,
rowOffset: placement.top + placement.height,
},
};
}
function validatePlacement(placement: ISheetDrawingPlacement): void {
if (placement.kind === SheetDrawingAnchorType.Position) {
validateCellPosition(placement.from);
validateExtent(placement.width, placement.height);
return;
}
if (placement.kind === SheetDrawingAnchorType.Both) {
validateCellPosition(placement.from);
validateCellPosition(placement.to);
return;
}
if (placement.kind === SheetDrawingAnchorType.None) {
if (!Number.isFinite(placement.left) || !Number.isFinite(placement.top)) {
throw new TypeError('SHEET_DRAWING_PLACEMENT_POSITION_INVALID');
}
validateExtent(placement.width, placement.height);
return;
}
throw new Error('SHEET_DRAWING_PLACEMENT_KIND_INVALID');
}
function validateBounds(bounds: IGroupBaseBound): void {
if (!Number.isFinite(bounds.left) || !Number.isFinite(bounds.top)) {
throw new TypeError('SHEET_DRAWING_PLACEMENT_POSITION_INVALID');
}
validateExtent(bounds.width, bounds.height);
}
function validateCellPosition(position: ICellOverGridPosition): void {
if (
!Number.isInteger(position.row) ||
!Number.isInteger(position.column) ||
position.row < 0 ||
position.column < 0 ||
!Number.isFinite(position.rowOffset) ||
!Number.isFinite(position.columnOffset)
) {
throw new Error('SHEET_DRAWING_PLACEMENT_CELL_INVALID');
}
}
function validateExtent(width: number, height: number): void {
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new Error('SHEET_DRAWING_PLACEMENT_EXTENT_INVALID');
}
}

View File

@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommandInfo, IDisposable, IMutationInfo } from '@univerjs/core';
import type { SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ISheetDrawing } from './sheet-drawing.service';
import { toDisposable } from '@univerjs/core';
export interface ISheetDrawingTransformPlan {
command: ICommandInfo;
mode: 'command' | 'refresh';
unitId: string;
subUnitId: string;
skeleton: SpreadsheetSkeleton;
originals: ReadonlyMap<string, ISheetDrawing>;
updates: Map<string, ISheetDrawing>;
deletes: Set<string>;
}
export interface ISheetDrawingTransformExtensionResult {
preRedos?: IMutationInfo[];
redos?: IMutationInfo[];
preUndos?: IMutationInfo[];
undos?: IMutationInfo[];
}
export interface ISheetDrawingTransformExtension {
priority?: number;
transform(plan: ISheetDrawingTransformPlan): ISheetDrawingTransformExtensionResult | void;
}
function createEmptyResult(): Required<ISheetDrawingTransformExtensionResult> {
return { preRedos: [], redos: [], preUndos: [], undos: [] };
}
export class SheetDrawingTransformPlanService {
private readonly _extensions: ISheetDrawingTransformExtension[] = [];
register(extension: ISheetDrawingTransformExtension): IDisposable {
this._extensions.push(extension);
this._extensions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
return toDisposable(() => {
const index = this._extensions.indexOf(extension);
if (index >= 0) {
this._extensions.splice(index, 1);
}
});
}
transform(plan: ISheetDrawingTransformPlan): Required<ISheetDrawingTransformExtensionResult> {
return this._extensions.reduce((all, extension) => {
const next = extension.transform(plan);
if (!next) {
return all;
}
all.preRedos.push(...(next.preRedos ?? []));
all.redos.push(...(next.redos ?? []));
all.preUndos.push(...(next.preUndos ?? []));
all.undos.push(...(next.undos ?? []));
return all;
}, createEmptyResult());
}
}

View File

@@ -0,0 +1,88 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam, IRotationSkewFlipTransform, Serializable } from '@univerjs/core';
import type { IDrawingJsonUndo1, IImageData, IUnitDrawingService } from '@univerjs/drawing';
import type { ISheetOverGridPosition } from '@univerjs/sheets';
import { createIdentifier } from '@univerjs/core';
import { UnitDrawingService } from '@univerjs/drawing';
export enum SheetDrawingAnchorType {
/**
* Only the position of the drawing follows the cell changes. When rows or columns are inserted or deleted, the position of the drawing changes, but the size remains the same.
*/
Position = '0',
/**
* The size and position of the drawing follow the cell changes. When rows or columns are inserted or deleted, the size and position of the drawing change accordingly.
*/
Both = '1',
/**
* The size and position of the drawing do not follow the cell changes. When rows or columns are inserted or deleted, the position and size of the drawing remain unchanged.
*/
None = '2',
}
export interface ISheetDrawingPosition extends IRotationSkewFlipTransform, ISheetOverGridPosition {
}
export interface ISheetDrawingBase {
sheetTransform: ISheetDrawingPosition;
/**
* this property is used to store the excel drawing axis alignment position, which is not always the same as the sheetTransform.
*/
axisAlignSheetTransform: ISheetDrawingPosition;
anchorType?: SheetDrawingAnchorType;
}
export interface ISheetImage extends IImageData, ISheetDrawingBase {
}
/**
* test type
*/
export interface ISheetShape extends IDrawingParam, ISheetDrawingBase {
}
export interface IFloatDomData extends IDrawingParam {
componentKey: string;
data?: Serializable;
allowTransform?: boolean;
/**
* this property is used to store the excel drawing axis alignment position, which is not always the same as the sheetTransform.
*/
axisAlignSheetTransform: ISheetDrawingPosition;
}
// TODO@wzhudev: this shouldn't be here. It should be in the sheets package
export interface ISheetFloatDom extends IFloatDomData, ISheetDrawingBase { }
export type ISheetDrawing = ISheetImage | ISheetShape | ISheetFloatDom;
type OptionalField<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
export type ISheetUpdateDrawing = OptionalField<ISheetImage | ISheetShape, 'sheetTransform'>;
export class SheetDrawingService extends UnitDrawingService<ISheetDrawing> { }
export interface ISheetDrawingService extends IUnitDrawingService<ISheetDrawing> {
getBatchUpdateOp(updateParams: ISheetDrawing[]): IDrawingJsonUndo1;
}
export const ISheetDrawingService = createIdentifier<ISheetDrawingService>('sheets-drawing.sheet-drawing.service');

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();