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/drawing-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/drawing-ui?style=flat-square)](https://npmjs.com/package/@univerjs/drawing-ui)
[![license](https://img.shields.io/npm/l/@univerjs/drawing-ui?style=flat-square)](https://npmjs.com/package/@univerjs/drawing-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/drawing-ui?style=flat-square)](https://npmjs.com/package/@univerjs/drawing-ui)
`@univerjs/drawing-ui` provides common drawing UI services and components that document- or sheet-specific drawing packages build on.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/drawing-ui` | `UniverDrawingUi` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/drawing-ui
# or
npm install @univerjs/drawing-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/drawing-ui/lib/index.css';
import EnUS from '@univerjs/drawing-ui/locale/en-US';
import { UniverDrawingUIPlugin } from '@univerjs/drawing-ui';
univer.registerPlugin(UniverDrawingUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/drawing-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,93 @@
{
"name": "@univerjs/drawing-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Shared drawing UI components and services for Univer.",
"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",
"drawing",
"graphics",
"ui",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.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"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.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"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,444 @@
/**
* 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, IDrawingParam } from '@univerjs/core';
import type { IDrawingGroupUpdateParam, IDrawingOrderUpdateParam } from '@univerjs/drawing';
import { ArrangeTypeEnum, DrawingTypeEnum, ICommandService, Univer } from '@univerjs/core';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { resolveDrawingUIRotateEnabled } from '../../../utils/rotate-enabled';
import {
AlignType,
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../drawing-align.operation';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeOperation,
} from '../drawing-arrange.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../drawing-group.operation';
import { AutoImageCropOperation, CloseImageCropOperation, CropType, OpenImageCropOperation } from '../image-crop.operation';
import { ImageResetSizeOperation } from '../image-reset-size.operation';
const unitId = 'drawing-ui-unit';
const subUnitId = 'drawing-ui-subunit';
function createDrawing(drawingId: string, left: number): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left,
top: 10,
width: 20,
height: 30,
},
};
}
function createChartDrawing(drawingId: string, left: number): IDrawingParam {
return {
...createDrawing(drawingId, left),
drawingType: DrawingTypeEnum.DRAWING_CHART,
};
}
function createGroupDrawing(drawingId: string, left: number, rotateEnabled?: boolean): IDrawingParam {
return {
...createDrawing(drawingId, left),
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
left,
top: 10,
width: 20,
height: 30,
rotateEnabled,
},
};
}
function expectNoPersistedRotateEnabled(drawing: IDrawingParam): void {
expect(Object.prototype.hasOwnProperty.call(drawing.transform ?? {}, 'rotateEnabled')).toBe(false);
}
describe('drawing arrange and group operations', () => {
let univer: Univer;
let commandService: ICommandService;
let drawingManagerService: IDrawingManagerService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
commandService = injector.get(ICommandService);
commandService.registerCommand(SetDrawingArrangeOperation);
commandService.registerCommand(SetDrawingArrangeFrontOperation);
commandService.registerCommand(SetDrawingArrangeForwardOperation);
commandService.registerCommand(SetDrawingArrangeBackOperation);
commandService.registerCommand(SetDrawingArrangeBackwardOperation);
commandService.registerCommand(SetDrawingGroupOperation);
commandService.registerCommand(CancelDrawingGroupOperation);
commandService.registerCommand(SetDrawingAlignOperation);
commandService.registerCommand(SetDrawingAlignLeftOperation);
commandService.registerCommand(SetDrawingAlignCenterOperation);
commandService.registerCommand(SetDrawingAlignRightOperation);
commandService.registerCommand(SetDrawingAlignTopOperation);
commandService.registerCommand(SetDrawingAlignMiddleOperation);
commandService.registerCommand(SetDrawingAlignBottomOperation);
commandService.registerCommand(SetDrawingAlignHorizonOperation);
commandService.registerCommand(SetDrawingAlignVerticalOperation);
commandService.registerCommand(OpenImageCropOperation);
commandService.registerCommand(CloseImageCropOperation);
commandService.registerCommand(AutoImageCropOperation);
commandService.registerCommand(ImageResetSizeOperation);
drawingManagerService = injector.get(IDrawingManagerService);
});
afterEach(() => {
univer.dispose();
});
it('requests a layer-order change for the selected drawings', async () => {
const orderUpdates: IDrawingOrderUpdateParam[] = [];
drawingManagerService.featurePluginOrderUpdate$.subscribe((update) => orderUpdates.push(update));
const result = await commandService.executeCommand(SetDrawingArrangeOperation.id, {
arrangeType: ArrangeTypeEnum.front,
drawings: [createDrawing('image-1', 0), createDrawing('image-2', 30)],
});
expect(result).toBe(true);
expect(orderUpdates).toEqual([{
unitId,
subUnitId,
drawingIds: ['image-1', 'image-2'],
arrangeType: ArrangeTypeEnum.front,
}]);
});
it('uses the focused drawings when arranging from the front command', async () => {
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': createDrawing('image-1', 0),
'image-2': createDrawing('image-2', 30),
},
order: ['image-1', 'image-2'],
},
});
drawingManagerService.focusDrawing([
{ unitId, subUnitId, drawingId: 'image-1' },
{ unitId, subUnitId, drawingId: 'image-2' },
]);
const orderUpdates: IDrawingOrderUpdateParam[] = [];
drawingManagerService.featurePluginOrderUpdate$.subscribe((update) => orderUpdates.push(update));
const result = await commandService.executeCommand(SetDrawingArrangeFrontOperation.id);
expect(result).toBe(true);
expect(orderUpdates).toEqual([{
unitId,
subUnitId,
drawingIds: ['image-1', 'image-2'],
arrangeType: ArrangeTypeEnum.front,
}]);
});
it('uses focused drawings for forward, backward and back layer commands', async () => {
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': createDrawing('image-1', 0),
'image-2': createDrawing('image-2', 30),
},
order: ['image-1', 'image-2'],
},
});
drawingManagerService.focusDrawing([
{ unitId, subUnitId, drawingId: 'image-1' },
{ unitId, subUnitId, drawingId: 'image-2' },
]);
const orderUpdates: IDrawingOrderUpdateParam[] = [];
drawingManagerService.featurePluginOrderUpdate$.subscribe((update) => orderUpdates.push(update));
expect(await commandService.executeCommand(SetDrawingArrangeForwardOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingArrangeBackwardOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingArrangeBackOperation.id)).toBe(true);
const arrangeTypes: ArrangeTypeEnum[] = [];
for (const update of orderUpdates) {
arrangeTypes.push(update.arrangeType as ArrangeTypeEnum);
expect(update).toMatchObject({
unitId,
subUnitId,
drawingIds: ['image-1', 'image-2'],
});
}
expect(arrangeTypes).toEqual([
ArrangeTypeEnum.forward,
ArrangeTypeEnum.backward,
ArrangeTypeEnum.back,
]);
});
it('groups two selected drawings under a generated group drawing', async () => {
const groupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginGroupUpdate$.subscribe((update) => groupUpdates.push(update));
const result = await commandService.executeCommand(SetDrawingGroupOperation.id, {
drawings: [createDrawing('image-1', 0), createDrawing('image-2', 30)],
});
expect(result).toBe(true);
expect(groupUpdates).toHaveLength(1);
expect(groupUpdates[0]).toHaveLength(1);
expect(groupUpdates[0][0].parent).toMatchObject({
unitId,
subUnitId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
});
expectNoPersistedRotateEnabled(groupUpdates[0][0].parent);
expect(resolveDrawingUIRotateEnabled(groupUpdates[0][0].parent, {
getChildren: (drawing) => drawing.drawingId === groupUpdates[0][0].parent.drawingId
? groupUpdates[0][0].children
: drawingManagerService.getDrawingsByGroup(drawing),
})).toBe(true);
const childIds: string[] = [];
for (const child of groupUpdates[0][0].children) {
childIds.push(child.drawingId);
expect(child.groupId).toBe(groupUpdates[0][0].parent.drawingId);
}
expect(childIds).toEqual(['image-1', 'image-2']);
});
it('groups chart drawings with regular drawings', async () => {
const groupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginGroupUpdate$.subscribe((update) => groupUpdates.push(update));
const result = await commandService.executeCommand(SetDrawingGroupOperation.id, {
drawings: [createChartDrawing('chart-1', 0), createDrawing('image-1', 30)],
});
expect(result).toBe(true);
expect(groupUpdates).toHaveLength(1);
expectNoPersistedRotateEnabled(groupUpdates[0][0].parent);
expect(resolveDrawingUIRotateEnabled(groupUpdates[0][0].parent, {
getChildren: (drawing) => drawing.drawingId === groupUpdates[0][0].parent.drawingId
? groupUpdates[0][0].children
: drawingManagerService.getDrawingsByGroup(drawing),
})).toBe(false);
expect(groupUpdates[0][0].children.map((child) => child.drawingId)).toEqual(['chart-1', 'image-1']);
expect(groupUpdates[0][0].children[0]).toMatchObject({
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: groupUpdates[0][0].parent.drawingId,
});
});
it('recomputes nested group rotate capability from current descendants', async () => {
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
staleGroup: {
...createGroupDrawing('staleGroup', 0, false),
},
'nested-child': {
...createDrawing('nested-child', 10),
groupId: 'staleGroup',
},
},
order: ['staleGroup', 'nested-child'],
},
});
const groupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginGroupUpdate$.subscribe((update) => groupUpdates.push(update));
const result = await commandService.executeCommand(SetDrawingGroupOperation.id, {
drawings: [createGroupDrawing('staleGroup', 0, false), createDrawing('image-1', 30)],
});
expect(result).toBe(true);
expectNoPersistedRotateEnabled(groupUpdates[0][0].parent);
expect(resolveDrawingUIRotateEnabled(groupUpdates[0][0].parent, {
getChildren: (drawing) => drawing.drawingId === groupUpdates[0][0].parent.drawingId
? groupUpdates[0][0].children
: drawingManagerService.getDrawingsByGroup(drawing),
})).toBe(true);
});
it('does not create a drawing group from a single selected drawing', async () => {
const groupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginGroupUpdate$.subscribe((update) => groupUpdates.push(update));
const result = await commandService.executeCommand(SetDrawingGroupOperation.id, {
drawings: [createDrawing('image-1', 0)],
});
expect(result).toBe(false);
expect(groupUpdates).toEqual([]);
});
it('ungroups a focused drawing group and restores children to the sheet layer', async () => {
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {},
order: ['group', 'image-1', 'image-2'],
},
});
drawingManagerService.setDrawingData(unitId, subUnitId, {
group: {
unitId,
subUnitId,
drawingId: 'group',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 10, top: 20, width: 100, height: 80 },
groupBaseBound: { left: 10, top: 20, width: 100, height: 80 },
},
'image-1': {
...createDrawing('image-1', 0),
groupId: 'group',
},
'image-2': {
...createDrawing('image-2', 30),
groupId: 'group',
},
});
drawingManagerService.focusDrawing([{ unitId, subUnitId, drawingId: 'group' }]);
const ungroupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginUngroupUpdate$.subscribe((update) => ungroupUpdates.push(update));
const result = await commandService.executeCommand(CancelDrawingGroupOperation.id, {});
expect(result).toBe(true);
expect(ungroupUpdates).toHaveLength(1);
expect(ungroupUpdates[0][0].parent).toMatchObject({
unitId,
subUnitId,
drawingId: 'group',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
});
const childIds: string[] = [];
for (const child of ungroupUpdates[0][0].children) {
childIds.push(child.drawingId);
expect(child.groupId).toBeUndefined();
}
expect(childIds).toEqual(['image-1', 'image-2']);
});
it('does not ungroup regular drawings', async () => {
const ungroupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginUngroupUpdate$.subscribe((update) => ungroupUpdates.push(update));
const result = await commandService.executeCommand(CancelDrawingGroupOperation.id, {
drawings: [createDrawing('image-1', 0), createDrawing('image-2', 30)],
});
expect(result).toBe(false);
expect(ungroupUpdates).toEqual([]);
});
it('translates drawing alignment menu actions into concrete alignment operations', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
expect(await commandService.executeCommand(SetDrawingAlignLeftOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignCenterOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignRightOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignTopOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignMiddleOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignBottomOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignHorizonOperation.id)).toBe(true);
expect(await commandService.executeCommand(SetDrawingAlignVerticalOperation.id)).toBe(true);
const alignTypes: AlignType[] = [];
for (const command of executedCommands) {
if (command.id === SetDrawingAlignOperation.id) {
alignTypes.push((command.params as { alignType: AlignType }).alignType);
}
}
expect(alignTypes).toEqual([
AlignType.left,
AlignType.center,
AlignType.right,
AlignType.top,
AlignType.middle,
AlignType.bottom,
AlignType.horizon,
AlignType.vertical,
]);
});
it('publishes image crop lifecycle operations with the target drawing and crop mode', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const targetDrawing = { unitId, subUnitId, drawingId: 'image-1' };
expect(await commandService.executeCommand(OpenImageCropOperation.id, targetDrawing)).toBe(true);
expect(await commandService.executeCommand(AutoImageCropOperation.id, { cropType: CropType.R16_9 })).toBe(true);
expect(await commandService.executeCommand(CloseImageCropOperation.id, { isAuto: true })).toBe(true);
expect(executedCommands).toEqual([
{
id: OpenImageCropOperation.id,
type: OpenImageCropOperation.type,
params: targetDrawing,
},
{
id: AutoImageCropOperation.id,
type: AutoImageCropOperation.type,
params: { cropType: CropType.R16_9 },
},
{
id: CloseImageCropOperation.id,
type: CloseImageCropOperation.type,
params: { isAuto: true },
},
]);
});
it('publishes image reset-size operation for selected image drawings', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const selectedImages = [
{ unitId, subUnitId, drawingId: 'image-1' },
{ unitId, subUnitId, drawingId: 'image-2' },
];
expect(await commandService.executeCommand(ImageResetSizeOperation.id, selectedImages)).toBe(true);
expect(executedCommands).toEqual([{
id: ImageResetSizeOperation.id,
type: ImageResetSizeOperation.type,
params: selectedImages,
}]);
});
});

View File

@@ -0,0 +1,110 @@
/**
* 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, IOperation } from '@univerjs/core';
import { CommandType, ICommandService } from '@univerjs/core';
export enum AlignType {
default = '0',
left = '1',
center = '2',
right = '3',
top = '4',
middle = '5',
bottom = '6',
horizon = '7',
vertical = '8',
}
export interface ISetDrawingAlignOperationParams {
alignType: AlignType;
drawings?: IDrawingParam[];
}
/**
* Set drawing align operation, including left, center, right, top, middle, bottom, horizon and vertical align.
*/
export const SetDrawingAlignOperation: IOperation<ISetDrawingAlignOperationParams> = {
id: 'sheet.operation.set-image-align',
type: CommandType.OPERATION,
handler: (accessor, params) => {
return true;
},
};
export const SetDrawingAlignLeftOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-left',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.left });
},
};
export const SetDrawingAlignCenterOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-center',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.center });
},
};
export const SetDrawingAlignRightOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-right',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.right });
},
};
export const SetDrawingAlignTopOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-top',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.top });
},
};
export const SetDrawingAlignMiddleOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-middle',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.middle });
},
};
export const SetDrawingAlignBottomOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-bottom',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.bottom });
},
};
export const SetDrawingAlignHorizonOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-horizon',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.horizon });
},
};
export const SetDrawingAlignVerticalOperation: IOperation = {
id: 'sheet.operation.set-drawing-align-vertical',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingAlignOperation.id, { alignType: AlignType.vertical });
},
};

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 { IDrawingParam, IOperation } from '@univerjs/core';
import { ArrangeTypeEnum, CommandType, ICommandService } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
export interface IDrawingArrangeOperationParams {
arrangeType: ArrangeTypeEnum;
drawings?: IDrawingParam[];
}
/**
* Set the layer of the drawing, including forward, backward, front, and back
*/
export const SetDrawingArrangeOperation: IOperation<IDrawingArrangeOperationParams> = {
id: 'drawing.operation.set-drawing-arrange',
type: CommandType.OPERATION,
handler: (accessor, params) => {
const drawingManagerService = accessor.get(IDrawingManagerService);
const { arrangeType } = params;
const drawings = params.drawings || drawingManagerService.getFocusDrawings();
const { unitId, subUnitId } = drawings[0];
const drawingIds = drawings.map((drawing) => drawing.drawingId);
drawingManagerService.featurePluginOrderUpdateNotification({ unitId, subUnitId, drawingIds, arrangeType });
return true;
},
};
export const SetDrawingArrangeFrontOperation: IOperation = {
id: 'drawing.operation.set-drawing-arrange-front',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingArrangeOperation.id, { arrangeType: ArrangeTypeEnum.front });
},
};
export const SetDrawingArrangeForwardOperation: IOperation = {
id: 'drawing.operation.set-drawing-arrange-forward',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingArrangeOperation.id, { arrangeType: ArrangeTypeEnum.forward });
},
};
export const SetDrawingArrangeBackOperation: IOperation = {
id: 'drawing.operation.set-drawing-arrange-back',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingArrangeOperation.id, { arrangeType: ArrangeTypeEnum.back });
},
};
export const SetDrawingArrangeBackwardOperation: IOperation = {
id: 'drawing.operation.set-drawing-arrange-backward',
type: CommandType.OPERATION,
handler: (accessor) => {
return accessor.get(ICommandService).syncExecuteCommand(SetDrawingArrangeOperation.id, { arrangeType: ArrangeTypeEnum.backward });
},
};

View File

@@ -0,0 +1,144 @@
/**
* 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, IOperation } from '@univerjs/core';
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import { CommandType, DrawingTypeEnum, generateRandomId } from '@univerjs/core';
import { DRAWING_GROUPABLE_TYPES, IDrawingManagerService, isGroupableDrawingType } from '@univerjs/drawing';
import { getGroupState, transformObjectOutOfGroup } from '@univerjs/engine-render';
/**
* Now only support grouping images, shapes, charts, and groups.
*/
export const DRAWING_GROUP_TYPES = DRAWING_GROUPABLE_TYPES;
export interface IDrawingGroupOperationParams {
drawings?: IDrawingParam[];
}
/**
* Group the selected drawings into a new group. The selected drawings must be
* of type image, shape, chart, or group, and there must be at least 2 drawings
* selected.
*/
export const SetDrawingGroupOperation: IOperation<IDrawingGroupOperationParams> = {
id: 'drawing.operation.set-drawing-group',
type: CommandType.OPERATION,
handler: (accessor, params) => {
const drawingManagerService = accessor.get(IDrawingManagerService);
const drawings = params.drawings || drawingManagerService.getFocusDrawings();
if (drawings.length < 2) return false;
if (!drawings.every((drawing) => isGroupableDrawingType(drawing.drawingType))) return false;
const { unitId, subUnitId } = drawings[0];
const groupId = generateRandomId(10);
const groupTransform = getGroupState(0, 0, drawings.map((o) => o.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 = drawings.map((drawing) => {
const transform = drawing.transform || { left: 0, top: 0 };
const { unitId, subUnitId, drawingId, drawingType } = drawing;
return {
unitId,
subUnitId,
drawingId,
drawingType,
transform: {
...transform,
// left: transform.left! - groupTransform.left,
// top: transform.top! - groupTransform.top,
},
groupId,
};
}) as IDrawingParam[];
drawingManagerService.featurePluginGroupUpdateNotification([{
parent: groupParam,
children,
}]);
return true;
},
};
export interface ICancelDrawingGroupOperationParams {
drawings?: IDrawingParam[];
}
/**
* Ungroup the selected groups. The selected drawings must be at least 1 group selected.
*/
export const CancelDrawingGroupOperation: IOperation<ICancelDrawingGroupOperationParams> = {
id: 'drawing.operation.cancel-drawing-group',
type: CommandType.OPERATION,
handler: (accessor, params) => {
const drawingManagerService = accessor.get(IDrawingManagerService);
const drawings = params.drawings || drawingManagerService.getFocusDrawings();
const groupParams = drawings
.map((drawing) => {
if (drawing.drawingType !== DrawingTypeEnum.DRAWING_GROUP) return null;
const { unitId, subUnitId, drawingId, transform: groupTransform = { width: 0, height: 0 }, groupBaseBound } = drawing;
if (groupTransform === null) return null;
const objects = drawingManagerService.getDrawingsByGroup({ unitId, subUnitId, drawingId });
if (objects.length === 0) return null;
const children = objects.map((object) => {
const { transform } = object;
const { unitId, subUnitId, drawingId } = object;
const newTransform = transformObjectOutOfGroup(transform || {}, groupTransform, groupTransform.width || 0, groupTransform.height || 0, groupBaseBound);
return {
unitId,
subUnitId,
drawingId,
transform: {
...transform,
...newTransform,
},
groupId: undefined,
};
});
return {
parent: drawing,
children,
} as IDrawingGroupUpdateParam;
})
.filter((o) => o !== null);
if (groupParams.length === 0) return false;
drawingManagerService.featurePluginUngroupUpdateNotification(groupParams);
return true;
},
};

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingSearch, IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
export const OpenImageCropOperation: IOperation<IDrawingSearch> = {
id: 'sheet.operation.open-image-crop',
type: CommandType.OPERATION,
handler: (_accessor, _params) => {
return true;
},
};
export interface ICloseImageCropOperationParams {
isAuto?: boolean;
isCancel?: boolean;
}
export const CloseImageCropOperation: IOperation<ICloseImageCropOperationParams> = {
id: 'sheet.operation.close-image-crop',
type: CommandType.OPERATION,
handler: (_accessor, _params) => {
return true;
},
};
export enum CropType {
FREE = '0',
R1_1 = '1',
R16_9 = '2',
R9_16 = '3',
R5_4 = '4',
R4_5 = '5',
R4_3 = '6',
R3_4 = '7',
R3_2 = '8',
R2_3 = '9',
}
export interface IOpenImageCropOperationBySrcRectParams {
cropType: CropType;
}
export const AutoImageCropOperation: IOperation<IOpenImageCropOperationBySrcRectParams> = {
id: 'sheet.operation.Auto-image-crop',
type: CommandType.OPERATION,
handler: (_accessor, _params) => {
return true;
},
};

View File

@@ -0,0 +1,26 @@
/**
* 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, IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
export const ImageResetSizeOperation: IOperation<IDrawingSearch[]> = {
id: 'sheet.operation.image-reset-size',
type: CommandType.OPERATION,
handler: (accessor, params) => {
return true;
},
};

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 type { MenuConfig } from '@univerjs/ui';
export const DRAWING_UI_PLUGIN_CONFIG_KEY = 'drawing-ui.config';
export const configSymbol = Symbol(DRAWING_UI_PLUGIN_CONFIG_KEY);
export interface IUniverDrawingUIConfig {
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverDrawingUIConfig = {};

View File

@@ -0,0 +1,503 @@
/**
* 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 } from '@univerjs/core';
import type { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import { DrawingTypeEnum, UniverInstanceType } from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { DRAWING_OBJECT_LAYER_INDEX, Rect } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { AlignType, SetDrawingAlignOperation } from '../../commands/operations/drawing-align.operation';
import { DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX } from '../../services/drawing-render.service';
import { DrawingUpdateController } from '../drawing-update.controller';
interface IDrawingNotification {
unitId: string;
subUnitId: string;
drawingId: string;
behindText?: boolean;
hidden?: boolean;
selectable?: boolean;
}
interface IDrawingTransformObject {
oKey: string;
left: number;
top: number;
width: number;
height: number;
angle: number;
isInGroup?: boolean;
}
function createHarness() {
const add$ = new Subject<IDrawingNotification[]>();
const remove$ = new Subject<IDrawingNotification[]>();
const update$ = new Subject<IDrawingNotification[]>();
const refreshTransform$ = new Subject<IDrawingNotification[]>();
const visible$ = new Subject<Array<IDrawingNotification & { visible: boolean }>>();
const order$ = new Subject<{ unitId: string; subUnitId: string; drawingIds: string[] }>();
const group$ = new Subject<IDrawingGroupUpdateParam[]>();
const ungroup$ = new Subject<IDrawingGroupUpdateParam[]>();
const changeStart$ = new Subject<{ objects: Map<string, IDrawingTransformObject> }>();
const changeEnd$ = new Subject<{ objects: Map<string, IDrawingTransformObject> }>();
const transformer = {
changeStart$,
changeEnd$,
clearSelectedObjects: vi.fn(),
setSelectedControl: vi.fn(),
refreshControls: vi.fn(() => ({ changeNotification: vi.fn() })),
};
const debounceRefreshControls = vi.fn();
const sceneTransformer = {
debounceRefreshControls,
clearSelectedObjects: vi.fn(),
clearControlByIds: vi.fn(),
};
const zIndexShape = { setProps: vi.fn(), makeDirty: vi.fn() };
const disposeShape = { dispose: vi.fn() };
const showHideShape = { show: vi.fn(), hide: vi.fn() };
const transformShape = { layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const hiddenTransformShape = { layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const selectableShape = { oKey: 'unit-1#-#sheet-1#-#drawing-selectable', evented: true, layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const eventlessShape = { oKey: 'unit-1#-#sheet-1#-#drawing-eventless', evented: false, layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX }, transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn() };
const getSceneObject = vi.fn((key: string): unknown => {
if (key === 'unit-1#-#sheet-1#-#drawing-visible') return showHideShape;
if (key === 'unit-1#-#sheet-1#-#drawing-transform') return transformShape;
if (key === 'unit-1#-#sheet-1#-#drawing-transform-refresh-hidden') return transformShape;
if (key === selectableShape.oKey) return selectableShape;
if (key === eventlessShape.oKey) return eventlessShape;
if (key === 'unit-1#-#sheet-1#-#drawing-remove') return disposeShape;
return null;
});
const scene = {
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => sceneTransformer),
getObject: getSceneObject,
getObjectIncludeInGroup: vi.fn((key: string) => {
if (key === 'unit-1#-#sheet-1#-#drawing-hidden-to-visible') return hiddenTransformShape;
return getSceneObject(key);
}),
fuzzyMathObjects: vi.fn((key: string) => {
if (key.includes('drawing-z')) return [zIndexShape];
if (key.includes('drawing-remove')) return [disposeShape];
return [];
}),
addObject: vi.fn((_object: any, _layerIndex?: number) => ({ attachTransformerTo: vi.fn() })),
addObjects: vi.fn(),
removeObject: vi.fn(),
makeDirty: vi.fn(),
};
const renderManagerService = {
getRenderUnitById: vi.fn(() => ({ scene })),
};
const drawingParams = new Map<string, { unitId: string; subUnitId: string; drawingId: string; drawingType: DrawingTypeEnum; behindText?: boolean; hidden?: boolean; selectable?: boolean; transform: { left: number; top: number; width: number; height: number; angle: number; clipBounds?: { left: number; top: number; width: number; height: number } } }>([
['drawing-a', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 0, top: 0, width: 10, height: 10, angle: 0 } }],
['drawing-b', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-b', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 20, top: 0, width: 10, height: 10, angle: 0 } }],
['chart-1', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'chart-1', drawingType: DrawingTypeEnum.DRAWING_CHART, transform: { left: 40, top: 0, width: 20, height: 10, angle: 0 } }],
['drawing-transform', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-transform', drawingType: DrawingTypeEnum.DRAWING_IMAGE, behindText: true, hidden: true, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0, clipBounds: { left: 0, top: 0, width: 80, height: 120 } } }],
['drawing-transform-in-group', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-transform-in-group', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 8, top: 9, width: 10, height: 11, angle: 0 } }],
['drawing-transform-refresh-hidden', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-transform-refresh-hidden', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0, clipBounds: { left: 0, top: 0, width: 80, height: 120 } } }],
['drawing-hidden-to-visible', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-hidden-to-visible', drawingType: DrawingTypeEnum.DRAWING_IMAGE, hidden: false, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0 } }],
['drawing-selectable', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-selectable', drawingType: DrawingTypeEnum.DRAWING_IMAGE, selectable: false, transform: { left: 1, top: 2, width: 3, height: 4, angle: 0 } }],
['drawing-eventless', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-eventless', drawingType: DrawingTypeEnum.DRAWING_CHART, transform: { left: 4, top: 5, width: 6, height: 7, angle: 0 } }],
['drawing-refresh-missing', { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 4, top: 5, width: 6, height: 7, angle: 0 } }],
]);
const drawingManagerService = {
drawingManagerData: {},
add$,
remove$,
update$,
refreshTransform$,
visible$,
order$,
group$,
ungroup$,
getDrawingByParam: vi.fn(({ drawingId }: { drawingId: string }) => drawingParams.get(drawingId) ?? null),
getOldDrawingByParam: vi.fn(({ drawingId }: { drawingId: string }) => {
const drawing = drawingParams.get(drawingId);
return drawingId === 'drawing-selectable' && drawing
? { ...drawing, selectable: false }
: drawing ?? null;
}),
getDrawingOKey: vi.fn((oKey: string) => ({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: oKey, drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 0, top: 0, width: 10, height: 10, angle: 0 } })),
getDrawingOrder: vi.fn(() => ['drawing-z']),
getFocusDrawings: vi.fn(() => [
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-b', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
]),
focusDrawing: vi.fn(),
addNotification: vi.fn(),
featurePluginUpdateNotification: vi.fn(),
};
let commandExecuted: ((cmd: ICommandInfo) => void) | undefined;
const commandService = {
onCommandExecuted: vi.fn((handler: (cmd: ICommandInfo) => void) => {
commandExecuted = handler;
return { dispose: vi.fn() };
}),
syncExecuteCommand: vi.fn(),
};
const controller = new DrawingUpdateController(
{
getFocusedUnit: vi.fn(() => ({
type: UniverInstanceType.UNIVER_SHEET,
getUnitId: () => 'unit-1',
getActiveSheet: () => ({ getSheetId: () => 'sheet-1' }),
})),
} as never,
commandService as never,
renderManagerService as never,
drawingManagerService as never
);
return {
controller,
add$,
changeStart$,
changeEnd$,
commandExecuted: () => commandExecuted,
drawingManagerService,
drawingParams,
group$,
order$,
refreshTransform$,
remove$,
scene,
showHideShape,
transformShape,
hiddenTransformShape,
selectableShape,
eventlessShape,
update$,
visible$,
zIndexShape,
disposeShape,
debounceRefreshControls,
transformer,
commandService,
};
}
describe('DrawingUpdateController', () => {
it('syncs selection on transform start and persists transform changes on end', () => {
const harness = createHarness();
harness.add$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a' }]);
const oKey = 'drawing-a';
const startObject = { oKey, left: 0, top: 0, width: 10, height: 10, angle: 0, isInGroup: false };
harness.changeStart$.next({ objects: new Map([[oKey, startObject]]) });
expect(harness.commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, [
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a' },
]);
const endObject = { ...startObject, left: 5 };
harness.changeEnd$.next({ objects: new Map([[oKey, endObject]]) });
expect(harness.drawingManagerService.featurePluginUpdateNotification).toHaveBeenCalledWith([
expect.objectContaining({ drawingId: 'drawing-a', transform: expect.objectContaining({ left: 5 }) }),
]);
harness.controller.dispose();
});
it('applies drawing alignment changes for focused drawings', () => {
const harness = createHarness();
harness.add$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-a' }]);
harness.commandExecuted()?.({ id: SetDrawingAlignOperation.id, params: { alignType: AlignType.left } });
expect(harness.drawingManagerService.featurePluginUpdateNotification).toHaveBeenCalledWith([
expect.objectContaining({ drawingId: 'drawing-b', transform: { left: 0, top: 0 } }),
]);
harness.controller.dispose();
});
it('updates zIndex, transforms, visibility, and disposes removed shapes', () => {
const harness = createHarness();
harness.order$.next({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingIds: ['drawing-z'] });
expect(harness.zIndexShape.setProps).toHaveBeenCalledWith({ zIndex: 0 });
expect(harness.zIndexShape.makeDirty).toHaveBeenCalledTimes(1);
const drawingId = 'drawing-transform';
harness.update$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(harness.transformShape.transformByState).toHaveBeenCalled();
expect(harness.transformShape.hide).toHaveBeenCalledTimes(1);
expect(harness.debounceRefreshControls).toHaveBeenCalledTimes(1);
harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(harness.transformShape.transformByState).toHaveBeenCalledTimes(2);
expect(harness.transformShape.setClipBounds).toHaveBeenCalledWith({ left: 0, top: 0, width: 80, height: 120 });
expect(harness.transformShape.hide).toHaveBeenCalledTimes(2);
expect(harness.scene.removeObject).toHaveBeenCalledWith(harness.transformShape);
expect(harness.scene.addObject).toHaveBeenCalledWith(harness.transformShape, DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
harness.visible$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-visible', visible: false }]);
expect(harness.showHideShape.hide).toHaveBeenCalledTimes(1);
harness.visible$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-visible', visible: true }]);
expect(harness.showHideShape.show).toHaveBeenCalledTimes(1);
harness.remove$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-remove' }]);
expect(harness.disposeShape.dispose).toHaveBeenCalledTimes(1);
harness.controller.dispose();
});
it('restores a hidden render object when drawing metadata becomes visible', () => {
const harness = createHarness();
harness.update$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-hidden-to-visible',
}]);
expect(harness.hiddenTransformShape.show).toHaveBeenCalledTimes(1);
harness.controller.dispose();
});
it('restores a hidden render object from a visibility notification', () => {
const harness = createHarness();
harness.visible$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-hidden-to-visible',
visible: true,
}]);
expect(harness.hiddenTransformShape.show).toHaveBeenCalledTimes(1);
harness.controller.dispose();
});
it('syncs drawing selectability to canvas picking and the active selection', () => {
const harness = createHarness();
const drawing = harness.drawingParams.get('drawing-selectable')!;
harness.drawingManagerService.getFocusDrawings.mockReturnValue([
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-selectable', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-b', drawingType: DrawingTypeEnum.DRAWING_IMAGE },
]);
harness.update$.next([{
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
}]);
expect(harness.selectableShape.evented).toBe(false);
expect(harness.scene.getTransformer().clearControlByIds).toHaveBeenCalledWith([harness.selectableShape.oKey]);
expect(harness.drawingManagerService.focusDrawing).toHaveBeenCalledWith([
expect.objectContaining({ drawingId: 'drawing-b' }),
]);
delete drawing.selectable;
harness.update$.next([{
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
drawingId: drawing.drawingId,
}]);
expect(harness.selectableShape.evented).toBe(true);
harness.controller.dispose();
});
it('preserves eventless render objects without selectable metadata', () => {
const harness = createHarness();
harness.update$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-eventless',
}]);
expect(harness.eventlessShape.evented).toBe(false);
harness.controller.dispose();
});
it('disposes grouped drawing children through grouped scene lookup on remove notifications', () => {
const harness = createHarness();
const groupedShape = { dispose: vi.fn() };
const drawingId = 'drawing-remove-in-group';
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId });
harness.scene.getObjectIncludeInGroup.mockImplementation((key: string) => {
if (key === drawingShapeKey) {
return groupedShape;
}
return null;
});
harness.scene.fuzzyMathObjects.mockClear();
harness.remove$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(groupedShape.dispose).toHaveBeenCalledTimes(1);
expect(harness.scene.getTransformer().clearSelectedObjects).toHaveBeenCalledTimes(1);
expect(harness.scene.fuzzyMathObjects).not.toHaveBeenCalled();
harness.controller.dispose();
});
it('requests drawing insertion when a refreshed transform belongs to a missing scene object', () => {
const harness = createHarness();
harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing' }]);
expect(harness.drawingManagerService.addNotification).toHaveBeenCalledTimes(1);
expect(harness.drawingManagerService.addNotification).toHaveBeenCalledWith([
{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'drawing-refresh-missing' },
]);
harness.controller.dispose();
});
it('refreshes drawing transforms for objects inside groups without requesting insertion', () => {
const harness = createHarness();
const groupedShape = { transformByState: vi.fn(), setClipBounds: vi.fn(), show: vi.fn(), hide: vi.fn(), layer: { zIndex: DRAWING_OBJECT_LAYER_INDEX } };
const drawingId = 'drawing-transform-in-group';
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId });
harness.scene.getObjectIncludeInGroup.mockImplementation((key: string) => {
if (key === drawingShapeKey) {
return groupedShape;
}
return null;
});
harness.refreshTransform$.next([{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId }]);
expect(groupedShape.transformByState).toHaveBeenCalledWith({
left: 8,
top: 9,
width: 10,
height: 11,
angle: 0,
flipX: false,
flipY: false,
skewX: 0,
skewY: 0,
});
expect(harness.drawingManagerService.addNotification).not.toHaveBeenCalled();
harness.controller.dispose();
});
it('groups existing chart render objects from the scene', () => {
const harness = createHarness();
const unitId = 'unit-1';
const subUnitId = 'sheet-1';
const imageKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'drawing-a' });
const chartKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'chart-1' });
const groupKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'group-1' });
const imageRect = new Rect(imageKey, { left: 0, top: 0, width: 10, height: 10 });
const chartRect = new Rect(chartKey, { left: 40, top: 0, width: 20, height: 10 });
const imageTransformSpy = vi.spyOn(imageRect, 'transformByState');
const chartTransformSpy = vi.spyOn(chartRect, 'transformByState');
let createdGroup: { oKey: string; getObjects: () => unknown[]; transformerConfig?: { rotateEnabled?: boolean }; angle?: number } | undefined;
harness.scene.getObjectIncludeInGroup.mockImplementation((key: string) => {
if (key === imageKey) {
return imageRect;
}
if (key === chartKey) {
return chartRect;
}
return null;
});
harness.scene.addObject.mockImplementation((object: { oKey: string; getObjects?: () => unknown[] }) => {
if (object.oKey === groupKey && object.getObjects) {
createdGroup = object as { oKey: string; getObjects: () => unknown[]; transformerConfig?: { rotateEnabled?: boolean }; angle?: number };
}
return { attachTransformerTo: vi.fn() };
});
harness.group$.next([{
parent: {
unitId,
subUnitId,
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 0, top: 0, width: 60, height: 10, angle: 30 },
groupBaseBound: { left: 0, top: 0, width: 60, height: 10 },
},
children: [
{ unitId, subUnitId, drawingId: 'drawing-a', drawingType: DrawingTypeEnum.DRAWING_IMAGE, transform: { left: 0, top: 0, width: 10, height: 10, angle: 0 }, groupId: 'group-1' },
{ unitId, subUnitId, drawingId: 'chart-1', drawingType: DrawingTypeEnum.DRAWING_CHART, transform: { left: 40, top: 0, width: 20, height: 10, angle: 0 }, groupId: 'group-1' },
],
}]);
expect(createdGroup?.oKey).toBe(groupKey);
expect(createdGroup?.getObjects()).toEqual([imageRect, chartRect]);
expect(createdGroup?.transformerConfig?.rotateEnabled).toBe(false);
expect(createdGroup?.angle).toBe(30);
expect(imageRect.isInGroup).toBe(true);
expect(chartRect.isInGroup).toBe(true);
expect(imageRect.groupKey).toBe(groupKey);
expect(chartRect.groupKey).toBe(groupKey);
expect(imageTransformSpy).toHaveBeenCalledWith({ left: 0, top: 0, width: 10, height: 10, angle: 0 });
expect(chartTransformSpy).toHaveBeenCalledWith({ left: 40, top: 0, width: 20, height: 10, angle: 0 });
expect(harness.transformer.clearSelectedObjects).toHaveBeenCalledTimes(1);
expect(harness.transformer.setSelectedControl).toHaveBeenCalledWith(createdGroup);
harness.controller.dispose();
});
it('uses refresh metadata to hide stale drawings without re-adding missing hidden shapes', () => {
const harness = createHarness();
harness.refreshTransform$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-transform-refresh-hidden',
behindText: true,
hidden: true,
}]);
expect(harness.transformShape.hide).toHaveBeenCalledTimes(1);
expect(harness.scene.removeObject).toHaveBeenCalledWith(harness.transformShape);
expect(harness.scene.addObject).toHaveBeenCalledWith(harness.transformShape, DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
harness.refreshTransform$.next([{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-refresh-missing',
hidden: true,
}]);
expect(harness.drawingManagerService.addNotification).not.toHaveBeenCalled();
harness.controller.dispose();
});
});

View File

@@ -0,0 +1,438 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommandInfo } from '@univerjs/core';
import { ContextService, DrawingTypeEnum, ICommandService, IContextService, Injector, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { CURSOR_TYPE, Image, IRenderManagerService } from '@univerjs/engine-render';
import { ILayoutService, IMessageService, IShortcutService, KeyCode } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AutoImageCropOperation, CloseImageCropOperation, CropType, OpenImageCropOperation } from '../../commands/operations/image-crop.operation';
import { ImageCropperObject } from '../../views/crop/image-cropper-object';
import { ImageCropperController } from '../image-cropper.controller';
import { MobileImageCropperController } from '../mobile/image-cropper.controller';
function createImage(id: string) {
const img = document.createElement('img');
Object.defineProperty(img, 'width', { value: 100, configurable: true });
Object.defineProperty(img, 'height', { value: 50, configurable: true });
return new Image(id, { image: img });
}
const injectors: Injector[] = [];
function createController(
command: ICommandService,
drawing: IDrawingManagerService,
render: IRenderManagerService,
instance: IUniverInstanceService,
message: IMessageService,
locale: LocaleService,
shortcut: IShortcutService,
layout: ILayoutService,
mobile = false
) {
const injector = new Injector([
[ICommandService, { useValue: command }],
[IDrawingManagerService, { useValue: drawing }],
[IRenderManagerService, { useValue: render }],
[IUniverInstanceService, { useValue: instance }],
[IMessageService, { useValue: message }],
[LocaleService, { useValue: locale }],
[IShortcutService, { useValue: shortcut }],
[ILayoutService, { useValue: layout }],
[IContextService, { useClass: ContextService }],
]);
injectors.push(injector);
if (mobile) {
injector.add([MobileImageCropperController]);
return injector.get(MobileImageCropperController);
}
injector.add([ImageCropperController]);
return injector.get(ImageCropperController);
}
afterEach(() => {
injectors.splice(0).forEach((injector) => injector.dispose());
});
describe('ImageCropperController', () => {
it('auto crops focused image and opens crop panel', () => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
image.transformByState({ left: 0, top: 0, width: 100, height: 50, angle: 0 });
const existingCropper = new ImageCropperObject(`${imageKey}-crop`, { applyTransform: image.getState() });
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const drawingManagerService = {
getFocusDrawings: vi.fn(() => [focusDrawing]),
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
};
const transformer = {
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
};
const scene = {
getAllObjectsByOrder: vi.fn(() => [existingCropper]),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => ({ scene })) };
const controller = createController(
commandService as never,
drawingManagerService as never,
renderManagerService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn() } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ focus: vi.fn() } as never
);
// handlers: [OpenImageCrop, CloseImageCrop, AutoImageCrop]
commandHandlers[2]({ id: AutoImageCropOperation.id, params: { cropType: CropType.R1_1 } } as never);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(CloseImageCropOperation.id, { isAuto: true });
expect(image.srcRect).toEqual({ left: 25, top: 0, right: 25, bottom: 0 });
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(OpenImageCropOperation.id, focusDrawing);
controller.dispose();
});
it('shows error message when auto crop target is not an Image', () => {
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const messageService = { show: vi.fn() };
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const drawingManagerService = {
getFocusDrawings: vi.fn(() => [{ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' }]),
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
};
const scene = {
getAllObjectsByOrder: vi.fn(() => []),
getObject: vi.fn(() => ({ not: 'image' })),
getTransformerByCreate: vi.fn(() => ({ changeStart$: new Subject(), changeEnd$: new Subject(), clearControl$: new Subject() })),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => ({ scene })) };
const controller = createController(
commandService as never,
drawingManagerService as never,
renderManagerService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn() } as never,
messageService as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ focus: vi.fn() } as never
);
commandHandlers[2]({ id: AutoImageCropOperation.id, params: { cropType: CropType.R1_1 } } as never);
expect(messageService.show).toHaveBeenCalledWith(expect.objectContaining({
type: MessageType.Error,
}));
controller.dispose();
});
it.each([false, true])('opens crop mode with mobile hit targets only when mobile=%s', (mobile) => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
image.transformByState({ left: 0, top: 0, width: 100, height: 50, angle: 0 });
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn(),
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE, srcRect: null, prstGeom: null })),
};
const transformer = {
clearControls: vi.fn(),
refreshControls: vi.fn(),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
detachFrom: vi.fn(),
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
};
let createdCropper: ImageCropperObject | null = null;
const scene = {
getAllObjectsByOrder: vi.fn(() => createdCropper ? [createdCropper] : []),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => transformer),
addObject: vi.fn((obj: ImageCropperObject) => {
createdCropper = obj;
return { attachTransformerTo: vi.fn() };
}),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => ({ scene })) };
const layoutService = { focus: vi.fn() };
const controller = createController(
commandService as never,
drawingManagerService as never,
renderManagerService as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn() } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
layoutService as never,
mobile
);
expect(controller instanceof ImageCropperController).toBe(!mobile);
// OpenImageCrop handler
commandHandlers[0]({ id: OpenImageCropOperation.id, params: focusDrawing } as never);
expect(createdCropper).not.toBeNull();
expect(createdCropper!.transformerConfig?.cropAnchorHitSize).toBe(mobile ? 44 : undefined);
const cropper = createdCropper!;
const originalImageState = image.getState();
const originalCropState = cropper.getState();
const objects = new Map([[cropper.oKey, cropper]]);
for (let attempt = 0; attempt < 5; attempt++) {
transformer.createControlForCopper.mockClear();
transformer.changeStart$.next({ objects });
transformer.changeEnd$.next({ objects });
expect(transformer.createControlForCopper).toHaveBeenCalledExactlyOnceWith(cropper);
expect(cropper.getState()).toEqual(originalCropState);
expect(image.getState()).toEqual(originalImageState);
}
createdCropper!.onPointerEnter$.emitEvent({} as never);
expect(createdCropper!.cursor).toBe(CURSOR_TYPE.MOVE);
createdCropper!.onPointerLeave$.emitEvent({} as never);
expect(createdCropper!.cursor).toBe(CURSOR_TYPE.DEFAULT);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, [focusDrawing]);
expect(layoutService.focus).toHaveBeenCalledOnce();
controller.dispose();
});
it('registers confirm and cancel shortcuts only while crop mode is open', () => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn((id, params) => {
if (id === CloseImageCropOperation.id) {
commandHandlers[1]({ id, params } as never);
}
return true;
}),
};
const transformer = {
clearControls: vi.fn(),
refreshControls: vi.fn(),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
detachFrom: vi.fn(),
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
};
let createdCropper: ImageCropperObject | null = null;
const scene = {
getAllObjectsByOrder: vi.fn(() => createdCropper ? [createdCropper] : []),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => transformer),
addObject: vi.fn((object: ImageCropperObject) => {
createdCropper = object;
return { attachTransformerTo: vi.fn() };
}),
};
const firstEnterShortcutDisposable = { dispose: vi.fn() };
const firstCancelShortcutDisposable = { dispose: vi.fn() };
const secondEnterShortcutDisposable = { dispose: vi.fn() };
const secondCancelShortcutDisposable = { dispose: vi.fn() };
const shortcutDisposables = [
firstEnterShortcutDisposable,
firstCancelShortcutDisposable,
secondEnterShortcutDisposable,
secondCancelShortcutDisposable,
];
const shortcutService = {
registerShortcut: vi.fn(() => shortcutDisposables.shift()!),
};
const controller = createController(
commandService as never,
{
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
getDrawingOKey: vi.fn(() => ({ ...focusDrawing, transform: image.getState() })),
featurePluginUpdateNotification: vi.fn(),
} as never,
{ getRenderUnitById: vi.fn(() => ({ scene })) } as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn(() => null) } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
shortcutService as never,
{ focus: vi.fn() } as never
);
commandHandlers[0]({ id: OpenImageCropOperation.id, params: focusDrawing } as never);
expect(shortcutService.registerShortcut).toHaveBeenCalledWith(expect.objectContaining({
id: CloseImageCropOperation.id,
binding: KeyCode.ENTER,
}));
expect(shortcutService.registerShortcut).toHaveBeenCalledWith(expect.objectContaining({
id: CloseImageCropOperation.id,
binding: KeyCode.ESC,
staticParameters: { isCancel: true },
}));
const firstCropperDispose = vi.spyOn(createdCropper!, 'dispose');
commandHandlers[0]({ id: OpenImageCropOperation.id, params: focusDrawing } as never);
expect(firstCropperDispose).toHaveBeenCalledOnce();
const secondCropperDispose = vi.spyOn(createdCropper!, 'dispose');
transformer.clearCopperControl.mockImplementationOnce(() => {
commandService.syncExecuteCommand(CloseImageCropOperation.id, undefined);
});
commandHandlers[1]({ id: CloseImageCropOperation.id, params: { isCancel: true } } as never);
expect(transformer.detachFrom).toHaveBeenCalledWith(createdCropper);
expect(secondCropperDispose).toHaveBeenCalledOnce();
expect(commandService.syncExecuteCommand).toHaveBeenLastCalledWith(CloseImageCropOperation.id, undefined);
expect(firstEnterShortcutDisposable.dispose).toHaveBeenCalledOnce();
expect(firstCancelShortcutDisposable.dispose).toHaveBeenCalledOnce();
expect(secondEnterShortcutDisposable.dispose).toHaveBeenCalledOnce();
expect(secondCancelShortcutDisposable.dispose).toHaveBeenCalledOnce();
controller.dispose();
});
it('restores the image state from before auto crop without publishing changes when cancelled', () => {
const focusDrawing = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'd1' };
const imageKey = getDrawingShapeKeyByDrawingSearch(focusDrawing);
const image = createImage(imageKey);
image.transformByState({ left: 10, top: 20, width: 100, height: 50, angle: 0 });
const originalTransform = image.getState();
const originalSrcRect = image.srcRect;
const commandHandlers: Array<(cmd: ICommandInfo) => void> = [];
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandlers.push(handler);
return { dispose: vi.fn() };
}),
executeCommand: vi.fn(),
syncExecuteCommand: vi.fn((id, params) => {
if (id === OpenImageCropOperation.id) {
commandHandlers[0]({ id, params } as never);
}
return true;
}),
};
const drawingManagerService = {
getFocusDrawings: vi.fn(() => [focusDrawing]),
getDrawingByParam: vi.fn(() => ({ drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
getDrawingOKey: vi.fn(() => ({ ...focusDrawing, drawingType: DrawingTypeEnum.DRAWING_IMAGE })),
featurePluginUpdateNotification: vi.fn(),
};
const refreshedImageStates: ReturnType<Image['getState']>[] = [];
const transformer = {
clearControls: vi.fn(),
refreshControls: vi.fn(() => refreshedImageStates.push(image.getState())),
clearCopperControl: vi.fn(),
createControlForCopper: vi.fn(),
detachFrom: vi.fn(),
changeStart$: new Subject(),
changeEnd$: new Subject(),
clearControl$: new Subject(),
};
let createdCropper: ImageCropperObject | null = null;
const scene = {
getAllObjectsByOrder: vi.fn(() => createdCropper ? [createdCropper] : []),
getObject: vi.fn(() => image),
getTransformerByCreate: vi.fn(() => transformer),
getTransformer: vi.fn(() => transformer),
addObject: vi.fn((object: ImageCropperObject) => {
createdCropper = object;
return { attachTransformerTo: vi.fn() };
}),
};
const controller = createController(
commandService as never,
drawingManagerService as never,
{ getRenderUnitById: vi.fn(() => ({ scene })) } as never,
{ getCurrentTypeOfUnit$: vi.fn(() => new Subject()), getFocusedUnit: vi.fn(() => ({ getUnitId: () => focusDrawing.unitId })) } as never,
{ show: vi.fn() } as never,
{ t: vi.fn((key: string) => key) } as never,
{ registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } as never,
{ focus: vi.fn() } as never
);
commandHandlers[2]({ id: AutoImageCropOperation.id, params: { cropType: CropType.R1_1 } } as never);
expect(image.srcRect).not.toBeNull();
expect(createdCropper).not.toBeNull();
refreshedImageStates.length = 0;
commandHandlers[1]({ id: CloseImageCropOperation.id, params: { isCancel: true } } as never);
expect(drawingManagerService.featurePluginUpdateNotification).not.toHaveBeenCalled();
expect(image.getState()).toEqual(originalTransform);
expect(image.srcRect).toBe(originalSrcRect);
expect(refreshedImageStates).toEqual([originalTransform]);
controller.dispose();
});
});

View File

@@ -0,0 +1,306 @@
/**
* 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 { ContextService, DrawingTypeEnum, ICommandService, IContextService, IImageIoService, ImageSourceType, Injector, IUniverInstanceService, LocaleService, ThemeService, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IDialogService, ILayoutService, IMessageService, IShortcutService } from '@univerjs/ui';
import { NEVER, Subject } from 'rxjs';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ImageResetSizeOperation } from '../../commands/operations/image-reset-size.operation';
import { DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX, DrawingRenderService } from '../../services/drawing-render.service';
import { ImageCropperController } from '../image-cropper.controller';
import { ImageUpdateController } from '../image-update.controller';
const injectors: Injector[] = [];
function createController(
command: ICommandService,
render: IRenderManagerService,
drawing: IDrawingManagerService,
dialog: IDialogService,
imageIo: IImageIoService,
instance: IUniverInstanceService,
drawingRender: DrawingRenderService
) {
const injector = new Injector([
[ICommandService, { useValue: command }],
[IRenderManagerService, { useValue: render }],
[IDrawingManagerService, { useValue: drawing }],
[IDialogService, { useValue: dialog }],
[IImageIoService, { useValue: imageIo }],
[IUniverInstanceService, { useValue: { ...instance, getCurrentTypeOfUnit$: () => NEVER } }],
[DrawingRenderService, { useValue: drawingRender }],
[IContextService, { useClass: ContextService }],
[ThemeService],
[LocaleService],
[ILayoutService, { useValue: { focus: vi.fn() } }],
[IMessageService, { useValue: { show: vi.fn() } }],
[IShortcutService, { useValue: { registerShortcut: vi.fn(() => ({ dispose: vi.fn() })) } }],
[ImageCropperController],
[ImageUpdateController],
]);
injectors.push(injector);
return injector.get(ImageUpdateController);
}
afterEach(() => {
injectors.splice(0).forEach((injector) => injector.dispose());
vi.useRealTimers();
});
function createSheetUnit(unitId = 'book-1', subUnitId = 'sheet-1') {
return {
type: UniverInstanceType.UNIVER_SHEET,
getUnitId: () => unitId,
getActiveSheet: () => ({ getSheetId: () => subUnitId }),
};
}
describe('ImageUpdateController', () => {
it('resets image size after command execution and pushes updated drawing state back', () => {
let commandHandler: ((command: { id: string; params?: unknown }) => void) | undefined;
const changeNotification = vi.fn();
const imageShape = {
resetSize: vi.fn(),
getNativeSize: vi.fn(() => ({ width: 320, height: 180 })),
};
const params = [{ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-1' }];
const drawingManagerService = {
add$: new Subject(),
update$: new Subject(),
getDrawingByParam: vi.fn(() => ({
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { width: 100, height: 50, angle: 30 },
})),
featurePluginUpdateNotification: vi.fn(),
};
const commandService = {
onCommandExecuted: vi.fn((handler) => {
commandHandler = handler;
return { dispose: vi.fn() };
}),
syncExecuteCommand: vi.fn(),
};
const controller = createController(
commandService as never,
{
getRenderUnitById: vi.fn(() => ({
scene: {
getObject: vi.fn(() => imageShape),
getTransformerByCreate: vi.fn(() => ({
refreshControls: vi.fn(() => ({ changeNotification })),
})),
},
})),
} as never,
drawingManagerService as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages: vi.fn() } as never
);
expect(controller).toBeTruthy();
commandHandler!({ id: ImageResetSizeOperation.id, params });
expect(imageShape.resetSize).toHaveBeenCalledTimes(1);
expect(drawingManagerService.featurePluginUpdateNotification).toHaveBeenCalledWith([
expect.objectContaining({
drawingId: 'shape-1',
srcRect: null,
prstGeom: null,
transform: expect.objectContaining({ width: 320, height: 180, angle: 0 }),
}),
]);
expect(changeNotification).toHaveBeenCalledTimes(1);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(SetDrawingSelectedOperation.id, params);
});
it('deduplicates add notifications before rendering newly inserted images', async () => {
vi.useFakeTimers();
const add$ = new Subject<Array<{ unitId: string; subUnitId: string; drawingId: string }>>();
const update$ = new Subject();
const imageParam = {
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-2',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { width: 100, height: 50, angle: 0 },
};
const drawingManagerService = {
add$,
update$,
getDrawingByParam: vi.fn(() => imageParam),
refreshTransform: vi.fn(),
};
const renderImages = vi.fn(async () => []);
const controller = createController(
{ onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })), syncExecuteCommand: vi.fn() } as never,
{
getRenderUnitById: vi.fn(() => ({
scene: { getTransformerByCreate: vi.fn(), getObject: vi.fn() },
})),
} as never,
drawingManagerService as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages } as never
);
expect(controller).toBeTruthy();
const duplicated = { unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-2' };
add$.next([duplicated]);
add$.next([duplicated]);
await vi.advanceTimersByTimeAsync(40);
expect(renderImages).toHaveBeenCalledTimes(1);
expect(drawingManagerService.refreshTransform).toHaveBeenCalledWith([imageParam]);
});
it('refreshes a loaded image from the current drawing state after updates during loading', async () => {
vi.useFakeTimers();
const add$ = new Subject<Array<{ unitId: string; subUnitId: string; drawingId: string }>>();
const update$ = new Subject();
const insertedImage = {
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-2',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: { left: 0, top: 0, width: 100, height: 50, angle: 0 },
};
const updatedImage = {
...insertedImage,
transform: { ...insertedImage.transform, left: 200, top: 120 },
};
let currentImage = insertedImage;
let finishRendering!: (images: never[]) => void;
const renderImages = vi.fn(() => new Promise<never[]>((resolve) => {
finishRendering = resolve;
}));
const refreshTransform = vi.fn();
const controller = createController(
{ onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })), syncExecuteCommand: vi.fn() } as never,
{
getRenderUnitById: vi.fn(() => ({
scene: { getTransformerByCreate: vi.fn(), getObject: vi.fn() },
})),
} as never,
{
add$,
update$,
getDrawingByParam: vi.fn(() => currentImage),
refreshTransform,
} as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages } as never
);
add$.next([{ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-2' }]);
await vi.advanceTimersByTimeAsync(40);
currentImage = updatedImage;
finishRendering([]);
await Promise.resolve();
expect(refreshTransform).toHaveBeenCalledWith([updatedImage]);
controller.dispose();
});
it('applies refreshed transform, clip bounds, and behind-text layer to existing images', () => {
const add$ = new Subject();
const update$ = new Subject<Array<{ unitId: string; subUnitId: string; drawingId: string }>>();
const transform = {
angle: 0,
clipBounds: { height: 120, left: 0, top: 0, width: 80 },
height: 60,
left: 10,
top: 20,
width: 100,
};
const imageParam = {
behindText: true,
drawingId: 'shape-3',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,Zm9v',
srcRect: { bottom: 0, left: 0, right: 0, top: 0 },
subUnitId: 'sheet-1',
transform,
unitId: 'book-1',
};
const imageShape = {
changeSource: vi.fn(),
layer: { zIndex: 4 },
setClipBounds: vi.fn(),
setPrstGeom: vi.fn(),
setSrcRect: vi.fn(),
transformByState: vi.fn(),
};
const scene = {
addObject: vi.fn(),
getObject: vi.fn(() => imageShape),
getTransformerByCreate: vi.fn(),
removeObject: vi.fn(),
};
const drawingManagerService = {
add$,
update$,
getDrawingByParam: vi.fn(() => imageParam),
};
const controller = createController(
{ onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })), syncExecuteCommand: vi.fn() } as never,
{
getRenderUnitById: vi.fn(() => ({ scene })),
} as never,
drawingManagerService as never,
{} as never,
{} as never,
{ getUnit: vi.fn(() => createSheetUnit()), getFocusedUnit: vi.fn(() => createSheetUnit()) } as never,
{ renderImages: vi.fn() } as never
);
expect(controller).toBeTruthy();
update$.next([{ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'shape-3' }]);
expect(imageShape.transformByState).toHaveBeenCalledWith({
angle: 0,
flipX: false,
flipY: false,
height: 60,
left: 10,
skewX: 0,
skewY: 0,
top: 20,
width: 100,
});
expect(imageShape.setClipBounds).toHaveBeenCalledWith(transform.clipBounds);
expect(scene.removeObject).toHaveBeenCalledWith(imageShape);
expect(scene.addObject).toHaveBeenCalledWith(imageShape, DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
});
});

View File

@@ -0,0 +1,311 @@
/**
* 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, UniverInstanceType } from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { DrawingGroupObject, Group } from '@univerjs/engine-render';
import { describe, expect, it, vi } from 'vitest';
import { disposeDrawingRenderObject, getCurrentUnitInfo, getDrawingRenderObject, insertGroupObject, syncGroupRotateEnabled } from '../utils';
const { MockGroup } = vi.hoisted(() => {
class HoistedMockGroup {
oKey: string;
transformerConfig?: { rotateEnabled?: boolean };
private _objects: Array<{ oKey: string }> = [];
setBaseBound = vi.fn();
transformByState = vi.fn();
constructor(oKey: string) {
this.oKey = oKey;
}
getObjects() {
return this._objects;
}
addObject(object: { oKey: string }) {
this._objects.push(object);
}
getObjectIncludeInGroup(key: string) {
return this._objects.find((obj) => obj.oKey === key) ?? null;
}
}
return {
MockGroup: HoistedMockGroup,
};
});
vi.mock('@univerjs/drawing', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/drawing')>();
return {
...actual,
getDrawingShapeKeyByDrawingSearch: vi.fn(({ drawingId }) => `group-${drawingId}`),
};
});
vi.mock('@univerjs/engine-render', () => ({
DRAWING_OBJECT_LAYER_INDEX: 99,
Group: MockGroup,
DrawingGroupObject: MockGroup,
}));
describe('drawing controller utils', () => {
it('creates a new drawing group, attaches it to the scene, and avoids duplicate children', () => {
const object = { oKey: 'child-1' };
const attachTransformerTo = vi.fn();
const scene = {
objects: new Map<string, InstanceType<typeof MockGroup> | { oKey: string }>(),
getObject(key: string) {
return this.objects.get(key) ?? null;
},
addObject(group: InstanceType<typeof MockGroup>) {
this.objects.set(group.oKey, group);
return {
attachTransformerTo,
};
},
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
},
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 10, top: 20, width: 30, height: 40, angle: 15 },
groupBaseBound: { left: 1, top: 2, width: 3, height: 4 },
})),
getDrawingsByGroup: vi.fn(() => []),
};
insertGroupObject({ drawingId: 'group-1' } as never, object as never, scene as never, drawingManagerService as never);
const group = scene.getObject('group-group-1') as InstanceType<typeof MockGroup>;
expect(getDrawingShapeKeyByDrawingSearch).toHaveBeenCalledWith({ drawingId: 'group-1' });
expect(group).toBeInstanceOf(DrawingGroupObject as unknown as typeof MockGroup);
expect(group.getObjects()).toEqual([object]);
expect(group.setBaseBound).toHaveBeenCalledWith({ left: 1, top: 2, width: 3, height: 4 });
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 15 });
expect(attachTransformerTo).toHaveBeenCalledWith(group);
insertGroupObject({ drawingId: 'group-1' } as never, object as never, scene as never, drawingManagerService as never);
expect(group.getObjects()).toEqual([object]);
const nextObject = { oKey: 'child-2' };
insertGroupObject({ drawingId: 'group-1' } as never, nextObject as never, scene as never, drawingManagerService as never);
expect(group.getObjects()).toEqual([object, nextObject]);
});
it('inserts nested groups into their parent group', () => {
const object = { oKey: 'child-1' };
const scene = {
objects: new Map<string, InstanceType<typeof MockGroup> | { oKey: string }>(),
getObject(key: string) {
return this.objects.get(key) ?? null;
},
addObject(group: InstanceType<typeof MockGroup>) {
this.objects.set(group.oKey, group);
return {
attachTransformerTo: vi.fn(),
};
},
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
},
};
const drawingManagerService = {
getDrawingByParam: vi.fn((param: { drawingId: string }) => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: param.drawingId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
groupId: param.drawingId === 'child-group' ? 'parent-group' : undefined,
transform: { left: 0, top: 0, width: 10, height: 10 },
})),
getDrawingsByGroup: vi.fn(() => []),
};
insertGroupObject({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-group' }, object as never, scene as never, drawingManagerService as never);
const childGroup = scene.getObject('group-child-group') as InstanceType<typeof MockGroup> & { isInGroup?: boolean };
const parentGroup = scene.getObject('group-parent-group') as InstanceType<typeof MockGroup>;
expect(childGroup.isInGroup).toBe(true);
expect(parentGroup.getObjects()).toEqual([childGroup]);
});
it('disables restored group rotation when descendants include an old chart drawing', () => {
const object = { oKey: 'chart-1' };
const scene = {
objects: new Map<string, InstanceType<typeof MockGroup> | { oKey: string }>(),
getObject(key: string) {
return this.objects.get(key) ?? null;
},
addObject(group: InstanceType<typeof MockGroup>) {
this.objects.set(group.oKey, group);
return {
attachTransformerTo: vi.fn(),
};
},
getObjectIncludeInGroup(key: string) {
return this.getObject(key);
},
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: { left: 10, top: 20, width: 30, height: 40, angle: 30 },
})),
getDrawingsByGroup: vi.fn(() => [{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
transform: { left: 10, top: 20, width: 30, height: 40 },
groupId: 'group-1',
}]),
};
insertGroupObject({ unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'group-1' }, object as never, scene as never, drawingManagerService as never);
const group = scene.getObject('group-group-1') as InstanceType<typeof MockGroup>;
expect(group.transformerConfig?.rotateEnabled).toBe(false);
expect(group.transformByState).toHaveBeenCalledWith({ left: 10, top: 20, width: 30, height: 40, angle: 30 });
});
it('uses explicit children when synchronizing group rotation state', () => {
const group = new MockGroup('group-group-1');
const scene = {
getObjectIncludeInGroup: vi.fn(() => null),
};
const drawingManagerService = {
getDrawingsByGroup: vi.fn(() => []),
};
syncGroupRotateEnabled(group as never, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'group-1',
drawingType: DrawingTypeEnum.DRAWING_GROUP,
}, scene as never, drawingManagerService as never, [{
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'chart-1',
drawingType: DrawingTypeEnum.DRAWING_CHART,
}]);
expect(group.transformerConfig?.rotateEnabled).toBe(false);
expect(drawingManagerService.getDrawingsByGroup).not.toHaveBeenCalled();
});
it('skips invalid group targets and resolves current unit info for sheet, doc, and slide', () => {
const scene = {
getObject: vi.fn(() => ({ oKey: 'not-a-group' })),
getObjectIncludeInGroup: vi.fn(() => ({ oKey: 'not-a-group' })),
addObject: vi.fn(() => ({ attachTransformerTo: vi.fn() })),
};
const drawingManagerService = {
getDrawingByParam: vi.fn(() => ({ transform: { left: 0, top: 0, width: 10, height: 10 } })),
getDrawingsByGroup: vi.fn(() => []),
};
insertGroupObject({ drawingId: 'group-2' } as never, { oKey: 'child-2' } as never, scene as never, drawingManagerService as never);
expect(scene.getObjectIncludeInGroup).toHaveBeenCalled();
expect(scene.addObject).not.toHaveBeenCalled();
insertGroupObject({ drawingId: 'missing-group' } as never, { oKey: 'child-3' } as never, scene as never, {
getDrawingByParam: vi.fn(() => null),
getDrawingsByGroup: vi.fn(() => []),
} as never);
const sheet = {
type: UniverInstanceType.UNIVER_SHEET,
getUnitId: () => 'workbook-1',
getActiveSheet: () => ({ getSheetId: () => 'sheet-1' }),
};
const doc = {
type: UniverInstanceType.UNIVER_DOC,
getUnitId: () => 'doc-1',
};
const slide = {
type: UniverInstanceType.UNIVER_SLIDE,
getUnitId: () => 'slide-1',
};
const currentUniverService = {
getFocusedUnit: vi.fn()
.mockReturnValueOnce(sheet)
.mockReturnValueOnce(doc)
.mockReturnValueOnce(slide)
.mockReturnValueOnce(null),
getUnit: vi.fn(() => sheet),
};
expect(getCurrentUnitInfo(currentUniverService as never)).toEqual({
unitId: 'workbook-1',
subUnitId: 'sheet-1',
current: sheet,
});
expect(getCurrentUnitInfo(currentUniverService as never)).toEqual({
unitId: 'doc-1',
subUnitId: 'doc-1',
current: doc,
});
expect(getCurrentUnitInfo(currentUniverService as never)).toEqual({
unitId: 'slide-1',
subUnitId: 'slide-1',
current: slide,
});
expect(getCurrentUnitInfo(currentUniverService as never)).toBeUndefined();
expect(getCurrentUnitInfo(currentUniverService as never, 'workbook-1')).toEqual({
unitId: 'workbook-1',
subUnitId: 'sheet-1',
current: sheet,
});
expect(Group).toBeDefined();
});
it('looks up and disposes grouped render objects through grouped scene lookup only', () => {
const renderObject = { oKey: 'group-child', dispose: vi.fn() };
const scene = {
getObject: vi.fn(() => ({ oKey: 'top-level-child' })),
getObjectIncludeInGroup: vi.fn(() => renderObject),
};
const drawingSearch = { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-1' };
expect(getDrawingRenderObject(scene as never, drawingSearch)).toBe(renderObject);
expect(disposeDrawingRenderObject(scene as never, drawingSearch)).toBe(true);
expect(renderObject.dispose).toHaveBeenCalledTimes(1);
expect(scene.getObject).not.toHaveBeenCalled();
});
it('does not fall back to top-level scene lookup when grouped lookup misses', () => {
const scene = {
getObject: vi.fn(() => ({ oKey: 'top-level-child', dispose: vi.fn() })),
getObjectIncludeInGroup: vi.fn(() => null),
};
expect(getDrawingRenderObject(scene as never, { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-1' })).toBeNull();
expect(disposeDrawingRenderObject(scene as never, { unitId: 'unit-1', subUnitId: 'sheet-1', drawingId: 'child-1' })).toBe(false);
expect(scene.getObject).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
/**
* 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 { Disposable, Inject } from '@univerjs/core';
import {
BottomIcon,
CropIcon,
DeleteIcon,
DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
} from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import {
COMPONENT_IMAGE_POPUP_MENU,
} from '../views/image-popup-menu/component-name';
import { ImagePopupMenu } from '../views/image-popup-menu/ImagePopupMenu';
import { DRAWING_ALIGN_COMPONENT, DRAWING_ARRANGE_COMPONENT, DRAWING_GROUP_COMPONENT, DRAWING_TRANSFORM_COMPONENT, IMAGE_CROPPER_COMPONENT } from '../views/panel/component-name';
import { DrawingAlign } from '../views/panel/DrawingAlign';
import { DrawingArrange } from '../views/panel/DrawingArrange';
import { DrawingGroup } from '../views/panel/DrawingGroup';
import { DrawingTransform } from '../views/panel/DrawingTransform';
import { ImageCropper } from '../views/panel/ImageCropper';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerIcons();
}
private _registerComponents(): void {
([
[COMPONENT_IMAGE_POPUP_MENU, ImagePopupMenu],
[DRAWING_ARRANGE_COMPONENT, DrawingArrange],
[DRAWING_ALIGN_COMPONENT, DrawingAlign],
[DRAWING_GROUP_COMPONENT, DrawingGroup],
[DRAWING_TRANSFORM_COMPONENT, DrawingTransform],
[IMAGE_CROPPER_COMPONENT, ImageCropper],
] as const).forEach(([key, component]) => {
this.disposeWithMe(this._componentManager.register(key, component));
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
BottomIcon,
DrawingCropIcon: CropIcon,
DrawingDeleteIcon: DeleteIcon,
DrawingEditIcon: DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
}));
}
}

View File

@@ -0,0 +1,934 @@
/**
* 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,
ICommandInfo,
IDrawingParam,
IDrawingSearch,
ITransformState,
Nullable,
} from '@univerjs/core';
import type { IDrawingGroupUpdateParam, IDrawingOrderMapParam } from '@univerjs/drawing';
import type { BaseObject, Image, IShapeProps, Scene, Shape } from '@univerjs/engine-render';
import type { ISetDrawingAlignOperationParams } from '../commands/operations/drawing-align.operation';
import {
checkIfMove,
Disposable,
ICommandService,
IUniverInstanceService,
toDisposable,
} from '@univerjs/core';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import {
DRAWING_OBJECT_LAYER_INDEX,
DrawingGroupObject,
Group,
IRenderManagerService,
RENDER_CLASS_TYPE,
} from '@univerjs/engine-render';
import { AlignType, SetDrawingAlignOperation } from '../commands/operations/drawing-align.operation';
import { CloseImageCropOperation } from '../commands/operations/image-crop.operation';
import { ensureDrawingRenderLayer } from '../services/drawing-render.service';
import { getUpdateParams } from '../utils/get-update-params';
import { disposeDrawingRenderObject, getCurrentUnitInfo, insertGroupObject, syncGroupRotateEnabled } from './utils';
interface IDrawingTransformCache {
unitId: string;
subUnitId: string;
drawingId: string;
drawingType: DrawingTypeEnum;
transform: ITransformState;
}
interface IDrawingTransformStateWithClipBounds extends ITransformState {
clipBounds?: Nullable<{ left: number; top: number; width: number; height: number }>;
}
interface IDrawingRefreshMetadata {
hidden?: boolean;
behindText?: boolean;
selectable?: boolean;
}
type IDrawingParamWithRefreshMetadata = IDrawingParam & IDrawingRefreshMetadata;
function hasRefreshMetadata(refreshParam: IDrawingSearch): refreshParam is IDrawingSearch & IDrawingRefreshMetadata {
return 'hidden' in refreshParam || 'behindText' in refreshParam || 'selectable' in refreshParam;
}
function syncDrawingHiddenState(shape: BaseObject, drawingParam: IDrawingParamWithRefreshMetadata): void {
if (!('hidden' in drawingParam)) {
return;
}
drawingParam.hidden === true ? shape.hide() : shape.show();
}
function syncDrawingSelectableState(
shape: BaseObject,
drawingParam: IDrawingParamWithRefreshMetadata,
scene: Scene,
drawingManagerService: IDrawingManagerService
): void {
const previousDrawing = drawingManagerService.getOldDrawingByParam(drawingParam);
// Some drawing types are eventless by design. Only override that behavior when selectable is explicitly
// managed; deleting a previously present field means restoring Shape's default selectable state.
if (!('selectable' in drawingParam) && (!previousDrawing || !('selectable' in previousDrawing))) {
return;
}
const selectable = drawingParam.selectable !== false;
shape.evented = selectable;
if (selectable) {
return;
}
// Disabling picking must also discard stale selection state, otherwise the transformer remains interactive.
scene.getTransformer()?.clearControlByIds([shape.oKey]);
const focusedDrawings = drawingManagerService.getFocusDrawings();
const remainingDrawings = focusedDrawings.filter(({ unitId, subUnitId, drawingId }) =>
unitId !== drawingParam.unitId || subUnitId !== drawingParam.subUnitId || drawingId !== drawingParam.drawingId
);
if (remainingDrawings.length !== focusedDrawings.length) {
drawingManagerService.focusDrawing(remainingDrawings);
}
}
function mergeRefreshMetadata(drawingParam: IDrawingParam, refreshParam: IDrawingSearch): IDrawingParamWithRefreshMetadata {
if (!hasRefreshMetadata(refreshParam)) {
return drawingParam;
}
return {
...drawingParam,
...refreshParam,
};
}
export class DrawingUpdateController extends Disposable {
constructor(
@IUniverInstanceService private readonly _currentUniverService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService
) {
super();
this._initialize();
}
override dispose(): void {
super.dispose();
}
private _initialize() {
this._recoveryImages();
this._drawingAddListener();
this._drawingRemoveListener();
this._drawingUpdateListener();
this._commandExecutedListener();
this._drawingArrangeListener();
this._drawingGroupListener();
this._drawingRefreshListener();
this._drawingVisibleListener();
}
private _recoveryImages() {
const drawingList = this._drawingManagerService.drawingManagerData;
const info = getCurrentUnitInfo(this._currentUniverService);
if (info == null) {
return;
}
const { unitId: currentUnitId, subUnitId: currentSubUnitId } = info;
Object.keys(drawingList).forEach((unitId) => {
Object.keys(drawingList[unitId]).forEach((subUnitId) => {
const drawingMap = drawingList[unitId][subUnitId].data;
if (drawingMap == null || unitId !== currentUnitId || subUnitId !== currentSubUnitId) {
return;
}
Object.keys(drawingMap).forEach((drawingId) => {
const drawing = drawingMap[drawingId];
if (drawing) {
this._insertDrawing([{ unitId, subUnitId, drawingId }]);
}
});
});
});
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetDrawingAlignOperation.id) {
const params = command.params as ISetDrawingAlignOperationParams;
if (params == null) {
return;
}
this._drawingAlign(params);
}
})
);
}
private _drawingGroupListener() {
this.disposeWithMe(
this._drawingManagerService.group$.subscribe((params) => {
this._groupDrawings(params);
})
);
this.disposeWithMe(
this._drawingManagerService.ungroup$.subscribe((params) => {
this._ungroupDrawings(params);
})
);
}
// private _drawingGroup(params: ISetImageGroupOperationParams) {
// const { groupType } = params;
// const drawings = this._drawingManagerService.getFocusDrawings();
// if (drawings.length === 0) {
// return;
// }
// switch (groupType) {
// case GroupType.group:
// this._groupDrawings(drawings);
// break;
// case GroupType.regroup:
// this._regroupDrawings(drawings);
// break;
// case GroupType.ungroup:
// this._ungroupDrawings(drawings);
// break;
// default:
// break;
// }
// }
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return null;
}
const transformer = scene.getTransformerByCreate();
return { scene, transformer };
}
private _groupDrawings(drawings: IDrawingGroupUpdateParam[]) {
drawings.forEach((drawing) => {
this._groupDrawing(drawing);
});
}
private _groupDrawing(params: IDrawingGroupUpdateParam) {
const { parent, children } = params;
const { unitId, subUnitId, drawingId } = parent;
const renderObject = this._getSceneAndTransformerByDrawingSearch(parent.unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
const objects: BaseObject[] = [];
children.forEach((drawing) => {
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch(drawing);
const object = scene.getObjectIncludeInGroup(drawingShapeKey);
if (object == null || objects.includes(object)) {
return;
}
objects.push(object);
const { transform } = drawing;
if (transform == null) {
return;
}
if (object.classType === RENDER_CLASS_TYPE.GROUP) {
object.transformByState({ left: transform.left, top: transform.top });
} else {
object.transformByState(transform);
}
// const group = object?.ancestorGroup;
// if (group != null && objects.includes(group) === false) {
// objects.push(group);
// } else if (object != null && objects.includes(object) === false) {
// objects.push(object);
// }
});
if (objects.length === 0) {
return;
}
const groupKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const group = new DrawingGroupObject(groupKey);
scene.addObject(group, DRAWING_OBJECT_LAYER_INDEX).attachTransformerTo(group);
group.addObjects(...objects);
syncGroupRotateEnabled(group, parent, scene, this._drawingManagerService, children);
if (parent.groupBaseBound) {
group.setBaseBound(parent.groupBaseBound);
}
if (parent.groupId) {
group.isInGroup = true;
insertGroupObject(
{ drawingId: parent.groupId, unitId, subUnitId },
group,
scene,
this._drawingManagerService
);
}
// group.reCalculateObjects();
parent.transform && group.transformByState({ left: parent.transform.left, top: parent.transform.top, width: parent.transform.width, height: parent.transform.height, angle: parent.transform.angle });
transformer.clearSelectedObjects();
transformer.setSelectedControl(group);
}
// private _regroupDrawings(drawings: IDrawingSearch[]) {
// const renderObject = this._getSceneAndTransformerByDrawingSearch(drawings[0].unitId);
// if (renderObject == null) {
// return;
// }
// const { scene, transformer } = renderObject;
// const objects: BaseObject[] = [];
// let firstGroup: Nullable<Group> = null;
// drawings.forEach((drawing) => {
// const imageShapeKey = getDrawingShapeKeyByDrawingSearch(drawing);
// const o = scene.getObject(imageShapeKey);
// if (o == null) {
// return true;
// }
// const group = o.ancestorGroup as Nullable<Group>;
// if (group != null && firstGroup == null) {
// firstGroup = group;
// } else if (group != null && !objects.includes(group)) {
// objects.push(group);
// } else if (!objects.includes(o)) {
// objects.push(o);
// }
// });
// if (firstGroup == null) {
// return;
// }
// if (objects.length === 0) {
// return;
// }
// (firstGroup as Group).addObjects(...objects);
// (firstGroup as Group).reCalculateObjects();
// transformer.clearSelectedObjects();
// transformer.setSelectedControl(firstGroup);
// }
private _ungroupDrawings(drawings: IDrawingGroupUpdateParam[]) {
drawings.forEach((drawing) => {
this._ungroupDrawing(drawing);
});
}
private _ungroupDrawing(drawing: IDrawingGroupUpdateParam) {
const { parent, children } = drawing;
const renderObject = this._getSceneAndTransformerByDrawingSearch(parent.unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
// const objects: BaseObject[] = [];
children.forEach((drawing) => {
const drawingKey = getDrawingShapeKeyByDrawingSearch(drawing);
const object = scene.getObjectIncludeInGroup(drawingKey);
if (object == null) {
return true;
}
if (object == null) {
return;
}
// objects.push(object);
const { transform } = drawing;
if (transform == null) {
return;
}
if (object.classType === RENDER_CLASS_TYPE.GROUP) {
object.transformByState({ left: transform.left, top: transform.top });
} else {
object.transformByState(transform);
}
// transform && object.transformByState(transform);
// const group = o.ancestorGroup as Nullable<Group>;
// if (group != null && !objects.includes(group)) {
// objects.push(group);
// } else if (o instanceof Group && !objects.includes(o)) {
// objects.push(o);
// }
});
const groupKey = getDrawingShapeKeyByDrawingSearch(parent);
const group = scene.getObject(groupKey) as Group;
const { width, height } = group;
group.getObjects().forEach((object) => {
group.removeSelfObjectAndTransform(object.oKey, width, height);
});
group.dispose();
// if (objects.length === 0) {
// return;
// }
// objects.forEach((group) => {
// const { width, height } = group;
// group.getObjects().forEach((object) => {
// group.removeSelfObjectAndTransform(object.oKey, width, height);
// });
// group.dispose();
// });
transformer.clearSelectedObjects();
}
private _drawingAlign(params: ISetDrawingAlignOperationParams) {
const { alignType } = params;
const drawings = params.drawings || this._drawingManagerService.getFocusDrawings();
if (alignType === AlignType.default) {
return;
}
const drawingTransformCaches: IDrawingTransformCache[] = [];
let minLeft = Number.POSITIVE_INFINITY;
let minTop = Number.POSITIVE_INFINITY;
let maxRight = Number.NEGATIVE_INFINITY;
let maxBottom = Number.NEGATIVE_INFINITY;
let drawingCount = 0;
drawings.forEach((drawing) => {
const { unitId, subUnitId, drawingId, drawingType } = drawing;
const drawingParam = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (drawingParam == null || drawingParam.transform == null) {
return;
}
drawingTransformCaches.push({
unitId,
subUnitId,
drawingId,
drawingType,
transform: drawingParam.transform,
});
const { left = 0, top = 0, width = 0, height = 0 } = drawingParam.transform;
minLeft = Math.min(minLeft, left);
minTop = Math.min(minTop, top);
maxRight = Math.max(maxRight, left + width);
maxBottom = Math.max(maxBottom, top + height);
drawingCount++;
});
if (drawingCount === 0) {
return;
}
this._sortDrawingTransform(drawingTransformCaches, alignType);
this._applyAlignType(drawingTransformCaches, alignType, minLeft, minTop, maxRight, maxBottom, drawingCount);
}
private _applyAlignType(drawingTransformCaches: IDrawingTransformCache[], alignType: AlignType, minLeft: number, minTop: number, maxRight: number, maxBottom: number, drawingCount: number) {
const averageHorizon = Math.round((maxRight - minLeft) / drawingCount * 10) / 10;
const averageVertical = Math.round((maxBottom - minTop) / drawingCount * 10) / 10;
const updateParams: IDrawingParam[] = [];
const renderObject = this._getSceneAndTransformerByDrawingSearch(drawingTransformCaches[0].unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
drawingTransformCaches.forEach((drawingTransformCache, index) => {
const { unitId, subUnitId, drawingId, transform, drawingType } = drawingTransformCache;
const { left = 0, top = 0, width = 0, height = 0 } = transform;
let newLeft = left;
let newTop = top;
switch (alignType) {
case AlignType.left:
newLeft = minLeft;
break;
case AlignType.center:
newLeft = minLeft + (maxRight - minLeft) / 2 - width / 2;
break;
case AlignType.right:
newLeft = maxRight - width;
break;
case AlignType.top:
newTop = minTop;
break;
case AlignType.middle:
newTop = minTop + (maxBottom - minTop) / 2 - height / 2;
break;
case AlignType.bottom:
newTop = maxBottom - height;
break;
case AlignType.horizon:
newLeft = minLeft + averageHorizon * index;
break;
case AlignType.vertical:
newTop = minTop + averageVertical * index;
break;
default:
break;
}
if (newLeft !== left || newTop !== top) {
updateParams.push({
unitId,
subUnitId,
drawingId,
drawingType,
transform: {
left: newLeft,
top: newTop,
},
});
}
});
this._drawingManagerService.featurePluginUpdateNotification(updateParams);
transformer.refreshControls().changeNotification();
}
private _sortDrawingTransform(drawingTransformCaches: IDrawingTransformCache[], alignType: AlignType) {
drawingTransformCaches.sort((a, b) => {
const aTransform = a.transform;
const bTransform = b.transform;
const {
left: aLeft = 0,
top: aTop = 0,
width: aWidth = 0,
height: aHeight = 0,
} = aTransform;
const {
left: bLeft = 0,
top: bTop = 0,
width: bWidth = 0,
height: bHeight = 0,
} = bTransform;
switch (alignType) {
case AlignType.left:
return aLeft - bLeft;
case AlignType.center:
return aLeft + aWidth / 2 - (bLeft + bWidth / 2);
case AlignType.right:
return aLeft + aWidth - (bLeft + bWidth);
case AlignType.top:
return aTop - bTop;
case AlignType.middle:
return aTop + aHeight / 2 - (bTop + bHeight / 2);
case AlignType.bottom:
return aTop + aHeight - (bTop + bHeight);
case AlignType.horizon:
return aLeft + aWidth / 2 - (bLeft + bWidth / 2);
case AlignType.vertical:
return aTop + aHeight / 2 - (bTop + bHeight / 2);
default:
return 0;
}
});
}
private _drawingArrangeListener() {
this.disposeWithMe(
this._drawingManagerService.order$.subscribe((params) => {
this._drawingArrange(params);
})
);
}
private _drawingArrange(params: IDrawingOrderMapParam) {
const { unitId, subUnitId, drawingIds } = params;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
drawingIds.forEach((drawingId) => {
const oKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const drawingShapes = scene.fuzzyMathObjects(oKey, true) as Shape<IShapeProps>[];
if (drawingShapes == null || drawingShapes.length === 0) {
return;
}
const index = this._drawingManagerService.getDrawingOrder(unitId, subUnitId).indexOf(drawingId);
for (const shape of drawingShapes) {
shape.setProps({ zIndex: index });
shape.makeDirty();
}
});
}
private _sceneListenerOnDrawingMap: WeakSet<Scene> = new WeakSet();
private _drawingAddListener() {
this.disposeWithMe(
this._drawingManagerService.add$.subscribe((params) => {
this._insertDrawing(params);
})
);
}
private _insertDrawing(params: IDrawingSearch[]) {
const sceneList: Scene[] = [];
(params).forEach((param) => {
const { unitId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IDrawingParam;
if (drawingParam == null) {
return;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (!sceneList.includes(scene)) {
sceneList.push(scene);
}
});
sceneList.forEach((scene) => {
if (this._sceneListenerOnDrawingMap.has(scene)) {
return;
}
this._addListenerOnDrawing(scene);
this._sceneListenerOnDrawingMap.add(scene);
});
}
private _drawingRemoveListener() {
this.disposeWithMe(
this._drawingManagerService.remove$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (disposeDrawingRenderObject(scene, { unitId, subUnitId, drawingId })) {
scene.getTransformer()?.clearSelectedObjects();
}
});
})
);
}
private _drawingUpdateListener() {
this.disposeWithMe(
this._drawingManagerService.update$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IDrawingParam;
if (drawingParam == null) {
return;
}
const { transform, drawingType } = drawingParam;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene, transformer } = renderObject;
if (transform == null) {
return true;
}
const { left = 0, top = 0, width = 0, height = 0, angle = 0, flipX = false, flipY = false, skewX = 0, skewY = 0 } = transform;
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
// getObject() excludes hidden and grouped children, but both still need model updates and may
// become visible again as part of this notification.
const drawingShape = scene.getObjectIncludeInGroup(drawingShapeKey) as Image;
if (drawingShape == null) {
return true;
}
drawingShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(drawingShape as Image).setClipBounds?.((transform as IDrawingTransformStateWithClipBounds).clipBounds);
syncDrawingHiddenState(drawingShape, drawingParam);
syncDrawingSelectableState(drawingShape, drawingParam, scene, this._drawingManagerService);
ensureDrawingRenderLayer(scene, drawingShape, drawingParam);
scene.getTransformer()?.debounceRefreshControls();
});
})
);
}
private _drawingRefreshListener() {
this.disposeWithMe(
this._drawingManagerService.refreshTransform$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IDrawingParam;
if (drawingParam == null) {
return;
}
const { transform } = drawingParam;
const { scene } = renderObject;
if (transform == null) {
return true;
}
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const drawingShape = scene.getObjectIncludeInGroup(drawingShapeKey);
const drawingParamWithRefreshMetadata = mergeRefreshMetadata(drawingParam, param);
if (drawingShape == null) {
if (drawingParamWithRefreshMetadata.hidden === true) {
return true;
}
this._drawingManagerService.addNotification([{ unitId, subUnitId, drawingId }]);
return true;
}
const {
left = 0,
top = 0,
width = 0,
height = 0,
angle = 0,
flipX = false,
flipY = false,
skewX = 0,
skewY = 0,
} = transform;
drawingShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(drawingShape as Image).setClipBounds?.((transform as IDrawingTransformStateWithClipBounds).clipBounds);
syncDrawingHiddenState(drawingShape as BaseObject, drawingParamWithRefreshMetadata);
syncDrawingSelectableState(drawingShape as BaseObject, drawingParamWithRefreshMetadata, scene, this._drawingManagerService);
ensureDrawingRenderLayer(scene, drawingShape as BaseObject, drawingParamWithRefreshMetadata);
});
})
);
}
private _drawingVisibleListener() {
this.disposeWithMe(
this._drawingManagerService.visible$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId, visible } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
// Hidden objects are absent from getObject(), so use the unfiltered lookup to show them again.
const drawingShape = scene.getObjectIncludeInGroup(drawingShapeKey);
if (drawingShape == null) {
return true;
}
if (visible) {
drawingShape.show();
} else {
drawingShape.hide();
}
});
})
);
}
private _filterUpdateParams(params: Nullable<IDrawingParam>[], startTransforms: Nullable<ITransformState[]>) {
return params.filter((param, index) => {
if (param == null) {
return false;
}
const { transform } = param;
return checkIfMove(transform, startTransforms?.[index]);
}) as IDrawingParam[];
}
// group?.getObjects().forEach((o) => {
// const drawing = this._drawingManagerService.getDrawingOKey(o.oKey);
// if (drawing != null) {
// const { unitId, subUnitId, drawingId } = drawing;
// drawings.push({ unitId, subUnitId, drawingId });
// }
// });
private _addListenerOnDrawing(scene: Scene) {
const transformer = scene.getTransformerByCreate();
let startTransforms: Nullable<ITransformState[]> = null;
this.disposeWithMe(
toDisposable(
transformer.changeStart$.subscribe((state) => {
const { objects } = state;
const objectArray = Array.from(objects.values());
const drawings: IDrawingSearch[] = [];
startTransforms = objectArray.map((object) => {
const { left, top, height, width, angle, oKey, isInGroup } = object;
const drawing = this._drawingManagerService.getDrawingOKey(oKey);
if (isInGroup || object instanceof Group) {
let group = object.ancestorGroup as Group;
if (group == null && object instanceof Group) {
group = object;
}
if (group == null) {
return null;
}
const groupDrawing = this._drawingManagerService.getDrawingOKey(group.oKey);
if (groupDrawing) {
const { unitId, subUnitId, drawingId } = groupDrawing;
drawings.push({ unitId, subUnitId, drawingId });
const { left, top, height, width, angle } = group;
return { left, top, height, width, angle };
}
} else if (drawing != null) {
const { unitId, subUnitId, drawingId } = drawing;
drawings.push({ unitId, subUnitId, drawingId });
return { left, top, height, width, angle };
}
return null;
}).filter((transform) => transform != null) as ITransformState[];
if (drawings.length > 0) {
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, drawings);
} else {
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, []);
}
})
)
);
this.disposeWithMe(
toDisposable(
transformer.changeEnd$.subscribe((state) => {
const { objects } = state;
const params = this._filterUpdateParams(getUpdateParams(objects, this._drawingManagerService), startTransforms);
if (params.length > 0) {
// this._drawingManagerService.batchUpdate(params);
this._drawingManagerService.featurePluginUpdateNotification(params);
}
})
)
);
}
}

View File

@@ -0,0 +1,525 @@
/**
* 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, IDrawingSearch, ISrcRect, ITransformState, Nullable, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Scene } from '@univerjs/engine-render';
import type { ICloseImageCropOperationParams, IOpenImageCropOperationBySrcRectParams } from '../commands/operations/image-crop.operation';
import type { LocaleKey } from '../locale/types';
import {
checkIfMove,
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import { CURSOR_TYPE, Image, IRenderManagerService, precisionTo } from '@univerjs/engine-render';
import { ILayoutService, IMessageService, IShortcutService, KeyCode } from '@univerjs/ui';
import { BehaviorSubject, of, switchMap } from 'rxjs';
import {
AutoImageCropOperation,
CloseImageCropOperation,
CropType,
OpenImageCropOperation,
} from '../commands/operations/image-crop.operation';
import { getImageCropRect } from '../utils/image-crop-transform';
import { ImageCropperObject } from '../views/crop/image-cropper-object';
interface IImageCropSnapshot {
transform: ITransformState;
srcRect: Nullable<ISrcRect>;
}
interface IImageCropSession {
scene: Scene;
imageShape: Image;
imageCropperObject: ImageCropperObject;
}
export class ImageCropperController extends Disposable {
private _sceneListenerOnImageMap: WeakSet<Scene> = new WeakSet();
private readonly _cropShortcutDisposables = new DisposableCollection();
private readonly _cropSnapshots = new WeakMap<ImageCropperObject, IImageCropSnapshot>();
private _pendingCropSnapshot: Nullable<IImageCropSnapshot> = null;
private _activeCropSession: Nullable<IImageCropSession> = null;
private readonly _cropping$ = new BehaviorSubject(false);
readonly cropping$ = this._cropping$.asObservable();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private _univerInstanceService: IUniverInstanceService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@ILayoutService private readonly _layoutService: ILayoutService
) {
super();
this.disposeWithMe(this._cropShortcutDisposables);
this.disposeWithMe({ dispose: () => this._cropping$.complete() });
this._init();
}
private _init(): void {
this._initOpenCrop();
this._initCloseCrop();
this._initAutoCrop();
}
private _initAutoCrop() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== AutoImageCropOperation.id) {
return;
}
const params = command.params as IOpenImageCropOperationBySrcRectParams;
if (params == null) {
return;
}
const { cropType } = params;
const drawingParams = this._drawingManagerService.getFocusDrawings();
if (drawingParams.length !== 1) {
return;
}
const drawingParam = drawingParams[0];
const { unitId, subUnitId, drawingId } = drawingParam;
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return true;
}
const imageCropperObject = this._searchCropObject(scene);
if (imageCropperObject != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
if (imageShape == null) {
return;
}
this._pendingCropSnapshot = this._captureCropSnapshot(imageShape);
try {
this._updateCropperObject(cropType, imageShape);
this._commandService.syncExecuteCommand(OpenImageCropOperation.id, { unitId, subUnitId, drawingId });
} finally {
this._pendingCropSnapshot = null;
}
})
);
}
private _calculateSrcRectByRatio(left: number, top: number, width: number, height: number, numerator: number, denominator: number) {
const srcRatio = width / height;
const ratio = numerator / denominator;
let newWidth = width;
let newHeight = height;
if (srcRatio > ratio) {
newWidth = height * ratio;
} else {
newHeight = width / ratio;
}
const newLeft = (width - newWidth) / 2;
const newTop = (height - newHeight) / 2;
return {
left: precisionTo(newLeft, 1),
top: precisionTo(newTop, 1),
right: precisionTo(width - (newLeft + newWidth), 1),
bottom: precisionTo(height - (newTop + newHeight), 1),
};
}
private _updateCropperObject(cropType: CropType, imageShape: Image) {
const { left, top, width, height } = imageShape.calculateTransformWithSrcRect();
let newSrcRect: Nullable<ISrcRect>;
switch (cropType) {
case CropType.R1_1:
// 1:1
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 1, 1);
break;
case CropType.R16_9:
// 16:9
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 16, 9);
break;
case CropType.R9_16:
// 9:16
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 9, 16);
break;
case CropType.R5_4:
// 5:4
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 5, 4);
break;
case CropType.R4_5:
// 4:5
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 5);
break;
case CropType.R4_3:
// 4:3
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 3);
break;
case CropType.R3_4:
// 3:4
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 4);
break;
case CropType.R3_2:
// 3:2
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 2);
break;
case CropType.R2_3:
// 2:3
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 2, 3);
break;
case CropType.FREE:
default:
break;
}
if (newSrcRect == null) {
return;
}
imageShape.setSrcRect(newSrcRect);
const { left: newLeft = 0, top: newTop = 0, bottom: newBottom = 0, right: newRight = 0 } = newSrcRect;
imageShape.transformByStateCloseCropper({
left: left + newLeft,
top: top + newTop,
width: width - newRight - newLeft,
height: height - newBottom - newTop,
});
}
private _initOpenCrop() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== OpenImageCropOperation.id) {
return;
}
const params = command.params as IDrawingSearch;
if (params == null) {
return;
}
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const { unitId, subUnitId, drawingId } = params;
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return true;
}
if (!this._sceneListenerOnImageMap.has(scene)) {
this._addListenerOnImage(scene);
this._sceneListenerOnImageMap.add(scene);
}
const imageData = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (imageData == null) {
return;
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
const transformer = scene.getTransformer();
transformer?.clearControls();
const imageCropperObject = new ImageCropperObject(`${imageShapeKey}-crop`, {
srcRect: imageShape.srcRect,
prstGeom: imageShape.prstGeom,
applyTransform: imageShape.calculateTransformWithSrcRect(),
});
this._cropSnapshots.set(imageCropperObject, this._pendingCropSnapshot ?? this._captureCropSnapshot(imageShape));
this._pendingCropSnapshot = null;
scene.addObject(imageCropperObject, imageShape.getLayerIndex() + 1).attachTransformerTo(imageCropperObject);
this._activeCropSession = { scene, imageShape, imageCropperObject };
this._cropping$.next(true);
transformer?.createControlForCopper(imageCropperObject);
this._addHoverForImageCopper(imageCropperObject);
imageShape.openRenderByCropper();
transformer?.refreshControls();
imageCropperObject.makeDirty(true);
this._registerCropShortcuts(imageCropperObject);
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [{ unitId, subUnitId, drawingId }]);
this._layoutService.focus();
})
);
}
private _searchCropObject(scene: Scene) {
const objects = scene.getAllObjectsByOrder();
for (const object of objects) {
if (object instanceof ImageCropperObject) {
return object as ImageCropperObject;
}
}
}
private _initCloseCrop() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== CloseImageCropOperation.id) {
return;
}
const cropSession = this._activeCropSession;
if (cropSession == null) {
return;
}
this._activeCropSession = null;
const { scene, imageShape, imageCropperObject } = cropSession;
const transformer = scene.getTransformerByCreate();
transformer.detachFrom(imageCropperObject);
transformer.clearCopperControl();
const params = command.params as ICloseImageCropOperationParams | undefined;
if (params?.isCancel) {
this._restoreCropSnapshot(imageShape, imageCropperObject);
} else {
const srcRect = getImageCropRect(imageShape, imageCropperObject);
const drawingParam = this._drawingManagerService.getDrawingOKey(imageShape.oKey);
if (drawingParam != null) {
const { left, top, height, width } = imageCropperObject;
this._drawingManagerService.featurePluginUpdateNotification([{
...drawingParam,
transform: {
...drawingParam.transform,
left,
top,
height,
width,
},
srcRect: srcRect.srcRectAngle,
}] as IImageData[]);
}
imageShape.setSrcRect({ ...srcRect.srcRectAngle });
}
imageShape.closeRenderByCropper();
imageShape.makeDirty(true);
transformer.refreshControls();
this._cropSnapshots.delete(imageCropperObject);
imageCropperObject.dispose();
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
})
);
const sheetUnit$ = this._univerInstanceService
.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(
switchMap((workbook) => workbook ? workbook.activeSheet$ : of(null))
);
this.disposeWithMe(sheetUnit$.subscribe(() => {
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
}));
}
private _getApplyObjectByCropObject(cropObject: ImageCropperObject): Nullable<Image> {
const cropOKey = cropObject.oKey;
const applyOKey = cropOKey.slice(0, cropOKey.length - 5);
const scene = cropObject.getScene();
if (!scene) return null;
const applyObject = scene.getObject(applyOKey) as Image;
if (applyObject == null) {
return null;
}
return applyObject;
}
private _captureCropSnapshot(imageShape: Image): IImageCropSnapshot {
return {
transform: imageShape.getState(),
srcRect: imageShape.srcRect == null ? imageShape.srcRect : { ...imageShape.srcRect },
};
}
private _restoreCropSnapshot(imageShape: Image, imageCropperObject: ImageCropperObject): void {
const snapshot = this._cropSnapshots.get(imageCropperObject);
if (snapshot) {
imageShape.transformByStateCloseCropper(snapshot.transform);
imageShape.setSrcRect(snapshot.srcRect);
}
}
private _registerCropShortcuts(imageCropperObject: ImageCropperObject): void {
this._cropShortcutDisposables.dispose();
this._cropShortcutDisposables.add(imageCropperObject.onDispose$.subscribeEvent(() => {
this._activeCropSession = null;
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ENTER,
priority: 1000,
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ESC,
priority: 1000,
staticParameters: { isCancel: true },
}));
}
private _addListenerOnImage(scene: Scene) {
const transformer = scene.getTransformerByCreate();
let startTransform: Nullable<ITransformState> = null;
this.disposeWithMe(
transformer.changeStart$.subscribe((state) => {
const { objects } = state;
const cropObject = objects.values().next().value as ImageCropperObject;
if (cropObject == null || !(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
startTransform = { left, top, height, width, angle };
transformer.clearCopperControl();
})
);
this.disposeWithMe(
transformer.changeEnd$.subscribe((state) => {
const { objects } = state;
const cropObject = objects.values().next().value as ImageCropperObject;
if (cropObject == null || !(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
if (!checkIfMove({ left, top, height, width, angle }, startTransform)) {
// Pointer-down removes the handles even when the gesture ends without a drag.
transformer.createControlForCopper(cropObject);
return;
}
const applyObject = this._getApplyObjectByCropObject(cropObject);
if (applyObject == null) {
return;
}
const srcRect = getImageCropRect(applyObject, cropObject);
cropObject.refreshSrcRect(srcRect.srcRect, applyObject.getState());
// cropObject.imageData = {
// ...cropObject.imageData,
// srcRect: srcRect.srcRect,
// } as IImageData;
transformer.createControlForCopper(cropObject);
})
);
this._endCropListener(scene);
}
private _addHoverForImageCopper(o: ImageCropperObject) {
this.disposeWithMe(
o.onPointerEnter$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.MOVE;
})
);
this.disposeWithMe(
o.onPointerLeave$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.DEFAULT;
})
);
}
private _endCropListener(scene: Scene) {
const transformer = scene.getTransformerByCreate();
this.disposeWithMe(
transformer.clearControl$.subscribe((changeSelf) => {
if (changeSelf === true) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
})
);
}
}

View File

@@ -0,0 +1,298 @@
/**
* 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, IDrawingSearch, Nullable } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Image, Scene } from '@univerjs/engine-render';
import {
Disposable,
DrawingTypeEnum,
ICommandService,
ImageSourceType,
Inject,
IUniverInstanceService,
toDisposable,
} from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { CURSOR_TYPE, IRenderManagerService } from '@univerjs/engine-render';
import { bufferTime, filter, map } from 'rxjs';
import { ImageResetSizeOperation } from '../commands/operations/image-reset-size.operation';
import { DrawingRenderService, ensureDrawingRenderLayer } from '../services/drawing-render.service';
import { getCurrentUnitInfo } from './utils';
export class ImageUpdateController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IUniverInstanceService private readonly _currentUniverService: IUniverInstanceService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService
) {
super();
this._initialize();
}
override dispose(): void {
super.dispose();
}
private _initialize() {
this._drawingAddListener();
this._commandExecutedListener();
this._imageUpdateListener();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === ImageResetSizeOperation.id) {
const params = command.params as IDrawingSearch[];
if (params == null) {
return;
}
this._resetImageSize(params);
}
})
);
}
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return null;
}
const transformer = scene.getTransformerByCreate();
return { scene, transformer };
}
private _resetImageSize(params: IDrawingSearch[]) {
const updateParams: IImageData[] = [];
const sceneList: Scene[] = [];
params.forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (imageShape == null) {
return true;
}
const imageData = this._drawingManagerService.getDrawingByParam(param);
if (imageData == null) {
return true;
}
if (imageData.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
(imageShape as Image).resetSize();
const { width, height } = (imageShape as Image).getNativeSize();
if (sceneList.includes(scene) === false) {
sceneList.push(scene);
}
updateParams.push({
...imageData,
transform: {
...imageData.transform,
height,
width,
angle: 0,
},
srcRect: null,
prstGeom: null,
} as IImageData);
});
this._drawingManagerService.featurePluginUpdateNotification(updateParams);
sceneList.forEach((scene) => {
const transformer = scene.getTransformerByCreate();
transformer.refreshControls().changeNotification();
});
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, params);
}
private _drawingAddListener() {
this.disposeWithMe(
this._drawingManagerService.add$
.pipe(
bufferTime(33),
filter((batches) => batches.length > 0),
map((batches) => batches.flat()),
map((items) => {
const map = new Map<string, IDrawingSearch>();
for (const it of items) {
map.set(`${it.unitId}|${it.subUnitId}|${it.drawingId}`, it);
}
return [...map.values()];
}),
filter((items) => items.length > 0)
)
.subscribe((uniqueParams) => {
this._insertImages(uniqueParams);
})
);
}
private _insertImages(params: IDrawingSearch[]) {
(params).forEach(async (param) => {
const { unitId, subUnitId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
const currentSubUnitId = getCurrentUnitInfo(this._currentUniverService, unitId)?.subUnitId;
if (renderObject == null || currentSubUnitId !== subUnitId) {
return;
}
const imageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (imageParam == null) {
return;
}
const images = await this._drawingRenderService.renderImages(imageParam, renderObject.scene);
// Image loading is asynchronous, so commands may have changed its transform or metadata while
// renderImages was pending. Refresh from the current model instead of the stale pre-load snapshot.
const currentImageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (currentImageParam) {
this._drawingManagerService.refreshTransform([currentImageParam]);
}
if (images == null || images.length === 0) {
return;
}
for (const image of images) {
this._addHoverForImage(image);
this._addDialogForImage(image);
}
});
}
private _imageUpdateListener() {
this.disposeWithMe(
this._drawingManagerService.update$.subscribe((params) => {
(params).forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (drawingParam == null) {
return;
}
const { transform, drawingType, srcRect, prstGeom, source, imageSourceType } = drawingParam;
if (drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (transform == null) {
return true;
}
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(drawingShapeKey) as Image;
if (imageShape == null) {
return true;
}
const { left = 0, top = 0, width = 0, height = 0, angle = 0, flipX = false, flipY = false, skewX = 0, skewY = 0 } = transform;
imageShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(imageShape as Image & { setClipBounds?: (clipBounds?: unknown) => void }).setClipBounds?.((transform as { clipBounds?: unknown }).clipBounds);
ensureDrawingRenderLayer(scene, imageShape, drawingParam);
imageShape.setSrcRect(srcRect);
imageShape.setPrstGeom(prstGeom);
if (source != null && source.length > 0 && (imageSourceType === ImageSourceType.BASE64 || imageSourceType === ImageSourceType.URL)) {
imageShape.changeSource(source);
}
});
})
);
}
private _addHoverForImage(o: Image) {
this.disposeWithMe(
toDisposable(
o.onPointerEnter$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.GRAB;
})
)
);
this.disposeWithMe(
toDisposable(
o.onPointerLeave$.subscribeEvent(() => {
o.cursor = CURSOR_TYPE.DEFAULT;
})
)
);
}
private _addDialogForImage(o: Image) {
const preview = () => {
const native = o.getNative();
if (native) {
this._drawingRenderService.previewImage(`${o.oKey}-viewer-dialog`, native.src, o.getNativeSize().width, o.getNativeSize().height);
}
};
this.disposeWithMe(
toDisposable(
o.onDblclick$.subscribeEvent(preview)
)
);
}
}

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import {
BottomIcon,
CropIcon,
DeleteIcon,
DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
} from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { COMPONENT_MOBILE_IMAGE_POPUP_MENU } from '../../views/image-popup-menu/component-name';
import { MobileImagePopupMenu } from '../../views/image-popup-menu/MobileImagePopupMenu';
import { DRAWING_ALIGN_COMPONENT, DRAWING_ARRANGE_COMPONENT, DRAWING_GROUP_COMPONENT, DRAWING_TRANSFORM_COMPONENT, IMAGE_CROPPER_COMPONENT } from '../../views/panel/component-name';
import { MobileDrawingArrange } from '../../views/panel/MobileDrawingArrange';
import { MobileDrawingAlign, MobileDrawingGroup, MobileDrawingTransform } from '../../views/panel/MobileDrawingControls';
import { MobileImageCropper } from '../../views/panel/MobileImageCropper';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) componentManager: ComponentManager,
@Inject(IconManager) iconManager: IconManager
) {
super();
this.disposeWithMe(componentManager.register(DRAWING_ALIGN_COMPONENT, MobileDrawingAlign));
this.disposeWithMe(componentManager.register(DRAWING_GROUP_COMPONENT, MobileDrawingGroup));
this.disposeWithMe(componentManager.register(DRAWING_TRANSFORM_COMPONENT, MobileDrawingTransform));
this.disposeWithMe(componentManager.register(DRAWING_ARRANGE_COMPONENT, MobileDrawingArrange));
this.disposeWithMe(componentManager.register(IMAGE_CROPPER_COMPONENT, MobileImageCropper));
this.disposeWithMe(componentManager.register(COMPONENT_MOBILE_IMAGE_POPUP_MENU, MobileImagePopupMenu));
this.disposeWithMe(iconManager.register({
BottomIcon,
DrawingCropIcon: CropIcon,
DrawingDeleteIcon: DeleteIcon,
DrawingEditIcon: DocSettingIcon,
GroupIcon,
MoveDownIcon,
MoveUpIcon,
TopmostIcon,
UngroupIcon,
}));
}
}

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 { ICommandInfo, IDrawingSearch, ISrcRect, ITransformState, Nullable, Workbook } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Scene } from '@univerjs/engine-render';
import type { ICloseImageCropOperationParams, IOpenImageCropOperationBySrcRectParams } from '../../commands/operations/image-crop.operation';
import type { LocaleKey } from '../../locale/types';
import {
checkIfMove,
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
SetDrawingSelectedOperation,
} from '@univerjs/drawing';
import { CURSOR_TYPE, Image, IRenderManagerService, precisionTo } from '@univerjs/engine-render';
import { ILayoutService, IMessageService, IShortcutService, KeyCode } from '@univerjs/ui';
import { BehaviorSubject, of, switchMap } from 'rxjs';
import {
AutoImageCropOperation,
CloseImageCropOperation,
CropType,
OpenImageCropOperation,
} from '../../commands/operations/image-crop.operation';
import { getImageCropRect } from '../../utils/image-crop-transform';
import { ImageCropperObject } from '../../views/crop/image-cropper-object';
const MOBILE_CROP_ANCHOR_HIT_SIZE = 44;
interface IImageCropSnapshot {
transform: ITransformState;
srcRect: Nullable<ISrcRect>;
}
interface IImageCropSession {
scene: Scene;
imageShape: Image;
imageCropperObject: ImageCropperObject;
}
export class MobileImageCropperController extends Disposable {
private _sceneListenerOnImageMap: WeakSet<Scene> = new WeakSet();
private readonly _cropShortcutDisposables = new DisposableCollection();
private readonly _cropSnapshots = new WeakMap<ImageCropperObject, IImageCropSnapshot>();
private _pendingCropSnapshot: Nullable<IImageCropSnapshot> = null;
private _activeCropSession: Nullable<IImageCropSession> = null;
private readonly _cropping$ = new BehaviorSubject(false);
readonly cropping$ = this._cropping$.asObservable();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@ILayoutService private readonly _layoutService: ILayoutService
) {
super();
this.disposeWithMe(this._cropShortcutDisposables);
this.disposeWithMe({ dispose: () => this._cropping$.complete() });
this._init();
}
private _init(): void {
this._initOpenCrop();
this._initCloseCrop();
this._initAutoCrop();
}
private _initAutoCrop(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== AutoImageCropOperation.id) {
return;
}
const params = command.params as IOpenImageCropOperationBySrcRectParams;
if (params == null) {
return;
}
const drawingParams = this._drawingManagerService.getFocusDrawings();
if (drawingParams.length !== 1) {
return;
}
const { cropType } = params;
const { unitId, subUnitId, drawingId } = drawingParams[0];
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (scene == null) {
return true;
}
if (this._searchCropObject(scene) != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
this._pendingCropSnapshot = this._captureCropSnapshot(imageShape);
try {
this._updateCropperObject(cropType, imageShape);
this._commandService.syncExecuteCommand(OpenImageCropOperation.id, { unitId, subUnitId, drawingId });
} finally {
this._pendingCropSnapshot = null;
}
})
);
}
private _calculateSrcRectByRatio(left: number, top: number, width: number, height: number, numerator: number, denominator: number) {
const srcRatio = width / height;
const ratio = numerator / denominator;
let newWidth = width;
let newHeight = height;
if (srcRatio > ratio) {
newWidth = height * ratio;
} else {
newHeight = width / ratio;
}
const newLeft = (width - newWidth) / 2;
const newTop = (height - newHeight) / 2;
return {
left: precisionTo(newLeft, 1),
top: precisionTo(newTop, 1),
right: precisionTo(width - (newLeft + newWidth), 1),
bottom: precisionTo(height - (newTop + newHeight), 1),
};
}
private _updateCropperObject(cropType: CropType, imageShape: Image): void {
const { left, top, width, height } = imageShape.calculateTransformWithSrcRect();
let newSrcRect: Nullable<ISrcRect>;
switch (cropType) {
case CropType.R1_1:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 1, 1);
break;
case CropType.R16_9:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 16, 9);
break;
case CropType.R9_16:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 9, 16);
break;
case CropType.R5_4:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 5, 4);
break;
case CropType.R4_5:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 5);
break;
case CropType.R4_3:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 4, 3);
break;
case CropType.R3_4:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 4);
break;
case CropType.R3_2:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 3, 2);
break;
case CropType.R2_3:
newSrcRect = this._calculateSrcRectByRatio(left, top, width, height, 2, 3);
break;
case CropType.FREE:
default:
break;
}
if (newSrcRect == null) {
return;
}
imageShape.setSrcRect(newSrcRect);
const { left: newLeft = 0, top: newTop = 0, bottom: newBottom = 0, right: newRight = 0 } = newSrcRect;
imageShape.transformByStateCloseCropper({
left: left + newLeft,
top: top + newTop,
width: width - newRight - newLeft,
height: height - newBottom - newTop,
});
}
private _initOpenCrop(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== OpenImageCropOperation.id) {
return;
}
const params = command.params as IDrawingSearch;
if (params == null) {
return;
}
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id, { isAuto: true });
}
const { unitId, subUnitId, drawingId } = params;
const scene = this._renderManagerService.getRenderUnitById(unitId)?.scene;
if (scene == null) {
return true;
}
if (!this._sceneListenerOnImageMap.has(scene)) {
this._addListenerOnImage(scene);
this._sceneListenerOnImageMap.add(scene);
}
const imageData = this._drawingManagerService.getDrawingByParam({ unitId, subUnitId, drawingId });
if (imageData == null) {
return;
}
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (!(imageShape instanceof Image)) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t<LocaleKey>('drawing-ui.image-cropper.error'),
});
return;
}
const transformer = scene.getTransformer();
transformer?.clearControls();
const imageCropperObject = new ImageCropperObject(`${imageShapeKey}-crop`, {
srcRect: imageShape.srcRect,
prstGeom: imageShape.prstGeom,
applyTransform: imageShape.calculateTransformWithSrcRect(),
});
imageCropperObject.transformerConfig = {
...imageCropperObject.transformerConfig,
cropAnchorHitSize: MOBILE_CROP_ANCHOR_HIT_SIZE,
};
this._cropSnapshots.set(imageCropperObject, this._pendingCropSnapshot ?? this._captureCropSnapshot(imageShape));
this._pendingCropSnapshot = null;
scene.addObject(imageCropperObject, imageShape.getLayerIndex() + 1).attachTransformerTo(imageCropperObject);
this._activeCropSession = { scene, imageShape, imageCropperObject };
this._cropping$.next(true);
transformer?.createControlForCopper(imageCropperObject);
this._addHoverForImageCopper(imageCropperObject);
imageShape.openRenderByCropper();
transformer?.refreshControls();
imageCropperObject.makeDirty(true);
this._registerCropShortcuts(imageCropperObject);
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, [{ unitId, subUnitId, drawingId }]);
this._layoutService.focus();
})
);
}
private _searchCropObject(scene: Scene): ImageCropperObject | undefined {
for (const object of scene.getAllObjectsByOrder()) {
if (object instanceof ImageCropperObject) {
return object;
}
}
}
private _initCloseCrop(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id !== CloseImageCropOperation.id) {
return;
}
const cropSession = this._activeCropSession;
if (cropSession == null) {
return;
}
this._activeCropSession = null;
const { scene, imageShape, imageCropperObject } = cropSession;
const transformer = scene.getTransformerByCreate();
transformer.detachFrom(imageCropperObject);
transformer.clearCopperControl();
const params = command.params as ICloseImageCropOperationParams | undefined;
if (params?.isCancel) {
this._restoreCropSnapshot(imageShape, imageCropperObject);
} else {
const srcRect = getImageCropRect(imageShape, imageCropperObject);
const drawingParam = this._drawingManagerService.getDrawingOKey(imageShape.oKey);
if (drawingParam != null) {
const { left, top, height, width } = imageCropperObject;
this._drawingManagerService.featurePluginUpdateNotification([{
...drawingParam,
transform: {
...drawingParam.transform,
left,
top,
height,
width,
},
srcRect: srcRect.srcRectAngle,
}] as IImageData[]);
}
imageShape.setSrcRect({ ...srcRect.srcRectAngle });
}
imageShape.closeRenderByCropper();
imageShape.makeDirty(true);
transformer.refreshControls();
this._cropSnapshots.delete(imageCropperObject);
imageCropperObject.dispose();
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
})
);
const sheetUnit$ = this._univerInstanceService
.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(switchMap((workbook) => workbook ? workbook.activeSheet$ : of(null)));
this.disposeWithMe(sheetUnit$.subscribe(() => {
if (this._activeCropSession != null) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
}));
}
private _getApplyObjectByCropObject(cropObject: ImageCropperObject): Nullable<Image> {
const applyOKey = cropObject.oKey.slice(0, cropObject.oKey.length - 5);
const scene = cropObject.getScene();
if (!scene) {
return null;
}
return scene.getObject(applyOKey) as Image ?? null;
}
private _captureCropSnapshot(imageShape: Image): IImageCropSnapshot {
return {
transform: imageShape.getState(),
srcRect: imageShape.srcRect == null ? imageShape.srcRect : { ...imageShape.srcRect },
};
}
private _restoreCropSnapshot(imageShape: Image, imageCropperObject: ImageCropperObject): void {
const snapshot = this._cropSnapshots.get(imageCropperObject);
if (snapshot) {
imageShape.transformByStateCloseCropper(snapshot.transform);
imageShape.setSrcRect(snapshot.srcRect);
}
}
private _registerCropShortcuts(imageCropperObject: ImageCropperObject): void {
this._cropShortcutDisposables.dispose();
this._cropShortcutDisposables.add(imageCropperObject.onDispose$.subscribeEvent(() => {
this._activeCropSession = null;
this._cropping$.next(false);
this._cropShortcutDisposables.dispose();
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ENTER,
priority: 1000,
}));
this._cropShortcutDisposables.add(this._shortcutService.registerShortcut({
id: CloseImageCropOperation.id,
binding: KeyCode.ESC,
priority: 1000,
staticParameters: { isCancel: true },
}));
}
private _addListenerOnImage(scene: Scene): void {
const transformer = scene.getTransformerByCreate();
let startTransform: Nullable<ITransformState> = null;
this.disposeWithMe(
transformer.changeStart$.subscribe((state) => {
const cropObject = state.objects.values().next().value as ImageCropperObject;
if (!(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
startTransform = { left, top, height, width, angle };
transformer.clearCopperControl();
})
);
this.disposeWithMe(
transformer.changeEnd$.subscribe((state) => {
const cropObject = state.objects.values().next().value as ImageCropperObject;
if (!(cropObject instanceof ImageCropperObject)) {
return;
}
const { left, top, height, width, angle } = cropObject;
if (!checkIfMove({ left, top, height, width, angle }, startTransform)) {
transformer.createControlForCopper(cropObject);
return;
}
const applyObject = this._getApplyObjectByCropObject(cropObject);
if (applyObject == null) {
return;
}
const srcRect = getImageCropRect(applyObject, cropObject);
cropObject.refreshSrcRect(srcRect.srcRect, applyObject.getState());
transformer.createControlForCopper(cropObject);
})
);
this._endCropListener(scene);
}
private _addHoverForImageCopper(cropper: ImageCropperObject): void {
this.disposeWithMe(cropper.onPointerEnter$.subscribeEvent(() => {
cropper.cursor = CURSOR_TYPE.MOVE;
}));
this.disposeWithMe(cropper.onPointerLeave$.subscribeEvent(() => {
cropper.cursor = CURSOR_TYPE.DEFAULT;
}));
}
private _endCropListener(scene: Scene): void {
const transformer = scene.getTransformerByCreate();
this.disposeWithMe(
transformer.clearControl$.subscribe((changeSelf) => {
if (changeSelf === true) {
this._commandService.syncExecuteCommand(CloseImageCropOperation.id);
}
})
);
}
}

View File

@@ -0,0 +1,285 @@
/**
* 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, IDrawingSearch, Nullable } from '@univerjs/core';
import type { IImageData } from '@univerjs/drawing';
import type { Image, Scene } from '@univerjs/engine-render';
import {
Disposable,
DrawingTypeEnum,
ICommandService,
ImageSourceType,
Inject,
IUniverInstanceService,
ThemeService,
toDisposable,
} from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService, SetDrawingSelectedOperation } from '@univerjs/drawing';
import { CURSOR_TYPE, IRenderManagerService } from '@univerjs/engine-render';
import { bufferTime, combineLatest, filter, map } from 'rxjs';
import { ImageResetSizeOperation } from '../../commands/operations/image-reset-size.operation';
import { DrawingRenderService, ensureDrawingRenderLayer } from '../../services/drawing-render.service';
import { MobileImagePreviewButton } from '../../views/image-preview/mobile-image-preview-button';
import { getCurrentUnitInfo } from '../utils';
import { MobileImageCropperController } from './image-cropper.controller';
export class MobileImageUpdateController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IUniverInstanceService private readonly _currentUniverService: IUniverInstanceService,
@Inject(DrawingRenderService) private readonly _drawingRenderService: DrawingRenderService,
@Inject(ThemeService) private readonly _themeService: ThemeService,
@Inject(MobileImageCropperController) private readonly _imageCropperController: MobileImageCropperController
) {
super();
this._initialize();
}
private _initialize(): void {
this._drawingAddListener();
this._commandExecutedListener();
this._imageUpdateListener();
}
private _commandExecutedListener(): void {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === ImageResetSizeOperation.id) {
const params = command.params as IDrawingSearch[];
if (params == null) {
return;
}
this._resetImageSize(params);
}
})
);
}
private _getSceneAndTransformerByDrawingSearch(unitId: Nullable<string>) {
if (unitId == null) {
return;
}
const renderObject = this._renderManagerService.getRenderUnitById(unitId);
const scene = renderObject?.scene;
if (scene == null) {
return null;
}
return { scene, transformer: scene.getTransformerByCreate() };
}
private _resetImageSize(params: IDrawingSearch[]): void {
const updateParams: IImageData[] = [];
const sceneList: Scene[] = [];
params.forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(imageShapeKey);
if (imageShape == null) {
return true;
}
const imageData = this._drawingManagerService.getDrawingByParam(param);
if (imageData == null) {
return true;
}
if (imageData.drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
(imageShape as Image).resetSize();
const { width, height } = (imageShape as Image).getNativeSize();
if (!sceneList.includes(scene)) {
sceneList.push(scene);
}
updateParams.push({
...imageData,
transform: {
...imageData.transform,
height,
width,
angle: 0,
},
srcRect: null,
prstGeom: null,
} as IImageData);
});
this._drawingManagerService.featurePluginUpdateNotification(updateParams);
sceneList.forEach((scene) => {
scene.getTransformerByCreate().refreshControls().changeNotification();
});
this._commandService.syncExecuteCommand(SetDrawingSelectedOperation.id, params);
}
private _drawingAddListener(): void {
this.disposeWithMe(
this._drawingManagerService.add$
.pipe(
bufferTime(33),
filter((batches) => batches.length > 0),
map((batches) => batches.flat()),
map((items) => {
const map = new Map<string, IDrawingSearch>();
for (const item of items) {
map.set(`${item.unitId}|${item.subUnitId}|${item.drawingId}`, item);
}
return [...map.values()];
}),
filter((items) => items.length > 0)
)
.subscribe((uniqueParams) => {
this._insertImages(uniqueParams);
})
);
}
private _insertImages(params: IDrawingSearch[]): void {
params.forEach(async (param) => {
const { unitId, subUnitId } = param;
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
const currentSubUnitId = getCurrentUnitInfo(this._currentUniverService, unitId)?.subUnitId;
if (renderObject == null || currentSubUnitId !== subUnitId) {
return;
}
const imageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (imageParam == null) {
return;
}
const images = await this._drawingRenderService.renderImages(imageParam, renderObject.scene);
const currentImageParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (currentImageParam) {
this._drawingManagerService.refreshTransform([currentImageParam]);
}
if (images == null || images.length === 0) {
return;
}
for (const image of images) {
this._addHoverForImage(image);
this._addDialogForImage(image, param);
}
});
}
private _imageUpdateListener(): void {
this.disposeWithMe(
this._drawingManagerService.update$.subscribe((params) => {
params.forEach((param) => {
const { unitId, subUnitId, drawingId } = param;
const drawingParam = this._drawingManagerService.getDrawingByParam(param) as IImageData;
if (drawingParam == null) {
return;
}
const { transform, drawingType, srcRect, prstGeom, source, imageSourceType } = drawingParam;
if (drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
const renderObject = this._getSceneAndTransformerByDrawingSearch(unitId);
if (renderObject == null) {
return;
}
const { scene } = renderObject;
if (transform == null) {
return true;
}
const drawingShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId });
const imageShape = scene.getObject(drawingShapeKey) as Image;
if (imageShape == null) {
return true;
}
const { left = 0, top = 0, width = 0, height = 0, angle = 0, flipX = false, flipY = false, skewX = 0, skewY = 0 } = transform;
imageShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(imageShape as Image & { setClipBounds?: (clipBounds?: unknown) => void }).setClipBounds?.((transform as { clipBounds?: unknown }).clipBounds);
ensureDrawingRenderLayer(scene, imageShape, drawingParam);
imageShape.setSrcRect(srcRect);
imageShape.setPrstGeom(prstGeom);
if (source != null && source.length > 0 && (imageSourceType === ImageSourceType.BASE64 || imageSourceType === ImageSourceType.URL)) {
imageShape.changeSource(source);
}
});
})
);
}
private _addHoverForImage(image: Image): void {
this.disposeWithMe(toDisposable(image.onPointerEnter$.subscribeEvent(() => {
image.cursor = CURSOR_TYPE.GRAB;
})));
this.disposeWithMe(toDisposable(image.onPointerLeave$.subscribeEvent(() => {
image.cursor = CURSOR_TYPE.DEFAULT;
})));
}
private _addDialogForImage(image: Image, param: IDrawingSearch): void {
const preview = () => {
const native = image.getNative();
if (native) {
this._drawingRenderService.previewImage(
`${image.oKey}-viewer-dialog`,
native.src,
image.getNativeSize().width,
image.getNativeSize().height
);
}
};
const scene = image.getScene();
if (scene) {
const button = new MobileImagePreviewButton(image, scene, this._themeService, preview);
const registration = this.disposeWithMe(() => button.dispose());
button.disposeWithMe(() => registration.dispose());
button.disposeWithMe(combineLatest([
this._drawingManagerService.focus$,
this._imageCropperController.cropping$,
]).subscribe(([drawings, cropping]) => {
button.setPreviewEnabled(!cropping && !drawings.some((drawing) =>
drawing.unitId === param.unitId && drawing.subUnitId === param.subUnitId && drawing.drawingId === param.drawingId));
}));
}
this.disposeWithMe(toDisposable(image.onDblclick$.subscribeEvent(preview)));
}
}

View File

@@ -0,0 +1,92 @@
/**
* 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 { Disposable, ICommandService, Inject, Injector } from '@univerjs/core';
import { BuiltInUIPart, connectInjector, IMenuManagerService, IUIPartsService } from '@univerjs/ui';
import {
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../../commands/operations/drawing-align.operation';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeOperation,
} from '../../commands/operations/drawing-arrange.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../../commands/operations/drawing-group.operation';
import { AutoImageCropOperation, CloseImageCropOperation, OpenImageCropOperation } from '../../commands/operations/image-crop.operation';
import { ImageResetSizeOperation } from '../../commands/operations/image-reset-size.operation';
import { menuSchema } from '../../menu/schema';
import { MobileCropActions } from '../../views/crop/MobileCropActions';
export class MobileDrawingUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IUIPartsService private readonly _uiPartsService: IUIPartsService,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initMenus();
this._initCommands();
this._initUiParts();
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands(): void {
[
OpenImageCropOperation,
CloseImageCropOperation,
ImageResetSizeOperation,
SetDrawingAlignOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignBottomOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignVerticalOperation,
AutoImageCropOperation,
SetDrawingGroupOperation,
CancelDrawingGroupOperation,
SetDrawingArrangeOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
private _initUiParts(): void {
this.disposeWithMe(this._uiPartsService.registerComponent(
BuiltInUIPart.CONTENT,
() => connectInjector(MobileCropActions, this._injector)
));
}
}

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 { Disposable, ICommandService } from '@univerjs/core';
import { IMenuManagerService } from '@univerjs/ui';
import {
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../commands/operations/drawing-align.operation';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeOperation,
} from '../commands/operations/drawing-arrange.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../commands/operations/drawing-group.operation';
import { AutoImageCropOperation, CloseImageCropOperation, OpenImageCropOperation } from '../commands/operations/image-crop.operation';
import { ImageResetSizeOperation } from '../commands/operations/image-reset-size.operation';
import { menuSchema } from '../menu/schema';
export class DrawingUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService
) {
super();
this._init();
}
private _init(): void {
this._initMenus();
this._initCommands();
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands() {
[
OpenImageCropOperation,
CloseImageCropOperation,
ImageResetSizeOperation,
SetDrawingAlignOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignBottomOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignVerticalOperation,
AutoImageCropOperation,
SetDrawingGroupOperation,
CancelDrawingGroupOperation,
SetDrawingArrangeOperation,
SetDrawingArrangeFrontOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
}

View File

@@ -0,0 +1,145 @@
/**
* 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, IDrawingSearch, IUniverInstanceService, Nullable, Workbook } from '@univerjs/core';
import type { IDrawingManagerService } from '@univerjs/drawing';
import type { BaseObject, Scene } from '@univerjs/engine-render';
import { UniverInstanceType } from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { DRAWING_OBJECT_LAYER_INDEX, DrawingGroupObject, Group } from '@univerjs/engine-render';
import { resolveDrawingUIRotateEnabled } from '../utils/rotate-enabled';
export function getDrawingRenderObject(scene: Scene, drawingSearch: IDrawingSearch): BaseObject | null {
const key = getDrawingShapeKeyByDrawingSearch(drawingSearch);
return scene.getObjectIncludeInGroup(key) ?? null;
}
export function disposeDrawingRenderObject(scene: Scene, drawingSearch: IDrawingSearch): boolean {
const object = getDrawingRenderObject(scene, drawingSearch);
if (object == null) {
return false;
}
object.dispose();
return true;
}
function getRenderObjectForDrawing(scene: Scene, drawing: IDrawingParam): BaseObject | null {
return getDrawingRenderObject(scene, drawing);
}
export function syncGroupRotateEnabled(
group: Group,
groupParam: IDrawingParam,
scene: Scene,
drawingManagerService: IDrawingManagerService,
children?: readonly IDrawingParam[]
): void {
const rotateEnabled = resolveDrawingUIRotateEnabled(groupParam, {
getChildren: (drawing) => {
if (children && drawing.drawingId === groupParam.drawingId && drawing.unitId === groupParam.unitId && drawing.subUnitId === groupParam.subUnitId) {
return children;
}
return drawingManagerService.getDrawingsByGroup(drawing);
},
getRenderObject: (drawing) => getRenderObjectForDrawing(scene, drawing),
});
group.transformerConfig = {
...group.transformerConfig,
rotateEnabled,
};
}
export function insertGroupObject(objectParam: IDrawingSearch, object: BaseObject, scene: Scene, drawingManagerService: IDrawingManagerService) {
const groupParam = drawingManagerService.getDrawingByParam(objectParam);
if (groupParam == null) {
return;
}
const groupKey = getDrawingShapeKeyByDrawingSearch(objectParam);
const groupObject = scene.getObjectIncludeInGroup(groupKey);
if (groupObject && !(groupObject instanceof Group)) {
return;
}
if (groupObject != null) {
const objects = groupObject.getObjects();
for (const obj of objects) {
if (obj.oKey === object.oKey) {
return;
}
}
groupObject.addObject(object);
syncGroupRotateEnabled(groupObject, groupParam, scene, drawingManagerService);
return;
}
const group = new DrawingGroupObject(groupKey);
scene.addObject(group, DRAWING_OBJECT_LAYER_INDEX).attachTransformerTo(group);
group.addObject(object);
syncGroupRotateEnabled(group, groupParam, scene, drawingManagerService);
const { transform, groupBaseBound } = groupParam;
if (groupBaseBound) {
group.setBaseBound(groupBaseBound);
}
if (groupParam.groupId) {
group.isInGroup = true;
insertGroupObject(
{ drawingId: groupParam.groupId, unitId: objectParam.unitId, subUnitId: objectParam.subUnitId },
group,
scene,
drawingManagerService
);
}
transform && group.transformByState(
{
left: transform.left,
top: transform.top,
angle: transform.angle,
width: transform.width,
height: transform.height,
}
);
}
export function getCurrentUnitInfo(currentUniverService: IUniverInstanceService, propUnitId?: string) {
const current = propUnitId ? currentUniverService.getUnit(propUnitId) : currentUniverService.getFocusedUnit();
if (current == null) {
return;
}
const unitId = current.getUnitId();
let subUnitId: Nullable<string>;
if (current.type === UniverInstanceType.UNIVER_SHEET) {
subUnitId = (current as Workbook).getActiveSheet()?.getSheetId();
} else if (current.type === UniverInstanceType.UNIVER_DOC) {
subUnitId = unitId;
} else if (current.type === UniverInstanceType.UNIVER_SLIDE) {
subUnitId = unitId;
}
return { unitId, subUnitId, current };
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,92 @@
/**
* 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 './global.css';
export { SetDrawingAlignOperation } from './commands/operations/drawing-align.operation';
export { SetDrawingArrangeOperation } from './commands/operations/drawing-arrange.operation';
export type {
IDrawingArrangeOperationParams,
} from './commands/operations/drawing-arrange.operation';
export {
CancelDrawingGroupOperation,
DRAWING_GROUP_TYPES,
SetDrawingGroupOperation,
} from './commands/operations/drawing-group.operation';
export type {
ICancelDrawingGroupOperationParams,
IDrawingGroupOperationParams,
} from './commands/operations/drawing-group.operation';
export {
AutoImageCropOperation,
CloseImageCropOperation,
OpenImageCropOperation,
} from './commands/operations/image-crop.operation';
export { ImageResetSizeOperation } from './commands/operations/image-reset-size.operation';
export type { IUniverDrawingUIConfig } from './config/config';
export {
disposeDrawingRenderObject,
getCurrentUnitInfo,
getDrawingRenderObject,
insertGroupObject,
} from './controllers/utils';
export { menuSchema as DrawingUIMenuSchema } from './menu/schema';
export { UniverDrawingMobileUIPlugin } from './mobile-plugin';
export { UniverDrawingUIPlugin } from './plugin';
export { DrawingImageClipService, IMAGE_CLIP_SHAPE_PICKER_COMPONENT } from './services/drawing-image-clip.service';
export type { ImageShapeClipDelegate } from './services/drawing-image-clip.service';
export { DrawingRenderService } from './services/drawing-render.service';
export {
extractClipboardHtmlImageFiles,
extractClipboardImageFiles,
extractClipboardTextImageFile,
isClipboardTextImage,
isImageOnlyClipboardHtml,
normalizeClipboardImageFile,
svgImageFileToDataUrl,
writeImageSourceToClipboard,
} from './utils/clipboard-image';
export { getUpdateParams } from './utils/get-update-params';
export { getImageCropRect } from './utils/image-crop-transform';
export { ImageCropperObject } from './views/crop/image-cropper-object';
export {
COMPONENT_IMAGE_POPUP_MENU,
COMPONENT_MOBILE_IMAGE_POPUP_MENU,
} from './views/image-popup-menu/component-name';
export type { IImagePopupMenuItem } from './views/image-popup-menu/ImagePopupMenu';
export { MobileObjectListPanel } from './views/object-list-panel/MobileObjectListPanel';
export {
getObjectListPanelSectionIdForDrawingType,
isFloatingObjectListDrawingType,
OBJECT_LIST_CANVAS_SECTION_ID,
OBJECT_LIST_FLOATING_SECTION_ID,
} from './views/object-list-panel/object-list-panel-layer';
export type { ObjectListPanelSectionId } from './views/object-list-panel/object-list-panel-layer';
export {
getObjectListPanelLabels,
getObjectListPanelTypeName,
ObjectListPanelBase,
} from './views/object-list-panel/ObjectListPanelBase';
export type {
IDrawingObjectListItem,
IDrawingObjectListPanelLabels,
IDrawingObjectListPanelProps,
IObjectListPanelBaseProps,
IObjectListPanelItem,
IObjectListPanelLabels,
ObjectListPanelTypeNameKey,
} from './views/object-list-panel/ObjectListPanelBase';
export { DrawingCommonPanel } from './views/panel/DrawingCommonPanel';

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'إغلاق', more: 'المزيد' },
'image-cropper': {
error: 'لا يمكن قص الكائنات غير الصورية.',
},
objectListPanel: {
title: 'الطبقات',
empty: 'لا توجد كائنات',
showAll: 'إظهار الكل',
hideAll: 'إخفاء الكل',
lockAll: 'قفل الكل',
unlockAll: 'إلغاء قفل الكل',
moveForward: 'إحضار للأمام',
moveBackward: 'إرسال للخلف',
close: 'إغلاق',
show: 'إظهار',
hide: 'إخفاء',
lock: 'قفل',
unlock: 'إلغاء القفل',
lockHint: 'قفل لمنع التحديد غير المقصود',
unlockHint: 'مقفل. انقر لإلغاء القفل',
name: 'الاسم',
nameInput: 'اسم الكائن',
description: 'الوصف',
descriptionPlaceholder: 'إضافة وصف',
details: 'التفاصيل',
locate: 'تحديد الموضع',
expand: 'توسيع',
collapse: 'طي',
dragToReorder: 'اسحب لإعادة الترتيب',
search: 'بحث',
filterAll: 'الكل',
filterHidden: 'المخفية',
filterLocked: 'المقفلة',
sectionCanvas: 'طبقة اللوحة',
sectionFloating: 'الطبقة العائمة',
typeNames: {
object: 'كائن',
shape: 'شكل',
connector: 'موصل',
image: 'صورة',
chart: 'مخطط',
table: 'جدول',
smartArt: 'SmartArt',
video: 'فيديو',
group: 'مجموعة',
unit: 'وحدة',
dom: 'DOM',
text: 'نص',
placeholder: 'عنصر نائب',
container: 'حاوية',
},
noSelection: 'حدد كائنا لتحرير تفاصيله',
},
'image-panel': {
arrange: {
title: 'ترتيب',
forward: 'إحضار إلى الأمام',
backward: 'إرسال إلى الخلف',
front: 'إحضار إلى المقدمة',
back: 'إرسال إلى المؤخرة',
},
transform: {
title: 'تحويل',
rotate: 'تدوير (°)',
x: 'س (بكسل)',
y: 'ص (بكسل)',
width: 'العرض (بكسل)',
height: 'الارتفاع (بكسل)',
lock: 'قفل النسبة (%)',
},
crop: {
cancel: 'إلغاء',
apply: 'تم',
title: 'قص',
start: 'بدء القص',
mode: 'حر',
},
group: {
title: 'تجميع',
group: 'تجميع',
unGroup: 'فك التجميع',
},
align: {
title: 'محاذاة',
default: 'تحديد نوع المحاذاة',
left: 'محاذاة لليسار',
center: 'محاذاة للوسط',
right: 'محاذاة لليمين',
top: 'محاذاة للأعلى',
middle: 'محاذاة للمنتصف',
bottom: 'محاذاة للأسفل',
horizon: 'توزيع أفقي ',
vertical: 'توزيع عمودي ',
},
null: 'لا يوجد تحديد للكائن',
},
'image-text-wrap': {
square: 'مربع',
topAndBottom: 'أعلى وأسفل',
inline: 'في نفس السطر مع النص',
behindText: 'خلف النص',
inFrontText: 'أمام النص',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Tanca', more: 'Més' },
'image-cropper': {
error: 'No es poden retallar objectes que no siguin imatges.',
},
objectListPanel: {
title: 'Capes',
empty: 'Sense objectes',
showAll: 'Mostra-ho tot',
hideAll: 'Amaga-ho tot',
lockAll: 'Bloqueja-ho tot',
unlockAll: 'Desbloqueja-ho tot',
moveForward: 'Porta endavant',
moveBackward: 'Envia enrere',
close: 'Tanca',
show: 'Mostra',
hide: 'Amaga',
lock: 'Bloqueja',
unlock: 'Desbloqueja',
lockHint: 'Bloqueja per evitar seleccions accidentals',
unlockHint: 'Bloquejat. Fes clic per desbloquejar',
name: 'Nom',
nameInput: 'Nom de l\'objecte',
description: 'Descripcio',
descriptionPlaceholder: 'Afegeix descripcio',
details: 'Detalls',
locate: 'Localitza',
expand: 'Expandeix',
collapse: 'Replega',
dragToReorder: 'Arrossegueu per reordenar',
search: 'Cerca',
filterAll: 'Tot',
filterHidden: 'Ocults',
filterLocked: 'Bloquejats',
sectionCanvas: 'Capa del llenç',
sectionFloating: 'Capa flotant',
typeNames: {
object: 'Objecte',
shape: 'Forma',
connector: 'Connector',
image: 'Imatge',
chart: 'Gràfic',
table: 'Taula',
smartArt: 'SmartArt',
video: 'Vídeo',
group: 'Grup',
unit: 'Unitat',
dom: 'DOM',
text: 'Text',
placeholder: 'Marcador de posició',
container: 'Contenidor',
},
noSelection: 'Seleccioneu un objecte per editar-ne els detalls',
},
'image-panel': {
arrange: {
title: 'Organitza',
forward: 'Porta endavant',
backward: 'Envia enrere',
front: 'Porta al davant',
back: 'Envia al fons',
},
transform: {
title: 'Transforma',
rotate: 'Gira (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Amplada (px)',
height: 'Alçada (px)',
lock: 'Bloca proporció (%)',
},
crop: {
cancel: 'Cancel·la',
apply: 'Fet',
title: 'Retalla',
start: 'Inicia retallada',
mode: 'Lliure',
},
group: {
title: 'Agrupa',
group: 'Agrupa',
unGroup: 'Desagrupa',
},
align: {
title: 'Alinea',
default: 'Selecciona tipus d’alineació',
left: 'Alinea a l’esquerra',
center: 'Alinea al centre',
right: 'Alinea a la dreta',
top: 'Alinea a dalt',
middle: 'Alinea al mig',
bottom: 'Alinea a baix',
horizon: 'Distribueix horitzontalment',
vertical: 'Distribueix verticalment',
},
null: 'Cap objecte seleccionat',
},
'image-text-wrap': {
square: 'Quadrat',
topAndBottom: 'A dalt i a baix',
inline: 'En línia amb el text',
behindText: 'Darrere del text',
inFrontText: 'Davant del text',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Schließen', more: 'Mehr' },
'image-cropper': {
error: 'Nicht-Bildobjekte können nicht zugeschnitten werden.',
},
objectListPanel: {
title: 'Ebenen',
empty: 'Keine Objekte',
showAll: 'Alle anzeigen',
hideAll: 'Alle ausblenden',
lockAll: 'Alle sperren',
unlockAll: 'Alle entsperren',
moveForward: 'Eine Ebene nach vorne',
moveBackward: 'Eine Ebene nach hinten',
close: 'Schliessen',
show: 'Anzeigen',
hide: 'Ausblenden',
lock: 'Sperren',
unlock: 'Entsperren',
lockHint: 'Sperren, um versehentliches Auswählen zu verhindern',
unlockHint: 'Gesperrt. Klicken zum Entsperren',
name: 'Name',
nameInput: 'Objektname',
description: 'Beschreibung',
descriptionPlaceholder: 'Beschreibung hinzufuegen',
details: 'Details',
locate: 'Suchen',
expand: 'Erweitern',
collapse: 'Reduzieren',
dragToReorder: 'Zum Neuordnen ziehen',
search: 'Suchen',
filterAll: 'Alle',
filterHidden: 'Ausgeblendet',
filterLocked: 'Gesperrt',
sectionCanvas: 'Canvas-Ebene',
sectionFloating: 'Schwebende Ebene',
typeNames: {
object: 'Objekt',
shape: 'Form',
connector: 'Verbinder',
image: 'Bild',
chart: 'Diagramm',
table: 'Tabelle',
smartArt: 'SmartArt',
video: 'Video',
group: 'Gruppe',
unit: 'Einheit',
dom: 'DOM',
text: 'Text',
placeholder: 'Platzhalter',
container: 'Container',
},
noSelection: 'Wählen Sie ein Objekt aus, um Details zu bearbeiten',
},
'image-panel': {
arrange: {
title: 'Anordnen',
forward: 'Eine Ebene nach vorne',
backward: 'Eine Ebene nach hinten',
front: 'In den Vordergrund',
back: 'In den Hintergrund',
},
transform: {
title: 'Transformieren',
rotate: 'Drehen (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Breite (px)',
height: 'Höhe (px)',
lock: 'Verhältnis sperren (%)',
},
crop: {
cancel: 'Abbrechen',
apply: 'Fertig',
title: 'Zuschneiden',
start: 'Zuschneiden starten',
mode: 'Frei',
},
group: {
title: 'Gruppe',
group: 'Gruppieren',
unGroup: 'Gruppierung aufheben',
},
align: {
title: 'Ausrichten',
default: 'Ausrichtungstyp auswählen',
left: 'Linksbündig',
center: 'Zentriert',
right: 'Rechtsbündig',
top: 'Oben bündig',
middle: 'Mittig',
bottom: 'Unten bündig',
horizon: 'Horizontal verteilen',
vertical: 'Vertikal verteilen',
},
null: 'Keine Objektauswahl',
},
'image-text-wrap': {
square: 'Quadratisch',
topAndBottom: 'Oben und unten',
inline: 'Im Textfluss',
behindText: 'Hinter dem Text',
inFrontText: 'Vor dem Text',
},
},
};
export default locale;

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.
*/
const locale = {
'drawing-ui': {
mobile: { close: 'Close', more: 'More' },
'image-cropper': {
error: 'Cannot crop non-image objects.',
},
objectListPanel: {
title: 'Layers',
empty: 'No objects',
showAll: 'Show all',
hideAll: 'Hide all',
lockAll: 'Lock all',
unlockAll: 'Unlock all',
moveForward: 'Bring forward',
moveBackward: 'Send backward',
close: 'Close',
show: 'Show',
hide: 'Hide',
lock: 'Lock',
unlock: 'Unlock',
lockHint: 'Lock to prevent accidental selection',
unlockHint: 'Locked. Click to unlock',
name: 'Name',
nameInput: 'Object name',
description: 'Description',
descriptionPlaceholder: 'Add description',
details: 'Details',
locate: 'Locate',
expand: 'Expand',
collapse: 'Collapse',
dragToReorder: 'Drag to reorder',
search: 'Search objects',
filterAll: 'All',
filterHidden: 'Hidden',
filterLocked: 'Locked',
sectionCanvas: 'Canvas layer',
sectionFloating: 'Floating layer',
typeNames: {
object: 'Object',
shape: 'Shape',
connector: 'Connector',
image: 'Image',
chart: 'Chart',
table: 'Table',
smartArt: 'SmartArt',
video: 'Video',
group: 'Group',
unit: 'Unit',
dom: 'DOM',
text: 'Text',
placeholder: 'Placeholder',
container: 'Container',
},
noSelection: 'Select an object to edit its details',
},
'image-panel': {
arrange: {
title: 'Arrange',
forward: 'Bring Forward',
backward: 'Send Backward',
front: 'Bring to Front',
back: 'Send to Back',
},
transform: {
title: 'Transform',
rotate: 'Rotate (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Width (px)',
height: 'Height (px)',
lock: 'Lock Ratio (%)',
},
crop: {
cancel: 'Cancel',
apply: 'Done',
title: 'Crop',
start: 'Start Crop',
mode: 'Free',
},
group: {
title: 'Group',
group: 'Group',
unGroup: 'Ungroup',
},
align: {
title: 'Align',
default: 'Select Align Type',
left: 'Align Left',
center: 'Align Center',
right: 'Align Right',
top: 'Align Top',
middle: 'Align Middle',
bottom: 'Align Bottom',
horizon: 'Distribute Horizontally ',
vertical: 'Distribute Vertically ',
},
null: 'No Object Selection',
},
'image-text-wrap': {
square: 'Square',
topAndBottom: 'Top and Bottom',
inline: 'In line with text',
behindText: 'Behind text',
inFrontText: 'In front of text',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Cerrar', more: 'Más' },
'image-cropper': {
error: 'No se pueden recortar objetos que no sean imágenes.',
},
objectListPanel: {
title: 'Capas',
empty: 'Sin objetos',
showAll: 'Mostrar todo',
hideAll: 'Ocultar todo',
lockAll: 'Bloquear todo',
unlockAll: 'Desbloquear todo',
moveForward: 'Traer hacia delante',
moveBackward: 'Enviar hacia atras',
close: 'Cerrar',
show: 'Mostrar',
hide: 'Ocultar',
lock: 'Bloquear',
unlock: 'Desbloquear',
lockHint: 'Bloquear para evitar selecciones accidentales',
unlockHint: 'Bloqueado. Haz clic para desbloquear',
name: 'Nombre',
nameInput: 'Nombre del objeto',
description: 'Descripcion',
descriptionPlaceholder: 'Agregar descripcion',
details: 'Detalles',
locate: 'Localizar',
expand: 'Expandir',
collapse: 'Contraer',
dragToReorder: 'Arrastre para reordenar',
search: 'Buscar objetos',
filterAll: 'Todo',
filterHidden: 'Ocultos',
filterLocked: 'Bloqueados',
sectionCanvas: 'Capa de lienzo',
sectionFloating: 'Capa flotante',
typeNames: {
object: 'Objeto',
shape: 'Forma',
connector: 'Conector',
image: 'Imagen',
chart: 'Gráfico',
table: 'Tabla',
smartArt: 'SmartArt',
video: 'Vídeo',
group: 'Grupo',
unit: 'Unidad',
dom: 'DOM',
text: 'Texto',
placeholder: 'Marcador de posición',
container: 'Contenedor',
},
noSelection: 'Seleccione un objeto para editar sus detalles',
},
'image-panel': {
arrange: {
title: 'Organizar',
forward: 'Traer adelante',
backward: 'Enviar atrás',
front: 'Traer al frente',
back: 'Enviar al fondo',
},
transform: {
title: 'Transformar',
rotate: 'Rotar (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Ancho (px)',
height: 'Alto (px)',
lock: 'Bloquear proporción (%)',
},
crop: {
cancel: 'Cancelar',
apply: 'Listo',
title: 'Recortar',
start: 'Iniciar recorte',
mode: 'Libre',
},
group: {
title: 'Agrupar',
group: 'Agrupar',
unGroup: 'Desagrupar',
},
align: {
title: 'Alinear',
default: 'Seleccionar tipo de alineación',
left: 'Alinear a la izquierda',
center: 'Alinear al centro',
right: 'Alinear a la derecha',
top: 'Alinear arriba',
middle: 'Alinear al medio',
bottom: 'Alinear abajo',
horizon: 'Distribuir horizontalmente',
vertical: 'Distribuir verticalmente',
},
null: 'Ningún objeto seleccionado',
},
'image-text-wrap': {
square: 'Cuadrado',
topAndBottom: 'Arriba y abajo',
inline: 'En línea con el texto',
behindText: 'Detrás del texto',
inFrontText: 'Delante del texto',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'بستن', more: 'بیشتر' },
'image-cropper': {
error: 'کراپ کردن اشیاء غیرتصویری امکان‌پذیر نیست.',
},
objectListPanel: {
title: 'لایه\u200Cها',
empty: 'هیچ شیئی وجود ندارد',
showAll: 'نمایش همه',
hideAll: 'پنهان کردن همه',
lockAll: 'قفل کردن همه',
unlockAll: 'باز کردن قفل همه',
moveForward: 'آوردن به جلو',
moveBackward: 'فرستادن به عقب',
close: 'بستن',
show: 'نمایش',
hide: 'پنهان کردن',
lock: 'قفل',
unlock: 'باز کردن قفل',
lockHint: 'قفل کردن برای جلوگیری از انتخاب تصادفی',
unlockHint: 'قفل شده است. برای باز کردن قفل کلیک کنید',
name: 'نام',
nameInput: 'نام شیء',
description: 'توضیحات',
descriptionPlaceholder: 'افزودن توضیحات',
details: 'جزئیات',
locate: 'مکان‌یابی',
expand: 'گسترش',
collapse: 'جمع کردن',
dragToReorder: 'برای مرتب‌سازی بکشید',
search: 'جستجوی اشیا',
filterAll: 'همه',
filterHidden: 'پنهان',
filterLocked: 'قفل شده',
sectionCanvas: 'لایه بوم',
sectionFloating: 'لایه شناور',
typeNames: {
object: 'شیء',
shape: 'شکل',
connector: 'رابط',
image: 'تصویر',
chart: 'نمودار',
table: 'جدول',
smartArt: 'SmartArt',
video: 'ویدیو',
group: 'گروه',
unit: 'واحد',
dom: 'DOM',
text: 'متن',
placeholder: 'نگهدارنده مکان',
container: 'محفظه',
},
noSelection: 'برای ویرایش جزئیات یک شیء را انتخاب کنید',
},
'image-panel': {
arrange: {
title: 'ترتیب‌دهی',
forward: 'آوردن به جلو',
backward: 'فرستادن به عقب',
front: 'آوردن به جلوی همه',
back: 'فرستادن به پشت همه',
},
transform: {
title: 'تبدیل',
rotate: 'چرخش (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'عرض (px)',
height: 'ارتفاع (px)',
lock: 'قفل نسبت (%)',
},
crop: {
cancel: 'لغو',
apply: 'انجام شد',
title: 'کراپ',
start: 'شروع کراپ',
mode: 'آزاد',
},
group: {
title: 'گروه',
group: 'گروه‌بندی',
unGroup: 'لغو گروه‌بندی',
},
align: {
title: 'تراز',
default: 'انتخاب نوع تراز',
left: 'تراز چپ',
center: 'تراز وسط',
right: 'تراز راست',
top: 'تراز بالا',
middle: 'تراز وسط',
bottom: 'تراز پایین',
horizon: 'توزیع افقی',
vertical: 'توزیع عمودی',
},
null: 'هیچ شیئی انتخاب نشده است',
},
'image-text-wrap': {
square: 'مربع',
topAndBottom: 'بالا و پایین',
inline: 'در یک خط با متن',
behindText: 'پشت متن',
inFrontText: 'جلوی متن',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Fermer', more: 'Plus' },
'image-cropper': {
error: 'Impossible de rogner des objets non image.',
},
objectListPanel: {
title: 'Calques',
empty: 'Aucun objet',
showAll: 'Tout afficher',
hideAll: 'Tout masquer',
lockAll: 'Tout verrouiller',
unlockAll: 'Tout déverrouiller',
moveForward: 'Avancer',
moveBackward: 'Reculer',
close: 'Fermer',
show: 'Afficher',
hide: 'Masquer',
lock: 'Verrouiller',
unlock: 'Deverrouiller',
lockHint: 'Verrouiller pour éviter une sélection accidentelle',
unlockHint: 'Verrouillé. Cliquer pour déverrouiller',
name: 'Nom',
nameInput: 'Nom de l\'objet',
description: 'Description',
descriptionPlaceholder: 'Ajouter une description',
details: 'Détails',
locate: 'Localiser',
expand: 'Développer',
collapse: 'Réduire',
dragToReorder: 'Faire glisser pour réorganiser',
search: 'Rechercher des objets',
filterAll: 'Tous',
filterHidden: 'Masqués',
filterLocked: 'Verrouillés',
sectionCanvas: 'Calque du canevas',
sectionFloating: 'Calque flottant',
typeNames: {
object: 'Objet',
shape: 'Forme',
connector: 'Connecteur',
image: 'Image',
chart: 'Graphique',
table: 'Tableau',
smartArt: 'SmartArt',
video: 'Vidéo',
group: 'Groupe',
unit: 'Unité',
dom: 'DOM',
text: 'Texte',
placeholder: 'Espace réservé',
container: 'Conteneur',
},
noSelection: 'Sélectionnez un objet pour modifier ses détails',
},
'image-panel': {
arrange: {
title: 'Arranger',
forward: 'Avancer',
backward: 'Reculer',
front: 'Mettre au premier plan',
back: 'Mettre à l\'arrière-plan',
},
transform: {
title: 'Transformer',
rotate: 'Pivoter (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Largeur (px)',
height: 'Hauteur (px)',
lock: 'Verrouiller le ratio (%)',
},
crop: {
cancel: 'Annuler',
apply: 'Terminé',
title: 'Rogner',
start: 'Commencer à rogner',
mode: 'Libre',
},
group: {
title: 'Grouper',
group: 'Grouper',
unGroup: 'Dégrouper',
},
align: {
title: 'Aligner',
default: 'Sélectionner le type d\'alignement',
left: 'Aligner à gauche',
center: 'Aligner au centre',
right: 'Aligner à droite',
top: 'Aligner en haut',
middle: 'Aligner au milieu',
bottom: 'Aligner en bas',
horizon: 'Distribuer horizontalement',
vertical: 'Distribuer verticalement',
},
null: 'Aucune sélection d\'objet',
},
'image-text-wrap': {
square: 'Carré',
topAndBottom: 'Haut et bas',
inline: 'Aligné avec le texte',
behindText: 'Derrière le texte',
inFrontText: 'Devant le texte',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Tutup', more: 'Lebih Banyak' },
'image-cropper': {
error: 'Tidak dapat memangkas objek non-gambar.',
},
objectListPanel: {
title: 'Lapisan',
empty: 'Tidak ada objek',
showAll: 'Tampilkan semua',
hideAll: 'Sembunyikan semua',
lockAll: 'Kunci semua',
unlockAll: 'Buka kunci semua',
moveForward: 'Bawa ke depan',
moveBackward: 'Kirim ke belakang',
close: 'Tutup',
show: 'Tampilkan',
hide: 'Sembunyikan',
lock: 'Kunci',
unlock: 'Buka kunci',
lockHint: 'Kunci untuk mencegah pemilihan tidak sengaja',
unlockHint: 'Terkunci. Klik untuk membuka kunci',
name: 'Nama',
nameInput: 'Nama objek',
description: 'Deskripsi',
descriptionPlaceholder: 'Tambahkan deskripsi',
details: 'Detail',
locate: 'Temukan',
expand: 'Bentangkan',
collapse: 'Ciutkan',
dragToReorder: 'Seret untuk mengurutkan',
search: 'Cari objek',
filterAll: 'Semua',
filterHidden: 'Tersembunyi',
filterLocked: 'Terkunci',
sectionCanvas: 'Lapisan kanvas',
sectionFloating: 'Lapisan mengambang',
typeNames: {
object: 'Objek',
shape: 'Bentuk',
connector: 'Konektor',
image: 'Gambar',
chart: 'Bagan',
table: 'Tabel',
smartArt: 'SmartArt',
video: 'Video',
group: 'Grup',
unit: 'Unit',
dom: 'DOM',
text: 'Teks',
placeholder: 'Placeholder',
container: 'Kontainer',
},
noSelection: 'Pilih objek untuk mengedit detailnya',
},
'image-panel': {
arrange: {
title: 'Atur',
forward: 'Bawa ke Depan',
backward: 'Kirim ke Belakang',
front: 'Bawa ke Paling Depan',
back: 'Kirim ke Paling Belakang',
},
transform: {
title: 'Transformasi',
rotate: 'Putar (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Lebar (px)',
height: 'Tinggi (px)',
lock: 'Kunci Rasio (%)',
},
crop: {
cancel: 'Batal',
apply: 'Selesai',
title: 'Pangkas',
start: 'Mulai Pangkas',
mode: 'Bebas',
},
group: {
title: 'Grup',
group: 'Grupkan',
unGroup: 'Pisahkan Grup',
},
align: {
title: 'Ratakan',
default: 'Pilih Jenis Perataan',
left: 'Rata Kiri',
center: 'Rata Tengah',
right: 'Rata Kanan',
top: 'Rata Atas',
middle: 'Rata Tengah',
bottom: 'Rata Bawah',
horizon: 'Distribusikan Secara Horizontal',
vertical: 'Distribusikan Secara Vertikal',
},
null: 'Tidak Ada Objek yang Dipilih',
},
'image-text-wrap': {
square: 'Kotak',
topAndBottom: 'Atas dan Bawah',
inline: 'Sejajar dengan teks',
behindText: 'Di belakang teks',
inFrontText: 'Di depan teks',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Chiudi', more: 'Altro' },
'image-cropper': {
error: 'Impossibile ritagliare oggetti non immagine.',
},
objectListPanel: {
title: 'Livelli',
empty: 'Nessun oggetto',
showAll: 'Mostra tutto',
hideAll: 'Nascondi tutto',
lockAll: 'Blocca tutto',
unlockAll: 'Sblocca tutto',
moveForward: 'Porta avanti',
moveBackward: 'Manda indietro',
close: 'Chiudi',
show: 'Mostra',
hide: 'Nascondi',
lock: 'Blocca',
unlock: 'Sblocca',
lockHint: 'Blocca per evitare selezioni accidentali',
unlockHint: 'Bloccato. Fai clic per sbloccare',
name: 'Nome',
nameInput: 'Nome oggetto',
description: 'Descrizione',
descriptionPlaceholder: 'Aggiungi descrizione',
details: 'Dettagli',
locate: 'Individua',
expand: 'Espandi',
collapse: 'Comprimi',
dragToReorder: 'Trascina per riordinare',
search: 'Cerca oggetti',
filterAll: 'Tutti',
filterHidden: 'Nascosti',
filterLocked: 'Bloccati',
sectionCanvas: 'Livello canvas',
sectionFloating: 'Livello mobile',
typeNames: {
object: 'Oggetto',
shape: 'Forma',
connector: 'Connettore',
image: 'Immagine',
chart: 'Grafico',
table: 'Tabella',
smartArt: 'SmartArt',
video: 'Video',
group: 'Gruppo',
unit: 'Unità',
dom: 'DOM',
text: 'Testo',
placeholder: 'Segnaposto',
container: 'Contenitore',
},
noSelection: 'Seleziona un oggetto per modificarne i dettagli',
},
'image-panel': {
arrange: {
title: 'Disponi',
forward: 'Porta avanti',
backward: 'Porta indietro',
front: 'Porta in primo piano',
back: 'Porta in secondo piano',
},
transform: {
title: 'Trasforma',
rotate: 'Ruota (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Larghezza (px)',
height: 'Altezza (px)',
lock: 'Blocca proporzioni (%)',
},
crop: {
cancel: 'Annulla',
apply: 'Fine',
title: 'Ritaglia',
start: 'Inizia ritaglio',
mode: 'Libero',
},
group: {
title: 'Raggruppa',
group: 'Raggruppa',
unGroup: 'Separa',
},
align: {
title: 'Allinea',
default: 'Seleziona tipo allineamento',
left: 'Allinea a sinistra',
center: 'Allinea al centro',
right: 'Allinea a destra',
top: 'Allinea in alto',
middle: 'Allinea al centro',
bottom: 'Allinea in basso',
horizon: 'Distribuisci orizzontalmente ',
vertical: 'Distribuisci verticalmente ',
},
null: 'Nessun oggetto selezionato',
},
'image-text-wrap': {
square: 'Quadrato',
topAndBottom: 'Superiore e inferiore',
inline: 'In linea con il testo',
behindText: 'Dietro il testo',
inFrontText: 'Davanti al testo',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: '閉じる', more: 'もっと見る' },
'image-cropper': {
error: '画像以外のオブジェクトはトリミングできません。',
},
objectListPanel: {
title: 'レイヤー',
empty: 'オブジェクトがありません',
showAll: 'すべて表示',
hideAll: 'すべて非表示',
lockAll: 'すべてロック',
unlockAll: 'すべてロック解除',
moveForward: '前面へ移動',
moveBackward: '背面へ移動',
close: '閉じる',
show: '表示',
hide: '非表示',
lock: 'ロック',
unlock: 'ロック解除',
lockHint: '誤選択を防ぐためにロック',
unlockHint: 'ロック済み。クリックして解除',
name: '名前',
nameInput: 'オブジェクト名',
description: '説明',
descriptionPlaceholder: '説明を追加',
details: '詳細',
locate: '位置を表示',
expand: '展開',
collapse: '折りたたむ',
dragToReorder: 'ドラッグして並べ替え',
search: 'オブジェクトを検索',
filterAll: 'すべて',
filterHidden: '非表示',
filterLocked: 'ロック済み',
sectionCanvas: 'キャンバスレイヤー',
sectionFloating: 'フローティングレイヤー',
typeNames: {
object: 'オブジェクト',
shape: '図形',
connector: 'コネクタ',
image: '画像',
chart: 'グラフ',
table: '表',
smartArt: 'SmartArt',
video: 'ビデオ',
group: 'グループ',
unit: 'ユニット',
dom: 'DOM',
text: 'テキスト',
placeholder: 'プレースホルダー',
container: 'コンテナー',
},
noSelection: 'オブジェクトを選択して詳細を編集',
},
'image-panel': {
arrange: {
title: '配置',
forward: '前面へ移動',
backward: '背面へ移動',
front: '最前面へ移動',
back: '最背面へ移動',
},
transform: {
title: '変形',
rotate: '回転 (°)',
x: 'X座標 (px)',
y: 'Y座標 (px)',
width: '幅 (px)',
height: '高さ (px)',
lock: '縦横比を固定 (%)',
},
crop: {
cancel: 'キャンセル',
apply: '完了',
title: 'トリミング',
start: 'トリミングを開始',
mode: '自由モード',
},
group: {
title: 'グループ',
group: 'グループ化',
unGroup: 'グループ解除',
},
align: {
title: '整列',
default: '整列タイプを選択',
left: '左揃え',
center: '中央揃え',
right: '右揃え',
top: '上揃え',
middle: '中央揃え',
bottom: '下揃え',
horizon: '水平間隔を均等に配分',
vertical: '垂直間隔を均等に配分',
},
null: '選択されたオブジェクトがありません',
},
'image-text-wrap': {
square: '四角形',
topAndBottom: '上下',
inline: '行内',
behindText: '文字列の背面',
inFrontText: '文字列の前面',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: '닫기', more: '더 보기' },
'image-cropper': {
error: '이미지가 아닌 객체는 자를 수 없습니다.',
},
objectListPanel: {
title: '레이어',
empty: '개체 없음',
showAll: '모두 표시',
hideAll: '모두 숨기기',
lockAll: '모두 잠금',
unlockAll: '모두 잠금 해제',
moveForward: '앞으로 가져오기',
moveBackward: '뒤로 보내기',
close: '닫기',
show: '표시',
hide: '숨기기',
lock: '잠금',
unlock: '잠금 해제',
lockHint: '실수로 선택하지 않도록 잠금',
unlockHint: '잠김. 클릭하여 잠금 해제',
name: '이름',
nameInput: '개체 이름',
description: '설명',
descriptionPlaceholder: '설명 추가',
details: '세부 정보',
locate: '위치 찾기',
expand: '펼치기',
collapse: '접기',
dragToReorder: '드래그하여 순서 변경',
search: '개체 검색',
filterAll: '전체',
filterHidden: '숨김',
filterLocked: '잠김',
sectionCanvas: '캔버스 레이어',
sectionFloating: '플로팅 레이어',
typeNames: {
object: '개체',
shape: '도형',
connector: '연결선',
image: '이미지',
chart: '차트',
table: '표',
smartArt: 'SmartArt',
video: '비디오',
group: '그룹',
unit: '단위',
dom: 'DOM',
text: '텍스트',
placeholder: '자리 표시자',
container: '컨테이너',
},
noSelection: '세부 정보를 편집할 개체를 선택하세요',
},
'image-panel': {
arrange: {
title: '정렬',
forward: '앞으로 가져오기',
backward: '뒤로 보내기',
front: '맨 앞으로 가져오기',
back: '맨 뒤로 보내기',
},
transform: {
title: '변형',
rotate: '회전 (°)',
x: 'X 좌표 (px)',
y: 'Y 좌표 (px)',
width: '너비 (px)',
height: '높이 (px)',
lock: '비율 고정 (%)',
},
crop: {
cancel: '취소',
apply: '완료',
title: '자르기',
start: '자르기 시작',
mode: '자유 모드',
},
group: {
title: '그룹',
group: '그룹화',
unGroup: '그룹 해제',
},
align: {
title: '맞춤',
default: '맞춤 유형 선택',
left: '왼쪽 맞춤',
center: '가운데 맞춤',
right: '오른쪽 맞춤',
top: '위쪽 맞춤',
middle: '가운데 맞춤',
bottom: '아래쪽 맞춤',
horizon: '수평 간격 균등 분배',
vertical: '수직 간격 균등 분배',
},
null: '선택된 객체가 없습니다',
},
'image-text-wrap': {
square: '사각형',
topAndBottom: '위아래',
inline: '텍스트와 같은 줄',
behindText: '텍스트 뒤',
inFrontText: '텍스트 앞',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Zamknij', more: 'Więcej' },
'image-cropper': {
error: 'Nie można przyciąć obiektów innych niż obraz.',
},
objectListPanel: {
title: 'Warstwy',
empty: 'Brak obiektow',
showAll: 'Pokaz wszystko',
hideAll: 'Ukryj wszystko',
lockAll: 'Zablokuj wszystko',
unlockAll: 'Odblokuj wszystko',
moveForward: 'Przesun do przodu',
moveBackward: 'Przesun do tylu',
close: 'Zamknij',
show: 'Pokaz',
hide: 'Ukryj',
lock: 'Zablokuj',
unlock: 'Odblokuj',
lockHint: 'Zablokuj, aby zapobiec przypadkowemu zaznaczeniu',
unlockHint: 'Zablokowano. Kliknij, aby odblokować',
name: 'Nazwa',
nameInput: 'Nazwa obiektu',
description: 'Opis',
descriptionPlaceholder: 'Dodaj opis',
details: 'Szczegoly',
locate: 'Zlokalizuj',
expand: 'Rozwiń',
collapse: 'Zwiń',
dragToReorder: 'Przeciągnij, aby zmienić kolejność',
search: 'Szukaj obiektów',
filterAll: 'Wszystkie',
filterHidden: 'Ukryte',
filterLocked: 'Zablokowane',
sectionCanvas: 'Warstwa kanwy',
sectionFloating: 'Warstwa pływająca',
typeNames: {
object: 'Obiekt',
shape: 'Kształt',
connector: 'Łącznik',
image: 'Obraz',
chart: 'Wykres',
table: 'Tabela',
smartArt: 'SmartArt',
video: 'Wideo',
group: 'Grupa',
unit: 'Jednostka',
dom: 'DOM',
text: 'Tekst',
placeholder: 'Symbol zastępczy',
container: 'Kontener',
},
noSelection: 'Wybierz obiekt, aby edytować szczegóły',
},
'image-panel': {
arrange: {
title: 'Rozmieść',
forward: 'Przesuń do przodu',
backward: 'Przesuń do tyłu',
front: 'Przesuń na wierzch',
back: 'Przesuń na spód',
},
transform: {
title: 'Przekształć',
rotate: 'Obrót (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Szerokość (px)',
height: 'Wysokość (px)',
lock: 'Zablokuj proporcje (%)',
},
crop: {
cancel: 'Anuluj',
apply: 'Gotowe',
title: 'Przytnij',
start: 'Rozpocznij przycinanie',
mode: 'Dowolny',
},
group: {
title: 'Grupa',
group: 'Grupuj',
unGroup: 'Rozgrupuj',
},
align: {
title: 'Wyrównaj',
default: 'Wybierz typ wyrównania',
left: 'Wyrównaj do lewej',
center: 'Wyrównaj do środka',
right: 'Wyrównaj do prawej',
top: 'Wyrównaj do góry',
middle: 'Wyrównaj do środka w pionie',
bottom: 'Wyrównaj do dołu',
horizon: 'Rozłóż poziomo ',
vertical: 'Rozłóż pionowo ',
},
null: 'Brak zaznaczonego obiektu',
},
'image-text-wrap': {
square: 'Kwadrat',
topAndBottom: 'Góra i dół',
inline: 'W linii z tekstem',
behindText: 'Za tekstem',
inFrontText: 'Przed tekstem',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Fechar', more: 'Mais' },
'image-cropper': {
error: 'Não é possível cortar objetos que não são imagens.',
},
objectListPanel: {
title: 'Camadas',
empty: 'Nenhum objeto',
showAll: 'Mostrar tudo',
hideAll: 'Ocultar tudo',
lockAll: 'Bloquear tudo',
unlockAll: 'Desbloquear tudo',
moveForward: 'Trazer para frente',
moveBackward: 'Enviar para tras',
close: 'Fechar',
show: 'Mostrar',
hide: 'Ocultar',
lock: 'Bloquear',
unlock: 'Desbloquear',
lockHint: 'Bloquear para evitar seleção acidental',
unlockHint: 'Bloqueado. Clique para desbloquear',
name: 'Nome',
nameInput: 'Nome do objeto',
description: 'Descricao',
descriptionPlaceholder: 'Adicionar descricao',
details: 'Detalhes',
locate: 'Localizar',
expand: 'Expandir',
collapse: 'Recolher',
dragToReorder: 'Arraste para reordenar',
search: 'Pesquisar objetos',
filterAll: 'Todos',
filterHidden: 'Ocultos',
filterLocked: 'Bloqueados',
sectionCanvas: 'Camada da tela',
sectionFloating: 'Camada flutuante',
typeNames: {
object: 'Objeto',
shape: 'Forma',
connector: 'Conector',
image: 'Imagem',
chart: 'Gráfico',
table: 'Tabela',
smartArt: 'SmartArt',
video: 'Vídeo',
group: 'Grupo',
unit: 'Unidade',
dom: 'DOM',
text: 'Texto',
placeholder: 'Espaço reservado',
container: 'Contêiner',
},
noSelection: 'Selecione um objeto para editar seus detalhes',
},
'image-panel': {
arrange: {
title: 'Organizar',
forward: 'Trazer para frente',
backward: 'Enviar para trás',
front: 'Trazer para a frente',
back: 'Enviar para trás',
},
transform: {
title: 'Transformar',
rotate: 'Girar (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Largura (px)',
height: 'Altura (px)',
lock: 'Bloquear proporção (%)',
},
crop: {
cancel: 'Cancelar',
apply: 'Concluído',
title: 'Cortar',
start: 'Iniciar corte',
mode: 'Livre',
},
group: {
title: 'Agrupar',
group: 'Agrupar',
unGroup: 'Desagrupar',
},
align: {
title: 'Alinhar',
default: 'Selecionar tipo de alinhamento',
left: 'Alinhar à esquerda',
center: 'Alinhar ao centro',
right: 'Alinhar à direita',
top: 'Alinhar ao topo',
middle: 'Alinhar ao meio',
bottom: 'Alinhar à base',
horizon: 'Distribuir horizontalmente',
vertical: 'Distribuir verticalmente',
},
null: 'Nenhum objeto selecionado',
},
'image-text-wrap': {
square: 'Quadrado',
topAndBottom: 'Superior e inferior',
inline: 'Em linha com o texto',
behindText: 'Atrás do texto',
inFrontText: 'À frente do texto',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Закрыть', more: 'Больше' },
'image-cropper': {
error: 'Невозможно обрезать не изображения.',
},
objectListPanel: {
title: 'Слои',
empty: 'Нет объектов',
showAll: 'Показать все',
hideAll: 'Скрыть все',
lockAll: 'Заблокировать всё',
unlockAll: 'Разблокировать всё',
moveForward: 'Переместить вперед',
moveBackward: 'Переместить назад',
close: 'Закрыть',
show: 'Показать',
hide: 'Скрыть',
lock: 'Заблокировать',
unlock: 'Разблокировать',
lockHint: 'Заблокировать для защиты от случайного выбора',
unlockHint: 'Заблокировано. Нажмите, чтобы разблокировать',
name: 'Имя',
nameInput: 'Имя объекта',
description: 'Описание',
descriptionPlaceholder: 'Добавить описание',
details: 'Сведения',
locate: 'Найти',
expand: 'Развернуть',
collapse: 'Свернуть',
dragToReorder: 'Перетащите, чтобы изменить порядок',
search: 'Поиск объектов',
filterAll: 'Все',
filterHidden: 'Скрытые',
filterLocked: 'Заблокированные',
sectionCanvas: 'Слой холста',
sectionFloating: 'Плавающий слой',
typeNames: {
object: 'Объект',
shape: 'Фигура',
connector: 'Соединитель',
image: 'Изображение',
chart: 'Диаграмма',
table: 'Таблица',
smartArt: 'SmartArt',
video: 'Видео',
group: 'Группа',
unit: 'Единица',
dom: 'DOM',
text: 'Текст',
placeholder: 'Заполнитель',
container: 'Контейнер',
},
noSelection: 'Выберите объект, чтобы изменить сведения',
},
'image-panel': {
arrange: {
title: 'Расположение',
forward: 'Переместить вперёд',
backward: 'Переместить назад',
front: 'На передний план',
back: 'На задний план',
},
transform: {
title: 'Трансформация',
rotate: 'Повернуть (°)',
x: 'X (пкс)',
y: 'Y (пкс)',
width: 'Ширина (пкс)',
height: 'Высота (пкс)',
lock: 'Заблокировать пропорции (%)',
},
crop: {
cancel: 'Отмена',
apply: 'Готово',
title: 'Обрезка',
start: 'Начать обрезку',
mode: 'Свободный',
},
group: {
title: 'Группировка',
group: 'Сгруппировать',
unGroup: 'Разгруппировать',
},
align: {
title: 'Выравнивание',
default: 'Выберите тип выравнивания',
left: 'Выровнять по левому краю',
center: 'Выровнять по центру',
right: 'Выровнять по правому краю',
top: 'Выровнять по верхнему краю',
middle: 'Выровнять посередине',
bottom: 'Выровнять по нижнему краю',
horizon: 'Распределить горизонтально',
vertical: 'Распределить вертикально',
},
null: 'Нет выбранных объектов',
},
'image-text-wrap': {
square: 'Вокруг рамки',
topAndBottom: 'Сверху и снизу',
inline: 'В тексте',
behindText: 'За текстом',
inFrontText: 'Перед текстом',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Zavrieť', more: 'Viac' },
'image-cropper': {
error: 'Nedá sa orezať neobrázkový objekt.',
},
objectListPanel: {
title: 'Vrstvy',
empty: 'Ziadne objekty',
showAll: 'Zobrazit vsetko',
hideAll: 'Skryt vsetko',
lockAll: 'Zamknúť všetko',
unlockAll: 'Odomknúť všetko',
moveForward: 'Presunut dopredu',
moveBackward: 'Presunut dozadu',
close: 'Zavriet',
show: 'Zobrazit',
hide: 'Skryt',
lock: 'Zamknut',
unlock: 'Odomknut',
lockHint: 'Zamknúť, aby sa zabránilo náhodnému výberu',
unlockHint: 'Zamknuté. Kliknutím odomknete',
name: 'Nazov',
nameInput: 'Nazov objektu',
description: 'Popis',
descriptionPlaceholder: 'Pridat popis',
details: 'Podrobnosti',
locate: 'Nájsť',
expand: 'Rozbaliť',
collapse: 'Zbaliť',
dragToReorder: 'Potiahnutím zmeňte poradie',
search: 'Hľadať objekty',
filterAll: 'Všetky',
filterHidden: 'Skryté',
filterLocked: 'Zamknuté',
sectionCanvas: 'Vrstva plátna',
sectionFloating: 'Plávajúca vrstva',
typeNames: {
object: 'Objekt',
shape: 'Tvar',
connector: 'Spojnica',
image: 'Obrázok',
chart: 'Graf',
table: 'Tabuľka',
smartArt: 'SmartArt',
video: 'Video',
group: 'Skupina',
unit: 'Jednotka',
dom: 'DOM',
text: 'Text',
placeholder: 'Zástupný symbol',
container: 'Kontajner',
},
noSelection: 'Vyberte objekt na úpravu podrobností',
},
'image-panel': {
arrange: {
title: 'Usporiadať',
forward: 'Posunúť dopredu',
backward: 'Posunúť dozadu',
front: 'Presunúť do popredia',
back: 'Presunúť do pozadia',
},
transform: {
title: 'Transformovať',
rotate: 'Otočiť (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Šírka (px)',
height: 'Výška (px)',
lock: 'Uzamknúť pomer (%)',
},
crop: {
cancel: 'Zrušiť',
apply: 'Hotovo',
title: 'Orezanie',
start: 'Začať orezanie',
mode: 'Voľne',
},
group: {
title: 'Zoskupiť',
group: 'Zoskupiť',
unGroup: 'Zrušiť zoskupenie',
},
align: {
title: 'Zarovnať',
default: 'Vyberte typ zarovnania',
left: 'Zarovnať doľava',
center: 'Zarovnať na stred',
right: 'Zarovnať doprava',
top: 'Zarovnať hore',
middle: 'Zarovnať na stred',
bottom: 'Zarovnať dole',
horizon: 'Rozložiť vodorovne',
vertical: 'Rozložiť zvisle',
},
null: 'Nie je vybraný žiadny objekt',
},
'image-text-wrap': {
square: 'Obdĺžnikové',
topAndBottom: 'Hore a dole',
inline: 'V riadku s textom',
behindText: 'Za textom',
inFrontText: 'Pred textom',
},
},
};
export default locale;

View File

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

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: 'Đóng', more: 'Thêm' },
'image-cropper': {
error: 'Không thể cắt phần tử không phải hình ảnh',
},
objectListPanel: {
title: 'Lớp',
empty: 'Khong co doi tuong',
showAll: 'Hien thi tat ca',
hideAll: 'An tat ca',
lockAll: 'Khóa tất cả',
unlockAll: 'Mở khóa tất cả',
moveForward: 'Dua len truoc',
moveBackward: 'Dua ra sau',
close: 'Dong',
show: 'Hien thi',
hide: 'An',
lock: 'Khoa',
unlock: 'Mo khoa',
lockHint: 'Khóa để tránh chọn nhầm',
unlockHint: 'Đã khóa. Nhấp để mở khóa',
name: 'Ten',
nameInput: 'Ten doi tuong',
description: 'Mo ta',
descriptionPlaceholder: 'Them mo ta',
details: 'Chi tiet',
locate: 'Định vị',
expand: 'Mở rộng',
collapse: 'Thu gọn',
dragToReorder: 'Kéo để sắp xếp lại',
search: 'Tìm đối tượng',
filterAll: 'Tất cả',
filterHidden: 'Đã ẩn',
filterLocked: 'Đã khóa',
sectionCanvas: 'Lớp canvas',
sectionFloating: 'Lớp nổi',
typeNames: {
object: 'Đối tượng',
shape: 'Hình dạng',
connector: 'Đường nối',
image: 'Hình ảnh',
chart: 'Biểu đồ',
table: 'Bảng',
smartArt: 'SmartArt',
video: 'Video',
group: 'Nhóm',
unit: 'Đơn vị',
dom: 'DOM',
text: 'Văn bản',
placeholder: 'Chỗ dành sẵn',
container: 'Vùng chứa',
},
noSelection: 'Chọn một đối tượng để chỉnh sửa chi tiết',
},
'image-panel': {
arrange: {
title: 'Sắp xếp',
forward: 'Di chuyển lên một lớp',
backward: 'Di chuyển xuống một lớp',
front: 'Đưa lên trên cùng',
back: 'Đưa xuống dưới cùng',
},
transform: {
title: 'Biến đổi',
rotate: 'Xoay (°)',
x: 'X (px)',
y: 'Y (px)',
width: 'Chiều rộng (px)',
height: 'Chiều cao (px)',
lock: 'Khóa tỷ lệ (%)',
},
crop: {
cancel: 'Hủy',
apply: 'Xong',
title: 'Cắt',
start: 'Bắt đầu cắt',
mode: 'Cắt tự do tỷ lệ',
},
group: {
title: 'Nhóm',
group: 'Nhóm',
unGroup: 'Hủy nhóm',
},
align: {
title: 'Căn chỉnh',
default: 'Chọn cách căn chỉnh',
left: 'Căn trái',
center: 'Căn giữa theo chiều ngang',
right: 'Căn phải',
top: 'Căn trên',
middle: 'Căn giữa theo chiều dọc',
bottom: 'Căn dưới',
horizon: 'Phân phối theo chiều ngang',
vertical: 'Phân phối theo chiều dọc',
},
null: 'Không có đối tượng nào được chọn',
},
'image-text-wrap': {
square: 'Vuông',
topAndBottom: 'Trên và dưới',
inline: 'Cùng dòng với văn bản',
behindText: 'Phía sau văn bản',
inFrontText: 'Phía trước văn bản',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: '关闭', more: '更多' },
'image-cropper': {
error: '无法裁剪非图片元素',
},
objectListPanel: {
title: '图层',
empty: '暂无对象',
showAll: '全部显示',
hideAll: '全部隐藏',
lockAll: '全部锁定',
unlockAll: '全部解锁',
moveForward: '上移一层',
moveBackward: '下移一层',
close: '关闭',
show: '显示',
hide: '隐藏',
lock: '锁定',
unlock: '解锁',
lockHint: '锁定,防止误选',
unlockHint: '已锁定,点击解锁',
name: '名称',
nameInput: '对象名称',
description: '描述',
descriptionPlaceholder: '添加描述',
details: '详情',
locate: '定位',
expand: '展开',
collapse: '折叠',
dragToReorder: '拖拽调整层级',
search: '搜索对象',
filterAll: '全部',
filterHidden: '隐藏',
filterLocked: '锁定',
sectionCanvas: '画布层',
sectionFloating: '浮动层',
typeNames: {
object: '对象',
shape: '形状',
connector: '连接线',
image: '图片',
chart: '图表',
table: '表格',
smartArt: 'SmartArt',
video: '视频',
group: '组合',
unit: '单元',
dom: 'DOM',
text: '文本',
placeholder: '占位符',
container: '容器',
},
noSelection: '选择一个对象以编辑详情',
},
'image-panel': {
arrange: {
title: '排列',
forward: '上移一层',
backward: '下移一层',
front: '置于顶层',
back: '置于底层',
},
transform: {
title: '变换',
rotate: '旋转 (°)',
x: 'X (px)',
y: 'Y (px)',
width: '宽度 (px)',
height: '高度 (px)',
lock: '锁定比例 (%)',
},
crop: {
cancel: '取消',
apply: '完成',
title: '裁剪',
start: '开始裁剪',
mode: '自由比例裁剪',
},
group: {
title: '组合',
group: '组合',
unGroup: '取消组合',
},
align: {
title: '对齐方式',
default: '选择对齐方式',
left: '左对齐',
center: '水平居中',
right: '右对齐',
top: '顶部对齐',
middle: '垂直居中',
bottom: '底部对齐',
horizon: '水平分布',
vertical: '垂直分布',
},
null: '未选中任何对象',
},
'image-text-wrap': {
square: '四周型',
topAndBottom: '上下型',
inline: '嵌入型',
behindText: '衬于文字下方',
inFrontText: '浮于文字上方',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: '關閉', more: '更多' },
'image-cropper': {
error: '無法裁切非圖片元素',
},
objectListPanel: {
title: '圖層',
empty: '暫無物件',
showAll: '全部顯示',
hideAll: '全部隱藏',
lockAll: '全部鎖定',
unlockAll: '全部解鎖',
moveForward: '上移一層',
moveBackward: '下移一層',
close: '關閉',
show: '顯示',
hide: '隱藏',
lock: '鎖定',
unlock: '解鎖',
lockHint: '鎖定,防止誤選',
unlockHint: '已鎖定,點擊解鎖',
name: '名稱',
nameInput: '物件名稱',
description: '描述',
descriptionPlaceholder: '新增描述',
details: '詳情',
locate: '定位',
expand: '展開',
collapse: '摺疊',
dragToReorder: '拖曳調整圖層',
search: '搜尋物件',
filterAll: '全部',
filterHidden: '隱藏',
filterLocked: '鎖定',
sectionCanvas: '畫布層',
sectionFloating: '浮動層',
typeNames: {
object: '物件',
shape: '形狀',
connector: '連接線',
image: '圖片',
chart: '圖表',
table: '表格',
smartArt: 'SmartArt',
video: '影片',
group: '群組',
unit: '單元',
dom: 'DOM',
text: '文字',
placeholder: '佔位符',
container: '容器',
},
noSelection: '請選取一個物件以編輯詳細資訊',
},
'image-panel': {
arrange: {
title: '排列',
forward: '上移一層',
backward: '下移一層',
front: '置於頂層',
back: '置於底層',
},
transform: {
title: '變換',
rotate: '旋轉 (°)',
x: 'X (px)',
y: 'Y (px)',
width: '寬度 (px)',
height: '高度 (px)',
lock: '鎖定比例 (%)',
},
crop: {
cancel: '取消',
apply: '完成',
title: '裁切',
start: '開始裁切',
mode: '自由比例裁切',
},
group: {
title: '組合',
group: '組合',
unGroup: '取消組合',
},
align: {
title: '對齊方式',
default: '選擇對齊方式',
left: '左對齊',
center: '水平居中',
right: '右對齊',
top: '頂部對齊',
middle: '垂直居中',
bottom: '底部對齊',
horizon: '水平分佈',
vertical: '垂直分佈',
},
null: '未選取任何物件',
},
'image-text-wrap': {
square: '矩形',
topAndBottom: '上及下',
inline: '與文字排列',
behindText: '文字在前',
inFrontText: '文字在後',
},
},
};
export default locale;

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'drawing-ui': {
mobile: { close: '關閉', more: '更多' },
'image-cropper': {
error: '無法裁切非圖片元素',
},
objectListPanel: {
title: '圖層',
empty: '暫無物件',
showAll: '全部顯示',
hideAll: '全部隱藏',
lockAll: '全部鎖定',
unlockAll: '全部解鎖',
moveForward: '上移一層',
moveBackward: '下移一層',
close: '關閉',
show: '顯示',
hide: '隱藏',
lock: '鎖定',
unlock: '解鎖',
lockHint: '鎖定,防止誤選',
unlockHint: '已鎖定,點擊解鎖',
name: '名稱',
nameInput: '物件名稱',
description: '描述',
descriptionPlaceholder: '新增描述',
details: '詳細資料',
locate: '定位',
expand: '展開',
collapse: '摺疊',
dragToReorder: '拖曳調整圖層',
search: '搜尋物件',
filterAll: '全部',
filterHidden: '隱藏',
filterLocked: '鎖定',
sectionCanvas: '畫布層',
sectionFloating: '浮動層',
typeNames: {
object: '物件',
shape: '形狀',
connector: '連接線',
image: '圖片',
chart: '圖表',
table: '表格',
smartArt: 'SmartArt',
video: '影片',
group: '群組',
unit: '單元',
dom: 'DOM',
text: '文字',
placeholder: '預留位置',
container: '容器',
},
noSelection: '請選取一個物件以編輯詳細資訊',
},
'image-panel': {
arrange: {
title: '排列',
forward: '上移一層',
backward: '下移一層',
front: '置於頂層',
back: '置於底層',
},
transform: {
title: '變換',
rotate: '旋轉 (°)',
x: 'X (px)',
y: 'Y (px)',
width: '寬度 (px)',
height: '高度 (px)',
lock: '鎖定比例 (%)',
},
crop: {
cancel: '取消',
apply: '完成',
title: '裁切',
start: '開始裁切',
mode: '自由比例裁切',
},
group: {
title: '組合',
group: '組合',
unGroup: '取消組合',
},
align: {
title: '對齊方式',
default: '選擇對齊方式',
left: '左對齊',
center: '水平居中',
right: '右對齊',
top: '頂部對齊',
middle: '垂直居中',
bottom: '底部對齊',
horizon: '水平分佈',
vertical: '垂直分佈',
},
null: '未選取任何物件',
},
'image-text-wrap': {
square: '矩形',
topAndBottom: '上及下',
inline: '與文字排列',
behindText: '文字在前',
inFrontText: '文字在後',
},
},
};
export default locale;

View File

@@ -0,0 +1,145 @@
/**
* 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, IDrawingParam } from '@univerjs/core';
import type { IMenuButtonItem, IMenuSelectorItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { IDrawingManagerService } from '@univerjs/drawing';
import { MenuItemType } from '@univerjs/ui';
import { Observable } from 'rxjs';
import {
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../commands/operations/drawing-align.operation';
const getMenuStateByDrawingFocusChangedObservable$ = (accessor: IAccessor): Observable<boolean> => {
const drawingManagerService = accessor.get(IDrawingManagerService);
return new Observable((subscriber) => {
const update = (drawings: IDrawingParam[]) => {
if (!drawings || drawings.length === 0) {
return subscriber.next(true);
}
if (drawings.length < 2) {
return subscriber.next(true);
}
subscriber.next(false);
};
const subscription = drawingManagerService.focus$.subscribe((drawings) => {
if (!drawings || drawings.length === 0) {
return subscriber.next(true);
}
update(drawings);
});
update(drawingManagerService.getFocusDrawings());
return () => subscription.unsubscribe();
});
};
export const DRAWING_ALIGN_CONTEXT_MENU_ID = 'contextMenu.drawing-align';
export function DrawingAlignContextMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
return {
id: DRAWING_ALIGN_CONTEXT_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'HorizontallyIcon',
title: 'drawing-ui.image-panel.align.title',
hidden$: getMenuStateByDrawingFocusChangedObservable$(accessor),
};
}
export function SetDrawingAlignLeftMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignLeftOperation.id,
type: MenuItemType.BUTTON,
icon: 'LeftJustifyingIcon',
title: 'drawing-ui.image-panel.align.left',
};
}
export function SetDrawingAlignCenterMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignCenterOperation.id,
type: MenuItemType.BUTTON,
icon: 'HorizontallyIcon',
title: 'drawing-ui.image-panel.align.center',
};
}
export function SetDrawingAlignRightMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignRightOperation.id,
type: MenuItemType.BUTTON,
icon: 'RightJustifyingIcon',
title: 'drawing-ui.image-panel.align.right',
};
}
export function SetDrawingAlignTopMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignTopOperation.id,
type: MenuItemType.BUTTON,
icon: 'AlignTopIcon',
title: 'drawing-ui.image-panel.align.top',
};
}
export function SetDrawingAlignMiddleMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignMiddleOperation.id,
type: MenuItemType.BUTTON,
icon: 'VerticalCenterIcon',
title: 'drawing-ui.image-panel.align.middle',
};
}
export function SetDrawingAlignBottomMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignBottomOperation.id,
type: MenuItemType.BUTTON,
icon: 'AlignBottomIcon',
title: 'drawing-ui.image-panel.align.bottom',
};
}
export function SetDrawingAlignHorizonMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignHorizonOperation.id,
type: MenuItemType.BUTTON,
icon: 'HorizontallyIcon',
title: 'drawing-ui.image-panel.align.horizon',
};
}
export function SetDrawingAlignVerticalMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingAlignVerticalOperation.id,
type: MenuItemType.BUTTON,
icon: 'VerticalCenterIcon',
title: 'drawing-ui.image-panel.align.vertical',
};
}

View File

@@ -0,0 +1,80 @@
/**
* 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 } from '@univerjs/core';
import type { IMenuButtonItem, IMenuSelectorItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { IDrawingManagerService } from '@univerjs/drawing';
import { MenuItemType } from '@univerjs/ui';
import { map, startWith } from 'rxjs';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
} from '../commands/operations/drawing-arrange.operation';
export const DRAWING_ARRANGE_CONTEXT_MENU_ID = 'contextMenu.drawing-arrange';
export function DrawingArrangeContextMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
const drawingManagerService = accessor.get(IDrawingManagerService);
return {
id: DRAWING_ARRANGE_CONTEXT_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'TopmostIcon',
title: 'drawing-ui.image-panel.arrange.title',
hidden$: drawingManagerService.focus$.pipe(
startWith(drawingManagerService.getFocusDrawings()),
map((drawings) => drawings.length === 0)
),
};
}
export function SetDrawingArrangeFrontMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingArrangeFrontOperation.id,
type: MenuItemType.BUTTON,
icon: 'TopmostIcon',
title: 'drawing-ui.image-panel.arrange.front',
};
}
export function SetDrawingArrangeForwardMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingArrangeForwardOperation.id,
type: MenuItemType.BUTTON,
icon: 'MoveUpIcon',
title: 'drawing-ui.image-panel.arrange.forward',
};
}
export function SetDrawingArrangeBackMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingArrangeBackOperation.id,
type: MenuItemType.BUTTON,
icon: 'BottomIcon',
title: 'drawing-ui.image-panel.arrange.back',
};
}
export function SetDrawingArrangeBackwardMenuItemFactory(): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingArrangeBackwardOperation.id,
type: MenuItemType.BUTTON,
icon: 'MoveDownIcon',
title: 'drawing-ui.image-panel.arrange.backward',
};
}

View File

@@ -0,0 +1,110 @@
/**
* 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, IDrawingParam } from '@univerjs/core';
import type { IMenuButtonItem, IMenuSelectorItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { DrawingTypeEnum } from '@univerjs/core';
import { IDrawingManagerService, isGroupableDrawingType } from '@univerjs/drawing';
import { MenuItemType } from '@univerjs/ui';
import { Observable } from 'rxjs';
import {
CancelDrawingGroupOperation,
SetDrawingGroupOperation,
} from '../commands/operations/drawing-group.operation';
function getMenuStateByDrawingFocusChangedObservable$(
accessor: IAccessor,
type?: 'group' | 'unGroup'
): Observable<boolean> {
const drawingManagerService = accessor.get(IDrawingManagerService);
return new Observable((subscriber) => {
const update = (drawings: IDrawingParam[]) => {
if (!drawings || drawings.length === 0) {
return subscriber.next(true);
}
if (type === 'group') {
// If there are less than 2 drawings that can be grouped, disable the group button
if (drawings.length < 2) {
return subscriber.next(true);
}
if (!drawings.every((drawing) => isGroupableDrawingType(drawing.drawingType))) {
return subscriber.next(true);
}
} else if (type === 'unGroup') {
// If there are no groups, disable the unGroup button
const groups = drawings.filter((drawing) => drawing.drawingType === DrawingTypeEnum.DRAWING_GROUP);
if (groups.length === 0) {
return subscriber.next(true);
}
} else {
// If there are drawings that cannot be grouped or ungrouped, hide the context menu
if (!drawings.every((drawing) => isGroupableDrawingType(drawing.drawingType))) {
return subscriber.next(true);
}
}
subscriber.next(false);
};
const subscription = drawingManagerService.focus$.subscribe((drawings) => {
if (!drawings || drawings.length === 0) {
return subscriber.next(true);
}
update(drawings);
});
update(drawingManagerService.getFocusDrawings());
return () => subscription.unsubscribe();
});
};
export const DRAWING_GROUP_CONTEXT_MENU_ID = 'contextMenu.drawing-group';
export function DrawingGroupContextMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
return {
id: DRAWING_GROUP_CONTEXT_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'GroupIcon',
title: 'drawing-ui.image-panel.group.title',
hidden$: getMenuStateByDrawingFocusChangedObservable$(accessor),
};
}
export function SetDrawingGroupMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: SetDrawingGroupOperation.id,
type: MenuItemType.BUTTON,
icon: 'GroupIcon',
title: 'drawing-ui.image-panel.group.group',
disabled$: getMenuStateByDrawingFocusChangedObservable$(accessor, 'group'),
};
}
export function CancelDrawingGroupMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: CancelDrawingGroupOperation.id,
type: MenuItemType.BUTTON,
icon: 'UngroupIcon',
title: 'drawing-ui.image-panel.group.unGroup',
disabled$: getMenuStateByDrawingFocusChangedObservable$(accessor, 'unGroup'),
};
}

View File

@@ -0,0 +1,136 @@
/**
* 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 { MenuSchemaType } from '@univerjs/ui';
import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui';
import {
SetDrawingAlignBottomOperation,
SetDrawingAlignCenterOperation,
SetDrawingAlignHorizonOperation,
SetDrawingAlignLeftOperation,
SetDrawingAlignMiddleOperation,
SetDrawingAlignRightOperation,
SetDrawingAlignTopOperation,
SetDrawingAlignVerticalOperation,
} from '../commands/operations/drawing-align.operation';
import {
SetDrawingArrangeBackOperation,
SetDrawingArrangeBackwardOperation,
SetDrawingArrangeForwardOperation,
SetDrawingArrangeFrontOperation,
} from '../commands/operations/drawing-arrange.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../commands/operations/drawing-group.operation';
import {
DRAWING_ALIGN_CONTEXT_MENU_ID,
DrawingAlignContextMenuItemFactory,
SetDrawingAlignBottomMenuItemFactory,
SetDrawingAlignCenterMenuItemFactory,
SetDrawingAlignHorizonMenuItemFactory,
SetDrawingAlignLeftMenuItemFactory,
SetDrawingAlignMiddleMenuItemFactory,
SetDrawingAlignRightMenuItemFactory,
SetDrawingAlignTopMenuItemFactory,
SetDrawingAlignVerticalMenuItemFactory,
} from './align.menu';
import {
DRAWING_ARRANGE_CONTEXT_MENU_ID,
DrawingArrangeContextMenuItemFactory,
SetDrawingArrangeBackMenuItemFactory,
SetDrawingArrangeBackwardMenuItemFactory,
SetDrawingArrangeForwardMenuItemFactory,
SetDrawingArrangeFrontMenuItemFactory,
} from './arrange.menu';
import {
CancelDrawingGroupMenuItemFactory,
DRAWING_GROUP_CONTEXT_MENU_ID,
DrawingGroupContextMenuItemFactory,
SetDrawingGroupMenuItemFactory,
} from './group.menu';
export const menuSchema: MenuSchemaType = {
[ContextMenuPosition.DRAWING]: {
[ContextMenuGroup.OTHERS]: {
[DRAWING_GROUP_CONTEXT_MENU_ID]: {
order: 1,
menuItemFactory: DrawingGroupContextMenuItemFactory,
[SetDrawingGroupOperation.id]: {
order: 0,
menuItemFactory: SetDrawingGroupMenuItemFactory,
},
[CancelDrawingGroupOperation.id]: {
order: 1,
menuItemFactory: CancelDrawingGroupMenuItemFactory,
},
},
[DRAWING_ARRANGE_CONTEXT_MENU_ID]: {
order: 2,
menuItemFactory: DrawingArrangeContextMenuItemFactory,
[SetDrawingArrangeFrontOperation.id]: {
order: 0,
menuItemFactory: SetDrawingArrangeFrontMenuItemFactory,
},
[SetDrawingArrangeForwardOperation.id]: {
order: 1,
menuItemFactory: SetDrawingArrangeForwardMenuItemFactory,
},
[SetDrawingArrangeBackOperation.id]: {
order: 2,
menuItemFactory: SetDrawingArrangeBackMenuItemFactory,
},
[SetDrawingArrangeBackwardOperation.id]: {
order: 3,
menuItemFactory: SetDrawingArrangeBackwardMenuItemFactory,
},
},
[DRAWING_ALIGN_CONTEXT_MENU_ID]: {
order: 3,
menuItemFactory: DrawingAlignContextMenuItemFactory,
[SetDrawingAlignLeftOperation.id]: {
order: 0,
menuItemFactory: SetDrawingAlignLeftMenuItemFactory,
},
[SetDrawingAlignCenterOperation.id]: {
order: 1,
menuItemFactory: SetDrawingAlignCenterMenuItemFactory,
},
[SetDrawingAlignRightOperation.id]: {
order: 2,
menuItemFactory: SetDrawingAlignRightMenuItemFactory,
},
[SetDrawingAlignTopOperation.id]: {
order: 3,
menuItemFactory: SetDrawingAlignTopMenuItemFactory,
},
[SetDrawingAlignMiddleOperation.id]: {
order: 4,
menuItemFactory: SetDrawingAlignMiddleMenuItemFactory,
},
[SetDrawingAlignBottomOperation.id]: {
order: 5,
menuItemFactory: SetDrawingAlignBottomMenuItemFactory,
},
[SetDrawingAlignHorizonOperation.id]: {
order: 6,
menuItemFactory: SetDrawingAlignHorizonMenuItemFactory,
},
[SetDrawingAlignVerticalOperation.id]: {
order: 7,
menuItemFactory: SetDrawingAlignVerticalMenuItemFactory,
},
},
},
},
};

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 { Dependency } from '@univerjs/core';
import type { IUniverDrawingUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin } from '@univerjs/core';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DRAWING_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { DrawingUpdateController } from './controllers/drawing-update.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { MobileImageCropperController } from './controllers/mobile/image-cropper.controller';
import { MobileImageUpdateController } from './controllers/mobile/image-update.controller';
import { MobileDrawingUIController } from './controllers/mobile/ui.controller';
import { DrawingImageClipService } from './services/drawing-image-clip.service';
import { DrawingRenderService } from './services/drawing-render.service';
@DependentOn(UniverDrawingPlugin, UniverRenderEnginePlugin)
export class UniverDrawingMobileUIPlugin extends Plugin {
static override pluginName = 'UNIVER_DRAWING_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverDrawingUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { menu, ...rest } = merge({}, defaultPluginConfig, this._config);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DRAWING_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([MobileComponentsController]);
this._injector.get(MobileComponentsController);
this._initDependencies();
}
override onRendered(): void {
this._injector.get(DrawingUpdateController);
this._injector.get(MobileDrawingUIController);
this._injector.get(MobileImageCropperController);
this._injector.get(MobileImageUpdateController);
}
private _initDependencies(): void {
const dependencies: Dependency[] = [
[DrawingImageClipService],
[DrawingRenderService],
[DrawingUpdateController],
[MobileDrawingUIController],
[MobileImageCropperController],
[MobileImageUpdateController],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
}
}

View File

@@ -0,0 +1,82 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDrawingUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin } from '@univerjs/core';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import pkg from '../package.json';
import { defaultPluginConfig, DRAWING_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DrawingUpdateController } from './controllers/drawing-update.controller';
import { ImageCropperController } from './controllers/image-cropper.controller';
import { ImageUpdateController } from './controllers/image-update.controller';
import { DrawingUIController } from './controllers/ui.controller';
import { DrawingImageClipService } from './services/drawing-image-clip.service';
import { DrawingRenderService } from './services/drawing-render.service';
@DependentOn(UniverDrawingPlugin, UniverRenderEnginePlugin)
export class UniverDrawingUIPlugin extends Plugin {
static override pluginName = 'UNIVER_DRAWING_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverDrawingUIConfig> = defaultPluginConfig,
@Inject(Injector) protected _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DRAWING_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
this._initDependencies();
}
override onRendered(): void {
this._injector.get(DrawingUpdateController);
this._injector.get(DrawingUIController);
this._injector.get(ImageCropperController);
this._injector.get(ImageUpdateController);
}
private _initDependencies(): void {
const dependencies: Dependency[] = [
[DrawingImageClipService],
[DrawingRenderService],
[DrawingUpdateController],
[DrawingUIController],
[ImageCropperController],
[ImageUpdateController],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
}
}

View File

@@ -0,0 +1,59 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DrawingImageClipService } from '../drawing-image-clip.service';
describe('DrawingImageClipService', () => {
let service: DrawingImageClipService;
beforeEach(() => {
const injector = new Injector();
injector.add([DrawingImageClipService]);
service = injector.get(DrawingImageClipService);
});
it('delegates image clipping to the registered shape engine', () => {
const bounds = { left: 0, top: 0, width: 20, height: 10 };
const delegate = vi.fn(() => bounds);
const disposable = service.registerClipDelegate(delegate);
expect(service.applyShapeClip({} as never, 'rect', 20, 10, { adj: 1 })).toBe(bounds);
expect(delegate).toHaveBeenCalledWith({}, 'rect', 20, 10, { adj: 1 });
disposable.dispose();
expect(service.applyShapeClip({} as never, 'rect', 20, 10)).toBe(false);
});
it('publishes whether shape clipping is available in the current runtime', () => {
const values: boolean[] = [];
service.canUseShapeClip$.subscribe((value) => values.push(value));
service.setCanUseShapeClip(true);
expect(values).toEqual([false, true]);
});
it('clears registered clipping behavior when disposed', () => {
const delegate = vi.fn(() => ({ left: 0, top: 0, width: 20, height: 10 }));
service.registerClipDelegate(delegate);
service.dispose();
expect(service.applyShapeClip({} as never, 'rect', 20, 10)).toBe(false);
});
});

View File

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

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 { IDisposable, Nullable } from '@univerjs/core';
import type { IImageShapeClipService, IShapeClipBounds, UniverRenderingContext } from '@univerjs/engine-render';
import { Disposable, toDisposable } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export const IMAGE_CLIP_SHAPE_PICKER_COMPONENT = 'sheet.image-clip.shape.picker.component';
/**
* Delegate function type for building a shape clip path.
* The function should call ctx.beginPath(), build the shape outline path, and call ctx.clip().
* Coordinate system: (0,0) is at the top-left of the shape area.
* @returns The actual bounding rect of the clip region, or false if no clip was built
*/
export type ImageShapeClipDelegate = (ctx: UniverRenderingContext, prstGeom: string, width: number, height: number, adjustValues?: Nullable<Record<string, number>>) => IShapeClipBounds | false;
/**
* Bridge service that enables shape-based image clipping.
* This service lives in the open-source drawing-ui package and delegates
* to a registered clip implementation (provided by pro engine-shape package).
*
* When no delegate is registered, applyShapeClip returns false and images render normally without shape clipping.
*/
export class DrawingImageClipService extends Disposable implements IImageShapeClipService {
private _clipDelegate: Nullable<ImageShapeClipDelegate> = null;
private readonly _canUseShapeClip$ = new BehaviorSubject<boolean>(false);
readonly canUseShapeClip$ = this._canUseShapeClip$.asObservable();
constructor() {
super();
}
setCanUseShapeClip(canUse: boolean) {
this._canUseShapeClip$.next(canUse);
}
/**
* Register a clip delegate that knows how to build shape clip paths.
* Typically called by the pro-side plugin with a ShapeModel-based implementation.
* @returns IDisposable to unregister the delegate
*/
registerClipDelegate(delegate: ImageShapeClipDelegate): IDisposable {
this._clipDelegate = delegate;
return toDisposable(() => {
if (this._clipDelegate === delegate) {
this._clipDelegate = null;
}
});
}
applyShapeClip(ctx: UniverRenderingContext, prstGeom: string, width: number, height: number, adjustValues?: Nullable<Record<string, number>>): IShapeClipBounds | false {
if (this._clipDelegate) {
return this._clipDelegate(ctx, prstGeom, width, height, adjustValues);
}
return false;
}
override dispose(): void {
this._clipDelegate = null;
this._canUseShapeClip$.complete();
super.dispose();
}
}

View File

@@ -0,0 +1,338 @@
/**
* 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, IDrawingSearch, Workbook } from '@univerjs/core';
import type { IDocFloatDomData, IImageData } from '@univerjs/drawing';
import type { BaseObject, IImageProps, IRectProps, Scene } from '@univerjs/engine-render';
import {
BooleanNumber,
DrawingTypeEnum,
IImageIoService,
ImageSourceType,
Inject,
IUniverInstanceService,
IURLImageService,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import {
getDrawingShapeKeyByDrawingSearch,
IDrawingManagerService,
} from '@univerjs/drawing';
import { DRAWING_OBJECT_LAYER_INDEX, Image, Rect } from '@univerjs/engine-render';
import { IGalleryService } from '@univerjs/ui';
import { insertGroupObject } from '../controllers/utils';
import { DrawingImageClipService } from './drawing-image-clip.service';
// const IMAGE_VIEWER_DROPDOWN_PADDING = 50;
interface IDrawingTransformStateWithClipBounds {
clipBounds?: { left: number; top: number; width: number; height: number } | null;
}
type IDrawingParamWithBehindText = (Partial<IDrawingParam> | Partial<IImageData>) & {
behindText?: boolean | BooleanNumber;
behindDoc?: BooleanNumber;
layoutType?: PositionedObjectLayoutType;
docxHeaderFooterDrawing?: boolean;
};
export const DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX = 1;
export function getDrawingRenderLayerIndex(param: IDrawingParamWithBehindText): number {
return param.behindText === true || param.behindText === BooleanNumber.TRUE
|| (param.layoutType === PositionedObjectLayoutType.WRAP_NONE && param.behindDoc === BooleanNumber.TRUE)
? DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX
: DRAWING_OBJECT_LAYER_INDEX;
}
export function ensureDrawingRenderLayer(scene: Scene, object: BaseObject, param: IDrawingParamWithBehindText): void {
const layerIndex = getDrawingRenderLayerIndex(param);
if (object.layer == null || object.layer.zIndex === layerIndex) {
return;
}
scene.removeObject(object);
scene.addObject(object, layerIndex);
}
function isRenderableImageCache(image: HTMLImageElement | null | undefined | void): image is HTMLImageElement {
return image?.complete === true && image.naturalWidth > 0 && image.naturalHeight > 0;
}
export class DrawingRenderService {
constructor(
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@IImageIoService private readonly _imageIoService: IImageIoService,
@IGalleryService private readonly _galleryService: IGalleryService,
@IURLImageService private readonly _urlImageService: IURLImageService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(DrawingImageClipService) private readonly _drawingImageClipService: DrawingImageClipService
) { }
// eslint-disable-next-line max-lines-per-function, complexity
async renderImages(imageParam: IImageData, scene: Scene, options?: { allowInactiveSheet?: boolean }) {
const {
transform: singleTransform,
drawingType,
source,
imageSourceType,
srcRect,
prstGeom,
groupId,
unitId,
subUnitId,
drawingId,
isMultiTransform,
transforms: multiTransforms,
adjustValues,
hidden,
} = imageParam;
const { docxHeaderFooterDrawing, layoutType } = imageParam as IImageData & {
docxHeaderFooterDrawing?: boolean;
layoutType?: PositionedObjectLayoutType;
};
if (drawingType !== DrawingTypeEnum.DRAWING_IMAGE) {
return;
}
if (!this._drawingManagerService.getDrawingVisible()) {
return;
}
// Isolated print scenes may target a worksheet that is not active in the editor.
if (!options?.allowInactiveSheet && this._univerInstanceService.getUnitType(unitId) === UniverInstanceType.UNIVER_SHEET && subUnitId !== this._getActiveSheetId()) {
return;
}
if (singleTransform == null) {
return;
}
const transforms = isMultiTransform && multiTransforms ? multiTransforms : [singleTransform];
const images = [];
for (const transform of transforms) {
const { left, top, width, height, angle, flipX, flipY, skewX, skewY } = transform;
const index = transforms.indexOf(transform);
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId }, isMultiTransform ? index : undefined);
const imageShape = scene.getObject(imageShapeKey);
if (imageShape != null) {
imageShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
(imageShape as Image).setClipBounds?.((transform as IDrawingTransformStateWithClipBounds).clipBounds);
if ('hidden' in imageParam) {
hidden ? imageShape.hide() : imageShape.show();
}
ensureDrawingRenderLayer(scene, imageShape, imageParam as IDrawingParamWithBehindText);
continue;
}
const orders = this._drawingManagerService.getDrawingOrder(unitId, subUnitId);
const zIndex = orders.indexOf(drawingId);
const imageConfig: IImageProps = { ...transform, zIndex: zIndex === -1 ? (orders.length - 1) : zIndex };
const imageNativeCache = this._imageIoService.getImageSourceCache(source, imageSourceType);
let shouldBeCache = false;
if (isRenderableImageCache(imageNativeCache)) {
imageConfig.image = imageNativeCache;
} else {
if (imageSourceType === ImageSourceType.UUID) {
try {
imageConfig.url = await this._imageIoService.getImage(source);
} catch (error) {
console.error(error);
continue;
}
} else if (imageSourceType === ImageSourceType.URL) {
try {
imageConfig.url = await this._urlImageService.getImage(source);
} catch (error) {
console.error(error);
imageConfig.url = source;
}
shouldBeCache = true;
} else {
imageConfig.url = source;
shouldBeCache = true;
}
}
const shouldWaitForInlineTransform =
docxHeaderFooterDrawing === true && layoutType === PositionedObjectLayoutType.INLINE;
if (hidden || shouldWaitForInlineTransform) {
imageConfig.visible = false;
}
if (scene.getObject(imageShapeKey)) {
// The image maybe already added in the time we are getting the source of the image
continue;
}
imageConfig.printable = true;
const image = new Image(imageShapeKey, imageConfig);
image.setClipService(this._drawingImageClipService);
if (shouldBeCache) {
this._imageIoService.addImageSourceCache(source, imageSourceType, image.getNative());
}
scene.addObject(image, getDrawingRenderLayerIndex(imageParam as IDrawingParamWithBehindText));
if (this._drawingManagerService.getDrawingEditable()) {
scene.attachTransformerTo(image);
}
groupId && insertGroupObject({ drawingId: groupId, unitId, subUnitId }, image, scene, this._drawingManagerService);
if (prstGeom != null) {
image.setPrstGeom(prstGeom);
}
if (adjustValues != null) {
image.setPrstGeomAdjValues(adjustValues);
}
if (srcRect != null) {
image.setSrcRect(srcRect);
}
images.push(image);
}
return images;
}
private _getActiveSheetId(): string | undefined {
return this._univerInstanceService
.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)
?.getActiveSheet()
?.getSheetId();
}
renderFloatDom(param: IDocFloatDomData, scene: Scene) {
const {
transform: singleTransform,
drawingType,
groupId,
unitId,
subUnitId,
drawingId,
isMultiTransform,
transforms: multiTransforms,
} = param;
if (drawingType !== DrawingTypeEnum.DRAWING_DOM) {
return;
}
if (!this._drawingManagerService.getDrawingVisible()) {
return;
}
if (singleTransform == null) {
return;
}
const transforms = isMultiTransform && multiTransforms ? multiTransforms : [singleTransform];
const rects = [];
for (const transform of transforms) {
const { left, top, width, height, angle, flipX, flipY, skewX, skewY } = transform;
const index = transforms.indexOf(transform);
const imageShapeKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId }, isMultiTransform ? index : undefined);
const imageShape = scene.getObject(imageShapeKey);
if (imageShape != null) {
imageShape.transformByState({ left, top, width, height, angle, flipX, flipY, skewX, skewY });
continue;
}
const orders = this._drawingManagerService.getDrawingOrder(unitId, subUnitId);
const zIndex = orders.indexOf(drawingId);
const rectConfig: IRectProps = { ...transform, zIndex: zIndex === -1 ? (orders.length - 1) : zIndex };
if (scene.getObject(imageShapeKey)) {
// The image maybe already added in the time we are getting the source of the image
continue;
}
rectConfig.printable = false;
const rect = new Rect(imageShapeKey, rectConfig);
if (!this._drawingManagerService.getDrawingVisible()) {
continue;
}
scene.addObject(rect, DRAWING_OBJECT_LAYER_INDEX);
if (this._drawingManagerService.getDrawingEditable() && param.allowTransform !== false) {
scene.attachTransformerTo(rect);
}
groupId && insertGroupObject({ drawingId: groupId, unitId, subUnitId }, rect, scene, this._drawingManagerService);
rects.push(rect);
}
return rects;
}
renderDrawing(param: IDrawingSearch, scene: Scene, options?: { allowInactiveSheet?: boolean }) {
const drawingParam = this._drawingManagerService.getDrawingByParam(param);
if (drawingParam == null) {
return;
}
switch (drawingParam.drawingType) {
case DrawingTypeEnum.DRAWING_IMAGE:
return this.renderImages(drawingParam as IImageData, scene, options);
default:
}
}
previewImage(key: string, src: string, width: number, height: number) {
// const dialogId = `${key}-viewer-dialog`;
// const screenWidth = window.innerWidth - IMAGE_VIEWER_DROPDOWN_PADDING;
// const screenHeight = window.innerHeight - IMAGE_VIEWER_DROPDOWN_PADDING;
// const adjustSize = this._adjustImageSize(width, height, screenWidth, screenHeight);
this._galleryService.open({
images: [src],
onOpenChange: (open) => {
if (!open) {
this._galleryService.close();
}
},
});
}
private _adjustImageSize(nativeWidth: number, nativeHeight: number, screenWidth: number, screenHeight: number) {
// Use native size if the image is smaller than the screen
if (nativeWidth <= screenWidth && nativeHeight <= screenHeight) {
return {
width: nativeWidth,
height: nativeHeight,
};
}
// Calculate scale ratios
const widthRatio = screenWidth / nativeWidth;
const heightRatio = screenHeight / nativeHeight;
const scale = Math.min(widthRatio, heightRatio); // Choose the smaller ratio to ensure the image fits within the screen
// Return new dimensions
return {
width: Math.floor(nativeWidth * scale),
height: Math.floor(nativeHeight * scale),
};
}
}

View File

@@ -0,0 +1,190 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
extractClipboardHtmlImageFiles,
extractClipboardImageFiles,
extractClipboardTextImageFile,
normalizeClipboardImageFile,
svgImageFileToDataUrl,
writeImageSourceToClipboard,
} from '../clipboard-image';
describe('writeImageSourceToClipboard', () => {
const originalClipboardItem = globalThis.ClipboardItem;
const originalFetch = globalThis.fetch;
const originalClipboardDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, 'clipboard');
afterEach(() => {
vi.useRealTimers();
globalThis.ClipboardItem = originalClipboardItem;
globalThis.fetch = originalFetch;
if (originalClipboardDescriptor) {
Object.defineProperty(globalThis.navigator, 'clipboard', originalClipboardDescriptor);
} else {
delete (globalThis.navigator as { clipboard?: Clipboard }).clipboard;
}
});
it('writes a PNG source through the browser clipboard API', async () => {
const blob = new Blob(['png'], { type: 'image/png' });
const write = vi.fn(async (_items: ClipboardItem[]) => undefined);
globalThis.fetch = vi.fn(async () => new Response(blob, { status: 200 })) as typeof fetch;
globalThis.ClipboardItem = class ClipboardItemMock {
readonly types = ['image/png'];
readonly presentationStyle = 'unspecified' as const;
constructor(readonly data: Record<string, Blob | Promise<Blob>>) {}
async getType(type: string): Promise<Blob> {
return this.data[type];
}
} as unknown as typeof ClipboardItem;
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: { write },
});
await expect(writeImageSourceToClipboard('data:image/png;base64,cG5n')).resolves.toBe(true);
const item = write.mock.calls[0][0][0] as unknown as { data: Record<string, Promise<Blob>> };
const copiedBlob = await item.data['image/png'];
expect(copiedBlob).toMatchObject({ size: blob.size, type: blob.type });
});
it('returns false when binary clipboard writing is unavailable', async () => {
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
value: undefined,
});
await expect(writeImageSourceToClipboard('data:image/png;base64,cG5n')).resolves.toBe(false);
});
it('prefers image item files over the fallback file list', () => {
const itemImage = new File(['item'], 'item.png', { type: 'image/png' });
const fallbackImage = new File(['fallback'], 'fallback.png', { type: 'image/png' });
const clipboardData = {
files: [fallbackImage],
items: [{ kind: 'file', type: 'image/png', getAsFile: () => itemImage }],
} as unknown as DataTransfer;
expect(extractClipboardImageFiles(clipboardData)).toEqual([itemImage]);
});
it('recognizes image files whose clipboard MIME type is missing', () => {
const image = new File(['image'], 'clipboard-image.PNG');
const clipboardData = {
files: [image],
items: [],
} as unknown as DataTransfer;
expect(extractClipboardImageFiles(clipboardData)).toEqual([image]);
});
it('deduplicates HTML images and ignores images from hidden content', async () => {
globalThis.fetch = vi.fn(async () => new Response(new Blob(['png'], { type: 'image/png' }), { status: 200 })) as typeof fetch;
const files = await extractClipboardHtmlImageFiles(`
<img src="https://example.com/visible.png">
<img src="https://example.com/visible.png">
<div aria-hidden="true"><img src="https://example.com/hidden.png"></div>
`);
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(files).toHaveLength(1);
});
it('extracts lazy HTML images and inline SVG as image files', async () => {
globalThis.fetch = vi.fn(async () => new Response(new Blob(['png'], { type: 'image/png' }), { status: 200 })) as typeof fetch;
const files = await extractClipboardHtmlImageFiles(`
<img data-src="https://example.com/lazy.png">
<picture><source srcset="https://example.com/picture.png 1x"><img></picture>
<svg viewBox="0 0 10 10"><path d="M0 0h10v10z"></path></svg>
`);
expect(globalThis.fetch).toHaveBeenCalledWith('https://example.com/lazy.png', expect.anything());
expect(globalThis.fetch).toHaveBeenCalledWith('https://example.com/picture.png', expect.anything());
expect(files).toEqual([
expect.objectContaining({ type: 'image/png' }),
expect.objectContaining({ type: 'image/png' }),
expect.objectContaining({ type: 'image/svg+xml' }),
]);
});
it('stops waiting for inaccessible HTML images', async () => {
vi.useFakeTimers();
globalThis.fetch = vi.fn((_input, init) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
})) as typeof fetch;
const result = extractClipboardHtmlImageFiles('<img src="https://example.com/blocked.png">');
await vi.advanceTimersByTimeAsync(5_000);
await expect(result).resolves.toEqual([]);
});
it('restores a missing MIME type before image insertion', async () => {
const image = new File(['image'], 'clipboard-image.png');
await expect(normalizeClipboardImageFile(image)).resolves.toMatchObject({ type: 'image/png' });
});
it('keeps sanitized SVG images as vectors during normalization', async () => {
const image = new File([
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24z"/></svg>',
], 'clipboard-image.svg', { type: 'image/svg+xml' });
const normalized = await normalizeClipboardImageFile(image);
expect(normalized).toMatchObject({ name: 'clipboard-image.svg', type: 'image/svg+xml' });
await expect(normalized?.text()).resolves.toContain('viewBox="0 0 24 24"');
await expect(svgImageFileToDataUrl(normalized!)).resolves.toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
});
it('converts a pasted image data URL into a file', async () => {
const file = await extractClipboardTextImageFile('data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=');
expect(file).toMatchObject({ name: 'pasted-image.svg', type: 'image/svg+xml' });
});
it('converts complete SVG text into a sanitized file', async () => {
const file = await extractClipboardTextImageFile(`
<svg onload="alert(1)" viewBox="0 0 24 24">
<script>alert(1)</script>
<style>.remote { fill: url(https://example.com/pixel.svg) }</style>
<image href="https://example.com/tracker.png" />
<use href="#safe-path" />
<path id="safe-path" onclick="alert(1)" style="fill:url(javascript:alert(1))" d="M0 0h24v24z"/>
</svg>
`);
expect(file).toMatchObject({ name: 'pasted-image.svg', type: 'image/svg+xml' });
await expect(file?.text()).resolves.not.toMatch(/script|onclick|example\.com|javascript:/);
await expect(file?.text()).resolves.toContain('href="#safe-path"');
});
it('keeps ordinary text as text', async () => {
await expect(extractClipboardTextImageFile('Use <svg> in this sentence.')).resolves.toBeNull();
});
it('rejects oversized raw SVG before parsing it', async () => {
const oversized = `<svg>${' '.repeat(5_000_000)}</svg>`;
await expect(extractClipboardTextImageFile(oversized)).resolves.toBeNull();
});
});

View File

@@ -0,0 +1,114 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingManagerService } from '@univerjs/drawing';
import type { BaseObject, Image } from '@univerjs/engine-render';
import { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { getUpdateParams } from '../get-update-params';
describe('getUpdateParams', () => {
it('maps drawing objects back to drawing params and keeps missing objects as null', () => {
const shapeObject = {
oKey: 'shape-1',
left: 10,
top: 20,
width: 30,
height: 40,
angle: 15,
} as BaseObject;
const imageObject = {
oKey: 'image-1',
left: 1,
top: 2,
width: 3,
height: 4,
angle: 5,
srcRect: {
left: 6,
top: 7,
width: 8,
height: 9,
},
} as unknown as Image;
const drawingManagerService = {
getDrawingOKey: (oKey: string) => {
if (oKey === 'shape-1') {
return {
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-shape',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
};
}
if (oKey === 'image-1') {
return {
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
};
}
return null;
},
} as IDrawingManagerService;
const result = getUpdateParams(new Map([
['shape-1', shapeObject],
['missing', { oKey: 'missing' } as BaseObject],
['image-1', imageObject],
]), drawingManagerService);
expect(result).toEqual([
{
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-shape',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
transform: {
left: 10,
top: 20,
width: 30,
height: 40,
angle: 15,
},
},
null,
{
unitId: 'unit-1',
subUnitId: 'sub-1',
drawingId: 'drawing-image',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left: 1,
top: 2,
width: 3,
height: 4,
angle: 5,
},
srcRect: {
left: 6,
top: 7,
width: 8,
height: 9,
},
},
]);
});
});

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 { describe, expect, it } from 'vitest';
import { getImageCropRect } from '../image-crop-transform';
describe('getImageCropRect', () => {
it('keeps asymmetric crop insets in image coordinates', () => {
const result = getImageCropRect({ left: 10, top: 20, width: 200, height: 100, angle: 0 }, { left: 30, top: 30, width: 150, height: 60 });
expect(result.srcRectAngle).toEqual({ left: 20, top: 10, right: 30, bottom: 30 });
expect(result.srcRect).toEqual(result.srcRectAngle);
});
it('accounts for different rotation centers without changing the crop size', () => {
const { srcRectAngle } = getImageCropRect({ left: 0, top: 0, width: 200, height: 100, angle: 90 }, { left: 50, top: 10, width: 100, height: 60 });
expect(srcRectAngle.left).toBeCloseTo(40);
expect(srcRectAngle.top).toBeCloseTo(20);
expect(srcRectAngle.right).toBeCloseTo(60);
expect(srcRectAngle.bottom).toBeCloseTo(20);
});
});

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 type { IDrawingParam } from '@univerjs/core';
import { DrawingTypeEnum } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { isKnownDrawingUINonRotatableType, resolveDrawingUIRotateEnabled } from '../rotate-enabled';
function createDrawing(drawingType: DrawingTypeEnum): IDrawingParam {
return {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-1',
drawingType,
};
}
describe('drawing-ui rotate-enabled helpers', () => {
it('uses chart as the default non-rotatable UI drawing type', () => {
expect(isKnownDrawingUINonRotatableType(DrawingTypeEnum.DRAWING_CHART)).toBe(true);
expect(resolveDrawingUIRotateEnabled(createDrawing(DrawingTypeEnum.DRAWING_CHART))).toBe(false);
});
it('lets callers override known non-rotatable type handling', () => {
expect(resolveDrawingUIRotateEnabled(createDrawing(DrawingTypeEnum.DRAWING_CHART), {
isKnownNonRotatableType: () => false,
})).toBe(true);
});
});

View File

@@ -0,0 +1,368 @@
/**
* 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 { IImageIoService } from '@univerjs/core';
import { ImageSourceType } from '@univerjs/core';
const IMAGE_PNG_MIME_TYPE = 'image/png';
const IMAGE_SVG_MIME_TYPE = 'image/svg+xml';
const CLIPBOARD_HTML_IMAGE_FETCH_TIMEOUT = 5_000;
const MAX_CLIPBOARD_HTML_IMAGE_COUNT = 32;
const MAX_CLIPBOARD_SVG_SOURCE_LENGTH = 5_000_000;
const MAX_CLIPBOARD_SVG_ELEMENT_COUNT = 50_000;
const PASSTHROUGH_IMAGE_MIME_TYPES = [
IMAGE_PNG_MIME_TYPE,
'image/jpeg',
'image/jpg',
'image/gif',
'image/bmp',
];
const IMAGE_MIME_TYPES_BY_EXTENSION: Record<string, string> = {
avif: 'image/avif',
bmp: 'image/bmp',
gif: 'image/gif',
heic: 'image/heic',
ico: 'image/x-icon',
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: IMAGE_PNG_MIME_TYPE,
svg: 'image/svg+xml',
tif: 'image/tiff',
tiff: 'image/tiff',
webp: 'image/webp',
};
export function extractClipboardImageFiles(clipboardData: DataTransfer): File[] {
const itemFiles = Array.from(clipboardData.items ?? [])
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null && isClipboardImageFile(file));
return itemFiles.length > 0
? itemFiles
: Array.from(clipboardData.files ?? []).filter(isClipboardImageFile);
}
export function isClipboardTextImage(text: string): boolean {
const source = text.trim();
return /^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(source) ||
(/^<svg(?:\s|>)/i.test(source) && /<\/svg>$/i.test(source));
}
export async function extractClipboardTextImageFile(text: string): Promise<File | null> {
const source = text.trim();
if (/^data:image\/[a-z0-9.+-]+(?:;[^,]*)?,/i.test(source)) {
try {
const response = await fetch(source);
const blob = await response.blob();
const type = blob.type.split(';')[0].toLowerCase();
if (type === IMAGE_SVG_MIME_TYPE) {
const svg = parseSvg(await blob.text());
return svg ? new File([svg], 'pasted-image.svg', { type }) : null;
}
return type.startsWith('image/')
? new File([blob], `pasted-image.${imageExtension(type)}`, { type })
: null;
} catch {
return null;
}
}
const svg = parseSvg(source);
return svg ? new File([svg], 'pasted-image.svg', { type: IMAGE_SVG_MIME_TYPE }) : null;
}
export function isImageOnlyClipboardHtml(html: string): boolean {
const doc = new DOMParser().parseFromString(html, 'text/html');
const body = doc.body.cloneNode(true) as HTMLElement;
const hasImage = body.querySelector('img, svg') !== null;
body.querySelectorAll('img, svg').forEach((element) => element.remove());
return hasImage && !(body.textContent ?? '').trim() && !body.querySelector('table');
}
export async function extractClipboardHtmlImageFiles(html: string): Promise<File[]> {
const doc = new DOMParser().parseFromString(html, 'text/html');
const sources = Array.from(new Set(Array.from(doc.body.querySelectorAll('img'))
.filter((image) => !hasHiddenClipboardAncestor(image))
.map(resolveClipboardHtmlImageSource)
.filter((source) => /^(?:data|blob|https?):/i.test(source))))
.slice(0, MAX_CLIPBOARD_HTML_IMAGE_COUNT);
const fetchedFiles = await Promise.all(sources.map(fetchClipboardImageFile));
const svgFiles = Array.from(doc.body.querySelectorAll('svg'))
.filter((svg) => !hasHiddenClipboardAncestor(svg))
.map((svg, index) => {
const source = parseSvg(new XMLSerializer().serializeToString(svg));
return source ? new File([source], `pasted-image-${index + 1}.svg`, { type: IMAGE_SVG_MIME_TYPE }) : null;
});
return [...fetchedFiles, ...svgFiles]
.filter((file): file is File => file !== null)
.slice(0, MAX_CLIPBOARD_HTML_IMAGE_COUNT);
}
function resolveClipboardHtmlImageSource(image: HTMLImageElement): string {
const source = image.getAttribute('src') ||
image.getAttribute('data-src') ||
image.getAttribute('data-lazy-src') ||
image.getAttribute('data-original');
if (source) {
return source.trim();
}
const srcset = image.getAttribute('srcset') ||
image.closest('picture')?.querySelector('source[srcset]')?.getAttribute('srcset') ||
'';
return (srcset.trim().split(/\s+/)[0] ?? '').replace(/,$/, '');
}
async function fetchClipboardImageFile(source: string): Promise<File | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), CLIPBOARD_HTML_IMAGE_FETCH_TIMEOUT);
try {
const response = await fetch(source, { signal: controller.signal });
if (!response.ok) {
return null;
}
const blob = await response.blob();
return blob.type.startsWith('image/')
? new File([blob], `pasted-image.${imageExtension(blob.type)}`, { type: blob.type })
: null;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function hasHiddenClipboardAncestor(element: Element): boolean {
let current: Element | null = element;
while (current) {
if (current.hasAttribute('hidden') || current.getAttribute('aria-hidden')?.toLowerCase() === 'true') {
return true;
}
const classes = current.classList;
if (classes.contains('sr-only') || classes.contains('visually-hidden') || classes.contains('screen-reader-only')) {
return true;
}
const style = current.getAttribute('style') ?? '';
if (/(?:^|;)\s*display\s*:\s*none(?:\s*!important)?\s*(?:;|$)/i.test(style) ||
/(?:^|;)\s*visibility\s*:\s*(?:hidden|collapse)(?:\s*!important)?\s*(?:;|$)/i.test(style)) {
return true;
}
current = current.parentElement;
}
return false;
}
export async function normalizeClipboardImageFile(file: File): Promise<File | null> {
const type = resolveClipboardImageMimeType(file);
if (!type) {
return null;
}
const typedFile = file.type.toLowerCase() === type ? file : new File([file], file.name, { type });
if (type === IMAGE_SVG_MIME_TYPE) {
const svg = parseSvg(await typedFile.text());
return svg ? new File([svg], typedFile.name || 'pasted-image.svg', { type }) : null;
}
if (PASSTHROUGH_IMAGE_MIME_TYPES.includes(type)) {
return typedFile;
}
try {
const blob = await rasterizeImageBlob(typedFile);
return blob ? new File([blob], 'pasted-image.png', { type: IMAGE_PNG_MIME_TYPE }) : null;
} catch {
return null;
}
}
export async function svgImageFileToDataUrl(file: File): Promise<string | null> {
if (resolveClipboardImageMimeType(file) !== IMAGE_SVG_MIME_TYPE) {
return null;
}
const svg = parseSvg(await file.text());
return svg ? `data:${IMAGE_SVG_MIME_TYPE};charset=utf-8,${encodeURIComponent(svg)}` : null;
}
export async function writeImageSourceToClipboard(
source: string,
imageSourceType?: ImageSourceType,
imageIoService?: IImageIoService
): Promise<boolean> {
const clipboard = globalThis.navigator?.clipboard;
const ClipboardItemCtor = globalThis.ClipboardItem;
if (!source || !clipboard?.write || typeof ClipboardItemCtor === 'undefined') {
return false;
}
try {
const pngBlob = resolveImageClipboardPng(source, imageSourceType, imageIoService);
await clipboard.write([new ClipboardItemCtor({ [IMAGE_PNG_MIME_TYPE]: pngBlob })]);
return true;
} catch {
return false;
}
}
async function resolveImageClipboardPng(
source: string,
imageSourceType: ImageSourceType | undefined,
imageIoService: IImageIoService | undefined
): Promise<Blob> {
const resolvedSource = await resolveImageSource(source, imageSourceType, imageIoService);
if (!resolvedSource) {
throw new Error('Clipboard image source could not be resolved.');
}
const response = await fetch(resolvedSource);
if (!response.ok) {
throw new Error('Clipboard image source could not be loaded.');
}
const imageBlob = await response.blob();
const pngBlob = imageBlob.type === IMAGE_PNG_MIME_TYPE
? imageBlob
: await rasterizeImageBlob(imageBlob);
if (!pngBlob) {
throw new Error('Clipboard image could not be converted to PNG.');
}
return pngBlob;
}
async function resolveImageSource(
source: string,
imageSourceType: ImageSourceType | undefined,
imageIoService: IImageIoService | undefined
): Promise<string | null> {
if (/^(?:data|blob|https?):/i.test(source) && imageSourceType !== ImageSourceType.UUID) {
return source;
}
return imageIoService ? imageIoService.getImage(source) : null;
}
async function rasterizeImageBlob(blob: Blob): Promise<Blob | null> {
if (!blob.type.startsWith('image/')) {
return null;
}
const objectUrl = URL.createObjectURL(blob);
try {
const image = await loadImage(objectUrl);
if (image.naturalWidth === 0 || image.naturalHeight === 0) {
return null;
}
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
context.drawImage(image, 0, 0);
return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, IMAGE_PNG_MIME_TYPE));
} finally {
URL.revokeObjectURL(objectUrl);
}
}
function loadImage(source: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Clipboard image could not be decoded.'));
image.src = source;
});
}
function parseSvg(source: string): string | null {
if (source.length > MAX_CLIPBOARD_SVG_SOURCE_LENGTH ||
!isClipboardTextImage(source) ||
!/^<svg(?:\s|>)/i.test(source)) {
return null;
}
const doc = new DOMParser().parseFromString(source, IMAGE_SVG_MIME_TYPE);
const root = doc.documentElement;
if (root.localName.toLowerCase() !== 'svg' || doc.querySelector('parsererror')) {
return null;
}
const descendants = root.querySelectorAll('*');
if (descendants.length > MAX_CLIPBOARD_SVG_ELEMENT_COUNT) {
return null;
}
root.querySelectorAll('script, foreignObject, iframe, object, embed, link').forEach((element) => element.remove());
root.querySelectorAll('style').forEach((element) => {
if (hasUnsafeSvgCssReference(element.textContent ?? '')) {
element.remove();
}
});
[root, ...Array.from(root.querySelectorAll('*'))].forEach((element) => {
Array.from(element.attributes).forEach((attribute) => {
if (/^on/i.test(attribute.name)) {
element.removeAttribute(attribute.name);
return;
}
if (/^(?:href|xlink:href)$/i.test(attribute.name) && !isSafeSvgReference(attribute.value)) {
element.removeAttribute(attribute.name);
return;
}
if (hasUnsafeSvgCssReference(attribute.value)) {
element.removeAttribute(attribute.name);
}
});
});
if (!root.hasAttribute('xmlns')) {
root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
}
return new XMLSerializer().serializeToString(root);
}
function isSafeSvgReference(value: string): boolean {
const reference = value.trim();
return reference.startsWith('#') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(reference);
}
function hasUnsafeSvgCssReference(value: string): boolean {
if (/@import/i.test(value)) {
return true;
}
const references = value.match(/url\([^)]*\)/gi) ?? [];
return references.some((reference) => !/^url\(\s*['"]?#/i.test(reference));
}
function imageExtension(type: string): string {
const extension = type.split('/')[1] ?? 'png';
return extension === 'svg+xml' ? 'svg' : extension;
}
function isClipboardImageFile(file: File): boolean {
return resolveClipboardImageMimeType(file) !== null;
}
function resolveClipboardImageMimeType(file: File): string | null {
const type = file.type.toLowerCase();
if (type.startsWith('image/')) {
return type;
}
const extension = file.name.split('.').pop()?.toLowerCase();
return extension ? IMAGE_MIME_TYPES_BY_EXTENSION[extension] ?? null : null;
}

View File

@@ -0,0 +1,21 @@
/**
* 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 MIN_DRAWING_WIDTH_LIMIT = 20;
export const MIN_DRAWING_HEIGHT_LIMIT = 20;
export const RANGE_DRAWING_ROTATION_LIMIT = [-360, 360];

View File

@@ -0,0 +1,58 @@
/**
* 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, Nullable } from '@univerjs/core';
import type { IDrawingManagerService, IImageData } from '@univerjs/drawing';
import type { BaseObject, Image } from '@univerjs/engine-render';
import { DrawingTypeEnum } from '@univerjs/core';
export function getUpdateParams(objects: Map<string, BaseObject>, drawingManagerService: IDrawingManagerService): Nullable<IDrawingParam>[] {
const params: Nullable<IDrawingParam>[] = [];
objects.forEach((object) => {
const { oKey, left, top, height, width, angle } = object;
const searchParam = drawingManagerService.getDrawingOKey(oKey);
if (searchParam == null) {
params.push(null);
return true;
}
const { unitId, subUnitId, drawingId, drawingType } = searchParam;
const param = {
unitId,
subUnitId,
drawingId,
drawingType,
transform: {
left,
top,
height,
width,
angle,
},
} as IDrawingParam;
if (drawingType === DrawingTypeEnum.DRAWING_IMAGE) {
(param as IImageData).srcRect = (object as Image).srcRect;
}
params.push(param);
});
return params;
}

View File

@@ -0,0 +1,39 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { BaseObject } from '@univerjs/engine-render';
import { degToRad, Vector2 } from '@univerjs/engine-render';
/** Crop insets in image coordinates, accounting for rotation around different centers. */
export function getImageCropRect(image: Pick<BaseObject, 'left' | 'top' | 'width' | 'height' | 'angle'>, crop: Pick<BaseObject, 'left' | 'top' | 'width' | 'height'>) {
const srcRect = {
left: crop.left - image.left,
top: crop.top - image.top,
right: image.left + image.width - crop.left - crop.width,
bottom: image.top + image.height - crop.top - crop.height,
};
const srcRectAngle = { ...srcRect };
if (image.angle) {
const origin = new Vector2(image.left, image.top);
origin.rotateByPoint(degToRad(image.angle), new Vector2(image.left + image.width / 2, image.top + image.height / 2));
origin.rotateByPoint(degToRad(-image.angle), new Vector2(crop.left + crop.width / 2, crop.top + crop.height / 2));
srcRectAngle.left = crop.left - origin.x;
srcRectAngle.top = crop.top - origin.y;
srcRectAngle.right = image.width - srcRectAngle.left - crop.width;
srcRectAngle.bottom = image.height - srcRectAngle.top - crop.height;
}
return { srcRect, srcRectAngle };
}

View File

@@ -0,0 +1,34 @@
/**
* 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 { IDrawingRotateEnabledResolverOptions } from '@univerjs/drawing';
import { DrawingTypeEnum } from '@univerjs/core';
import { resolveDrawingRotateEnabled } from '@univerjs/drawing';
export function isKnownDrawingUINonRotatableType(drawingType: DrawingType): boolean {
return drawingType === DrawingTypeEnum.DRAWING_CHART;
}
export function resolveDrawingUIRotateEnabled(
drawing: IDrawingParam,
options: IDrawingRotateEnabledResolverOptions = {}
): boolean {
return resolveDrawingRotateEnabled(drawing, {
...options,
isKnownNonRotatableType: options.isKnownNonRotatableType ?? isKnownDrawingUINonRotatableType,
});
}

View File

@@ -0,0 +1,284 @@
/**
* 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 { Root } from 'react-dom/client';
import { DrawingTypeEnum, LocaleType, Univer } from '@univerjs/core';
import { DrawingManagerService, getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ComponentManager, IconManager, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { ComponentsController } from '../../controllers/components.controller';
import { DrawingImageClipService } from '../../services/drawing-image-clip.service';
import { DrawingCommonPanel } from '../panel/DrawingCommonPanel';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const unitId = 'drawing-common-panel-unit';
const subUnitId = 'drawing-common-panel-subunit';
class TestTransformer {
readonly clearControl$ = new Subject<boolean>();
readonly changeStart$ = new Subject<{ objects: Map<string, { oKey: string }> }>();
readonly changing$ = new Subject<{ objects: Map<string, { oKey: string }> }>();
readonly changeEnd$ = new Subject<{ objects: Map<string, { oKey: string }> }>();
keepRatio = true;
refreshControls() {
return {
changeNotification: () => undefined,
};
}
}
class TestScene {
ancestorLeft = 0;
ancestorTop = 0;
constructor(private readonly _transformer: TestTransformer) {}
getEngine() {
return {
activeScene: {
width: 500,
height: 400,
},
};
}
getTransformerByCreate() {
return this._transformer;
}
}
class TestRenderManagerService {
readonly transformer = new TestTransformer();
readonly scene = new TestScene(this.transformer);
getRenderUnitById() {
return {
scene: this.scene,
};
}
}
function createDrawing(drawingId: string, left: number): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left,
top: 20,
width: 100,
height: 50,
angle: 0,
},
};
}
function renderWithRediContext(injector: ReturnType<Univer['__getInjector']>, drawings: IDrawingParam[]) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
<DrawingCommonPanel drawings={drawings} />
</RediContext.Provider>
);
});
return { container, root };
}
const PanelIndex = {
empty: 0,
arrange: 1,
transform: 2,
align: 3,
crop: 4,
} as const;
function panelIsAvailable(container: HTMLElement, index: number) {
const panel = container.children[index];
return Boolean(panel && !panel.classList.contains('univer-hidden'));
}
describe('DrawingCommonPanel behavior', () => {
let univer: Univer;
let drawingManagerService: IDrawingManagerService;
let root: Root | undefined;
let container: HTMLElement | undefined;
beforeEach(() => {
univer = new Univer({ locales: { [LocaleType.ZH_CN]: {} } });
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IconManager]);
injector.add([ComponentManager]);
injector.add([DrawingImageClipService]);
injector.add([ComponentsController]);
injector.get(ComponentsController);
drawingManagerService = injector.get(IDrawingManagerService);
});
afterEach(() => {
if (root) {
act(() => root!.unmount());
}
container?.remove();
root = undefined;
container = undefined;
univer.dispose();
});
it('starts in multi-select mode when multiple drawings are already focused', () => {
const drawings = [createDrawing('image-1', 10), createDrawing('image-2', 140)];
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': drawings[0],
'image-2': drawings[1],
},
order: ['image-1', 'image-2'],
},
});
drawingManagerService.focusDrawing(drawings.map(({ unitId, subUnitId, drawingId }) => ({ unitId, subUnitId, drawingId })));
const rendered = renderWithRediContext(univer.__getInjector(), drawings);
root = rendered.root;
container = rendered.container;
expect(panelIsAvailable(container, PanelIndex.align)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.arrange)).toBe(true);
});
it('switches from single-image controls to multi-select controls when drawing focus changes', () => {
const drawings = [createDrawing('image-1', 10), createDrawing('image-2', 140)];
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': drawings[0],
'image-2': drawings[1],
},
order: ['image-1', 'image-2'],
},
});
drawingManagerService.focusDrawing([{ unitId, subUnitId, drawingId: 'image-1' }]);
const rendered = renderWithRediContext(univer.__getInjector(), [drawings[0]]);
root = rendered.root;
container = rendered.container;
expect(panelIsAvailable(container, PanelIndex.align)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.arrange)).toBe(true);
act(() => {
drawingManagerService.focusDrawing(drawings.map(({ unitId, subUnitId, drawingId }) => ({ unitId, subUnitId, drawingId })));
});
expect(panelIsAvailable(container, PanelIndex.align)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.arrange)).toBe(true);
});
it('switches from multi-select controls to single-image controls when the transformer starts editing one drawing', () => {
const drawings = [createDrawing('image-1', 10), createDrawing('image-2', 140)];
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': drawings[0],
'image-2': drawings[1],
},
order: ['image-1', 'image-2'],
},
});
drawingManagerService.focusDrawing(drawings.map(({ unitId, subUnitId, drawingId }) => ({ unitId, subUnitId, drawingId })));
const rendered = renderWithRediContext(univer.__getInjector(), drawings);
root = rendered.root;
container = rendered.container;
expect(panelIsAvailable(container, PanelIndex.align)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(false);
const oKey = getDrawingShapeKeyByDrawingSearch({
unitId,
subUnitId,
drawingId: 'image-1',
});
act(() => {
(univer.__getInjector().get(IRenderManagerService) as unknown as TestRenderManagerService)
.transformer
.changeStart$
.next({ objects: new Map([[oKey, { oKey }]]) });
});
expect(panelIsAvailable(container, PanelIndex.align)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.arrange)).toBe(true);
});
it('returns to the empty panel when the active transformer clears its controls', () => {
const drawing = createDrawing('image-1', 10);
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': drawing,
},
order: ['image-1'],
},
});
drawingManagerService.focusDrawing([{ unitId, subUnitId, drawingId: 'image-1' }]);
const rendered = renderWithRediContext(univer.__getInjector(), [drawing]);
root = rendered.root;
container = rendered.container;
expect(panelIsAvailable(container, PanelIndex.empty)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.arrange)).toBe(true);
act(() => {
(univer.__getInjector().get(IRenderManagerService) as unknown as TestRenderManagerService)
.transformer
.clearControl$
.next(true);
});
expect(panelIsAvailable(container, PanelIndex.empty)).toBe(true);
expect(panelIsAvailable(container, PanelIndex.transform)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.crop)).toBe(false);
expect(panelIsAvailable(container, PanelIndex.arrange)).toBe(false);
});
});

View File

@@ -0,0 +1,285 @@
/**
* 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 { IDrawingGroupUpdateParam } from '@univerjs/drawing';
import type { ReactElement } from 'react';
import type { Root } from 'react-dom/client';
import { DrawingTypeEnum, ICommandService, LocaleType, Univer } from '@univerjs/core';
import { DrawingManagerService, getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IconManager, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
CancelDrawingGroupOperation,
SetDrawingGroupOperation,
} from '../../commands/operations/drawing-group.operation';
import { DrawingGroup } from '../panel/DrawingGroup';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const unitId = 'drawing-group-unit';
const subUnitId = 'drawing-group-subunit';
function createDrawing(drawingId: string, left: number, groupId?: string): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
groupId,
transform: {
left,
top: 10,
width: 20,
height: 30,
angle: 0,
},
};
}
function createGroup(drawingId: string): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
left: 0,
top: 0,
width: 100,
height: 100,
angle: 0,
},
groupBaseBound: {
left: 0,
top: 0,
width: 100,
height: 100,
},
};
}
class TestRenderManagerService {
readonly clearControl$ = new Subject<boolean>();
readonly changeStart$ = new Subject<{ objects: Map<string, unknown> }>();
getRenderUnitById() {
return {
scene: {
getTransformerByCreate: () => ({
clearControl$: this.clearControl$,
changeStart$: this.changeStart$,
}),
},
};
}
}
function clickElement(element: Element): void {
act(() => {
element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
function getGroupButton(container: HTMLElement, index: number): HTMLButtonElement {
const button = container.querySelectorAll<HTMLButtonElement>('[data-u-comp="button"]')[index];
if (!button) {
throw new Error(`Group button at index ${index} was not found.`);
}
return button;
}
function buttonIsAvailable(container: HTMLElement, index: number): boolean {
const button = container.querySelectorAll<HTMLButtonElement>('[data-u-comp="button"]')[index];
if (!button) {
return false;
}
let current: HTMLElement | null = button;
while (current && current !== container) {
if (current.classList.contains('univer-hidden')) {
return false;
}
current = current.parentElement;
}
return true;
}
function renderWithRediContext(injector: ReturnType<Univer['__getInjector']>, element: ReactElement) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
{element}
</RediContext.Provider>
);
});
return { container, root };
}
describe('DrawingGroup behavior', () => {
let univer: Univer;
let drawingManagerService: IDrawingManagerService;
let root: Root | undefined;
let container: HTMLElement | undefined;
beforeEach(() => {
univer = new Univer({ locales: { [LocaleType.ZH_CN]: {} } });
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IconManager]);
const commandService = injector.get(ICommandService);
commandService.registerCommand(SetDrawingGroupOperation);
commandService.registerCommand(CancelDrawingGroupOperation);
drawingManagerService = injector.get(IDrawingManagerService);
});
afterEach(() => {
if (root) {
act(() => root!.unmount());
}
container?.remove();
root = undefined;
container = undefined;
univer.dispose();
});
it('emits a group update that links every selected drawing to the new group', () => {
const drawings = [createDrawing('image-1', 0), createDrawing('image-2', 30)];
const groupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.featurePluginGroupUpdate$.subscribe((update) => groupUpdates.push(update));
const rendered = renderWithRediContext(
univer.__getInjector(),
<DrawingGroup hasGroup drawings={drawings} />
);
root = rendered.root;
container = rendered.container;
clickElement(getGroupButton(container, 0));
expect(groupUpdates).toHaveLength(1);
const update = groupUpdates[0][0];
expect(update.parent).toMatchObject({
unitId,
subUnitId,
drawingType: DrawingTypeEnum.DRAWING_GROUP,
transform: {
left: 0,
top: 10,
width: 50,
height: 30,
},
});
expect(update.children.map((drawing) => ({
drawingId: drawing.drawingId,
groupId: drawing.groupId,
}))).toEqual([
{ drawingId: 'image-1', groupId: update.parent.drawingId },
{ drawingId: 'image-2', groupId: update.parent.drawingId },
]);
});
it('emits an ungroup update that detaches every child from the selected group', () => {
const group = createGroup('group-1');
const childA = createDrawing('image-1', 0, group.drawingId);
const childB = createDrawing('image-2', 30, group.drawingId);
const ungroupUpdates: IDrawingGroupUpdateParam[][] = [];
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
[group.drawingId]: group,
[childA.drawingId]: childA,
[childB.drawingId]: childB,
},
order: [group.drawingId, childA.drawingId, childB.drawingId],
},
});
drawingManagerService.featurePluginUngroupUpdate$.subscribe((update) => ungroupUpdates.push(update));
const rendered = renderWithRediContext(
univer.__getInjector(),
<DrawingGroup hasGroup drawings={[group]} />
);
root = rendered.root;
container = rendered.container;
clickElement(getGroupButton(container, 1));
expect(ungroupUpdates).toHaveLength(1);
const update = ungroupUpdates[0][0];
expect(update.parent).toBe(group);
expect(update.children.map((drawing) => ({
drawingId: drawing.drawingId,
groupId: drawing.groupId,
}))).toEqual([
{ drawingId: 'image-1', groupId: undefined },
{ drawingId: 'image-2', groupId: undefined },
]);
});
it('shows only the group action when the transformer selects multiple ungrouped drawings', () => {
const drawings = [createDrawing('image-1', 0), createDrawing('image-2', 30)];
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
'image-1': drawings[0],
'image-2': drawings[1],
},
order: ['image-1', 'image-2'],
},
});
const rendered = renderWithRediContext(
univer.__getInjector(),
<DrawingGroup hasGroup drawings={drawings} />
);
root = rendered.root;
container = rendered.container;
const firstKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'image-1' });
const secondKey = getDrawingShapeKeyByDrawingSearch({ unitId, subUnitId, drawingId: 'image-2' });
act(() => {
(univer.__getInjector().get(IRenderManagerService) as unknown as TestRenderManagerService)
.changeStart$
.next({
objects: new Map([
[firstKey, { oKey: firstKey, left: 0, top: 10, width: 20, height: 30, angle: 0 }],
[secondKey, { oKey: secondKey, left: 30, top: 10, width: 20, height: 30, angle: 0 }],
]),
});
});
expect(buttonIsAvailable(container, 0)).toBe(true);
expect(buttonIsAvailable(container, 1)).toBe(false);
});
});

View File

@@ -0,0 +1,489 @@
/**
* 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, IDrawingParam } from '@univerjs/core';
import type { IDrawingOrderUpdateParam } from '@univerjs/drawing';
import type { ReactElement } from 'react';
import type { Root } from 'react-dom/client';
import { ArrangeTypeEnum, CommandType, DrawingTypeEnum, ICommandService, LocaleType, Univer } from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { ComponentManager, IconManager, IDialogService, IMenuManagerService, IUIPartsService, MenuManagerService, MobileDialogService, RediContext, UIPartsService } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SetDrawingArrangeOperation } from '../../commands/operations/drawing-arrange.operation';
import {
AutoImageCropOperation,
CloseImageCropOperation,
CropType,
} from '../../commands/operations/image-crop.operation';
import { DrawingImageClipService } from '../../services/drawing-image-clip.service';
import { ImagePopupMenu } from '../image-popup-menu/ImagePopupMenu';
import { MobileImagePopupMenu } from '../image-popup-menu/MobileImagePopupMenu';
import { DrawingArrange } from '../panel/DrawingArrange';
import { ImageCropper } from '../panel/ImageCropper';
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
const unitId = 'drawing-panel-unit';
const subUnitId = 'drawing-panel-subunit';
const chartEditCommandId = 'drawing.command.edit-chart';
function createDrawing(drawingId: string, left: number): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left,
top: 10,
width: 20,
height: 30,
},
};
}
function clickElement(element: Element): void {
act(() => {
element.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
async function flushPendingCommands(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
function getButton(container: HTMLElement, index: number): HTMLButtonElement {
const action = container.querySelectorAll<HTMLButtonElement>('[data-u-comp="button"]')[index];
if (!action) {
throw new Error(`Button at index ${index} was not found.`);
}
return action;
}
function getSelectOption(index: number): HTMLElement {
const option = document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')[index];
if (!option) {
throw new Error(`Select option at index ${index} was not found.`);
}
return option;
}
function renderWithRediContext(injector: ReturnType<Univer['__getInjector']>, element: ReactElement) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
{element}
</RediContext.Provider>
);
});
return { container, root };
}
describe('drawing panel actions', () => {
let univer: Univer;
let commandService: ICommandService;
let drawingManagerService: IDrawingManagerService;
let root: Root | undefined;
let container: HTMLElement | undefined;
beforeEach(() => {
univer = new Univer({ locales: { [LocaleType.ZH_CN]: {} } });
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IconManager]);
injector.add([ComponentManager]);
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
injector.add([DrawingImageClipService]);
injector.add([IUIPartsService, { useClass: UIPartsService }]);
injector.add([IDialogService, { useClass: MobileDialogService }]);
injector.get(IconManager).register({ DrawingEditIcon: () => <span /> });
commandService = injector.get(ICommandService);
commandService.registerCommand(SetDrawingArrangeOperation);
commandService.registerCommand(AutoImageCropOperation);
commandService.registerCommand(CloseImageCropOperation);
commandService.registerCommand({
id: chartEditCommandId,
type: CommandType.OPERATION,
handler: () => true,
});
drawingManagerService = injector.get(IDrawingManagerService);
});
afterEach(() => {
if (root) {
act(() => root!.unmount());
}
container?.remove();
root = undefined;
container = undefined;
univer.dispose();
});
it('sends the selected drawings to the requested layer-order operation', () => {
const drawings = [createDrawing('image-1', 0), createDrawing('image-2', 30)];
const orderUpdates: IDrawingOrderUpdateParam[] = [];
drawingManagerService.featurePluginOrderUpdate$.subscribe((update) => orderUpdates.push(update));
const rendered = renderWithRediContext(
univer.__getInjector(),
<DrawingArrange arrangeShow drawings={drawings} />
);
root = rendered.root;
container = rendered.container;
clickElement(getButton(container, 2));
expect(orderUpdates).toEqual([{
unitId,
subUnitId,
drawingIds: ['image-1', 'image-2'],
arrangeType: ArrangeTypeEnum.front,
}]);
});
it('uses the latest focused drawings when arranging after selection changes', () => {
const originalDrawing = createDrawing('image-1', 0);
const focusedDrawing = createDrawing('image-2', 30);
const orderUpdates: IDrawingOrderUpdateParam[] = [];
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
[originalDrawing.drawingId]: originalDrawing,
[focusedDrawing.drawingId]: focusedDrawing,
},
order: [originalDrawing.drawingId, focusedDrawing.drawingId],
},
});
drawingManagerService.featurePluginOrderUpdate$.subscribe((update) => orderUpdates.push(update));
const rendered = renderWithRediContext(
univer.__getInjector(),
<DrawingArrange arrangeShow drawings={[originalDrawing]} />
);
root = rendered.root;
container = rendered.container;
act(() => {
drawingManagerService.focusDrawing([{
unitId,
subUnitId,
drawingId: focusedDrawing.drawingId,
}]);
});
clickElement(getButton(container, 0));
expect(orderUpdates).toEqual([{
unitId,
subUnitId,
drawingIds: [focusedDrawing.drawingId],
arrangeType: ArrangeTypeEnum.forward,
}]);
});
it('starts image cropping with the current crop mode', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const rendered = renderWithRediContext(
univer.__getInjector(),
<ImageCropper cropperShow drawings={[createDrawing('image-1', 0)]} />
);
root = rendered.root;
container = rendered.container;
clickElement(getButton(container, 0));
await flushPendingCommands();
expect(executedCommands).toEqual([{
id: AutoImageCropOperation.id,
type: AutoImageCropOperation.type,
params: {
cropType: CropType.FREE,
},
}]);
});
it('reapplies image cropping when the crop mode changes while cropping', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const rendered = renderWithRediContext(
univer.__getInjector(),
<ImageCropper cropperShow drawings={[createDrawing('image-1', 0)]} />
);
root = rendered.root;
container = rendered.container;
clickElement(getButton(container, 0));
await flushPendingCommands();
await act(async () => {
container!.querySelector('[data-u-comp="select"]')!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
await Promise.resolve();
});
const squareCropOption = getSelectOption(1);
await act(async () => {
squareCropOption.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(executedCommands).toEqual([
{
id: AutoImageCropOperation.id,
type: AutoImageCropOperation.type,
params: {
cropType: CropType.FREE,
},
},
{
id: AutoImageCropOperation.id,
type: AutoImageCropOperation.type,
params: {
cropType: CropType.R1_1,
},
},
]);
});
it('does not reapply image cropping after manual crop close when the crop mode changes', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const rendered = renderWithRediContext(
univer.__getInjector(),
<ImageCropper cropperShow drawings={[createDrawing('image-1', 0)]} />
);
root = rendered.root;
container = rendered.container;
clickElement(getButton(container, 0));
await flushPendingCommands();
await act(async () => {
await commandService.executeCommand(CloseImageCropOperation.id);
await Promise.resolve();
});
await act(async () => {
container!.querySelector('[data-u-comp="select"]')!.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
await Promise.resolve();
});
await act(async () => {
getSelectOption(2).dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(executedCommands.filter((command) => command.id === AutoImageCropOperation.id)).toEqual([
{
id: AutoImageCropOperation.id,
type: AutoImageCropOperation.type,
params: {
cropType: CropType.FREE,
},
},
]);
});
it('hides the chart toolbar after a hide-on-click action', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const rendered = renderWithRediContext(
univer.__getInjector(),
<ImagePopupMenu
popup={{
extraProps: {
variant: 'doc-chart-floating-toolbar',
menuItems: [{
label: 'chart.edit',
index: 0,
commandId: chartEditCommandId,
disable: false,
hideOnClick: true,
icon: 'DrawingEditIcon',
}],
},
}}
/>
);
root = rendered.root;
container = rendered.container;
const chartToolbarButton = container.querySelector('[data-u-comp="doc-chart-floating-toolbar"] button');
expect(chartToolbarButton).not.toBeNull();
clickElement(chartToolbarButton!);
await flushPendingCommands();
expect(executedCommands.map((command) => command.id)).toEqual([chartEditCommandId]);
expect(container.querySelector('[data-u-comp="doc-chart-floating-toolbar"]')).toBeNull();
});
it('renders the icon provided by each chart toolbar option', () => {
const iconManager = univer.__getInjector().get(IconManager);
iconManager.register({
LineChartIcon: () => <span data-testid="line-chart-icon" />,
ColumnChartIcon: () => <span data-testid="column-chart-icon" />,
});
const rendered = renderWithRediContext(
univer.__getInjector(),
<ImagePopupMenu
popup={{
extraProps: {
variant: 'doc-chart-floating-toolbar',
menuItems: [{
type: 'select',
label: 'chart.type',
index: 0,
commandId: chartEditCommandId,
disable: false,
value: 'line',
options: [
{ label: 'Line', value: 'line', icon: 'LineChartIcon' },
{ label: 'Column', value: 'column', icon: 'ColumnChartIcon' },
],
commandParamsFactory: (value) => ({ value }),
}],
},
}}
/>
);
root = rendered.root;
container = rendered.container;
const chartTypeButton = container.querySelector<HTMLButtonElement>(
'[data-u-comp="doc-chart-floating-toolbar"] button'
);
expect(chartTypeButton?.querySelector('[data-testid="line-chart-icon"]')).not.toBeNull();
clickElement(chartTypeButton!);
expect(document.querySelector('[data-testid="line-chart-icon"]')).not.toBeNull();
expect(document.querySelector('[data-testid="column-chart-icon"]')).not.toBeNull();
});
it('executes document image actions directly from the mobile context menu', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const rendered = renderWithRediContext(
univer.__getInjector(),
<ConfigProvider mountContainer={document.body}>
<MobileImagePopupMenu
popup={{
extraProps: {
variant: 'doc-floating-toolbar',
menuItems: [{
label: 'drawing.edit',
index: 0,
commandId: chartEditCommandId,
commandParams: { source: 'mobile' },
disable: false,
}],
},
}}
/>
</ConfigProvider>
);
root = rendered.root;
container = rendered.container;
expect(container.querySelector('button[aria-label="drawing-ui.mobile.more"]')).toBeNull();
const edit = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'drawing.edit');
if (!edit) {
throw new Error('The document image edit action was not found');
}
clickElement(edit);
await flushPendingCommands();
expect(executedCommands).toContainEqual({
id: chartEditCommandId,
type: CommandType.OPERATION,
params: { source: 'mobile' },
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
it('executes select options from the shared mobile drawing drawer', async () => {
const executedCommands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => executedCommands.push(command));
const rendered = renderWithRediContext(
univer.__getInjector(),
<ConfigProvider mountContainer={document.body}>
<MobileImagePopupMenu
popup={{
extraProps: {
menuItems: [{
type: 'select',
label: 'chart.type',
index: 0,
commandId: chartEditCommandId,
disable: false,
value: 'line',
options: [
{ label: 'Line', value: 'line' },
{ label: 'Column', value: 'column' },
],
commandParamsFactory: (value) => ({ value }),
}],
},
}}
/>
</ConfigProvider>
);
root = rendered.root;
container = rendered.container;
const select = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('chart.type'));
if (!select) {
throw new Error('Mobile chart selector was not found');
}
clickElement(select);
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('button[aria-pressed]'))
.find((button) => button.textContent === 'Column');
if (!option) {
throw new Error('Mobile chart option was not found.');
}
clickElement(option);
await flushPendingCommands();
expect(executedCommands).toContainEqual({
id: chartEditCommandId,
type: CommandType.OPERATION,
params: { value: 'column' },
});
expect(document.querySelector('[role="dialog"]')).toBeNull();
});
});

View File

@@ -0,0 +1,387 @@
/**
* 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 { Root } from 'react-dom/client';
import { DrawingTypeEnum, LocaleType, Univer } from '@univerjs/core';
import { DrawingManagerService, getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DrawingTransform } from '../panel/DrawingTransform';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const unitId = 'drawing-transform-unit';
const subUnitId = 'drawing-transform-subunit';
interface ITransformObject {
oKey: string;
left?: number;
top?: number;
width?: number;
height?: number;
angle?: number;
}
class TestTransformer {
readonly changeStart$ = new Subject<{ objects: Map<string, ITransformObject> }>();
readonly changing$ = new Subject<{ objects: Map<string, ITransformObject> }>();
readonly changeEnd$ = new Subject<{ objects: Map<string, ITransformObject> }>();
keepRatio = true;
refreshCount = 0;
notificationCount = 0;
refreshControls() {
this.refreshCount += 1;
return {
changeNotification: () => {
this.notificationCount += 1;
},
};
}
}
class TestScene {
ancestorLeft = 0;
ancestorTop = 0;
constructor(private readonly _transformer: TestTransformer) {}
getEngine() {
return {
activeScene: {
width: 500,
height: 400,
},
};
}
getTransformerByCreate() {
return this._transformer;
}
}
class TestRenderManagerService {
readonly transformer = new TestTransformer();
readonly scene = new TestScene(this.transformer);
getRenderUnitById() {
return {
scene: this.scene,
};
}
}
function createDrawing(drawingId: string): IDrawingParam {
return {
unitId,
subUnitId,
drawingId,
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left: 10,
top: 20,
width: 100,
height: 50,
angle: 0,
},
};
}
function renderWithRediContext(injector: ReturnType<Univer['__getInjector']>, drawing: IDrawingParam) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
<DrawingTransform transformShow drawings={[drawing]} />
</RediContext.Provider>
);
});
return { container, root };
}
function getNumberInput(container: HTMLElement, index: number): HTMLInputElement {
return container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input')[index];
}
function getLockRatioInput(container: HTMLElement): HTMLInputElement {
return container.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input')!;
}
function setInputValue(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function inputNumberValue(container: HTMLElement, index: number) {
return Number(getNumberInput(container, index).value);
}
async function waitForDebouncedInput() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 350));
});
}
describe('DrawingTransform behavior', () => {
let univer: Univer;
let drawingManagerService: IDrawingManagerService;
let renderManagerService: TestRenderManagerService;
let root: Root | undefined;
let container: HTMLElement | undefined;
beforeEach(() => {
univer = new Univer({ locales: { [LocaleType.ZH_CN]: {} } });
const injector = univer.__getInjector();
injector.add([IDrawingManagerService, { useClass: DrawingManagerService }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
drawingManagerService = injector.get(IDrawingManagerService);
renderManagerService = injector.get(IRenderManagerService) as unknown as TestRenderManagerService;
});
afterEach(() => {
if (root) {
act(() => root!.unmount());
}
container?.remove();
root = undefined;
container = undefined;
univer.dispose();
});
it('resizes the focused drawing proportionally while aspect ratio is locked', async () => {
const drawing = createDrawing('image-1');
const updates: IDrawingParam[][] = [];
drawingManagerService.featurePluginUpdate$.subscribe((update) => updates.push(update));
const rendered = renderWithRediContext(univer.__getInjector(), drawing);
root = rendered.root;
container = rendered.container;
setInputValue(getNumberInput(container, 0), '120');
await waitForDebouncedInput();
expect(updates).toEqual([[
{
unitId,
subUnitId,
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
width: 120,
height: 60,
},
},
]]);
expect(renderManagerService.transformer.refreshCount).toBe(1);
expect(renderManagerService.transformer.notificationCount).toBe(1);
});
it('resizes only the edited dimension after aspect ratio is unlocked', async () => {
const drawing = createDrawing('image-1');
const updates: IDrawingParam[][] = [];
drawingManagerService.featurePluginUpdate$.subscribe((update) => updates.push(update));
const rendered = renderWithRediContext(univer.__getInjector(), drawing);
root = rendered.root;
container = rendered.container;
act(() => {
getLockRatioInput(container!).click();
});
expect(renderManagerService.transformer.keepRatio).toBe(false);
setInputValue(getNumberInput(container, 0), '120');
await waitForDebouncedInput();
expect(updates).toEqual([[
{
unitId,
subUnitId,
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
width: 120,
},
},
]]);
expect(renderManagerService.transformer.refreshCount).toBe(1);
expect(renderManagerService.transformer.notificationCount).toBe(1);
});
it('emits the requested rotation for the focused drawing', () => {
const drawing = createDrawing('image-1');
const updates: IDrawingParam[][] = [];
drawingManagerService.featurePluginUpdate$.subscribe((update) => updates.push(update));
const rendered = renderWithRediContext(univer.__getInjector(), drawing);
root = rendered.root;
container = rendered.container;
act(() => {
setInputValue(getNumberInput(container!, 4), '45');
});
expect(updates).toEqual([[
{
unitId,
subUnitId,
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
angle: 45,
},
},
]]);
expect(renderManagerService.transformer.refreshCount).toBe(1);
expect(renderManagerService.transformer.notificationCount).toBe(1);
});
it('disables rotation input for chart drawings', () => {
const drawing = {
...createDrawing('chart-1'),
drawingType: DrawingTypeEnum.DRAWING_CHART,
};
const rendered = renderWithRediContext(univer.__getInjector(), drawing);
root = rendered.root;
container = rendered.container;
expect(getNumberInput(container, 4).disabled).toBe(true);
});
it('disables rotation input for groups containing chart drawings', () => {
const group = {
...createDrawing('group-1'),
drawingType: DrawingTypeEnum.DRAWING_GROUP,
};
const chart = {
...createDrawing('chart-child'),
drawingType: DrawingTypeEnum.DRAWING_CHART,
groupId: 'group-1',
};
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
[group.drawingId]: group,
[chart.drawingId]: chart,
},
order: [group.drawingId, chart.drawingId],
},
});
const rendered = renderWithRediContext(univer.__getInjector(), group);
root = rendered.root;
container = rendered.container;
expect(getNumberInput(container, 4).disabled).toBe(true);
});
it('clamps negative position inputs to the canvas origin', async () => {
const drawing = createDrawing('image-1');
const updates: IDrawingParam[][] = [];
drawingManagerService.featurePluginUpdate$.subscribe((update) => updates.push(update));
const rendered = renderWithRediContext(univer.__getInjector(), drawing);
root = rendered.root;
container = rendered.container;
act(() => {
setInputValue(getNumberInput(container!, 2), '-30');
});
await waitForDebouncedInput();
act(() => {
setInputValue(getNumberInput(container!, 3), '-40');
});
await waitForDebouncedInput();
expect(updates).toHaveLength(2);
expect(updates[0][0]).toMatchObject({
unitId,
subUnitId,
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
left: expect.closeTo(0),
},
});
expect(updates[1][0]).toMatchObject({
unitId,
subUnitId,
drawingId: 'image-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
transform: {
top: expect.closeTo(0),
},
});
});
it('syncs transform fields from canvas transformer updates', () => {
const drawing = createDrawing('image-1');
drawingManagerService.registerDrawingData(unitId, {
[subUnitId]: {
data: {
[drawing.drawingId]: drawing,
},
order: [drawing.drawingId],
},
});
const rendered = renderWithRediContext(univer.__getInjector(), drawing);
root = rendered.root;
container = rendered.container;
const oKey = getDrawingShapeKeyByDrawingSearch({
unitId,
subUnitId,
drawingId: drawing.drawingId,
});
act(() => {
renderManagerService.transformer.changeEnd$.next({
objects: new Map([[
oKey,
{
oKey,
left: 44,
top: 66,
width: 180,
height: 90,
angle: 15,
},
]]),
});
});
expect(inputNumberValue(container, 0)).toBe(180);
expect(inputNumberValue(container, 1)).toBe(90);
expect(inputNumberValue(container, 2)).toBe(44);
expect(inputNumberValue(container, 3)).toBe(66);
expect(inputNumberValue(container, 4)).toBe(15);
});
});

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 { Vector2 } from '@univerjs/engine-render';
import { describe, expect, it } from 'vitest';
import { ImageCropperObject } from '../crop/image-cropper-object';
describe('ImageCropperObject behavior', () => {
it('treats the crop frame as draggable while leaving the image center untouched', () => {
const cropper = new ImageCropperObject('cropper', {
applyTransform: {
left: 0,
top: 0,
width: 100,
height: 80,
angle: 0,
},
dragPadding: 10,
});
expect(cropper.isHit(Vector2.FromArray([5, 5]))).toBe(true);
expect(cropper.isHit(Vector2.FromArray([50, 40]))).toBe(false);
expect(cropper.isHit(Vector2.FromArray([120, 40]))).toBe(false);
});
it('positions the crop frame from the source rectangle and refreshes when the crop changes', () => {
const cropper = new ImageCropperObject('cropper', {
applyTransform: {
left: 10,
top: 20,
width: 120,
height: 80,
angle: 15,
},
srcRect: {
left: 5,
top: 10,
right: 15,
bottom: 20,
},
});
expect(cropper.getState()).toMatchObject({
left: 15,
top: 30,
width: 100,
height: 50,
angle: 15,
});
cropper.refreshSrcRect({
left: 0,
top: 5,
right: 30,
bottom: 10,
}, {
left: 10,
top: 20,
width: 120,
height: 80,
angle: 45,
});
expect(cropper.getState()).toMatchObject({
left: 10,
top: 25,
width: 90,
height: 65,
angle: 45,
});
});
});

View File

@@ -0,0 +1,60 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { MobileActionRow } from '@univerjs/design';
import { CheckMarkIcon, CloseIcon } from '@univerjs/icons';
import { useDependency, useObservable } from '@univerjs/ui';
import { CloseImageCropOperation } from '../../commands/operations/image-crop.operation';
import { MobileImageCropperController } from '../../controllers/mobile/image-cropper.controller';
export function MobileCropActions() {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const cropperController = useDependency(MobileImageCropperController);
const cropping = useObservable(cropperController.cropping$, false);
if (!cropping) {
return null;
}
return (
<div
role="group"
aria-label={localeService.t<LocaleKey>('drawing-ui.image-panel.crop.title')}
className="
univer-absolute univer-inset-x-0 univer-bottom-0 univer-z-[1200] univer-grid univer-grid-cols-2
univer-gap-3 univer-bg-gray-0 univer-p-3 univer-pb-[max(12px,env(safe-area-inset-bottom))]
univer-shadow-lg
dark:!univer-bg-gray-900
"
>
<MobileActionRow
icon={<CloseIcon />}
title={localeService.t<LocaleKey>('drawing-ui.image-panel.crop.cancel')}
variant="subtle"
onClick={() => commandService.executeCommand(CloseImageCropOperation.id, { isCancel: true })}
/>
<MobileActionRow
icon={<CheckMarkIcon />}
title={localeService.t<LocaleKey>('drawing-ui.image-panel.crop.apply')}
className="!univer-bg-primary-600 !univer-text-white"
onClick={() => commandService.executeCommand(CloseImageCropOperation.id)}
/>
</div>
);
}

View File

@@ -0,0 +1,264 @@
/**
* 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 { ISrcRect, ITransformState, Nullable } from '@univerjs/core';
import type {
Engine,
IShapeProps,
IViewportInfo,
Scene,
UniverRenderingContext,
Vector2,
} from '@univerjs/engine-render';
import { Canvas, Rect, Shape } from '@univerjs/engine-render';
export interface IImageCropperObjectProps extends IShapeProps {
/**
* 20.1.8.55 srcRect (Source Rectangle)
*/
srcRect?: Nullable<ISrcRect>;
/**
* 20.1.9.18 prstGeom (Preset geometry)
*/
prstGeom?: Nullable<string>;
applyTransform?: ITransformState;
dragPadding?: number;
}
enum ImageCropperObjectType {
RECT,
PATH,
}
export class ImageCropperObject<T extends IImageCropperObjectProps = IImageCropperObjectProps> extends Shape<T> {
private _srcRect: Nullable<ISrcRect>;
private _prstGeom: Nullable<string>;
private _applyTransform: Nullable<ITransformState>;
private _dragPadding = 8;
private _cacheCanvas: Nullable<Canvas>;
constructor(key?: string, props?: T) {
if (props == null) {
props = {} as T;
}
props.transformerConfig = {
keepRatio: false,
isCropper: true,
anchorFill: 'rgb(0, 0, 0)',
anchorStroke: 'rgb(255, 255, 255)',
anchorSize: 24,
};
super(key, props);
if (props?.srcRect) {
this._srcRect = props.srcRect;
}
if (props?.prstGeom) {
this._prstGeom = props.prstGeom;
}
if (props?.applyTransform) {
this._applyTransform = props.applyTransform;
}
if (props?.dragPadding) {
this._dragPadding = props.dragPadding;
}
this._applyProps();
}
refreshSrcRect(value: Nullable<ISrcRect>, transform: Nullable<ITransformState>) {
this._srcRect = value;
this._applyTransform = transform;
this._applyProps();
}
get srcRect() {
return this._srcRect;
}
override dispose() {
super.dispose();
this._cacheCanvas?.dispose();
this._srcRect = null;
}
override isHit(coord: Vector2) {
const oCoord = this.getInverseCoord(coord);
if (
oCoord.x >= -this.strokeWidth / 2 &&
oCoord.x <= this.width + this.strokeWidth / 2 &&
oCoord.y >= -this.strokeWidth / 2 &&
oCoord.y <= this.height + this.strokeWidth / 2 &&
!this._inSurround(oCoord)
) {
return true;
}
return false;
}
private _inSurround(oCoord: Vector2) {
const padding = this._dragPadding;
if (
oCoord.x >= padding - this.strokeWidth / 2 &&
oCoord.x <= this.width + this.strokeWidth / 2 - padding &&
oCoord.y >= padding - this.strokeWidth / 2 &&
oCoord.y <= this.height + this.strokeWidth / 2 - padding
) {
return true;
}
return false;
}
override render(mainCtx: UniverRenderingContext, bounds?: IViewportInfo) {
if (!this.visible) {
this.makeDirty(false);
return this;
}
mainCtx.save();
this._draw(mainCtx);
mainCtx.restore();
this.makeDirty(false);
return this;
}
protected override _draw(ctx: UniverRenderingContext) {
// const { left, top, width, height, angle } = applyObject;
const scene = this.getScene() as Scene;
const engine = scene.getEngine() as Engine;
const { width: engineWidth, height: engineHeight } = engine;
this._initialCacheCanvas();
// if (this._dirty) {
this._cacheCanvas?.clear();
const cacheCtx = this._cacheCanvas?.getContext();
if (cacheCtx == null) {
return;
}
cacheCtx.save();
Rect.drawWith(cacheCtx, {
left: 0,
top: 0,
width: engineWidth,
height: engineHeight,
fill: 'rgba(0, 0, 0, 0.5)',
});
cacheCtx.setTransform(ctx.getTransform());
this._clipForApplyObject(cacheCtx);
this._applyCache(ctx);
cacheCtx.restore();
// } else {
// this._applyCache(ctx);
// }
}
private _clipForApplyObject(cacheCtx: UniverRenderingContext) {
let objectType = ImageCropperObjectType.RECT;
if (this._prstGeom != null) {
objectType = ImageCropperObjectType.PATH;
}
cacheCtx.globalCompositeOperation = 'destination-out';
cacheCtx.beginPath();
if (objectType === ImageCropperObjectType.RECT) {
const m = this.transform.getMatrix();
cacheCtx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
cacheCtx.rect(0, 0, this.width, this.height);
cacheCtx.fill();
} else {
// const path = new Path2D(imageData.prstGeom);
// cacheCtx.fill(path);
}
}
private _applyProps() {
if (this._applyTransform == null) {
return;
}
let cropLeft = 0;
let cropTop = 0;
let cropRight = 0;
let cropBottom = 0;
const { left: applyLeft = 0, top: applyTop = 0, width: applyWidth = 0, height: applyHeight = 0, angle } = this._applyTransform;
if (this._srcRect != null) {
const { left = 0, top = 0, right = 0, bottom = 0 } = this._srcRect;
cropLeft = left;
cropTop = top;
cropRight = right;
cropBottom = bottom;
}
const left = applyLeft + cropLeft;
const top = applyTop + cropTop;
this.transformByState({
left,
top,
width: applyLeft + applyWidth - cropRight - left,
height: applyTop + applyHeight - cropBottom - top,
angle,
});
}
private _applyCache(ctx?: UniverRenderingContext) {
if (!ctx || this._cacheCanvas == null) {
return;
}
const cacheCtx = this._cacheCanvas.getContext();
cacheCtx.save();
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
cacheCtx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(this._cacheCanvas.getCanvasEle(), 0, 0);
ctx.restore();
cacheCtx.restore();
}
private _initialCacheCanvas() {
if (this._cacheCanvas != null) {
return;
}
const scene = this.getScene();
if (scene == null) return;
this._cacheCanvas = new Canvas();
const engine = scene.getEngine() as Engine;
this._cacheCanvas.setSize(engine.width, engine.height);
engine.onTransformChange$.subscribeEvent(() => {
this._cacheCanvas?.setSize(engine.width, engine.height);
this.makeDirty(true);
});
}
}

View File

@@ -0,0 +1,510 @@
/**
* 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 { DocumentDataModel } from '@univerjs/core';
import type { IValueOption } from '@univerjs/ui';
import type { ReactNode } from 'react';
import type { LocaleKey } from '../../locale/types';
import {
BooleanNumber,
ICommandService,
IUniverInstanceService,
LocaleService,
PositionedObjectLayoutType,
UniverInstanceType,
} from '@univerjs/core';
import { borderClassName, clsx, Dropdown, DropdownMenu, Separator, Tooltip } from '@univerjs/design';
import {
AutofillDoubleIcon,
ChartIcon,
MoreDownIcon,
TextWrapShapeIcon,
} from '@univerjs/icons';
import { IconManager, useDependency } from '@univerjs/ui';
import { useState } from 'react';
export interface IImagePopupMenuItem {
label: string;
index: number;
commandId: string;
commandParams?: object;
disable: boolean;
type?: 'button' | 'select';
value?: string;
options?: Array<{ icon?: string; label: IValueOption['label']; value: string }>;
commandParamsFactory?: (value: string) => object;
hideOnClick?: boolean;
icon?: string;
}
export interface IImagePopupMenuExtraProps {
menuItems: IImagePopupMenuItem[];
variant?: 'doc-floating-toolbar' | 'doc-chart-floating-toolbar';
unitId?: string;
subUnitId?: string;
drawingId?: string;
}
export interface IImagePopupMenuProps {
popup: {
extraProps?: IImagePopupMenuExtraProps;
};
}
export function ImagePopupMenu(props: IImagePopupMenuProps) {
const { popup } = props;
const menuItems = popup?.extraProps?.menuItems;
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const [visible, setVisible] = useState(false);
const [isHovered, setIsHovered] = useState(false);
if (!menuItems) return null;
if (
popup.extraProps?.variant === 'doc-floating-toolbar' &&
popup.extraProps.unitId &&
popup.extraProps.subUnitId &&
popup.extraProps.drawingId
) {
return (
<DocImageFloatingToolbar
menuItems={menuItems}
unitId={popup.extraProps.unitId}
subUnitId={popup.extraProps.subUnitId}
drawingId={popup.extraProps.drawingId}
/>
);
}
if (popup.extraProps?.variant === 'doc-chart-floating-toolbar') {
return <DocChartFloatingToolbar menuItems={menuItems} />;
}
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const onVisibleChange = (visible: boolean) => {
setVisible(visible);
};
const handleClick = (item: IImagePopupMenuItem) => {
commandService.executeCommand(item.commandId, item.commandParams);
setVisible(false);
};
const showMore = visible || isHovered;
return (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<DropdownMenu
align="start"
items={menuItems.map((item) => ({
type: 'item',
children: localeService.t(item.label as LocaleKey),
disabled: item.disable,
onSelect: () => handleClick(item),
}))}
open={visible}
onOpenChange={onVisibleChange}
>
<div
className={clsx(`
univer-flex univer-items-center univer-gap-2 univer-rounded univer-p-1 univer-text-gray-900
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-800
`, borderClassName, {
'univer-bg-gray-100 dark:!univer-bg-gray-800': visible,
'univer-bg-gray-0 dark:!univer-bg-gray-900': !visible,
})}
>
<AutofillDoubleIcon
className={`
univer-fill-primary-600 univer-text-gray-900
dark:!univer-text-gray-0
`}
/>
{showMore && <MoreDownIcon className="dark:!univer-text-gray-0" />}
</div>
</DropdownMenu>
</div>
);
};
const UPDATE_DOC_DRAWING_WRAPPING_STYLE_COMMAND_ID = 'doc.command.update-doc-drawing-wrapping-style';
enum TextWrappingStyle {
INLINE = 'inline',
BEHIND_TEXT = 'behindText',
IN_FRONT_OF_TEXT = 'inFrontOfText',
WRAP_SQUARE = 'wrapSquare',
WRAP_TOP_AND_BOTTOM = 'wrapTopAndBottom',
}
interface IDocImageFloatingToolbarProps {
menuItems: IImagePopupMenuItem[];
unitId: string;
subUnitId: string;
drawingId: string;
}
interface IToolbarDropdownOption<T extends string | number> {
label: string;
value: T;
icon: ReactNode;
}
function getWrappingStyle(documentDataModel: DocumentDataModel | undefined, drawingId: string): TextWrappingStyle {
const drawing = documentDataModel?.getSnapshot().drawings?.[drawingId];
if (!drawing) {
return TextWrappingStyle.INLINE;
}
if (drawing.layoutType === PositionedObjectLayoutType.WRAP_NONE) {
return drawing.behindDoc === BooleanNumber.TRUE ? TextWrappingStyle.BEHIND_TEXT : TextWrappingStyle.IN_FRONT_OF_TEXT;
}
if (drawing.layoutType === PositionedObjectLayoutType.WRAP_SQUARE) {
return TextWrappingStyle.WRAP_SQUARE;
}
if (drawing.layoutType === PositionedObjectLayoutType.WRAP_TOP_AND_BOTTOM) {
return TextWrappingStyle.WRAP_TOP_AND_BOTTOM;
}
return TextWrappingStyle.INLINE;
}
function ToolbarGroup(props: { children: ReactNode }) {
return (
<div className="univer-flex univer-h-7 univer-items-center univer-gap-1 univer-px-1">
{props.children}
</div>
);
}
function ToolbarButton(props: {
title: string;
active?: boolean;
disabled?: boolean;
children: ReactNode;
onClick: () => void;
}) {
return (
<Tooltip
className="univer-fill-mode-backwards univer-delay-100"
title={props.title}
placement="bottom"
>
<button
type="button"
disabled={props.disabled}
onClick={props.onClick}
className={clsx(`
univer-flex univer-h-6 univer-w-6 univer-items-center univer-justify-center univer-rounded-md
univer-border-none univer-bg-transparent univer-p-0 univer-text-sm univer-text-gray-700
univer-transition-colors
hover:univer-bg-gray-100
disabled:univer-cursor-not-allowed disabled:univer-opacity-40
dark:!univer-text-gray-100
dark:hover:!univer-bg-gray-700
`, {
'univer-bg-gray-100 univer-text-primary-600 dark:!univer-bg-gray-700 dark:!univer-text-primary-300': props.active,
})}
>
{props.children}
</button>
</Tooltip>
);
}
function ToolbarDropdownButton<T extends string | number>(props: {
title: string;
value: T;
options: Array<IToolbarDropdownOption<T>>;
onChange: (value: T) => void;
}) {
const [open, setOpen] = useState(false);
const activeOption = props.options.find((option) => option.value === props.value) ?? props.options[0];
return (
<Dropdown
open={open}
onOpenChange={setOpen}
overlay={(
<div
className={`
univer-min-w-32 univer-rounded-lg univer-border univer-border-solid univer-border-gray-200
univer-bg-gray-0 univer-p-1 univer-shadow-lg
dark:!univer-border-gray-700 dark:!univer-bg-gray-900
`}
>
{props.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
props.onChange(option.value);
setOpen(false);
}}
className={clsx(`
univer-flex univer-h-8 univer-w-full univer-items-center univer-gap-2 univer-rounded-md
univer-border-none univer-bg-transparent univer-px-2 univer-text-left univer-text-sm
univer-text-gray-700 univer-transition-colors
hover:univer-bg-gray-100
dark:!univer-text-gray-100
dark:hover:!univer-bg-gray-800
`, {
'univer-bg-primary-50 univer-text-primary-600 dark:!univer-bg-gray-800 dark:!univer-text-primary-300': option.value === props.value,
})}
>
<span className="univer-flex univer-size-4 univer-items-center univer-justify-center">{option.icon}</span>
<span className="univer-flex-1">{option.label}</span>
</button>
))}
</div>
)}
>
<span>
<Tooltip
className="univer-fill-mode-backwards univer-delay-100"
title={props.title}
placement="bottom"
>
<button
type="button"
className={clsx(`
univer-flex univer-h-6 univer-min-w-9 univer-items-center univer-justify-center univer-gap-1
univer-rounded-md univer-border-none univer-bg-transparent univer-px-1.5 univer-text-sm
univer-text-gray-700 univer-transition-colors
hover:univer-bg-gray-100
dark:!univer-text-gray-100
dark:hover:!univer-bg-gray-700
`, {
'univer-bg-gray-100 univer-text-primary-600 dark:!univer-bg-gray-700 dark:!univer-text-primary-300': open,
})}
>
{activeOption.icon}
<MoreDownIcon className="univer-text-xs" />
</button>
</Tooltip>
</span>
</Dropdown>
);
}
function DocImageFloatingToolbar(props: IDocImageFloatingToolbarProps) {
const commandService = useDependency(ICommandService);
const iconManager = useDependency(IconManager);
const localeService = useDependency(LocaleService);
const univerInstanceService = useDependency(IUniverInstanceService);
const documentDataModel = univerInstanceService.getUnit<DocumentDataModel>(props.unitId, UniverInstanceType.UNIVER_DOC) ?? undefined;
const [wrappingStyle, setWrappingStyle] = useState(() => getWrappingStyle(documentDataModel, props.drawingId));
const [hidden, setHidden] = useState(false);
const actionItems = props.menuItems
.filter((item) => item.type !== 'select' && item.icon)
.sort((a, b) => a.index - b.index);
const wrappingStyleOptions = [
{
label: localeService.t<LocaleKey>('drawing-ui.image-text-wrap.inline'),
value: TextWrappingStyle.INLINE,
icon: <TextWrapShapeIcon />,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-text-wrap.square'),
value: TextWrappingStyle.WRAP_SQUARE,
icon: <TextWrapShapeIcon />,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-text-wrap.topAndBottom'),
value: TextWrappingStyle.WRAP_TOP_AND_BOTTOM,
icon: <TextWrapShapeIcon />,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-text-wrap.behindText'),
value: TextWrappingStyle.BEHIND_TEXT,
icon: <TextWrapShapeIcon />,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-text-wrap.inFrontText'),
value: TextWrappingStyle.IN_FRONT_OF_TEXT,
icon: <TextWrapShapeIcon />,
},
];
const executeMenuItem = (item: IImagePopupMenuItem | undefined) => {
if (!item || item.disable) {
return;
}
if (item.hideOnClick) {
setHidden(true);
}
commandService.executeCommand(item.commandId, item.commandParams);
};
const updateWrappingStyle = (value: TextWrappingStyle) => {
setWrappingStyle(value);
commandService.executeCommand(UPDATE_DOC_DRAWING_WRAPPING_STYLE_COMMAND_ID, {
unitId: props.unitId,
subUnitId: props.subUnitId,
drawings: [{ unitId: props.unitId, subUnitId: props.subUnitId, drawingId: props.drawingId }],
wrappingStyle: value,
});
};
if (hidden) {
return null;
}
return (
<div
data-u-comp="doc-image-floating-toolbar"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => {
event.stopPropagation();
event.preventDefault();
}}
className={clsx(`
univer-box-border univer-flex univer-items-center univer-rounded univer-bg-gray-0 univer-px-1 univer-py-1
univer-shadow-sm
dark:!univer-border-gray-700 dark:!univer-bg-gray-900
`, borderClassName)}
>
<ToolbarGroup>
<ToolbarDropdownButton
title={wrappingStyleOptions.find((option) => option.value === wrappingStyle)?.label ?? localeService.t<LocaleKey>('drawing-ui.image-text-wrap.inline')}
value={wrappingStyle}
options={wrappingStyleOptions}
onChange={updateWrappingStyle}
/>
</ToolbarGroup>
{actionItems.length > 0 && (
<>
<Separator orientation="vertical" />
<ToolbarGroup>
{actionItems.map((item) => {
const Icon = iconManager.get(item.icon!);
return (
<ToolbarButton
key={`${item.commandId}-${item.index}`}
title={localeService.t(item.label as LocaleKey)}
disabled={item.disable}
onClick={() => executeMenuItem(item)}
>
<Icon />
</ToolbarButton>
);
})}
</ToolbarGroup>
</>
)}
</div>
);
}
function DocChartFloatingToolbar(props: Pick<IDocImageFloatingToolbarProps, 'menuItems'>) {
const commandService = useDependency(ICommandService);
const iconManager = useDependency(IconManager);
const localeService = useDependency(LocaleService);
const [hidden, setHidden] = useState(false);
const chartTypeItem = props.menuItems.find((item) => item.type === 'select');
const actionItems = props.menuItems
.filter((item) => item.type !== 'select' && item.icon)
.sort((a, b) => a.index - b.index);
const chartTypeOptions = (chartTypeItem?.options ?? []).map((option) => {
const Icon = option.icon ? iconManager.get(option.icon) : ChartIcon;
return {
label: String(option.label),
value: option.value,
icon: <Icon />,
};
});
const executeMenuItem = (item: IImagePopupMenuItem | undefined) => {
if (!item || item.disable) {
return;
}
if (item.hideOnClick) {
setHidden(true);
}
commandService.executeCommand(item.commandId, item.commandParams);
};
if (hidden) {
return null;
}
return (
<div
data-u-comp="doc-chart-floating-toolbar"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => {
event.stopPropagation();
event.preventDefault();
}}
className={clsx(`
univer-box-border univer-flex univer-items-center univer-rounded univer-bg-gray-0 univer-px-1 univer-py-1
univer-shadow-sm
dark:!univer-border-gray-700 dark:!univer-bg-gray-900
`, borderClassName)}
>
{chartTypeItem && (
<>
<ToolbarGroup>
<ToolbarDropdownButton
title={localeService.t(chartTypeItem.label as LocaleKey)}
value={chartTypeItem.value ?? chartTypeOptions[0]?.value ?? ''}
options={chartTypeOptions}
onChange={(value) => {
if (!chartTypeItem.disable) {
commandService.executeCommand(chartTypeItem.commandId, chartTypeItem.commandParamsFactory?.(value));
}
}}
/>
</ToolbarGroup>
{actionItems.length > 0 && <Separator orientation="vertical" />}
</>
)}
{actionItems.length > 0 && (
<ToolbarGroup>
{actionItems.map((item) => {
const Icon = iconManager.get(item.icon!);
return (
<ToolbarButton
key={`${item.commandId}-${item.index}`}
title={localeService.t(item.label as LocaleKey)}
disabled={item.disable}
onClick={() => executeMenuItem(item)}
>
<Icon />
</ToolbarButton>
);
})}
</ToolbarGroup>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMenuSchema } from '@univerjs/ui';
import type { IImagePopupMenuExtraProps } from './ImagePopupMenu';
import { ICommandService } from '@univerjs/core';
import { IDialogService, MenuItemType, MobileMenu, useDependency } from '@univerjs/ui';
import { of } from 'rxjs';
interface IMobileImagePopupMenuProps {
popup: {
extraProps?: Pick<IImagePopupMenuExtraProps, 'menuItems' | 'variant'> & { dialogId?: string; onClose?: () => void };
};
}
export function MobileImagePopupMenu({ popup }: IMobileImagePopupMenuProps) {
const commandService = useDependency(ICommandService);
const dialogService = useDependency(IDialogService);
const menuItems = popup.extraProps?.menuItems;
if (!menuItems) {
return null;
}
const schemas: IMenuSchema[] = menuItems.map((item) => ({
key: item.commandId,
order: item.index,
item: item.type === 'select'
? {
id: item.commandId,
type: MenuItemType.SELECTOR,
title: item.label,
value$: of(item.value),
disabled$: of(item.disable || !item.options?.length),
selections: item.options,
}
: {
id: item.commandId,
type: MenuItemType.BUTTON,
title: item.label,
disabled$: of(item.disable),
},
}));
return (
<MobileMenu
presentation={popup.extraProps?.variant ? 'context-bar' : 'drawer'}
schemas={schemas}
onOptionSelect={async ({ id, value }) => {
const item = menuItems.find((item) => item.commandId === id);
if (!item || item.disable) {
return;
}
if (item.type === 'select' && (typeof value !== 'string' || !item.options?.some((option) => option.value === value))) {
return;
}
const params = item.type === 'select' && typeof value === 'string'
? item.commandParamsFactory?.(value) ?? { ...item.commandParams, value }
: item.commandParams;
await commandService.executeCommand(item.commandId, params);
if (item.hideOnClick !== false) {
popup.extraProps?.onClose?.();
if (popup.extraProps?.dialogId) {
dialogService.close(popup.extraProps.dialogId);
}
}
}}
/>
);
}

View File

@@ -0,0 +1,18 @@
/**
* 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 COMPONENT_IMAGE_POPUP_MENU = 'COMPONENT_IMAGE_POPUP_MENU';
export const COMPONENT_MOBILE_IMAGE_POPUP_MENU = 'COMPONENT_MOBILE_IMAGE_POPUP_MENU';

View File

@@ -0,0 +1,116 @@
/**
* 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, ThemeService } from '@univerjs/core';
import { DeviceType, Image, PointerInput, Scene, SceneViewer, Vector2, Viewport } from '@univerjs/engine-render';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MobileImagePreviewButton } from '../mobile-image-preview-button';
function pointer(type: string, x = 280, y = 30) {
const event = Object.assign(new MouseEvent(type, { clientX: x, clientY: y }), {
deviceType: DeviceType.Touch,
inputIndex: PointerInput.LeftClick,
currentState: null,
previousState: null,
});
Object.defineProperties(event, { offsetX: { value: x }, offsetY: { value: y } });
return event;
}
const cleanups: Array<() => void> = [];
function setup() {
const injector = new Injector([[ThemeService]]);
const host = new SceneViewer('host');
const scene = new Scene('preview', host);
const viewport = new Viewport('main', scene, { left: 0, top: 0, width: 400, height: 600 });
const image = new Image('image', { image: document.createElement('img'), left: 20, top: 10, width: 280, height: 160 });
scene.addObject(image);
const preview = vi.fn();
const button = new MobileImagePreviewButton(image, scene, injector.get(ThemeService), preview);
cleanups.push(() => {
viewport.dispose();
host.dispose();
injector.dispose();
});
return { scene, image, preview, button };
}
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
vi.useRealTimers();
});
describe('MobileImagePreviewButton', () => {
it('picks the preview button above its image and previews on one tap without moving the image', () => {
const { scene, image, button, preview } = setup();
const original = image.getState();
expect(scene.pick(new Vector2(280, 30))).toBe(button);
button.triggerPointerDown(pointer('pointerdown'));
button.triggerPointerUp(pointer('pointerup'));
expect(preview).toHaveBeenCalledOnce();
expect(image.getState()).toEqual(original);
});
it('allows the host to scroll when a gesture starts on the button without previewing or dragging the image', () => {
const { scene, image, button, preview } = setup();
const original = image.getState();
const down = vi.fn();
scene.onPointerDown$.subscribeEvent(down);
button.triggerPointerDown(pointer('pointerdown'));
scene.onPointerMove$.emitEvent(pointer('pointermove', 280, 70));
scene.onPointerUp$.emitEvent(pointer('pointerup', 280, 70));
expect(down).toHaveBeenCalledOnce();
expect(preview).not.toHaveBeenCalled();
expect(image.getState()).toEqual(original);
});
it('does not preview cancelled, long-press, or multi-pointer gestures', () => {
vi.useFakeTimers();
const { scene, button, preview } = setup();
button.triggerPointerDown(pointer('pointerdown'));
scene.onPointerCancel$.emitEvent(pointer('pointercancel'));
button.triggerPointerUp(pointer('pointerup'));
button.triggerPointerDown(pointer('pointerdown'));
vi.advanceTimersByTime(600);
button.triggerPointerUp(pointer('pointerup'));
button.triggerPointerDown(pointer('pointerdown'));
scene.onPointerDown$.emitEvent(pointer('pointerdown', 200, 100));
button.triggerPointerUp(pointer('pointerup'));
expect(preview).not.toHaveBeenCalled();
});
it('hides when selected or cropping, reappears when enabled, and is removed with the image', () => {
const { scene, image, button } = setup();
button.setPreviewEnabled(false);
expect(button.visible).toBe(false);
expect(scene.pick(new Vector2(280, 30))).toBe(image);
button.setPreviewEnabled(true);
expect(button.visible).toBe(true);
image.dispose();
expect(scene.getObject(button.oKey)).toBeFalsy();
});
it.each([0.5, 1, 2])('keeps the tap area screen-sized and follows the image at zoom %s', (scale) => {
const { scene, image, button } = setup();
scene.scale(scale, scale);
image.translate(60, 80);
expect(button.width * scale).toBe(44);
expect(button.height * scale).toBe(44);
expect(button.left + button.width).toBe(image.left + image.width);
expect(button.top).toBe(image.top);
});
});

View File

@@ -0,0 +1,171 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ThemeService } from '@univerjs/core';
import type { Image, Scene, UniverRenderingContext } from '@univerjs/engine-render';
import { DisposableCollection } from '@univerjs/core';
import { render, unmount } from '@univerjs/design';
import { CURSOR_TYPE, Rect, Vector2 } from '@univerjs/engine-render';
import { ExpandIcon } from '@univerjs/icons';
import { createElement } from 'react';
/** Transient canvas chrome: never part of the image model or its transformer. */
export class MobileImagePreviewButton extends Rect {
private _enabled = true;
private readonly _icon = document.createElement('img');
private readonly _iconContainer = document.createElement('div');
private readonly _gesture = new DisposableCollection();
private _pressed = false;
private _background = '';
private _border = '';
private _pressedBackground = '';
constructor(
private readonly _image: Image,
private readonly _scene: Scene,
themeService: ThemeService,
preview: () => void
) {
super(`${_image.oKey}-mobile-preview`, { width: 44, height: 44 });
this.cursor = CURSOR_TYPE.POINTER;
this.disposeWithMe(this._gesture);
this.disposeWithMe(_image.onDispose$.subscribeEvent(() => this.dispose()));
this.disposeWithMe(_image.onTransformChange$.subscribeEvent(() => this._syncPosition()));
this.disposeWithMe(_scene.onTransformChange$.subscribeEvent(() => this._syncPosition()));
this.disposeWithMe(_scene.beforeRender$.subscribe(() => this._syncPosition()));
this.disposeWithMe(themeService.currentTheme$.subscribe(() => {
this._background = themeService.getColorFromTheme('white');
this._border = themeService.getColorFromTheme('gray.200');
this._pressedBackground = themeService.getColorFromTheme('gray.100');
render(createElement(
ExpandIcon,
{
width: 20,
height: 20,
color: themeService.getColorFromTheme('gray.900'),
ref: (svg) => {
if (svg) {
this._icon.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
}
},
}
), this._iconContainer);
this.makeDirty(true);
}));
this._icon.onload = () => this.makeDirty(true);
this.disposeWithMe(() => {
this._icon.onload = null;
unmount(this._iconContainer);
});
this.disposeWithMe(this.onPointerDown$.subscribeEvent((event) => {
this._gesture.dispose();
const start = { x: event.offsetX, y: event.offsetY, time: Date.now() };
let moved = false;
this._setPressed(true);
// Let the host handle scrolling, but never attach the image transformer to this button.
this._gesture.add(_scene.onPointerDown$.subscribeEvent((nextEvent) => {
if (nextEvent !== event) {
moved = true;
this._setPressed(false);
}
}));
this._gesture.add(_scene.onPointerMove$.subscribeEvent((moveEvent) => {
moved ||= Math.hypot(moveEvent.offsetX - start.x, moveEvent.offsetY - start.y) >= 12;
if (moved) {
this._setPressed(false);
}
}));
this._gesture.add(_scene.onPointerCancel$.subscribeEvent(() => {
this._gesture.dispose();
this._setPressed(false);
}));
this._gesture.add(_scene.onPointerUp$.subscribeEvent((upEvent) => {
this._gesture.dispose();
this._setPressed(false);
if (!moved && this.visible && Date.now() - start.time < 500 &&
Math.hypot(upEvent.offsetX - start.x, upEvent.offsetY - start.y) < 12) {
preview();
}
}));
}));
this.disposeWithMe(this.onClick$.subscribeEvent((_event, state) => state.stopPropagation()));
this.disposeWithMe(this.onDblclick$.subscribeEvent((_event, state) => state.stopPropagation()));
_scene.addObject(this, _image.getLayerIndex());
this._syncPosition();
}
setPreviewEnabled(enabled: boolean): void {
this._enabled = enabled;
this._syncPosition();
}
override dispose(): void {
if (this._disposed) {
return;
}
super.dispose();
}
private _setPressed(pressed: boolean): void {
this._pressed = pressed;
this.makeDirty(true);
}
private _syncPosition(): void {
const { scaleX, scaleY } = this._scene.getAncestorScale();
const width = 44 / Math.max(scaleX, 0.01);
const height = 44 / Math.max(scaleY, 0.01);
const visible = this._enabled && this._image.visible && !this._image.isInGroup &&
this._image.width >= width && this._image.height >= height;
if (visible !== this.visible) {
visible ? this.show() : this.hide();
}
if (!visible) {
return;
}
if (this.layer && this.getLayerIndex() !== this._image.getLayerIndex()) {
this._scene.removeObject(this);
this._scene.addObject(this, this._image.getLayerIndex());
}
const point = this._image.ancestorTransform.applyPoint(new Vector2(this._image.width, 0));
const left = point.x - width;
const top = point.y;
if (left !== this.left || top !== this.top || width !== this.width || height !== this.height) {
this.transformByState({ left, top, width, height });
}
if (this.zIndex !== this._image.zIndex + 0.5) {
this.zIndex = this._image.zIndex + 0.5;
}
}
protected override _draw(ctx: UniverRenderingContext): void {
ctx.save();
ctx.scale(this.width / 44, this.height / 44);
ctx.translate(6, 6);
Rect.drawWith(ctx, {
width: 32,
height: 32,
radius: 8,
fill: this._pressed ? this._pressedBackground : this._background,
stroke: this._border,
strokeWidth: 1,
});
if (this._icon.complete && this._icon.naturalWidth > 0) {
ctx.drawImage(this._icon, 6, 6, 20, 20);
}
ctx.restore();
}
}

View File

@@ -0,0 +1,359 @@
/**
* 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 { ComponentProps } from 'react';
import type { IObjectListPanelBaseProps, IObjectListRowProps } from './ObjectListPanelBase';
import { Button, clsx, Input, StateIconButton } from '@univerjs/design';
import { ArrowDownIcon, ArrowUpIcon, EyeIcon, EyelashIcon, LocateFixedIcon, LockIcon, MoreDownIcon, UnlockIcon } from '@univerjs/icons';
import { hasCapability, ObjectDetailsEditor, useObjectListPanel, useObjectListRow } from './ObjectListPanelBase';
const mobileIconButtonClassName = `
univer-flex univer-size-11 univer-shrink-0 univer-items-center univer-justify-center univer-rounded-xl
univer-border-0 univer-bg-gray-100 univer-p-0 univer-text-lg univer-text-gray-600 univer-outline-none
active:univer-scale-95 active:univer-bg-primary-100 active:univer-text-primary-600
disabled:univer-opacity-40
dark:!univer-bg-gray-800 dark:!univer-text-gray-200
`;
function MobileObjectListInput(props: ComponentProps<typeof Input>) {
return (
<Input
{...props}
className={clsx(props.className, 'univer-h-12')}
inputClass={clsx(props.inputClass, '!univer-text-base')}
/>
);
}
export function MobileObjectListPanel(props: IObjectListPanelBaseProps) {
const { labels, capabilities, onSelect, onSetVisible, onCommitName, onCommitDescription, onMoveForward, onMoveBackward, onToggleExpanded, onToggleSelectable, onSetSelectable, onLocate, renderPermissionAction } = props;
const { selectedIdSet, draggingId, setDraggingId, searchQuery, setSearchQuery, filterMode, setFilterMode, collapsedSectionIds, selectedItem, visibleItems, visibleItemSections, showSectionHeaders, visibilityItems, selectableItems, allVisible, allLocked, canMoveForward, canMoveBackward, showArrangeControls, showLocateControl, canReorder, handleDrop, toggleSectionCollapsed } = useObjectListPanel(props);
return (
<div
className={clsx(`
univer-box-border univer-flex univer-min-w-0 univer-max-w-full univer-flex-col univer-gap-3 univer-py-2
univer-text-gray-700
dark:!univer-text-gray-200
`, 'univer-h-auto univer-overflow-visible univer-pb-4')}
data-drawing-object-list-panel="true"
>
<div className="univer-flex univer-min-w-0 univer-flex-col univer-gap-2">
<div className="univer-flex univer-w-full univer-min-w-0 univer-gap-2">
<Button
className="univer-h-12 univer-flex-1"
size="middle"
disabled={visibilityItems.length === 0}
onClick={() => onSetVisible(visibilityItems.map((item) => item.id), !allVisible)}
>
{allVisible ? labels.hideAll : labels.showAll}
</Button>
{onSetSelectable && (
<Button
size="middle"
className="univer-h-12 univer-flex-1"
disabled={selectableItems.length === 0}
onClick={() => onSetSelectable(selectableItems.map((item) => item.id), allLocked)}
>
{allLocked ? (labels.unlockAll ?? labels.unlock) : (labels.lockAll ?? labels.lock)}
</Button>
)}
</div>
<div className="univer-flex univer-shrink-0 univer-justify-end univer-gap-1">
{showLocateControl && (
<button
type="button"
className={mobileIconButtonClassName}
aria-label={labels.locate}
disabled={!selectedItem?.id}
onClick={() => selectedItem && onLocate?.(selectedItem.id)}
>
<LocateFixedIcon />
</button>
)}
{showArrangeControls && (
<>
<button
type="button"
className={mobileIconButtonClassName}
aria-label={labels.moveForward}
disabled={!canMoveForward}
onClick={() => selectedItem && onMoveForward?.(selectedItem.id)}
>
<ArrowUpIcon />
</button>
<button
type="button"
className={mobileIconButtonClassName}
aria-label={labels.moveBackward}
disabled={!canMoveBackward}
onClick={() => selectedItem && onMoveBackward?.(selectedItem.id)}
>
<ArrowDownIcon />
</button>
</>
)}
</div>
</div>
<div className="univer-flex univer-shrink-0 univer-flex-col univer-gap-2">
<Input
className="univer-h-12"
inputClass="!univer-text-base"
value={searchQuery}
aria-label={labels.search}
placeholder={labels.search}
onChange={setSearchQuery}
/>
<div className="univer-flex univer-min-w-0 univer-gap-1">
{([
['all', labels.filterAll],
['hidden', labels.filterHidden],
['locked', labels.filterLocked],
] as const).map(([mode, label]) => (
<button
key={mode}
type="button"
className={clsx(
`
univer-min-w-0 univer-flex-1 univer-rounded-md univer-border univer-border-solid
univer-px-2 univer-text-xs univer-outline-none univer-transition-colors
dark:!univer-border-gray-700
`,
'univer-h-11 univer-text-sm',
filterMode === mode
? `
dark:!univer-bg-primary-900/30
univer-border-primary-500 univer-bg-primary-50 univer-text-primary-600
dark:!univer-text-primary-200
`
: clsx(`
univer-border-gray-200 univer-bg-gray-0 univer-text-gray-600
dark:!univer-bg-gray-900 dark:!univer-text-gray-300
`)
)}
aria-pressed={filterMode === mode}
onClick={() => setFilterMode(mode)}
>
<span className="univer-block univer-truncate">{label}</span>
</button>
))}
</div>
</div>
<div
className="univer-flex-none univer-overflow-visible"
>
{visibleItems.length === 0
? <div className="univer-py-6 univer-text-center univer-text-sm univer-text-gray-500">{labels.empty}</div>
: (
<div className="univer-flex univer-min-w-0 univer-flex-col univer-gap-0.5">
{visibleItemSections.map((section) => (
<div
key={section.id || 'default'}
className="univer-flex univer-min-w-0 univer-flex-col univer-gap-0.5"
>
{showSectionHeaders && section.title && (() => {
const sectionCollapsed = !!section.id && collapsedSectionIds.has(section.id);
return (
<button
type="button"
className={clsx(`
univer-box-border univer-flex univer-h-11 univer-w-full
univer-items-center univer-gap-1 univer-rounded-md univer-border-0
univer-bg-transparent univer-px-2 univer-pt-1 univer-text-xs
univer-font-semibold univer-uppercase univer-text-gray-400
univer-outline-none
disabled:univer-cursor-default
dark:!univer-text-gray-500
`)}
aria-label={sectionCollapsed ? labels.expand : labels.collapse}
disabled={!section.id}
onClick={() => toggleSectionCollapsed(section.id)}
>
{section.id
? (
<span
className="
univer-flex univer-size-4 univer-shrink-0
univer-items-center univer-justify-center
"
>
<MoreDownIcon
className={clsx({
'-univer-rotate-90 rtl:univer-rotate-90': sectionCollapsed,
})}
/>
</span>
)
: <span className="univer-size-4 univer-shrink-0" />}
<span
className="
univer-min-w-0 univer-flex-1 univer-truncate univer-text-left
"
>
{section.title}
</span>
<span
className="
univer-shrink-0 univer-text-[10px] univer-font-medium
univer-text-gray-400
dark:!univer-text-gray-500
"
>
{section.items.length}
</span>
</button>
);
})()}
{(!section.id || !collapsedSectionIds.has(section.id)) && section.items.map((item) => (
<MobileObjectListRow
key={item.id}
item={item}
selected={selectedIdSet.has(item.id)}
labels={labels}
permissionAction={renderPermissionAction?.(item)}
showLock={!!onToggleSelectable && hasCapability(capabilities, item, 'selectable')}
showName={hasCapability(capabilities, item, 'name')}
showVisible={hasCapability(capabilities, item, 'visible')}
draggable={canReorder && hasCapability(capabilities, item, 'reorder') && !item.disabled && item.canReorder !== false}
dragging={draggingId === item.id}
onSelect={(multiSelect) => onSelect(item.id, multiSelect)}
onToggleExpanded={() => onToggleExpanded?.(item.id)}
onToggleVisible={() => onSetVisible([item.id], !item.visible)}
onToggleSelectable={() => onToggleSelectable?.(item.id)}
onCommitName={(value) => onCommitName(item.id, value)}
onDragStart={() => setDraggingId(item.id)}
onDragEnd={() => setDraggingId(null)}
onDrop={() => handleDrop(item.id)}
/>
))}
</div>
))}
</div>
)}
</div>
<ObjectDetailsEditor
InputComponent={MobileObjectListInput}
descriptionClassName="!univer-text-base"
item={selectedItem}
labels={labels}
showName={hasCapability(capabilities, selectedItem, 'name')}
showDescription={hasCapability(capabilities, selectedItem, 'description')}
onCommitName={(value) => selectedItem && onCommitName(selectedItem.id, value)}
onCommitDescription={(value) => selectedItem && onCommitDescription(selectedItem.id, value)}
/>
</div>
);
}
function MobileObjectListRow(props: IObjectListRowProps) {
const { item, selected, labels, showLock, showName, showVisible, permissionAction, onSelect, onToggleExpanded, onToggleVisible, onToggleSelectable } = props;
const { draftName, setDraftName, disabled, level, locked, handleBlur, handleKeyDown } = useObjectListRow(props);
return (
<div
className={clsx(`
univer-flex univer-min-h-12 univer-min-w-0 univer-items-center univer-gap-1.5 univer-rounded-xl
univer-py-1 univer-pr-1.5
`, selected
? 'univer-bg-primary-50'
: `
univer-bg-gray-50
dark:!univer-bg-gray-800
`, disabled && 'univer-opacity-50')}
style={{ paddingLeft: level * 14 + 4 }}
onClick={() => !disabled && onSelect(false)}
>
{item.isGroup
? (
<button
type="button"
className={mobileIconButtonClassName}
aria-label={item.expanded ? labels.collapse : labels.expand}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onToggleExpanded();
}}
>
<MoreDownIcon
className={clsx(!item.expanded && `
-univer-rotate-90
rtl:univer-rotate-90
`)}
/>
</button>
)
: <span className="univer-w-2 univer-shrink-0" />}
{showName
? (
<MobileObjectListInput
className="univer-w-0 univer-min-w-0 univer-flex-1"
value={draftName}
disabled={disabled}
aria-label={labels.nameInput}
onChange={setDraftName}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onClick={(event) => {
event.stopPropagation();
if (!disabled) {
onSelect(false);
}
}}
/>
)
: <span className="univer-min-w-0 univer-flex-1 univer-truncate univer-text-sm">{item.name}</span>}
{permissionAction}
{showLock && (
<StateIconButton
active={locked}
emphasizeActive
className={mobileIconButtonClassName}
disabled={disabled}
aria-pressed={locked}
aria-label={locked ? labels.unlock : labels.lock}
onClick={(event) => {
event.stopPropagation();
onToggleSelectable();
}}
>
{locked ? <LockIcon /> : <UnlockIcon />}
</StateIconButton>
)}
{showVisible && (
<StateIconButton
active={!item.visible}
className={mobileIconButtonClassName}
disabled={disabled}
aria-pressed={!item.visible}
aria-label={item.visible ? labels.hide : labels.show}
onClick={(event) => {
event.stopPropagation();
onToggleVisible();
}}
>
{item.visible ? <EyeIcon /> : <EyelashIcon />}
</StateIconButton>
)}
</div>
);
}

View File

@@ -0,0 +1,910 @@
/**
* 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 { LocaleService } from '@univerjs/core';
import type { ComponentProps, ComponentType, DragEvent, FocusEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react';
import { Button, clsx, Input, StateIconButton } from '@univerjs/design';
import {
ArrowDownIcon,
ArrowUpIcon,
EyeIcon,
EyelashIcon,
LocateFixedIcon,
LockIcon,
MoreDownIcon,
UnlockIcon,
} from '@univerjs/icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { OBJECT_LIST_CANVAS_SECTION_ID, OBJECT_LIST_FLOATING_SECTION_ID } from './object-list-panel-layer';
export interface IObjectListPanelCapabilities {
arrange?: boolean;
description?: boolean;
locate?: boolean;
name?: boolean;
reorder?: boolean;
selectable?: boolean;
visible?: boolean;
}
type ObjectListFilterMode = 'all' | 'hidden' | 'locked';
export interface IObjectListPanelItem {
id: string;
name: string;
description?: string;
visible: boolean;
selectable?: boolean;
disabled?: boolean;
level?: number;
isGroup?: boolean;
expanded?: boolean;
canMoveForward?: boolean;
canMoveBackward?: boolean;
canReorder?: boolean;
parentId?: string;
sectionId?: string;
sectionTitle?: string;
capabilities?: IObjectListPanelCapabilities;
}
export interface IObjectListPanelLabels {
title: string;
empty: string;
showAll: string;
hideAll: string;
lockAll?: string;
unlockAll?: string;
moveForward: string;
moveBackward: string;
close: string;
show: string;
hide: string;
lock: string;
unlock: string;
lockHint?: string;
unlockHint?: string;
name: string;
nameInput: string;
description: string;
descriptionPlaceholder: string;
details: string;
noSelection: string;
locate: string;
expand: string;
collapse: string;
dragToReorder: string;
search: string;
filterAll: string;
filterHidden: string;
filterLocked: string;
sectionCanvas: string;
sectionFloating: string;
}
export type ObjectListPanelTypeNameKey =
| 'chart'
| 'connector'
| 'container'
| 'dom'
| 'group'
| 'image'
| 'object'
| 'placeholder'
| 'shape'
| 'smartArt'
| 'table'
| 'text'
| 'unit'
| 'video';
export function getObjectListPanelLabels(localeService: LocaleService): IObjectListPanelLabels {
return {
title: localeService.t('drawing-ui.objectListPanel.title'),
empty: localeService.t('drawing-ui.objectListPanel.empty'),
showAll: localeService.t('drawing-ui.objectListPanel.showAll'),
hideAll: localeService.t('drawing-ui.objectListPanel.hideAll'),
lockAll: localeService.t('drawing-ui.objectListPanel.lockAll'),
unlockAll: localeService.t('drawing-ui.objectListPanel.unlockAll'),
moveForward: localeService.t('drawing-ui.objectListPanel.moveForward'),
moveBackward: localeService.t('drawing-ui.objectListPanel.moveBackward'),
close: localeService.t('drawing-ui.objectListPanel.close'),
show: localeService.t('drawing-ui.objectListPanel.show'),
hide: localeService.t('drawing-ui.objectListPanel.hide'),
lock: localeService.t('drawing-ui.objectListPanel.lock'),
unlock: localeService.t('drawing-ui.objectListPanel.unlock'),
lockHint: localeService.t('drawing-ui.objectListPanel.lockHint'),
unlockHint: localeService.t('drawing-ui.objectListPanel.unlockHint'),
name: localeService.t('drawing-ui.objectListPanel.name'),
nameInput: localeService.t('drawing-ui.objectListPanel.nameInput'),
description: localeService.t('drawing-ui.objectListPanel.description'),
descriptionPlaceholder: localeService.t('drawing-ui.objectListPanel.descriptionPlaceholder'),
details: localeService.t('drawing-ui.objectListPanel.details'),
noSelection: localeService.t('drawing-ui.objectListPanel.noSelection'),
locate: localeService.t('drawing-ui.objectListPanel.locate'),
expand: localeService.t('drawing-ui.objectListPanel.expand'),
collapse: localeService.t('drawing-ui.objectListPanel.collapse'),
dragToReorder: localeService.t('drawing-ui.objectListPanel.dragToReorder'),
search: localeService.t('drawing-ui.objectListPanel.search'),
filterAll: localeService.t('drawing-ui.objectListPanel.filterAll'),
filterHidden: localeService.t('drawing-ui.objectListPanel.filterHidden'),
filterLocked: localeService.t('drawing-ui.objectListPanel.filterLocked'),
sectionCanvas: localeService.t('drawing-ui.objectListPanel.sectionCanvas'),
sectionFloating: localeService.t('drawing-ui.objectListPanel.sectionFloating'),
};
}
export function getObjectListPanelTypeName(localeService: LocaleService, typeName: ObjectListPanelTypeNameKey): string {
return localeService.t(`drawing-ui.objectListPanel.typeNames.${typeName}`);
}
export interface IObjectListPanelBaseProps {
items: IObjectListPanelItem[];
selectedIds: string[];
allObjectIds?: string[];
allItems?: Pick<IObjectListPanelItem, 'id' | 'visible' | 'selectable' | 'disabled' | 'capabilities'>[];
focusedId?: string | null;
renderPermissionAction?: (item: IObjectListPanelItem) => ReactNode;
labels: IObjectListPanelLabels;
showHeader?: boolean;
capabilities?: IObjectListPanelCapabilities;
onSelect: (objectId: string, multiSelect: boolean) => void;
onSetVisible: (objectIds: string[], visible: boolean) => void;
onCommitName: (objectId: string, value: string) => void;
onCommitDescription: (objectId: string, value: string) => void;
onMoveForward?: (objectId: string) => void;
onMoveBackward?: (objectId: string) => void;
onToggleExpanded?: (objectId: string) => void;
onToggleSelectable?: (objectId: string) => void;
onSetSelectable?: (objectIds: string[], selectable: boolean) => void;
onLocate?: (objectId: string) => void;
onReorder?: (sourceObjectId: string, targetObjectId: string) => void;
}
const iconButtonClassName = `
univer-flex univer-size-7 univer-shrink-0 univer-items-center univer-justify-center univer-rounded-md
univer-border-0 univer-bg-transparent univer-p-0 univer-text-gray-500 univer-outline-none univer-transition-colors
hover:univer-bg-gray-100 hover:univer-text-gray-900
disabled:univer-cursor-not-allowed disabled:univer-opacity-40
dark:!univer-text-gray-300 dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-gray-100
`;
const sectionOrder = new Map([
[OBJECT_LIST_FLOATING_SECTION_ID, 0],
[OBJECT_LIST_CANVAS_SECTION_ID, 1],
['', 2],
]);
function normalizeText(value: string): string {
return value.trim();
}
export function hasCapability(
panelCapabilities: IObjectListPanelCapabilities | undefined,
item: Pick<IObjectListPanelItem, 'capabilities'> | null | undefined,
key: keyof IObjectListPanelCapabilities,
fallback = true
): boolean {
return item?.capabilities?.[key] ?? panelCapabilities?.[key] ?? fallback;
}
export function useObjectListPanel(props: IObjectListPanelBaseProps) {
const {
items,
selectedIds,
allObjectIds,
allItems = items,
focusedId,
capabilities,
onMoveForward,
onMoveBackward,
onLocate,
onReorder,
} = props;
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const [draggingId, setDraggingId] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterMode, setFilterMode] = useState<ObjectListFilterMode>('all');
const [collapsedSectionIds, setCollapsedSectionIds] = useState<Set<string>>(() => new Set());
const selectedItem = useMemo(
() => items.find((item) => item.id === focusedId) ?? items.find((item) => selectedIdSet.has(item.id)) ?? null,
[focusedId, items, selectedIdSet]
);
const visibleItems = useMemo(() => {
const normalizedQuery = normalizeText(searchQuery).toLocaleLowerCase();
return items.filter((item) => {
if (filterMode === 'hidden' && item.visible !== false) {
return false;
}
if (filterMode === 'locked' && item.selectable !== false) {
return false;
}
if (!normalizedQuery) {
return true;
}
return item.name.toLocaleLowerCase().includes(normalizedQuery) ||
(item.description ?? '').toLocaleLowerCase().includes(normalizedQuery);
});
}, [filterMode, items, searchQuery]);
const visibleItemSections = useMemo(() => {
const sectionMap = new Map<string, { id: string; title: string; items: IObjectListPanelItem[] }>();
visibleItems.forEach((item) => {
const sectionId = item.sectionId ?? '';
const existingSection = sectionMap.get(sectionId);
if (existingSection) {
existingSection.items.push(item);
return;
}
sectionMap.set(sectionId, {
id: sectionId,
title: item.sectionTitle ?? '',
items: [item],
});
});
return [...sectionMap.values()].sort((a, b) => (sectionOrder.get(a.id) ?? 3) - (sectionOrder.get(b.id) ?? 3));
}, [visibleItems]);
const showSectionHeaders = visibleItemSections.length > 1 || visibleItemSections.some((section) => section.id === OBJECT_LIST_FLOATING_SECTION_ID);
const bulkItems = useMemo(() => {
if (!allObjectIds) {
return allItems;
}
const itemMap = new Map(allItems.map((item) => [item.id, item]));
return allObjectIds.map((id) => itemMap.get(id) ?? { id, visible: true });
}, [allObjectIds, allItems]);
const visibilityItems = bulkItems.filter((item) => !item.disabled && hasCapability(capabilities, item, 'visible'));
const selectableItems = bulkItems.filter((item) => !item.disabled && hasCapability(capabilities, item, 'selectable'));
const allVisible = visibilityItems.length > 0 && visibilityItems.every((item) => item.visible);
const allLocked = selectableItems.length > 0 && selectableItems.every((item) => item.selectable === false);
const selectedIndex = selectedItem ? items.findIndex((item) => item.id === selectedItem.id) : -1;
const canMoveForward = !!selectedItem && !!onMoveForward && !selectedItem.disabled && (
selectedItem.canMoveForward ?? selectedIndex > 0
) && hasCapability(capabilities, selectedItem, 'arrange');
const canMoveBackward = !!selectedItem && !!onMoveBackward && !selectedItem.disabled && (
selectedItem.canMoveBackward ?? (selectedIndex >= 0 && selectedIndex < items.length - 1)
) && hasCapability(capabilities, selectedItem, 'arrange');
const showArrangeControls = hasCapability(capabilities, selectedItem, 'arrange') && (!!onMoveForward || !!onMoveBackward);
const showLocateControl = !!onLocate && hasCapability(capabilities, selectedItem, 'locate');
const canReorder = !!onReorder && (capabilities?.reorder ?? true);
const findItem = (objectId: string | null): IObjectListPanelItem | null => {
return objectId ? items.find((item) => item.id === objectId) ?? null : null;
};
const handleDrop = (targetId: string) => {
const source = findItem(draggingId);
const target = findItem(targetId);
setDraggingId(null);
if (!source || !target || source.id === target.id || source.disabled || target.disabled) {
return;
}
if ((source.parentId ?? '') !== (target.parentId ?? '')) {
return;
}
if ((source.sectionId ?? '') !== (target.sectionId ?? '')) {
return;
}
if (source.canReorder === false || target.canReorder === false) {
return;
}
onReorder?.(source.id, target.id);
};
const toggleSectionCollapsed = (sectionId: string) => {
if (!sectionId) {
return;
}
setCollapsedSectionIds((current) => {
const next = new Set(current);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
return next;
});
};
return { selectedIdSet, draggingId, setDraggingId, searchQuery, setSearchQuery, filterMode, setFilterMode, collapsedSectionIds, selectedItem, visibleItems, visibleItemSections, showSectionHeaders, visibilityItems, selectableItems, allVisible, allLocked, canMoveForward, canMoveBackward, showArrangeControls, showLocateControl, canReorder, handleDrop, toggleSectionCollapsed };
}
export function ObjectListPanelBase(props: IObjectListPanelBaseProps) {
const { labels, showHeader = true, capabilities, onSelect, onSetVisible, onCommitName, onCommitDescription, onMoveForward, onMoveBackward, onToggleExpanded, onToggleSelectable, onSetSelectable, onLocate, renderPermissionAction } = props;
const { selectedIdSet, draggingId, setDraggingId, searchQuery, setSearchQuery, filterMode, setFilterMode, collapsedSectionIds, selectedItem, visibleItems, visibleItemSections, showSectionHeaders, visibilityItems, selectableItems, allVisible, allLocked, canMoveForward, canMoveBackward, showArrangeControls, showLocateControl, canReorder, handleDrop, toggleSectionCollapsed } = useObjectListPanel(props);
return (
<div
className={clsx(`
univer-box-border univer-flex univer-min-w-0 univer-max-w-full univer-flex-col univer-gap-3 univer-py-2
univer-text-gray-700
dark:!univer-text-gray-200
`, 'univer-size-full univer-overflow-hidden')}
data-drawing-object-list-panel="true"
>
{showHeader && (
<div className="univer-flex univer-h-8 univer-shrink-0 univer-items-center univer-gap-2">
<div
className="
univer-min-w-0 univer-flex-1 univer-truncate univer-text-sm univer-font-semibold
univer-text-gray-900
dark:!univer-text-gray-100
"
>
{labels.title}
</div>
</div>
)}
<div className="univer-flex univer-min-w-0 univer-items-center univer-justify-between univer-gap-2">
<div className="univer-flex univer-min-w-0 univer-flex-1 univer-gap-2">
<Button
size="small"
disabled={visibilityItems.length === 0}
onClick={() => onSetVisible(visibilityItems.map((item) => item.id), !allVisible)}
>
{allVisible ? labels.hideAll : labels.showAll}
</Button>
{onSetSelectable && (
<Button
size="small"
disabled={selectableItems.length === 0}
onClick={() => onSetSelectable(selectableItems.map((item) => item.id), allLocked)}
>
{allLocked ? (labels.unlockAll ?? labels.unlock) : (labels.lockAll ?? labels.lock)}
</Button>
)}
</div>
<div className="univer-flex univer-shrink-0 univer-gap-1">
{showLocateControl && (
<button
type="button"
className={iconButtonClassName}
title={labels.locate}
aria-label={labels.locate}
disabled={!selectedItem?.id}
onClick={() => selectedItem && onLocate?.(selectedItem.id)}
>
<LocateFixedIcon />
</button>
)}
{showArrangeControls && (
<>
<button
type="button"
className={iconButtonClassName}
title={labels.moveForward}
aria-label={labels.moveForward}
disabled={!canMoveForward}
onClick={() => selectedItem && onMoveForward?.(selectedItem.id)}
>
<ArrowUpIcon />
</button>
<button
type="button"
className={iconButtonClassName}
title={labels.moveBackward}
aria-label={labels.moveBackward}
disabled={!canMoveBackward}
onClick={() => selectedItem && onMoveBackward?.(selectedItem.id)}
>
<ArrowDownIcon />
</button>
</>
)}
</div>
</div>
<div className="univer-flex univer-shrink-0 univer-flex-col univer-gap-2">
<Input
value={searchQuery}
aria-label={labels.search}
placeholder={labels.search}
onChange={setSearchQuery}
/>
<div className="univer-flex univer-min-w-0 univer-gap-1">
{([
['all', labels.filterAll],
['hidden', labels.filterHidden],
['locked', labels.filterLocked],
] as const).map(([mode, label]) => (
<button
key={mode}
type="button"
className={clsx(
`
univer-min-w-0 univer-flex-1 univer-rounded-md univer-border univer-border-solid
univer-px-2 univer-text-xs univer-outline-none univer-transition-colors
dark:!univer-border-gray-700
`,
'univer-h-7',
filterMode === mode
? `
dark:!univer-bg-primary-900/30
univer-border-primary-500 univer-bg-primary-50 univer-text-primary-600
dark:!univer-text-primary-200
`
: clsx(`
univer-border-gray-200 univer-bg-gray-0 univer-text-gray-600
dark:!univer-bg-gray-900 dark:!univer-text-gray-300
`, `
hover:univer-bg-gray-50
dark:hover:!univer-bg-gray-800
`)
)}
aria-pressed={filterMode === mode}
onClick={() => setFilterMode(mode)}
>
<span className="univer-block univer-truncate">{label}</span>
</button>
))}
</div>
</div>
<div
className="univer-min-h-0 univer-flex-1 univer-overflow-y-auto univer-overflow-x-hidden"
>
{visibleItems.length === 0
? <div className="univer-py-6 univer-text-center univer-text-sm univer-text-gray-500">{labels.empty}</div>
: (
<div className="univer-flex univer-min-w-0 univer-flex-col univer-gap-0.5">
{visibleItemSections.map((section) => (
<div
key={section.id || 'default'}
className="univer-flex univer-min-w-0 univer-flex-col univer-gap-0.5"
>
{showSectionHeaders && section.title && (() => {
const sectionCollapsed = !!section.id && collapsedSectionIds.has(section.id);
return (
<button
type="button"
className={clsx(`
univer-box-border univer-flex univer-h-7 univer-w-full
univer-items-center univer-gap-1 univer-rounded-md univer-border-0
univer-bg-transparent univer-px-2 univer-pt-1 univer-text-xs
univer-font-semibold univer-uppercase univer-text-gray-400
univer-outline-none
disabled:univer-cursor-default
dark:!univer-text-gray-500
`, `
hover:univer-bg-gray-50 hover:univer-text-gray-600
disabled:hover:univer-bg-transparent
dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-gray-300
`)}
aria-label={sectionCollapsed ? labels.expand : labels.collapse}
disabled={!section.id}
onClick={() => toggleSectionCollapsed(section.id)}
>
{section.id
? (
<span
className="
univer-flex univer-size-4 univer-shrink-0
univer-items-center univer-justify-center
"
>
<MoreDownIcon
className={clsx({
'-univer-rotate-90 rtl:univer-rotate-90': sectionCollapsed,
})}
/>
</span>
)
: <span className="univer-size-4 univer-shrink-0" />}
<span
className="
univer-min-w-0 univer-flex-1 univer-truncate univer-text-left
"
>
{section.title}
</span>
<span
className="
univer-shrink-0 univer-text-[10px] univer-font-medium
univer-text-gray-400
dark:!univer-text-gray-500
"
>
{section.items.length}
</span>
</button>
);
})()}
{(!section.id || !collapsedSectionIds.has(section.id)) && section.items.map((item) => (
<ObjectListRow
key={item.id}
item={item}
selected={selectedIdSet.has(item.id)}
labels={labels}
permissionAction={renderPermissionAction?.(item)}
showLock={!!onToggleSelectable && hasCapability(capabilities, item, 'selectable')}
showName={hasCapability(capabilities, item, 'name')}
showVisible={hasCapability(capabilities, item, 'visible')}
draggable={canReorder && hasCapability(capabilities, item, 'reorder') && !item.disabled && item.canReorder !== false}
dragging={draggingId === item.id}
onSelect={(multiSelect) => onSelect(item.id, multiSelect)}
onToggleExpanded={() => onToggleExpanded?.(item.id)}
onToggleVisible={() => onSetVisible([item.id], !item.visible)}
onToggleSelectable={() => onToggleSelectable?.(item.id)}
onCommitName={(value) => onCommitName(item.id, value)}
onDragStart={() => setDraggingId(item.id)}
onDragEnd={() => setDraggingId(null)}
onDrop={() => handleDrop(item.id)}
/>
))}
</div>
))}
</div>
)}
</div>
<ObjectDetailsEditor
item={selectedItem}
labels={labels}
showName={hasCapability(capabilities, selectedItem, 'name')}
showDescription={hasCapability(capabilities, selectedItem, 'description')}
onCommitName={(value) => selectedItem && onCommitName(selectedItem.id, value)}
onCommitDescription={(value) => selectedItem && onCommitDescription(selectedItem.id, value)}
/>
</div>
);
}
export interface IObjectListRowProps {
permissionAction?: ReactNode;
item: IObjectListPanelItem;
selected: boolean;
labels: IObjectListPanelLabels;
showLock: boolean;
showName: boolean;
showVisible: boolean;
draggable: boolean;
dragging: boolean;
onSelect: (multiSelect: boolean) => void;
onToggleExpanded: () => void;
onToggleVisible: () => void;
onToggleSelectable: () => void;
onCommitName: (value: string) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDrop: () => void;
}
function ObjectListRow(props: IObjectListRowProps) {
const { permissionAction, item, selected, labels, showLock, showName, showVisible, draggable, dragging, onSelect, onToggleExpanded, onToggleVisible, onToggleSelectable, onDragStart, onDragEnd, onDrop } = props;
const { draftName, setDraftName, disabled, level, locked, handleBlur, handleKeyDown } = useObjectListRow(props);
return (
<div
className={clsx(
`
univer-box-border univer-flex univer-w-full univer-min-w-0 univer-max-w-full univer-items-center
univer-gap-1.5 univer-overflow-hidden univer-rounded-md univer-pr-1.5 univer-transition-colors
`,
'univer-h-8',
!disabled && `
hover:univer-bg-gray-100
dark:hover:!univer-bg-gray-800
`,
selected && `
univer-bg-gray-100
dark:!univer-bg-gray-800
`,
disabled && `
univer-text-gray-400
dark:!univer-text-gray-500
`,
dragging && 'univer-opacity-50'
)}
style={{ paddingLeft: level * 14 + 4 }}
draggable={draggable}
title={draggable ? labels.dragToReorder : undefined}
onClick={disabled ? undefined : (event: MouseEvent<HTMLDivElement>) => onSelect(event.ctrlKey || event.metaKey)}
onDragStart={(event: DragEvent<HTMLDivElement>) => {
if (!draggable) {
event.preventDefault();
return;
}
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', item.id);
onDragStart();
}}
onDragEnd={onDragEnd}
onDragOver={(event: DragEvent<HTMLDivElement>) => {
if (draggable) {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
}}
onDrop={(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
onDrop();
}}
>
{item.isGroup
? (
<button
type="button"
className={clsx(`
univer-flex univer-shrink-0 univer-items-center univer-justify-center univer-rounded
univer-border-0 univer-bg-transparent univer-p-0 univer-text-gray-500 univer-outline-none
univer-transition-transform
dark:!univer-text-gray-300
`, `
univer-size-5
hover:univer-bg-gray-100 hover:univer-text-gray-900
dark:hover:!univer-bg-gray-800 dark:hover:!univer-text-gray-100
`)}
title={item.expanded ? labels.collapse : labels.expand}
aria-label={item.expanded ? labels.collapse : labels.expand}
onClick={(event) => {
event.stopPropagation();
onToggleExpanded();
}}
>
<MoreDownIcon
className={clsx({
'-univer-rotate-90 rtl:univer-rotate-90': !item.expanded,
})}
/>
</button>
)
: <span className="univer-size-5 univer-shrink-0" />}
{showName
? (
<Input
key={item.name}
className={clsx('univer-h-7 univer-w-0 univer-min-w-0 univer-flex-1', disabled && `
univer-cursor-not-allowed
`)}
inputClass={clsx(
`
univer-min-w-0 univer-overflow-hidden univer-text-ellipsis univer-whitespace-nowrap
!univer-rounded !univer-border-transparent !univer-bg-transparent !univer-px-1
univer-text-sm univer-text-gray-900 !univer-shadow-none
focus:!univer-border-primary-500 focus:!univer-bg-gray-0 focus:!univer-ring-1
focus:!univer-ring-primary-100
disabled:!univer-bg-transparent disabled:univer-opacity-100
dark:!univer-text-gray-100
dark:focus:!univer-bg-gray-900 dark:focus:!univer-ring-primary-900
`,
'!univer-h-7',
disabled && `
univer-text-gray-400
dark:!univer-text-gray-500
`
)}
inputStyle={{ maxWidth: '100%', boxSizing: 'border-box' }}
value={draftName}
disabled={disabled}
aria-label={labels.nameInput}
onChange={setDraftName}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
onClick={(event) => {
event.stopPropagation();
if (!disabled) {
onSelect(event.ctrlKey || event.metaKey);
}
}}
/>
)
: <span className="univer-min-w-0 univer-flex-1 univer-truncate univer-px-1 univer-text-sm">{item.name}</span>}
{permissionAction}
{showLock && (
<StateIconButton
active={locked}
emphasizeActive
aria-pressed={locked}
type="button"
disabled={disabled}
title={locked ? (labels.unlockHint ?? labels.unlock) : (labels.lockHint ?? labels.lock)}
aria-label={locked ? labels.unlock : labels.lock}
onClick={(event) => {
event.stopPropagation();
onToggleSelectable();
}}
>
{locked ? <LockIcon aria-hidden="true" /> : <UnlockIcon aria-hidden="true" />}
</StateIconButton>
)}
{showVisible && (
<StateIconButton
active={!item.visible}
aria-pressed={!item.visible}
type="button"
disabled={disabled}
title={item.visible ? labels.hide : labels.show}
aria-label={item.visible ? labels.hide : labels.show}
onClick={(event) => {
event.stopPropagation();
onToggleVisible();
}}
>
{item.visible ? <EyeIcon /> : <EyelashIcon />}
</StateIconButton>
)}
</div>
);
}
export function ObjectDetailsEditor(props: {
item: IObjectListPanelItem | null;
InputComponent?: ComponentType<ComponentProps<typeof Input>>;
descriptionClassName?: string;
labels: IObjectListPanelLabels;
showName: boolean;
showDescription: boolean;
onCommitName: (value: string) => void;
onCommitDescription: (value: string) => void;
}) {
const { item, InputComponent = Input, descriptionClassName, labels, showName, showDescription, onCommitName, onCommitDescription } = props;
const [draftName, setDraftName] = useState(item?.name ?? '');
const [draftDescription, setDraftDescription] = useState(item?.description ?? '');
const disabled = item?.disabled === true;
useEffect(() => {
setDraftName(item?.name ?? '');
setDraftDescription(item?.description ?? '');
}, [item?.description, item?.id, item?.name]);
if (!item) {
return (
<div
className="
univer-rounded-md univer-border univer-border-solid univer-border-gray-200 univer-p-3 univer-text-sm
univer-text-gray-500
dark:!univer-border-gray-700 dark:!univer-text-gray-400
"
>
{labels.noSelection}
</div>
);
}
return (
<div
className="
univer-flex univer-shrink-0 univer-flex-col univer-gap-2 univer-border-0 univer-border-t
univer-border-solid univer-border-gray-200 univer-pt-3
dark:!univer-border-gray-700
"
>
<div
className="
univer-text-xs univer-font-semibold univer-uppercase univer-text-gray-500
dark:!univer-text-gray-400
"
>
{labels.details}
</div>
{showName && (
<label className="univer-flex univer-flex-col univer-gap-1">
<span
className="
univer-text-xs univer-text-gray-500
dark:!univer-text-gray-400
"
>
{labels.name}
</span>
<InputComponent
value={draftName}
aria-label={labels.nameInput}
disabled={disabled}
onChange={setDraftName}
onBlur={() => {
if (!disabled && normalizeText(draftName) !== item.name) {
onCommitName(draftName);
}
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
</label>
)}
{showDescription && (
<label className="univer-flex univer-flex-col univer-gap-1">
<span
className="
univer-text-xs univer-text-gray-500
dark:!univer-text-gray-400
"
>
{labels.description}
</span>
<textarea
className={clsx(descriptionClassName, `
univer-box-border univer-min-h-16 univer-w-full univer-resize-none univer-rounded-md
univer-border univer-border-solid univer-border-gray-200 univer-bg-gray-0 univer-p-2
univer-text-sm univer-text-gray-900 univer-outline-none
focus:univer-border-primary-500 focus:univer-ring-1 focus:univer-ring-primary-100
disabled:univer-cursor-not-allowed disabled:univer-opacity-70
dark:!univer-border-gray-700 dark:!univer-bg-gray-900 dark:!univer-text-gray-100
dark:focus:!univer-ring-primary-900
`)}
value={draftDescription}
disabled={disabled}
placeholder={labels.descriptionPlaceholder}
aria-label={labels.description}
onChange={(event) => setDraftDescription(event.currentTarget.value)}
onBlur={() => {
if (!disabled && normalizeText(draftDescription) !== (item.description ?? '')) {
onCommitDescription(draftDescription);
}
}}
/>
</label>
)}
</div>
);
}
export type IDrawingObjectListItem = IObjectListPanelItem;
export type IDrawingObjectListPanelLabels = IObjectListPanelLabels;
export type IDrawingObjectListPanelProps = IObjectListPanelBaseProps;
export function useObjectListRow({ item, onCommitName }: Pick<IObjectListRowProps, 'item' | 'onCommitName'>) {
const [draftName, setDraftName] = useState(item.name);
const skipCommitOnBlurRef = useRef(false);
const disabled = item.disabled === true;
const level = item.level ?? 0;
const locked = item.selectable === false;
useEffect(() => {
setDraftName(item.name);
}, [item.name]);
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
if (skipCommitOnBlurRef.current) {
skipCommitOnBlurRef.current = false;
return;
}
if (event.currentTarget.value !== item.name) {
onCommitName(event.currentTarget.value);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
return;
}
if (event.key === 'Escape') {
skipCommitOnBlurRef.current = true;
setDraftName(item.name);
event.currentTarget.blur();
}
};
return { draftName, setDraftName, disabled, level, locked, handleBlur, handleKeyDown };
}

View File

@@ -0,0 +1,142 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactElement } from 'react';
import type { Root } from 'react-dom/client';
import type { IObjectListPanelBaseProps, IObjectListPanelItem } from '../ObjectListPanelBase';
import { LocaleService, LocaleType, Univer } from '@univerjs/core';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import enUS from '../../../locale/en-US';
import { MobileObjectListPanel } from '../MobileObjectListPanel';
import { getObjectListPanelLabels, ObjectListPanelBase } from '../ObjectListPanelBase';
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
describe.each([
{ name: 'desktop', PanelComponent: ObjectListPanelBase },
{ name: 'mobile', PanelComponent: MobileObjectListPanel },
])('$name object list', ({ PanelComponent }) => {
let root: Root;
let container: HTMLDivElement;
let univer: Univer;
let visibilityChanges: Array<{ ids: string[]; visible: boolean }>;
let selectableChanges: Array<{ ids: string[]; selectable: boolean }>;
beforeEach(() => {
univer = new Univer({ locale: LocaleType.EN_US, locales: { [LocaleType.EN_US]: enUS } });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
visibilityChanges = [];
selectableChanges = [];
});
afterEach(() => {
act(() => root.unmount());
container.remove();
univer.dispose();
});
function render(element: ReactElement) {
act(() => root.render(element));
return { rerender: (next: ReactElement) => act(() => root.render(next)) };
}
function getButton(name: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll('button')).find((item) => item.textContent === name);
if (!button) {
throw new Error(`Button not found: ${name}`);
}
return button;
}
function click(button: HTMLButtonElement): void {
act(() => button.click());
}
function panel(items: IObjectListPanelItem[], props: Partial<IObjectListPanelBaseProps> = {}) {
const injector = univer.__getInjector();
return (
<RediContext.Provider value={{ injector }}>
<PanelComponent
items={items}
selectedIds={[]}
labels={getObjectListPanelLabels(injector.get(LocaleService))}
onSelect={() => {}}
onCommitName={() => {}}
onCommitDescription={() => {}}
onSetVisible={(ids, visible) => visibilityChanges.push({ ids, visible })}
onSetSelectable={(ids, selectable) => selectableChanges.push({ ids, selectable })}
{...props}
/>
</RediContext.Provider>
);
}
it('switches both bulk actions from current model state, including external updates', () => {
const original = [{ id: 'a', name: 'A', visible: true }, { id: 'b', name: 'B', visible: true }];
const view = render(panel(original));
click(getButton('Hide all'));
expect(visibilityChanges).toEqual([{ ids: ['a', 'b'], visible: false }]);
click(getButton('Lock all'));
expect(selectableChanges).toEqual([{ ids: ['a', 'b'], selectable: false }]);
view.rerender(panel(original.map((item) => ({ ...item, visible: false, selectable: false }))));
click(getButton('Show all'));
click(getButton('Unlock all'));
expect(visibilityChanges[1]).toEqual({ ids: ['a', 'b'], visible: true });
expect(selectableChanges[1]).toEqual({ ids: ['a', 'b'], selectable: true });
view.rerender(panel(original));
expect(getButton('Hide all')).toBeTruthy();
expect(getButton('Lock all')).toBeTruthy();
});
it('includes collapsed children and ignores the search filter when normalizing mixed states', () => {
const group = { id: 'group', name: 'Group', visible: true, selectable: false, isGroup: true, expanded: false };
render(panel([group], {
allItems: [group, { id: 'child', visible: false, selectable: true }],
}));
const search = container.querySelector<HTMLInputElement>('input[placeholder="Search objects"]')!;
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(search, 'unmatched');
search.dispatchEvent(new Event('input', { bubbles: true }));
});
click(getButton('Show all'));
click(getButton('Lock all'));
expect(visibilityChanges).toEqual([{ ids: ['group', 'child'], visible: true }]);
expect(selectableChanges).toEqual([{ ids: ['group', 'child'], selectable: false }]);
});
it('excludes unavailable capabilities and disables actions when no targets exist', () => {
const items = [
{ id: 'available', name: 'Available', visible: true },
{ id: 'disabled', name: 'Disabled', visible: false, disabled: true },
{ id: 'unsupported', name: 'Unsupported', visible: false, capabilities: { visible: false, selectable: false } },
];
const view = render(panel(items));
click(getButton('Hide all'));
click(getButton('Lock all'));
expect(visibilityChanges).toEqual([{ ids: ['available'], visible: false }]);
expect(selectableChanges).toEqual([{ ids: ['available'], selectable: false }]);
view.rerender(panel([]));
expect(getButton('Show all').disabled).toBe(true);
expect(getButton('Lock all').disabled).toBe(true);
});
});

View File

@@ -0,0 +1,37 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DrawingType } from '@univerjs/core';
import { DrawingTypeEnum } from '@univerjs/core';
export const OBJECT_LIST_FLOATING_SECTION_ID = 'floating';
export const OBJECT_LIST_CANVAS_SECTION_ID = 'canvas';
export type ObjectListPanelSectionId =
| typeof OBJECT_LIST_FLOATING_SECTION_ID
| typeof OBJECT_LIST_CANVAS_SECTION_ID;
export function isFloatingObjectListDrawingType(drawingType: DrawingType | null | undefined): boolean {
return drawingType === DrawingTypeEnum.DRAWING_CHART ||
drawingType === DrawingTypeEnum.DRAWING_DOM ||
drawingType === DrawingTypeEnum.DRAWING_UNIT;
}
export function getObjectListPanelSectionIdForDrawingType(drawingType: DrawingType | null | undefined): ObjectListPanelSectionId {
return isFloatingObjectListDrawingType(drawingType)
? OBJECT_LIST_FLOATING_SECTION_ID
: OBJECT_LIST_CANVAS_SECTION_ID;
}

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { clsx, Select } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
import { AlignType, SetDrawingAlignOperation } from '../../commands/operations/drawing-align.operation';
export interface IDrawingAlignProps {
drawings: IDrawingParam[];
alignShow: boolean;
}
export const DrawingAlign = (props: IDrawingAlignProps) => {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const { drawings, alignShow } = props;
const [alignValue, setAlignValue] = useState<AlignType>(AlignType.default);
const alignOptions = [
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.default'),
value: AlignType.default,
},
{
options: [
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.left'),
value: AlignType.left,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.center'),
value: AlignType.center,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.right'),
value: AlignType.right,
},
],
},
{
options: [
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.top'),
value: AlignType.top,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.middle'),
value: AlignType.middle,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.bottom'),
value: AlignType.bottom,
},
],
},
{
options: [
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.horizon'),
value: AlignType.horizon,
},
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.vertical'),
value: AlignType.vertical,
},
],
},
];
function handleAlignChange(value: string | number | boolean) {
const alignType = Object.values(AlignType).find((option) => option === value);
if (alignType == null) {
return;
}
setAlignValue(alignType);
commandService.executeCommand(SetDrawingAlignOperation.id, {
alignType,
drawings,
});
}
return (
<div
className={clsx('univer-relative univer-w-full', {
'univer-hidden': !alignShow,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.align.title')}</div>
</header>
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div
className={`
univer-w-full univer-text-gray-900
dark:!univer-text-gray-0
`}
>
<Select value={alignValue} options={alignOptions} onChange={handleAlignChange} />
</div>
</div>
</div>
);
};

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 { IDrawingParam } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { ArrangeTypeEnum, ICommandService, LocaleService } from '@univerjs/core';
import { Button, clsx } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IconManager, useDependency, useObservable } from '@univerjs/ui';
import { SetDrawingArrangeOperation } from '../../commands/operations/drawing-arrange.operation';
export interface IDrawingArrangeProps {
arrangeShow: boolean;
drawings: IDrawingParam[];
}
export const DrawingArrange = (props: IDrawingArrangeProps) => {
const { arrangeShow, drawings: focusDrawings } = props;
const localeService = useDependency(LocaleService);
const drawingManagerService = useDependency(IDrawingManagerService);
const commandService = useDependency(ICommandService);
const iconManager = useDependency(IconManager);
const MoveUpIcon = iconManager.get('MoveUpIcon');
const MoveDownIcon = iconManager.get('MoveDownIcon');
const TopmostIcon = iconManager.get('TopmostIcon');
const BottomIcon = iconManager.get('BottomIcon');
const drawings = useObservable(
() => drawingManagerService.focus$,
focusDrawings,
false,
[drawingManagerService]
);
const onArrangeBtnClick = (arrangeType: ArrangeTypeEnum) => {
commandService.syncExecuteCommand(SetDrawingArrangeOperation.id, { arrangeType, drawings });
};
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !arrangeShow,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.title')}</div>
</header>
<div className="univer-grid univer-grid-cols-2 univer-gap-2">
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.forward); }}>
<MoveUpIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.forward')}
</Button>
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.backward); }}>
<MoveDownIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.backward')}
</Button>
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.front); }}>
<TopmostIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.front')}
</Button>
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.back); }}>
<BottomIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.back')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,134 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { clsx } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ComponentManager, useDependency, useObservable } from '@univerjs/ui';
import { filter, map, merge } from 'rxjs';
import { getUpdateParams } from '../../utils/get-update-params';
import { DRAWING_ALIGN_COMPONENT, DRAWING_ARRANGE_COMPONENT, DRAWING_GROUP_COMPONENT, DRAWING_TRANSFORM_COMPONENT, IMAGE_CROPPER_COMPONENT } from './component-name';
export interface IDrawingCommonPanelProps {
drawings: IDrawingParam[];
hasArrange?: boolean;
hasTransform?: boolean;
hasAlign?: boolean;
hasCropper?: boolean;
hasGroup?: boolean;
onCropStart?: () => void;
}
function getPanelShowState(drawings: IDrawingParam[]) {
if (drawings.length === 0) {
return {
arrangeShow: false,
transformShow: false,
alignShow: false,
cropperShow: false,
nullShow: true,
};
}
if (drawings.length === 1) {
return {
arrangeShow: true,
transformShow: true,
alignShow: false,
cropperShow: true,
nullShow: false,
};
}
return {
arrangeShow: true,
transformShow: false,
alignShow: true,
cropperShow: false,
nullShow: false,
};
}
export const DrawingCommonPanel = (props: IDrawingCommonPanelProps) => {
const componentManager = useDependency(ComponentManager);
const DrawingArrange = componentManager.get(DRAWING_ARRANGE_COMPONENT);
const DrawingAlign = componentManager.get(DRAWING_ALIGN_COMPONENT);
const DrawingGroup = componentManager.get(DRAWING_GROUP_COMPONENT);
const DrawingTransform = componentManager.get(DRAWING_TRANSFORM_COMPONENT);
const ImageCropper = componentManager.get(IMAGE_CROPPER_COMPONENT);
const drawingManagerService = useDependency(IDrawingManagerService);
const renderManagerService = useDependency(IRenderManagerService);
const localeService = useDependency(LocaleService);
const { drawings, hasArrange = true, hasTransform = true, hasAlign = true, hasCropper = true, hasGroup = true } = props;
const drawingParam = drawings[0];
const renderObject = drawingParam ? renderManagerService.getRenderUnitById(drawingParam.unitId) : undefined;
const scene = renderObject?.scene;
const transformer = scene?.getTransformerByCreate();
const panelState = useObservable(
() => merge(
drawingManagerService.focus$.pipe(map(getPanelShowState)),
...(transformer
? [
transformer.clearControl$.pipe(
filter((changeSelf) => changeSelf === true),
map(() => getPanelShowState([]))
),
transformer.changeStart$.pipe(
map((state) => getPanelShowState(
getUpdateParams(state.objects, drawingManagerService).filter((drawing): drawing is IDrawingParam => drawing != null)
))
),
]
: [])
),
getPanelShowState(drawings),
false,
[drawingManagerService, transformer]
);
const { arrangeShow, transformShow, alignShow, cropperShow, nullShow } = panelState;
if (!drawingParam || !scene || !transformer) {
return null;
}
return (
<>
<div
className={clsx('univer-h-full', {
'univer-hidden': !nullShow,
})}
>
<div className="univer-flex univer-h-full univer-items-center univer-justify-center">
<span>
{localeService.t<LocaleKey>('drawing-ui.image-panel.null')}
</span>
</div>
</div>
<DrawingArrange arrangeShow={hasArrange === true ? arrangeShow : false} drawings={drawings} />
<DrawingTransform transformShow={hasTransform === true ? transformShow : false} drawings={drawings} />
<DrawingAlign alignShow={hasAlign === true ? alignShow : false} drawings={drawings} />
<ImageCropper cropperShow={hasCropper === true ? cropperShow : false} drawings={drawings} onCropStart={props.onCropStart} />
<DrawingGroup hasGroup={hasGroup} drawings={drawings} />
</>
);
};

View File

@@ -0,0 +1,121 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { DrawingTypeEnum, ICommandService, LocaleService } from '@univerjs/core';
import { Button, clsx } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IconManager, useDependency, useObservable } from '@univerjs/ui';
import { filter, map, merge } from 'rxjs';
import {
CancelDrawingGroupOperation,
SetDrawingGroupOperation,
} from '../../commands/operations/drawing-group.operation';
import { getUpdateParams } from '../../utils/get-update-params';
export interface IDrawingGroupProps {
drawings: IDrawingParam[];
hasGroup: boolean;
}
export const DrawingGroup = (props: IDrawingGroupProps) => {
const localeService = useDependency(LocaleService);
const renderManagerService = useDependency(IRenderManagerService);
const drawingManagerService = useDependency(IDrawingManagerService);
const commandService = useDependency(ICommandService);
const iconManager = useDependency(IconManager);
const { hasGroup, drawings } = props;
const GroupIcon = iconManager.get('GroupIcon');
const UngroupIcon = iconManager.get('UngroupIcon');
const drawingParam = drawings[0];
const transformer = drawingParam
? renderManagerService.getRenderUnitById(drawingParam.unitId)?.scene?.getTransformerByCreate()
: undefined;
const groupState = useObservable(
transformer
? () => merge(
transformer.clearControl$.pipe(
filter((changeSelf) => changeSelf === true),
map(() => ({ groupShow: false, groupBtnShow: false, ungroupBtnShow: false }))
),
transformer.changeStart$.pipe(map((state) => {
const params = getUpdateParams(state.objects, drawingManagerService);
const groupBtnShow = params.length > 1;
const ungroupBtnShow = params.some((item) => item?.drawingType === DrawingTypeEnum.DRAWING_GROUP);
return {
groupShow: groupBtnShow || ungroupBtnShow,
groupBtnShow,
ungroupBtnShow,
};
}))
)
: null,
{ groupShow: false, groupBtnShow: true, ungroupBtnShow: true },
false,
[drawingManagerService, transformer]
);
const { groupShow, groupBtnShow, ungroupBtnShow } = groupState;
const onGroupBtnClick = () => {
commandService.syncExecuteCommand(SetDrawingGroupOperation.id, { drawings });
};
const onUngroupBtnClick = () => {
commandService.syncExecuteCommand(CancelDrawingGroupOperation.id, { drawings });
};
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': (hasGroup === true && groupShow === false) || hasGroup === false,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.group.title')}</div>
</header>
<div className="univer-flex univer-items-center univer-justify-center univer-gap-2">
<Button
className={clsx({
'univer-hidden': !groupBtnShow,
})}
onClick={onGroupBtnClick}
>
<GroupIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.group.group')}
</Button>
<Button
className={clsx({
'univer-hidden': !ungroupBtnShow,
})}
onClick={onUngroupBtnClick}
>
<UngroupIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.group.unGroup')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,428 @@
/**
* 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, Nullable } from '@univerjs/core';
import type { IChangeObserverConfig, Scene } from '@univerjs/engine-render';
import type { LocaleKey } from '../../locale/types';
import { debounce, LocaleService } from '@univerjs/core';
import { Checkbox, clsx, InputNumber } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { useDependency } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import { MIN_DRAWING_HEIGHT_LIMIT, MIN_DRAWING_WIDTH_LIMIT, RANGE_DRAWING_ROTATION_LIMIT } from '../../utils/config';
import { getUpdateParams } from '../../utils/get-update-params';
import { resolveDrawingUIRotateEnabled } from '../../utils/rotate-enabled';
import { createDrawingTransformRotationChangeHandler, isDrawingTransformRotationDisabled } from './drawing-transform-rotation';
export interface IDrawingTransformProps {
transformShow: boolean;
drawings: IDrawingParam[];
}
const INPUT_DEBOUNCE_TIME = 300;
export const DrawingTransform = (props: IDrawingTransformProps) => {
const renderManagerService = useDependency(IRenderManagerService);
const drawingParam = props.drawings[0];
const scene = drawingParam ? renderManagerService.getRenderUnitById(drawingParam.unitId)?.scene : undefined;
const topScene = scene?.getEngine()?.activeScene as Nullable<Scene>;
if (!drawingParam?.transform || !scene || !topScene) {
return null;
}
return <DrawingTransformContent {...props} />;
};
function DrawingTransformContent(props: IDrawingTransformProps) {
const localeService = useDependency(LocaleService);
const drawingManagerService = useDependency(IDrawingManagerService);
const renderManagerService = useDependency(IRenderManagerService);
const { drawings, transformShow } = props;
const drawingParam = drawings[0]!;
const transform = drawingParam.transform!;
const { unitId, subUnitId, drawingId, drawingType } = drawingParam;
const renderObject = renderManagerService.getRenderUnitById(unitId);
const scene = renderObject!.scene!;
const topScene = scene.getEngine()!.activeScene as Scene;
const transformer = scene.getTransformerByCreate();
const {
width: originWidth = 0,
height: originHeight = 0,
left: originX = 0,
top: originY = 0,
angle: originRotation = 0,
} = transform;
const [width, setWidth] = useState<number>(originWidth);
const [height, setHeight] = useState(originHeight);
const [xPosition, setXPosition] = useState(originX);
const [yPosition, setYPosition] = useState(originY);
const [rotation, setRotation] = useState(originRotation);
const [lockRatio, setLockRatio] = useState(transformer.keepRatio);
const rotateEnabled = resolveDrawingUIRotateEnabled(drawingParam, {
getChildren: (drawing) => drawingManagerService.getDrawingsByGroup(drawing),
});
const rotationDisabled = isDrawingTransformRotationDisabled(rotateEnabled);
const checkMoveBoundary = (left: number, top: number, width: number, height: number) => {
const { width: topSceneWidth, height: topSceneHeight } = topScene;
const { ancestorLeft, ancestorTop } = scene;
let limitLeft = left;
let limitTop = top;
let limitWidth = width;
let limitHeight = height;
if (left + ancestorLeft < 0) {
limitLeft = -ancestorLeft;
}
if (top + ancestorTop < 0) {
limitTop = -ancestorTop;
}
limitWidth = topSceneWidth - limitLeft - ancestorLeft;
if (limitWidth < MIN_DRAWING_WIDTH_LIMIT) {
limitWidth = MIN_DRAWING_WIDTH_LIMIT;
}
limitHeight = topSceneHeight - limitTop - ancestorTop;
if (limitHeight < MIN_DRAWING_HEIGHT_LIMIT) {
limitHeight = MIN_DRAWING_HEIGHT_LIMIT;
}
if (left + limitWidth + ancestorLeft > topSceneWidth) {
limitLeft = topSceneWidth - width - ancestorLeft;
}
if (top + limitHeight + ancestorTop > topSceneHeight) {
limitTop = topSceneHeight - height - ancestorTop;
}
return {
limitLeft,
limitTop,
limitWidth,
limitHeight,
};
};
const changeObs = (state: IChangeObserverConfig) => {
const { objects } = state;
const params = getUpdateParams(objects, drawingManagerService);
if (params.length !== 1) {
return;
}
const drawingParam = params[0];
if (drawingParam == null) {
return;
}
const { transform } = drawingParam;
if (transform == null) {
return;
}
const {
width: originWidth,
height: originHeight,
left: originX,
top: originY,
angle: originRotation,
} = transform;
if (originWidth != null) {
setWidth(originWidth);
}
if (originHeight != null) {
setHeight(originHeight);
}
if (originX != null) {
setXPosition(originX);
}
if (originY != null) {
setYPosition(originY);
}
if (originRotation != null) {
setRotation(originRotation);
}
};
useEffect(() => {
const subscriptions = [
transformer.changeStart$.subscribe((state) => {
changeObs(state);
}),
transformer.changing$.subscribe((state) => {
changeObs(state);
}),
transformer.changeEnd$.subscribe((state) => {
changeObs(state);
}),
drawingManagerService.focus$.subscribe((drawings) => {
if (drawings.length !== 1) {
return;
}
const drawingParam = drawingManagerService.getDrawingByParam(drawings[0]);
if (drawingParam == null) {
return;
}
const transform = drawingParam.transform;
if (transform == null) {
return;
}
const {
width: originWidth,
height: originHeight,
left: originX,
top: originY,
angle: originRotation,
} = transform;
if (originWidth != null) {
setWidth(originWidth);
}
if (originHeight != null) {
setHeight(originHeight);
}
if (originX != null) {
setXPosition(originX);
}
if (originY != null) {
setYPosition(originY);
}
if (originRotation != null) {
setRotation(originRotation);
}
}),
];
return () => {
subscriptions.forEach((sub) => sub.unsubscribe());
};
}, []);
const handleWidthChange = debounce((val: number | null) => {
if (val == null) {
return;
}
const { limitWidth, limitHeight } = checkMoveBoundary(xPosition, yPosition, val, height);
val = Math.min(val, limitWidth);
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { width: val } };
if (lockRatio) {
let heightFix = (val / width) * height;
heightFix = Math.max(heightFix, MIN_DRAWING_HEIGHT_LIMIT);
if (heightFix > limitHeight) {
return;
}
setHeight(heightFix);
updateParam.transform!.height = heightFix;
}
setWidth(val);
drawingManagerService.featurePluginUpdateNotification([updateParam]);
transformer.refreshControls().changeNotification();
}, INPUT_DEBOUNCE_TIME);
const handleHeightChange = debounce((val: number | null) => {
if (val == null) {
return;
}
const { limitHeight, limitWidth } = checkMoveBoundary(xPosition, yPosition, width, val);
val = Math.min(val, limitHeight);
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { height: val } };
if (lockRatio) {
let widthFix = (val / height) * width;
widthFix = Math.max(widthFix, MIN_DRAWING_WIDTH_LIMIT);
if (widthFix > limitWidth) {
return;
}
setWidth(widthFix);
updateParam.transform!.width = widthFix;
}
setHeight(val);
drawingManagerService.featurePluginUpdateNotification([updateParam]);
transformer.refreshControls().changeNotification();
}, INPUT_DEBOUNCE_TIME);
const handleXChange = debounce((val: number | null) => {
if (val == null) {
return;
}
const { limitLeft } = checkMoveBoundary(val, yPosition, width, height);
val = limitLeft;
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { left: val } };
setXPosition(val);
drawingManagerService.featurePluginUpdateNotification([updateParam]);
transformer.refreshControls().changeNotification();
}, INPUT_DEBOUNCE_TIME);
const handleYChange = debounce((val: number | null) => {
if (val == null) {
return;
}
const { limitTop } = checkMoveBoundary(xPosition, val, width, height);
val = limitTop;
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { top: val } };
setYPosition(val);
drawingManagerService.featurePluginUpdateNotification([updateParam]);
transformer.refreshControls().changeNotification();
}, INPUT_DEBOUNCE_TIME);
const handleRotationChange = createDrawingTransformRotationChangeHandler({
rotateEnabled,
drawingParam: { unitId, subUnitId, drawingId, drawingType },
setRotation,
emitUpdate: (updateParams) => drawingManagerService.featurePluginUpdateNotification(updateParams),
notifyChange: () => transformer.refreshControls().changeNotification(),
});
const handleLockRatioChange = (val: string | number | boolean) => {
if (typeof val !== 'boolean') {
return;
}
setLockRatio(val);
transformer.keepRatio = val;
};
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !transformShow,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.title')}</div>
</header>
<div
className={`
univer-grid univer-grid-cols-3 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
`}
>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.width')}</span>
<InputNumber
precision={1}
value={width}
min={MIN_DRAWING_WIDTH_LIMIT}
onChange={(val) => { handleWidthChange(val); }}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.height')}</span>
<InputNumber
precision={1}
value={height}
min={MIN_DRAWING_HEIGHT_LIMIT}
onChange={(val) => { handleHeightChange(val); }}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.lock')}</span>
<div className="univer-text-center">
<Checkbox checked={lockRatio} onChange={handleLockRatioChange} />
</div>
</div>
</div>
<div
className={`
univer-grid univer-grid-cols-3 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
`}
>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.x')}</span>
<InputNumber precision={1} value={xPosition} onChange={(val) => { handleXChange(val); }} />
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.y')}</span>
<InputNumber precision={1} value={yPosition} onChange={(val) => { handleYChange(val); }} />
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.rotate')}</span>
<InputNumber
precision={1}
value={rotation}
min={RANGE_DRAWING_ROTATION_LIMIT[0]}
max={RANGE_DRAWING_ROTATION_LIMIT[1]}
disabled={rotationDisabled}
onChange={handleRotationChange}
/>
</div>
</div>
</div>
);
}

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 { IDrawingParam } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { Button, clsx, Select } from '@univerjs/design';
import { CreateCopyIcon } from '@univerjs/icons';
import { useImageCropper } from './use-image-cropper';
export interface IImageCropperProps {
drawings: IDrawingParam[];
cropperShow: boolean;
onCropStart?: () => void;
}
export const ImageCropper = (props: IImageCropperProps) => {
const { drawingParam, cropperShow, localeService, cropValue, cropOptions, handleCropChange, onCropperBtnClick, ShapeClipPicker } = useImageCropper(props);
if (!drawingParam) {
return null;
}
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !cropperShow,
})}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.crop.title')}</div>
</header>
<div className="univer-flex univer-items-center univer-justify-center univer-gap-2">
<Button onClick={() => onCropperBtnClick(cropValue)}>
<CreateCopyIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.crop.start')}
</Button>
<Select value={cropValue} options={cropOptions} onChange={handleCropChange} />
</div>
{ShapeClipPicker && <ShapeClipPicker />}
</div>
);
};

View File

@@ -0,0 +1,86 @@
/**
* 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 { LocaleKey } from '../../locale/types';
import { ArrangeTypeEnum, ICommandService, LocaleService } from '@univerjs/core';
import { Button, clsx } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IconManager, useDependency, useObservable } from '@univerjs/ui';
import { SetDrawingArrangeOperation } from '../../commands/operations/drawing-arrange.operation';
interface IMobileDrawingArrangeProps {
arrangeShow: boolean;
drawings: IDrawingParam[];
}
export function MobileDrawingArrange(props: IMobileDrawingArrangeProps) {
const { arrangeShow, drawings: focusDrawings } = props;
const localeService = useDependency(LocaleService);
const drawingManagerService = useDependency(IDrawingManagerService);
const commandService = useDependency(ICommandService);
const iconManager = useDependency(IconManager);
const MoveUpIcon = iconManager.get('MoveUpIcon');
const MoveDownIcon = iconManager.get('MoveDownIcon');
const TopmostIcon = iconManager.get('TopmostIcon');
const BottomIcon = iconManager.get('BottomIcon');
const drawings = useObservable(
() => drawingManagerService.focus$,
focusDrawings,
false,
[drawingManagerService]
);
const arrange = (arrangeType: ArrangeTypeEnum) => {
commandService.syncExecuteCommand(SetDrawingArrangeOperation.id, { arrangeType, drawings });
};
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !arrangeShow,
})}
>
<header
className="
univer-text-gray-600
dark:!univer-text-gray-200
"
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.title')}</div>
</header>
<div className="univer-grid univer-grid-cols-2 univer-gap-2">
<Button className="univer-h-12 univer-w-full" onClick={() => arrange(ArrangeTypeEnum.forward)}>
<MoveUpIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.forward')}
</Button>
<Button className="univer-h-12 univer-w-full" onClick={() => arrange(ArrangeTypeEnum.backward)}>
<MoveDownIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.backward')}
</Button>
<Button className="univer-h-12 univer-w-full" onClick={() => arrange(ArrangeTypeEnum.front)}>
<TopmostIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.front')}
</Button>
<Button className="univer-h-12 univer-w-full" onClick={() => arrange(ArrangeTypeEnum.back)}>
<BottomIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.arrange.back')}
</Button>
</div>
</div>
);
}

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 { IDrawingParam, Nullable } from '@univerjs/core';
import type { IChangeObserverConfig, Scene } from '@univerjs/engine-render';
import type { ComponentProps } from 'react';
import type { LocaleKey } from '../../locale/types';
import { debounce, DrawingTypeEnum, ICommandService, LocaleService } from '@univerjs/core';
import { Button, Checkbox, clsx, InputNumber, MobileSelect } from '@univerjs/design';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IconManager, useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import { filter, map, merge } from 'rxjs';
import { AlignType, SetDrawingAlignOperation } from '../../commands/operations/drawing-align.operation';
import { CancelDrawingGroupOperation, SetDrawingGroupOperation } from '../../commands/operations/drawing-group.operation';
import { MIN_DRAWING_HEIGHT_LIMIT, MIN_DRAWING_WIDTH_LIMIT, RANGE_DRAWING_ROTATION_LIMIT } from '../../utils/config';
import { getUpdateParams } from '../../utils/get-update-params';
import { resolveDrawingUIRotateEnabled } from '../../utils/rotate-enabled';
import { createDrawingTransformRotationChangeHandler, isDrawingTransformRotationDisabled } from './drawing-transform-rotation';
const INPUT_DEBOUNCE_TIME = 300;
interface IMobileDrawingTransformProps {
transformShow: boolean;
drawings: IDrawingParam[];
}
interface IMobileDrawingAlignProps {
drawings: IDrawingParam[];
alignShow: boolean;
}
interface IMobileDrawingGroupProps {
drawings: IDrawingParam[];
hasGroup: boolean;
}
function MobileDrawingInputNumber(props: ComponentProps<typeof InputNumber>) {
return (
<InputNumber
{...props}
controls={false}
className={clsx('univer-h-12 univer-w-full', props.className)}
inputClassName={clsx('[&_input]:!univer-h-12 [&_input]:!univer-text-base', props.inputClassName)}
/>
);
}
function MobileDrawingCheckbox(props: ComponentProps<typeof Checkbox>) {
return <Checkbox {...props} className={clsx('univer-flex univer-min-h-12 univer-items-center univer-justify-center', props.className)} />;
}
function MobileDrawingButton(props: ComponentProps<typeof Button>) {
return (
<Button
{...props}
className={clsx(`
univer-flex univer-min-h-12 univer-flex-1 univer-items-center univer-justify-center univer-gap-2
univer-text-base
`, props.className)}
/>
);
}
export function MobileDrawingTransform(props: IMobileDrawingTransformProps) {
const renderManagerService = useDependency(IRenderManagerService);
const drawingParam = props.drawings[0];
const scene = drawingParam ? renderManagerService.getRenderUnitById(drawingParam.unitId)?.scene : undefined;
const topScene = scene?.getEngine()?.activeScene as Nullable<Scene>;
if (!drawingParam?.transform || !scene || !topScene) {
return null;
}
return <MobileDrawingTransformContent {...props} />;
}
function MobileDrawingTransformContent(props: IMobileDrawingTransformProps) {
const localeService = useDependency(LocaleService);
const drawingManagerService = useDependency(IDrawingManagerService);
const renderManagerService = useDependency(IRenderManagerService);
const { drawings, transformShow } = props;
const drawingParam = drawings[0]!;
const transform = drawingParam.transform!;
const { unitId, subUnitId, drawingId, drawingType } = drawingParam;
const scene = renderManagerService.getRenderUnitById(unitId)!.scene!;
const topScene = scene.getEngine()!.activeScene as Scene;
const transformer = scene.getTransformerByCreate();
const {
width: originWidth = 0,
height: originHeight = 0,
left: originX = 0,
top: originY = 0,
angle: originRotation = 0,
} = transform;
const [width, setWidth] = useState<number>(originWidth);
const [height, setHeight] = useState(originHeight);
const [xPosition, setXPosition] = useState(originX);
const [yPosition, setYPosition] = useState(originY);
const [rotation, setRotation] = useState(originRotation);
const [lockRatio, setLockRatio] = useState(transformer.keepRatio);
const rotateEnabled = resolveDrawingUIRotateEnabled(drawingParam, {
getChildren: (drawing) => drawingManagerService.getDrawingsByGroup(drawing),
});
const rotationDisabled = isDrawingTransformRotationDisabled(rotateEnabled);
const checkMoveBoundary = (left: number, top: number, drawingWidth: number, drawingHeight: number) => {
const { width: topSceneWidth, height: topSceneHeight } = topScene;
const { ancestorLeft, ancestorTop } = scene;
let limitLeft = left;
let limitTop = top;
let limitWidth = drawingWidth;
let limitHeight = drawingHeight;
if (left + ancestorLeft < 0) {
limitLeft = -ancestorLeft;
}
if (top + ancestorTop < 0) {
limitTop = -ancestorTop;
}
limitWidth = Math.max(topSceneWidth - limitLeft - ancestorLeft, MIN_DRAWING_WIDTH_LIMIT);
limitHeight = Math.max(topSceneHeight - limitTop - ancestorTop, MIN_DRAWING_HEIGHT_LIMIT);
if (left + limitWidth + ancestorLeft > topSceneWidth) {
limitLeft = topSceneWidth - drawingWidth - ancestorLeft;
}
if (top + limitHeight + ancestorTop > topSceneHeight) {
limitTop = topSceneHeight - drawingHeight - ancestorTop;
}
return { limitLeft, limitTop, limitWidth, limitHeight };
};
const syncTransform = (drawing: IDrawingParam) => {
const nextTransform = drawing.transform;
if (nextTransform == null) {
return;
}
const { width, height, left, top, angle } = nextTransform;
if (width != null) setWidth(width);
if (height != null) setHeight(height);
if (left != null) setXPosition(left);
if (top != null) setYPosition(top);
if (angle != null) setRotation(angle);
};
const changeObserver = (state: IChangeObserverConfig) => {
const params = getUpdateParams(state.objects, drawingManagerService);
if (params.length === 1 && params[0] != null) {
syncTransform(params[0]);
}
};
useEffect(() => {
const subscriptions = [
transformer.changeStart$.subscribe(changeObserver),
transformer.changing$.subscribe(changeObserver),
transformer.changeEnd$.subscribe(changeObserver),
drawingManagerService.focus$.subscribe((drawings) => {
if (drawings.length !== 1) {
return;
}
const drawing = drawingManagerService.getDrawingByParam(drawings[0]);
if (drawing != null) {
syncTransform(drawing);
}
}),
];
return () => subscriptions.forEach((subscription) => subscription.unsubscribe());
}, []);
const notifyTransformChange = (updateParam: IDrawingParam) => {
drawingManagerService.featurePluginUpdateNotification([updateParam]);
transformer.refreshControls().changeNotification();
};
const handleWidthChange = debounce((value: number | null) => {
if (value == null) {
return;
}
const { limitWidth, limitHeight } = checkMoveBoundary(xPosition, yPosition, value, height);
const nextWidth = Math.min(value, limitWidth);
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { width: nextWidth } };
if (lockRatio) {
const nextHeight = Math.max((nextWidth / width) * height, MIN_DRAWING_HEIGHT_LIMIT);
if (nextHeight > limitHeight) {
return;
}
setHeight(nextHeight);
updateParam.transform!.height = nextHeight;
}
setWidth(nextWidth);
notifyTransformChange(updateParam);
}, INPUT_DEBOUNCE_TIME);
const handleHeightChange = debounce((value: number | null) => {
if (value == null) {
return;
}
const { limitHeight, limitWidth } = checkMoveBoundary(xPosition, yPosition, width, value);
const nextHeight = Math.min(value, limitHeight);
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { height: nextHeight } };
if (lockRatio) {
const nextWidth = Math.max((nextHeight / height) * width, MIN_DRAWING_WIDTH_LIMIT);
if (nextWidth > limitWidth) {
return;
}
setWidth(nextWidth);
updateParam.transform!.width = nextWidth;
}
setHeight(nextHeight);
notifyTransformChange(updateParam);
}, INPUT_DEBOUNCE_TIME);
const handleXChange = debounce((value: number | null) => {
if (value == null) {
return;
}
const { limitLeft } = checkMoveBoundary(value, yPosition, width, height);
setXPosition(limitLeft);
notifyTransformChange({ unitId, subUnitId, drawingId, drawingType, transform: { left: limitLeft } });
}, INPUT_DEBOUNCE_TIME);
const handleYChange = debounce((value: number | null) => {
if (value == null) {
return;
}
const { limitTop } = checkMoveBoundary(xPosition, value, width, height);
setYPosition(limitTop);
notifyTransformChange({ unitId, subUnitId, drawingId, drawingType, transform: { top: limitTop } });
}, INPUT_DEBOUNCE_TIME);
const handleRotationChange = createDrawingTransformRotationChangeHandler({
rotateEnabled,
drawingParam: { unitId, subUnitId, drawingId, drawingType },
setRotation,
emitUpdate: (updateParams) => drawingManagerService.featurePluginUpdateNotification(updateParams),
notifyChange: () => transformer.refreshControls().changeNotification(),
});
const handleLockRatioChange = (value: string | number | boolean) => {
if (typeof value !== 'boolean') {
return;
}
setLockRatio(value);
transformer.keepRatio = value;
};
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': !transformShow,
})}
>
<header
className="
univer-text-gray-600
dark:!univer-text-gray-200
"
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.title')}</div>
</header>
<div
className="
univer-grid univer-grid-cols-3 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
"
>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.width')}</span>
<MobileDrawingInputNumber precision={1} value={width} min={MIN_DRAWING_WIDTH_LIMIT} onChange={handleWidthChange} />
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.height')}</span>
<MobileDrawingInputNumber precision={1} value={height} min={MIN_DRAWING_HEIGHT_LIMIT} onChange={handleHeightChange} />
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.lock')}</span>
<div className="univer-text-center">
<MobileDrawingCheckbox checked={lockRatio} onChange={handleLockRatioChange} />
</div>
</div>
</div>
<div
className="
univer-grid univer-grid-cols-3 univer-gap-2
[&>div]:univer-grid [&>div]:univer-gap-2
"
>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.x')}</span>
<MobileDrawingInputNumber precision={1} value={xPosition} onChange={handleXChange} />
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.y')}</span>
<MobileDrawingInputNumber precision={1} value={yPosition} onChange={handleYChange} />
</div>
<div>
<span>{localeService.t<LocaleKey>('drawing-ui.image-panel.transform.rotate')}</span>
<MobileDrawingInputNumber
precision={1}
value={rotation}
min={RANGE_DRAWING_ROTATION_LIMIT[0]}
max={RANGE_DRAWING_ROTATION_LIMIT[1]}
disabled={rotationDisabled}
onChange={handleRotationChange}
/>
</div>
</div>
</div>
);
}
export function MobileDrawingAlign(props: IMobileDrawingAlignProps) {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const { drawings, alignShow } = props;
const [alignValue, setAlignValue] = useState<AlignType>(AlignType.default);
const alignOptions = [
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.default'),
value: AlignType.default,
},
{
options: [
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.left'), value: AlignType.left },
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.center'), value: AlignType.center },
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.right'), value: AlignType.right },
],
},
{
options: [
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.top'), value: AlignType.top },
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.middle'), value: AlignType.middle },
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.bottom'), value: AlignType.bottom },
],
},
{
options: [
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.horizon'), value: AlignType.horizon },
{ label: localeService.t<LocaleKey>('drawing-ui.image-panel.align.vertical'), value: AlignType.vertical },
],
},
];
const handleAlignChange = (value: string | number | boolean) => {
const alignType = Object.values(AlignType).find((option) => option === value);
if (alignType == null) {
return;
}
setAlignValue(alignType);
commandService.executeCommand(SetDrawingAlignOperation.id, { alignType, drawings });
};
return (
<div className={clsx('univer-relative univer-w-full', { 'univer-hidden': !alignShow })}>
<header
className="
univer-text-gray-600
dark:!univer-text-gray-200
"
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.align.title')}</div>
</header>
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div
className="
univer-w-full univer-text-gray-900
dark:!univer-text-gray-0
"
>
<MobileSelect value={alignValue} options={alignOptions} onChange={handleAlignChange} />
</div>
</div>
</div>
);
}
export function MobileDrawingGroup(props: IMobileDrawingGroupProps) {
const localeService = useDependency(LocaleService);
const renderManagerService = useDependency(IRenderManagerService);
const drawingManagerService = useDependency(IDrawingManagerService);
const commandService = useDependency(ICommandService);
const iconManager = useDependency(IconManager);
const { hasGroup, drawings } = props;
const GroupIcon = iconManager.get('GroupIcon');
const UngroupIcon = iconManager.get('UngroupIcon');
const drawingParam = drawings[0];
const transformer = drawingParam
? renderManagerService.getRenderUnitById(drawingParam.unitId)?.scene?.getTransformerByCreate()
: undefined;
const groupState = useObservable(
transformer
? () => merge(
transformer.clearControl$.pipe(
filter((changeSelf) => changeSelf === true),
map(() => ({ groupShow: false, groupBtnShow: false, ungroupBtnShow: false }))
),
transformer.changeStart$.pipe(map((state) => {
const params = getUpdateParams(state.objects, drawingManagerService);
const groupBtnShow = params.length > 1;
const ungroupBtnShow = params.some((item) => item?.drawingType === DrawingTypeEnum.DRAWING_GROUP);
return {
groupShow: groupBtnShow || ungroupBtnShow,
groupBtnShow,
ungroupBtnShow,
};
}))
)
: null,
{ groupShow: false, groupBtnShow: true, ungroupBtnShow: true },
false,
[drawingManagerService, transformer]
);
const { groupShow, groupBtnShow, ungroupBtnShow } = groupState;
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', {
'univer-hidden': (hasGroup && !groupShow) || !hasGroup,
})}
>
<header
className="
univer-text-gray-600
dark:!univer-text-gray-200
"
>
<div>{localeService.t<LocaleKey>('drawing-ui.image-panel.group.title')}</div>
</header>
<div className="univer-flex univer-items-center univer-justify-center univer-gap-2">
<MobileDrawingButton
className={clsx({ 'univer-hidden': !groupBtnShow })}
onClick={() => commandService.syncExecuteCommand(SetDrawingGroupOperation.id, { drawings })}
>
<GroupIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.group.group')}
</MobileDrawingButton>
<MobileDrawingButton
className={clsx({ 'univer-hidden': !ungroupBtnShow })}
onClick={() => commandService.syncExecuteCommand(CancelDrawingGroupOperation.id, { drawings })}
>
<UngroupIcon />
{localeService.t<LocaleKey>('drawing-ui.image-panel.group.unGroup')}
</MobileDrawingButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ReactNode } from 'react';
import type { CropType } from '../../commands/operations/image-crop.operation';
import type { LocaleKey } from '../../locale/types';
import type { IImageCropperProps } from './ImageCropper';
import { LocaleService } from '@univerjs/core';
import { MobileActionRow } from '@univerjs/design';
import { CropIcon } from '@univerjs/icons';
import { useDependency } from '@univerjs/ui';
import { useImageCropper } from './use-image-cropper';
interface IMobileImageCropperProps {
cropValue: CropType;
cropOptions: { label: string; value: CropType }[];
onCropChange: (value: CropType) => void;
onStartCrop: () => void;
children?: ReactNode;
}
function MobileImageCropperControls(props: IMobileImageCropperProps) {
const localeService = useDependency(LocaleService);
return (
<section className="univer-grid univer-gap-3 univer-py-3">
<h3 className="univer-m-0 univer-text-sm univer-font-medium">
{localeService.t<LocaleKey>('drawing-ui.image-panel.crop.title')}
</h3>
<div className="univer-grid univer-grid-cols-3 univer-gap-2">
{props.cropOptions.map((option) => (
<MobileActionRow
key={option.value}
title={option.label}
variant="subtle"
aria-pressed={props.cropValue === option.value}
className="
univer-px-2 univer-text-center
aria-pressed:!univer-bg-primary-50 aria-pressed:!univer-text-primary-600
"
onClick={() => props.onCropChange(option.value)}
/>
))}
</div>
<MobileActionRow
icon={<CropIcon />}
title={localeService.t<LocaleKey>('drawing-ui.image-panel.crop.start')}
className="!univer-bg-primary-600 !univer-text-white"
onClick={props.onStartCrop}
/>
{props.children}
</section>
);
}
export function MobileImageCropper(props: IImageCropperProps) {
const { drawingParam, cropperShow, cropValue, cropOptions, handleCropChange, onCropperBtnClick, ShapeClipPicker } = useImageCropper(props);
if (!drawingParam || !cropperShow) {
return null;
}
return (
<MobileImageCropperControls
cropValue={cropValue}
cropOptions={cropOptions}
onCropChange={handleCropChange}
onStartCrop={() => onCropperBtnClick(cropValue)}
>
{ShapeClipPicker && <ShapeClipPicker />}
</MobileImageCropperControls>
);
}

View File

@@ -0,0 +1,93 @@
/**
* 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 } from '@univerjs/core';
import type { Root } from 'react-dom/client';
import { DrawingTypeEnum, ICommandService, LocaleService, LocaleType, Univer } from '@univerjs/core';
import { ConfigProvider } from '@univerjs/design';
import { ComponentManager, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { AutoImageCropOperation, CloseImageCropOperation, CropType } from '../../../commands/operations/image-crop.operation';
import locale from '../../../locale/en-US';
import { DrawingImageClipService } from '../../../services/drawing-image-clip.service';
import { MobileImageCropper } from '../MobileImageCropper';
describe('ImageCropper mobile', () => {
let univer: Univer | undefined;
let root: Root | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
univer?.dispose();
});
it('selects ratios in place and starts cropping once before releasing the drawer', async () => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([DrawingImageClipService]);
injector.add([ComponentManager]);
const localeService = injector.get(LocaleService);
localeService.load({ [LocaleType.EN_US]: locale });
localeService.setLocale(LocaleType.EN_US);
const commandService = injector.get(ICommandService);
commandService.registerCommand(AutoImageCropOperation);
commandService.registerCommand(CloseImageCropOperation);
const commands: ICommandInfo[] = [];
commandService.onCommandExecuted((command) => commands.push(command));
let cropStarts = 0;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => root?.render(
<RediContext.Provider value={{ injector }}>
<ConfigProvider mountContainer={document.body}>
<MobileImageCropper
drawings={[{ unitId: 'doc', subUnitId: 'doc', drawingId: 'image', drawingType: DrawingTypeEnum.DRAWING_IMAGE }]}
cropperShow
onCropStart={() => { cropStarts++; }}
/>
</ConfigProvider>
</RediContext.Provider>
));
const buttons = Array.from(container.querySelectorAll('button'));
const ratio = buttons.find((button) => button.textContent === '16:9');
const start = buttons.find((button) => button.textContent === 'Start Crop');
if (!ratio || !start) {
throw new Error('Mobile crop actions are missing');
}
await act(async () => ratio.click());
expect(ratio.getAttribute('aria-pressed')).toBe('true');
expect(commands).toEqual([]);
expect(container.querySelector('[role="combobox"]')).toBeNull();
await act(async () => start.click());
expect(commands).toMatchObject([{ id: AutoImageCropOperation.id, params: { cropType: CropType.R16_9 } }]);
expect(cropStarts).toBe(1);
await act(async () => commandService.executeCommand(CloseImageCropOperation.id));
const square = buttons.find((button) => button.textContent === '1:1');
if (!square) {
throw new Error('Square crop option is missing');
}
await act(async () => square.click());
expect(commands.filter((command) => command.id === AutoImageCropOperation.id)).toHaveLength(1);
expect(square.getAttribute('aria-pressed')).toBe('true');
});
});

View File

@@ -0,0 +1,94 @@
/**
* 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 { describe, expect, it, vi } from 'vitest';
import { createDrawingTransformRotationChangeHandler, isDrawingTransformRotationDisabled } from '../drawing-transform-rotation';
const drawingParam = {
unitId: 'unit-1',
subUnitId: 'sheet-1',
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
};
describe('drawing transform rotation controls', () => {
it('disables rotation and drops direct handler calls when rotation is not enabled', () => {
const setRotation = vi.fn();
const emitUpdate = vi.fn();
const notifyChange = vi.fn();
const handleRotationChange = createDrawingTransformRotationChangeHandler({
rotateEnabled: false,
drawingParam,
setRotation,
emitUpdate,
notifyChange,
});
expect(isDrawingTransformRotationDisabled(false)).toBe(true);
handleRotationChange(45);
expect(setRotation).not.toHaveBeenCalled();
expect(emitUpdate).not.toHaveBeenCalled();
expect(notifyChange).not.toHaveBeenCalled();
});
it('emits angle updates when rotation is enabled', () => {
const setRotation = vi.fn();
const emitUpdate = vi.fn();
const notifyChange = vi.fn();
const handleRotationChange = createDrawingTransformRotationChangeHandler({
rotateEnabled: true,
drawingParam,
setRotation,
emitUpdate,
notifyChange,
});
expect(isDrawingTransformRotationDisabled(true)).toBe(false);
handleRotationChange(45);
expect(setRotation).toHaveBeenCalledWith(45);
expect(emitUpdate).toHaveBeenCalledWith([{
...drawingParam,
transform: {
angle: 45,
},
}]);
expect(notifyChange).toHaveBeenCalledTimes(1);
});
it('ignores null rotation updates even when rotation is enabled', () => {
const setRotation = vi.fn();
const emitUpdate = vi.fn();
const notifyChange = vi.fn();
const handleRotationChange = createDrawingTransformRotationChangeHandler({
rotateEnabled: true,
drawingParam,
setRotation,
emitUpdate,
notifyChange,
});
handleRotationChange(null);
expect(setRotation).not.toHaveBeenCalled();
expect(emitUpdate).not.toHaveBeenCalled();
expect(notifyChange).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,21 @@
/**
* 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 DRAWING_ARRANGE_COMPONENT = 'drawing-ui.arrange';
export const IMAGE_CROPPER_COMPONENT = 'drawing-ui.cropper';
export const DRAWING_TRANSFORM_COMPONENT = 'drawing-ui.transform';
export const DRAWING_ALIGN_COMPONENT = 'drawing-ui.align';
export const DRAWING_GROUP_COMPONENT = 'drawing-ui.group';

View File

@@ -0,0 +1,48 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDrawingParam } from '@univerjs/core';
interface IDrawingTransformRotationChangeHandlerOptions {
rotateEnabled: boolean;
drawingParam: Pick<IDrawingParam, 'unitId' | 'subUnitId' | 'drawingId' | 'drawingType'>;
setRotation: (rotation: number) => void;
emitUpdate: (updateParams: IDrawingParam[]) => void;
notifyChange: () => void;
}
export function isDrawingTransformRotationDisabled(rotateEnabled: boolean): boolean {
return !rotateEnabled;
}
export function createDrawingTransformRotationChangeHandler(options: IDrawingTransformRotationChangeHandlerOptions) {
return (val: number | null) => {
if (isDrawingTransformRotationDisabled(options.rotateEnabled)) {
return;
}
if (val == null) {
return;
}
const { unitId, subUnitId, drawingId, drawingType } = options.drawingParam;
const updateParam: IDrawingParam = { unitId, subUnitId, drawingId, drawingType, transform: { angle: val } };
options.setRotation(val);
options.emitUpdate([updateParam]);
options.notifyChange();
};
}

View File

@@ -0,0 +1,127 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommandInfo } from '@univerjs/core';
import type { ICloseImageCropOperationParams } from '../../commands/operations/image-crop.operation';
import type { LocaleKey } from '../../locale/types';
import type { IImageCropperProps } from './ImageCropper';
import { ICommandService, LocaleService } from '@univerjs/core';
import { ComponentManager, useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useRef, useState } from 'react';
import {
AutoImageCropOperation,
CloseImageCropOperation,
CropType,
} from '../../commands/operations/image-crop.operation';
import { DrawingImageClipService, IMAGE_CLIP_SHAPE_PICKER_COMPONENT } from '../../services/drawing-image-clip.service';
export function useImageCropper(props: IImageCropperProps) {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const clipService = useDependency(DrawingImageClipService);
const componentManager = useDependency(ComponentManager);
const canUseShapeClip = useObservable(clipService.canUseShapeClip$, false);
const { drawings, cropperShow } = props;
const drawingParam = drawings[0];
const [cropValue, setCropValue] = useState(CropType.FREE);
const cropStateRef = useRef(false);
const cropOptions = [
{
label: localeService.t<LocaleKey>('drawing-ui.image-panel.crop.mode'),
value: CropType.FREE,
},
{
label: '1:1',
value: CropType.R1_1,
},
{
label: '16:9',
value: CropType.R16_9,
},
{
label: '9:16',
value: CropType.R9_16,
},
{
label: '5:4',
value: CropType.R5_4,
},
{
label: '4:5',
value: CropType.R4_5,
},
{
label: '4:3',
value: CropType.R4_3,
},
{
label: '3:4',
value: CropType.R3_4,
},
{
label: '3:2',
value: CropType.R3_2,
},
{
label: '2:3',
value: CropType.R2_3,
},
];
useEffect(() => {
const onChangeStartObserver = commandService.onCommandExecuted((command: ICommandInfo<ICloseImageCropOperationParams>) => {
if (command.id === CloseImageCropOperation.id) {
const params = command.params;
if (!params?.isAuto) {
cropStateRef.current = false;
}
}
});
return () => {
onChangeStartObserver?.dispose();
};
}, [commandService]);
function handleCropChange(value: string | number | boolean) {
const cropType = cropOptions.find((option) => option.value === value)?.value;
if (cropType == null) {
return;
}
setCropValue(cropType);
if (cropStateRef.current) {
commandService.executeCommand(AutoImageCropOperation.id, {
cropType,
});
}
}
const onCropperBtnClick = async (val: CropType) => {
await commandService.executeCommand(AutoImageCropOperation.id, {
cropType: val,
});
cropStateRef.current = true;
props.onCropStart?.();
};
const ShapeClipPicker = canUseShapeClip ? componentManager.get(IMAGE_CLIP_SHAPE_PICKER_COMPONENT) : undefined;
return { drawingParam, cropperShow, localeService, cropValue, cropOptions, handleCropChange, onCropperBtnClick, ShapeClipPicker };
}

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 type { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

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