snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
41
packages/drawing-ui/README.md
Normal file
41
packages/drawing-ui/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @univerjs/drawing-ui
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/drawing-ui)
|
||||
[](https://npmjs.com/package/@univerjs/drawing-ui)
|
||||
[](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)
|
||||
93
packages/drawing-ui/package.json
Normal file
93
packages/drawing-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/drawing-ui/postcss.config.mjs
Normal file
1
packages/drawing-ui/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@univerjs-infra/shared/postcss';
|
||||
@@ -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,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
27
packages/drawing-ui/src/config/config.ts
Normal file
27
packages/drawing-ui/src/config/config.ts
Normal 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 = {};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
311
packages/drawing-ui/src/controllers/__tests__/utils.spec.ts
Normal file
311
packages/drawing-ui/src/controllers/__tests__/utils.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
80
packages/drawing-ui/src/controllers/components.controller.ts
Normal file
80
packages/drawing-ui/src/controllers/components.controller.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
934
packages/drawing-ui/src/controllers/drawing-update.controller.ts
Normal file
934
packages/drawing-ui/src/controllers/drawing-update.controller.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
525
packages/drawing-ui/src/controllers/image-cropper.controller.ts
Normal file
525
packages/drawing-ui/src/controllers/image-cropper.controller.ts
Normal 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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
298
packages/drawing-ui/src/controllers/image-update.controller.ts
Normal file
298
packages/drawing-ui/src/controllers/image-update.controller.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
92
packages/drawing-ui/src/controllers/mobile/ui.controller.ts
Normal file
92
packages/drawing-ui/src/controllers/mobile/ui.controller.ts
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
85
packages/drawing-ui/src/controllers/ui.controller.ts
Normal file
85
packages/drawing-ui/src/controllers/ui.controller.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
145
packages/drawing-ui/src/controllers/utils.ts
Normal file
145
packages/drawing-ui/src/controllers/utils.ts
Normal 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 };
|
||||
}
|
||||
2
packages/drawing-ui/src/global.css
Normal file
2
packages/drawing-ui/src/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
92
packages/drawing-ui/src/index.ts
Normal file
92
packages/drawing-ui/src/index.ts
Normal 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';
|
||||
127
packages/drawing-ui/src/locale/ar-SA.ts
Normal file
127
packages/drawing-ui/src/locale/ar-SA.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/ca-ES.ts
Normal file
127
packages/drawing-ui/src/locale/ca-ES.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/de-DE.ts
Normal file
127
packages/drawing-ui/src/locale/de-DE.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
125
packages/drawing-ui/src/locale/en-US.ts
Normal file
125
packages/drawing-ui/src/locale/en-US.ts
Normal 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;
|
||||
127
packages/drawing-ui/src/locale/es-ES.ts
Normal file
127
packages/drawing-ui/src/locale/es-ES.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/fa-IR.ts
Normal file
127
packages/drawing-ui/src/locale/fa-IR.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/fr-FR.ts
Normal file
127
packages/drawing-ui/src/locale/fr-FR.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/id-ID.ts
Normal file
127
packages/drawing-ui/src/locale/id-ID.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/it-IT.ts
Normal file
127
packages/drawing-ui/src/locale/it-IT.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/ja-JP.ts
Normal file
127
packages/drawing-ui/src/locale/ja-JP.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/ko-KR.ts
Normal file
127
packages/drawing-ui/src/locale/ko-KR.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/pl-PL.ts
Normal file
127
packages/drawing-ui/src/locale/pl-PL.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/pt-BR.ts
Normal file
127
packages/drawing-ui/src/locale/pt-BR.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/ru-RU.ts
Normal file
127
packages/drawing-ui/src/locale/ru-RU.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/sk-SK.ts
Normal file
127
packages/drawing-ui/src/locale/sk-SK.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
20
packages/drawing-ui/src/locale/types.ts
Normal file
20
packages/drawing-ui/src/locale/types.ts
Normal 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>;
|
||||
127
packages/drawing-ui/src/locale/vi-VN.ts
Normal file
127
packages/drawing-ui/src/locale/vi-VN.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/zh-CN.ts
Normal file
127
packages/drawing-ui/src/locale/zh-CN.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/zh-HK.ts
Normal file
127
packages/drawing-ui/src/locale/zh-HK.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
127
packages/drawing-ui/src/locale/zh-TW.ts
Normal file
127
packages/drawing-ui/src/locale/zh-TW.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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;
|
||||
145
packages/drawing-ui/src/menu/align.menu.ts
Normal file
145
packages/drawing-ui/src/menu/align.menu.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
80
packages/drawing-ui/src/menu/arrange.menu.ts
Normal file
80
packages/drawing-ui/src/menu/arrange.menu.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
110
packages/drawing-ui/src/menu/group.menu.ts
Normal file
110
packages/drawing-ui/src/menu/group.menu.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
136
packages/drawing-ui/src/menu/schema.ts
Normal file
136
packages/drawing-ui/src/menu/schema.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
77
packages/drawing-ui/src/mobile-plugin.ts
Normal file
77
packages/drawing-ui/src/mobile-plugin.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
82
packages/drawing-ui/src/plugin.ts
Normal file
82
packages/drawing-ui/src/plugin.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector } from '@univerjs/core';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DrawingImageClipService } from '../drawing-image-clip.service';
|
||||
|
||||
describe('DrawingImageClipService', () => {
|
||||
let service: DrawingImageClipService;
|
||||
|
||||
beforeEach(() => {
|
||||
const injector = new Injector();
|
||||
injector.add([DrawingImageClipService]);
|
||||
service = injector.get(DrawingImageClipService);
|
||||
});
|
||||
|
||||
it('delegates image clipping to the registered shape engine', () => {
|
||||
const bounds = { left: 0, top: 0, width: 20, height: 10 };
|
||||
const delegate = vi.fn(() => bounds);
|
||||
const disposable = service.registerClipDelegate(delegate);
|
||||
|
||||
expect(service.applyShapeClip({} as never, 'rect', 20, 10, { adj: 1 })).toBe(bounds);
|
||||
expect(delegate).toHaveBeenCalledWith({}, 'rect', 20, 10, { adj: 1 });
|
||||
|
||||
disposable.dispose();
|
||||
expect(service.applyShapeClip({} as never, 'rect', 20, 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('publishes whether shape clipping is available in the current runtime', () => {
|
||||
const values: boolean[] = [];
|
||||
service.canUseShapeClip$.subscribe((value) => values.push(value));
|
||||
|
||||
service.setCanUseShapeClip(true);
|
||||
|
||||
expect(values).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('clears registered clipping behavior when disposed', () => {
|
||||
const delegate = vi.fn(() => ({ left: 0, top: 0, width: 20, height: 10 }));
|
||||
service.registerClipDelegate(delegate);
|
||||
|
||||
service.dispose();
|
||||
|
||||
expect(service.applyShapeClip({} as never, 'rect', 20, 10)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDrawingSearch } from '@univerjs/core';
|
||||
import type { IGalleryProps } from '@univerjs/design';
|
||||
import type { IDocFloatDomData, IImageData } from '@univerjs/drawing';
|
||||
import type { BaseObject, Scene } from '@univerjs/engine-render';
|
||||
import {
|
||||
BooleanNumber,
|
||||
DrawingTypeEnum,
|
||||
IImageIoService,
|
||||
ImageSourceType,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
IURLImageService,
|
||||
PositionedObjectLayoutType,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import {
|
||||
getDrawingShapeKeyByDrawingSearch,
|
||||
IDrawingManagerService,
|
||||
} from '@univerjs/drawing';
|
||||
import { DRAWING_OBJECT_LAYER_INDEX, Image, Rect } from '@univerjs/engine-render';
|
||||
import { IGalleryService } from '@univerjs/ui';
|
||||
import { Subject } from 'rxjs';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DrawingImageClipService } from '../drawing-image-clip.service';
|
||||
import { DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX, DrawingRenderService } from '../drawing-render.service';
|
||||
|
||||
const BASE_TRANSFORM = {
|
||||
left: 10,
|
||||
top: 20,
|
||||
width: 30,
|
||||
height: 40,
|
||||
angle: 0,
|
||||
flipX: false,
|
||||
flipY: false,
|
||||
skewX: 0,
|
||||
skewY: 0,
|
||||
};
|
||||
|
||||
const SECOND_TRANSFORM = {
|
||||
left: 50,
|
||||
top: 60,
|
||||
width: 70,
|
||||
height: 80,
|
||||
angle: 15,
|
||||
flipX: true,
|
||||
flipY: false,
|
||||
skewX: 1,
|
||||
skewY: 2,
|
||||
};
|
||||
|
||||
function imageParam(overrides: Partial<IImageData> = {}): IImageData {
|
||||
return {
|
||||
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
|
||||
drawingId: 'drawing-1',
|
||||
unitId: 'book-1',
|
||||
subUnitId: 'sheet-1',
|
||||
source: 'image-source',
|
||||
imageSourceType: ImageSourceType.BASE64,
|
||||
transform: BASE_TRANSFORM,
|
||||
...overrides,
|
||||
} as IImageData;
|
||||
}
|
||||
|
||||
function floatDomParam(overrides: Partial<IDocFloatDomData> = {}): IDocFloatDomData {
|
||||
return {
|
||||
drawingType: DrawingTypeEnum.DRAWING_DOM,
|
||||
drawingId: 'float-1',
|
||||
unitId: 'doc-1',
|
||||
subUnitId: 'doc-1',
|
||||
transform: BASE_TRANSFORM,
|
||||
...overrides,
|
||||
} as IDocFloatDomData;
|
||||
}
|
||||
|
||||
function drawingKey(param: IDrawingSearch, index?: number) {
|
||||
return getDrawingShapeKeyByDrawingSearch(param, index);
|
||||
}
|
||||
|
||||
function cacheKey(source: string, imageSourceType: ImageSourceType) {
|
||||
return `${source}:${imageSourceType}`;
|
||||
}
|
||||
|
||||
class TestDrawingManagerService {
|
||||
visible = true;
|
||||
editable = true;
|
||||
orders = ['drawing-1'];
|
||||
visibleReads: boolean[] = [];
|
||||
readonly drawings = new Map<string, unknown>();
|
||||
|
||||
getDrawingVisible() {
|
||||
return this.visibleReads.length > 0 ? this.visibleReads.shift()! : this.visible;
|
||||
}
|
||||
|
||||
getDrawingEditable() {
|
||||
return this.editable;
|
||||
}
|
||||
|
||||
getDrawingOrder() {
|
||||
return this.orders;
|
||||
}
|
||||
|
||||
getDrawingByParam(param: IDrawingSearch | null | undefined) {
|
||||
if (param == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.drawings.get(drawingKey(param)) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class TestImageIoService {
|
||||
imageUrl = 'resolved-image-url';
|
||||
imageError: unknown;
|
||||
beforeResolveImage: (() => void) | undefined;
|
||||
readonly imageRequests: string[] = [];
|
||||
readonly cache = new Map<string, HTMLImageElement>();
|
||||
readonly cacheWrites: Array<{ source: string; imageSourceType: ImageSourceType; image: HTMLImageElement | null }> = [];
|
||||
readonly change$ = new Subject<number>();
|
||||
|
||||
setWaitCount() {
|
||||
// not used by DrawingRenderService
|
||||
}
|
||||
|
||||
async saveImage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getImage(imageId: string) {
|
||||
this.imageRequests.push(imageId);
|
||||
this.beforeResolveImage?.();
|
||||
if (this.imageError) {
|
||||
throw this.imageError;
|
||||
}
|
||||
return this.imageUrl;
|
||||
}
|
||||
|
||||
getImageSourceCache(source: string, imageSourceType: ImageSourceType) {
|
||||
return this.cache.get(cacheKey(source, imageSourceType)) ?? null;
|
||||
}
|
||||
|
||||
addImageSourceCache(source: string, imageSourceType: ImageSourceType, image: HTMLImageElement | null) {
|
||||
this.cacheWrites.push({ source, imageSourceType, image });
|
||||
if (image) {
|
||||
this.cache.set(cacheKey(source, imageSourceType), image);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TestUrlImageService {
|
||||
imageUrl = 'downloaded-image-url';
|
||||
imageError: unknown;
|
||||
readonly requests: string[] = [];
|
||||
|
||||
async getImage(url: string) {
|
||||
this.requests.push(url);
|
||||
if (this.imageError) {
|
||||
throw this.imageError;
|
||||
}
|
||||
return this.imageUrl;
|
||||
}
|
||||
|
||||
async downloadImage() {
|
||||
return new Blob();
|
||||
}
|
||||
|
||||
registerURLImageDownloader() {
|
||||
return toDisposable(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
class TestGalleryService {
|
||||
gallery$ = new Subject<IGalleryProps>();
|
||||
lastOpen: IGalleryProps | undefined;
|
||||
closeCount = 0;
|
||||
|
||||
open(params: IGalleryProps) {
|
||||
this.lastOpen = params;
|
||||
return toDisposable(() => {});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
unitType = UniverInstanceType.UNIVER_DOC;
|
||||
activeSheetId = 'sheet-1';
|
||||
|
||||
getUnitType() {
|
||||
return this.unitType;
|
||||
}
|
||||
|
||||
getCurrentUnitOfType() {
|
||||
return {
|
||||
getActiveSheet: () => ({
|
||||
getSheetId: () => this.activeSheetId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface IRecordedScene {
|
||||
readonly objects: Map<string, BaseObject | ITransformRecorder>;
|
||||
readonly addedObjects: Array<{ object: BaseObject; layerIndex: number }>;
|
||||
readonly transformerObjects: BaseObject[];
|
||||
readonly getObjectKeys: string[];
|
||||
getObject(key: string): BaseObject | ITransformRecorder | null;
|
||||
getObjectIncludeInGroup(key: string): BaseObject | ITransformRecorder | null;
|
||||
addObject(object: BaseObject, layerIndex: number): Scene;
|
||||
attachTransformerTo(object: BaseObject): Scene;
|
||||
}
|
||||
|
||||
interface ITransformRecorder {
|
||||
readonly oKey: string;
|
||||
readonly transformCalls: unknown[];
|
||||
transformByState(state: unknown): void;
|
||||
}
|
||||
|
||||
function createTransformRecorder(oKey: string): ITransformRecorder {
|
||||
return {
|
||||
oKey,
|
||||
transformCalls: [],
|
||||
transformByState(state: unknown) {
|
||||
this.transformCalls.push(state);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createScene(): IRecordedScene {
|
||||
const scene: IRecordedScene = {
|
||||
objects: new Map(),
|
||||
addedObjects: [],
|
||||
transformerObjects: [],
|
||||
getObjectKeys: [],
|
||||
getObject(key: string) {
|
||||
this.getObjectKeys.push(key);
|
||||
return this.objects.get(key) ?? null;
|
||||
},
|
||||
getObjectIncludeInGroup(key: string) {
|
||||
return this.objects.get(key) ?? null;
|
||||
},
|
||||
addObject(object: BaseObject, layerIndex: number) {
|
||||
this.objects.set(object.oKey, object);
|
||||
this.addedObjects.push({ object, layerIndex });
|
||||
return this as unknown as Scene;
|
||||
},
|
||||
attachTransformerTo(object: BaseObject) {
|
||||
this.transformerObjects.push(object);
|
||||
return this as unknown as Scene;
|
||||
},
|
||||
};
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const injector = new Injector();
|
||||
|
||||
injector.add([IDrawingManagerService, { useClass: TestDrawingManagerService as never }]);
|
||||
injector.add([IImageIoService, { useClass: TestImageIoService as never }]);
|
||||
injector.add([IGalleryService, { useClass: TestGalleryService as never }]);
|
||||
injector.add([IURLImageService, { useClass: TestUrlImageService as never }]);
|
||||
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
|
||||
injector.add([DrawingImageClipService]);
|
||||
injector.add([DrawingRenderService]);
|
||||
|
||||
return {
|
||||
service: injector.get(DrawingRenderService),
|
||||
drawingManagerService: injector.get(IDrawingManagerService) as unknown as TestDrawingManagerService,
|
||||
imageIoService: injector.get(IImageIoService) as unknown as TestImageIoService,
|
||||
urlImageService: injector.get(IURLImageService) as unknown as TestUrlImageService,
|
||||
galleryService: injector.get(IGalleryService) as unknown as TestGalleryService,
|
||||
univerInstanceService: injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService,
|
||||
drawingImageClipService: injector.get(DrawingImageClipService),
|
||||
scene: createScene(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DrawingRenderService', () => {
|
||||
it('skips image rendering when the drawing is not renderable in the current context', async () => {
|
||||
const hiddenCase = createHarness();
|
||||
hiddenCase.drawingManagerService.visible = false;
|
||||
expect(await hiddenCase.service.renderImages(imageParam(), hiddenCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(hiddenCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const nonImageCase = createHarness();
|
||||
expect(await nonImageCase.service.renderImages(imageParam({ drawingType: DrawingTypeEnum.DRAWING_SHAPE }), nonImageCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(nonImageCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const inactiveSheetCase = createHarness();
|
||||
inactiveSheetCase.univerInstanceService.unitType = UniverInstanceType.UNIVER_SHEET;
|
||||
inactiveSheetCase.univerInstanceService.activeSheetId = 'sheet-2';
|
||||
expect(await inactiveSheetCase.service.renderImages(imageParam(), inactiveSheetCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(inactiveSheetCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const inactivePrintingSheetCase = createHarness();
|
||||
inactivePrintingSheetCase.univerInstanceService.unitType = UniverInstanceType.UNIVER_SHEET;
|
||||
inactivePrintingSheetCase.univerInstanceService.activeSheetId = 'sheet-2';
|
||||
expect(await inactivePrintingSheetCase.service.renderImages(
|
||||
imageParam(),
|
||||
inactivePrintingSheetCase.scene as unknown as Scene,
|
||||
{ allowInactiveSheet: true }
|
||||
)).toHaveLength(1);
|
||||
|
||||
const noTransformCase = createHarness();
|
||||
expect(await noTransformCase.service.renderImages(imageParam({ transform: null }), noTransformCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(noTransformCase.scene.getObjectKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds a base64 image with drawing metadata, cache, and transformer state', async () => {
|
||||
const { service, scene, drawingManagerService, imageIoService, drawingImageClipService } = createHarness();
|
||||
drawingManagerService.orders = ['background', 'drawing-1'];
|
||||
const srcRect = { left: 0.1, top: 0.2, right: 0.3, bottom: 0.4 };
|
||||
|
||||
const result = await service.renderImages(imageParam({
|
||||
hidden: true,
|
||||
prstGeom: 'roundRect',
|
||||
adjustValues: { adj: 0.25 },
|
||||
srcRect,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
const key = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' });
|
||||
const image = scene.objects.get(key) as Image;
|
||||
|
||||
expect(result).toEqual([image]);
|
||||
expect(image).toBeInstanceOf(Image);
|
||||
expect(image.left).toBe(10);
|
||||
expect(image.top).toBe(20);
|
||||
expect(image.width).toBe(30);
|
||||
expect(image.height).toBe(40);
|
||||
expect(image.zIndex).toBe(1);
|
||||
expect(image.visible).toBe(false);
|
||||
expect(image.printable).toBe(true);
|
||||
expect(image.getProps().url).toBe('image-source');
|
||||
expect(image.getClipService()).toBe(drawingImageClipService);
|
||||
expect(image.prstGeom).toBe('roundRect');
|
||||
expect(image.prstGeomAdjValues).toEqual({ adj: 0.25 });
|
||||
expect(image.srcRect).toEqual(srcRect);
|
||||
expect(scene.addedObjects.map(({ object, layerIndex }) => [object.oKey, layerIndex])).toEqual([[key, DRAWING_OBJECT_LAYER_INDEX]]);
|
||||
expect(scene.transformerObjects).toEqual([image]);
|
||||
expect(imageIoService.cacheWrites).toEqual([{ source: 'image-source', imageSourceType: ImageSourceType.BASE64, image: image.getNative() }]);
|
||||
});
|
||||
|
||||
it('syncs hidden state when an existing image shape is refreshed', async () => {
|
||||
const { service, scene } = createHarness();
|
||||
const key = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' });
|
||||
const image = new Image(key, { ...BASE_TRANSFORM, url: 'existing-image' });
|
||||
scene.objects.set(key, image);
|
||||
|
||||
await service.renderImages(imageParam({ hidden: true, transform: SECOND_TRANSFORM }), scene as unknown as Scene);
|
||||
|
||||
expect(image.visible).toBe(false);
|
||||
expect(image.left).toBe(50);
|
||||
expect(image.top).toBe(60);
|
||||
expect(image.width).toBe(70);
|
||||
expect(image.height).toBe(80);
|
||||
|
||||
await service.renderImages(imageParam({ hidden: false, transform: BASE_TRANSFORM }), scene as unknown as Scene);
|
||||
|
||||
expect(image.visible).toBe(true);
|
||||
});
|
||||
|
||||
it('uses cached native images instead of resolving the source again', async () => {
|
||||
const { service, scene, imageIoService } = createHarness();
|
||||
const nativeImage = document.createElement('img');
|
||||
Object.defineProperty(nativeImage, 'complete', { value: true });
|
||||
Object.defineProperty(nativeImage, 'naturalWidth', { value: 1 });
|
||||
Object.defineProperty(nativeImage, 'naturalHeight', { value: 1 });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), nativeImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(image.getNative()).toBe(nativeImage);
|
||||
expect(imageIoService.imageRequests).toEqual([]);
|
||||
expect(imageIoService.cacheWrites).toEqual([]);
|
||||
});
|
||||
|
||||
it('loads stored and URL images through their respective services', async () => {
|
||||
const uuidCase = createHarness();
|
||||
uuidCase.imageIoService.imageUrl = 'stored-image-url';
|
||||
await uuidCase.service.renderImages(imageParam({
|
||||
source: 'stored-image-id',
|
||||
imageSourceType: ImageSourceType.UUID,
|
||||
}), uuidCase.scene as unknown as Scene);
|
||||
const storedImage = uuidCase.scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(storedImage.getProps().url).toBe('stored-image-url');
|
||||
expect(uuidCase.imageIoService.imageRequests).toEqual(['stored-image-id']);
|
||||
expect(uuidCase.imageIoService.cacheWrites).toEqual([]);
|
||||
|
||||
const urlCase = createHarness();
|
||||
urlCase.urlImageService.imageUrl = 'downloaded-url-image';
|
||||
await urlCase.service.renderImages(imageParam({
|
||||
source: 'https://example.test/image.png',
|
||||
imageSourceType: ImageSourceType.URL,
|
||||
}), urlCase.scene as unknown as Scene);
|
||||
const urlImage = urlCase.scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(urlImage.getProps().url).toBe('downloaded-url-image');
|
||||
expect(urlCase.urlImageService.requests).toEqual(['https://example.test/image.png']);
|
||||
expect(urlCase.imageIoService.cacheWrites).toEqual([{ source: 'https://example.test/image.png', imageSourceType: ImageSourceType.URL, image: urlImage.getNative() }]);
|
||||
});
|
||||
|
||||
it('keeps URL drawings visible with their original source when downloading fails', async () => {
|
||||
const { service, scene, urlImageService } = createHarness();
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
urlImageService.imageError = new Error('network unavailable');
|
||||
|
||||
await service.renderImages(imageParam({
|
||||
source: 'https://example.test/offline.png',
|
||||
imageSourceType: ImageSourceType.URL,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
const image = scene.objects.get(drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' })) as Image;
|
||||
expect(image.getProps().url).toBe('https://example.test/offline.png');
|
||||
expect(errorSpy).toHaveBeenCalledWith(urlImageService.imageError);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not duplicate an image that appears while its source is loading', async () => {
|
||||
const { service, scene, imageIoService } = createHarness();
|
||||
const key = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' });
|
||||
const existingObject = createTransformRecorder(key);
|
||||
imageIoService.beforeResolveImage = () => scene.objects.set(key, existingObject);
|
||||
|
||||
const result = await service.renderImages(imageParam({
|
||||
source: 'stored-image-id',
|
||||
imageSourceType: ImageSourceType.UUID,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(scene.addedObjects).toEqual([]);
|
||||
expect(scene.objects.get(key)).toBe(existingObject);
|
||||
});
|
||||
|
||||
it('updates existing image objects for each transform instead of recreating them', async () => {
|
||||
const { service, scene } = createHarness();
|
||||
const firstKey = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' }, 0);
|
||||
const secondKey = drawingKey({ unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' }, 1);
|
||||
const firstObject = createTransformRecorder(firstKey);
|
||||
const secondObject = createTransformRecorder(secondKey);
|
||||
scene.objects.set(firstKey, firstObject);
|
||||
scene.objects.set(secondKey, secondObject);
|
||||
|
||||
const result = await service.renderImages(imageParam({
|
||||
isMultiTransform: 1,
|
||||
transforms: [BASE_TRANSFORM, SECOND_TRANSFORM],
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(firstObject.transformCalls).toEqual([BASE_TRANSFORM]);
|
||||
expect(secondObject.transformCalls).toEqual([SECOND_TRANSFORM]);
|
||||
expect(scene.addedObjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips float DOM rendering when the drawing cannot produce a scene object', () => {
|
||||
const hiddenCase = createHarness();
|
||||
hiddenCase.drawingManagerService.visible = false;
|
||||
expect(hiddenCase.service.renderFloatDom(floatDomParam(), hiddenCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(hiddenCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const nonDomCase = createHarness();
|
||||
expect(nonDomCase.service.renderFloatDom(floatDomParam({ drawingType: DrawingTypeEnum.DRAWING_IMAGE }), nonDomCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(nonDomCase.scene.getObjectKeys).toEqual([]);
|
||||
|
||||
const noTransformCase = createHarness();
|
||||
expect(noTransformCase.service.renderFloatDom(floatDomParam({ transform: null }), noTransformCase.scene as unknown as Scene)).toBeUndefined();
|
||||
expect(noTransformCase.scene.getObjectKeys).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds float DOM placeholders for every transform and respects transform permissions', () => {
|
||||
const { service, scene, drawingManagerService } = createHarness();
|
||||
drawingManagerService.orders = ['float-1', 'later'];
|
||||
|
||||
const result = service.renderFloatDom(floatDomParam({
|
||||
isMultiTransform: 1,
|
||||
transforms: [BASE_TRANSFORM, SECOND_TRANSFORM],
|
||||
allowTransform: false,
|
||||
}), scene as unknown as Scene);
|
||||
|
||||
const firstKey = drawingKey({ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'float-1' }, 0);
|
||||
const secondKey = drawingKey({ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'float-1' }, 1);
|
||||
const firstRect = scene.objects.get(firstKey) as Rect;
|
||||
const secondRect = scene.objects.get(secondKey) as Rect;
|
||||
|
||||
expect(result).toEqual([firstRect, secondRect]);
|
||||
expect(firstRect).toBeInstanceOf(Rect);
|
||||
expect(firstRect.left).toBe(10);
|
||||
expect(firstRect.top).toBe(20);
|
||||
expect(firstRect.zIndex).toBe(0);
|
||||
expect(firstRect.printable).toBe(false);
|
||||
expect(secondRect.left).toBe(50);
|
||||
expect(secondRect.top).toBe(60);
|
||||
expect(scene.addedObjects.map(({ object, layerIndex }) => [object.oKey, layerIndex])).toEqual([
|
||||
[firstKey, DRAWING_OBJECT_LAYER_INDEX],
|
||||
[secondKey, DRAWING_OBJECT_LAYER_INDEX],
|
||||
]);
|
||||
expect(scene.transformerObjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('updates existing float DOM placeholders and does not add stale placeholders after visibility changes', () => {
|
||||
const updateCase = createHarness();
|
||||
const key = drawingKey({ unitId: 'doc-1', subUnitId: 'doc-1', drawingId: 'float-1' });
|
||||
const existingObject = createTransformRecorder(key);
|
||||
updateCase.scene.objects.set(key, existingObject);
|
||||
|
||||
const updateResult = updateCase.service.renderFloatDom(floatDomParam(), updateCase.scene as unknown as Scene);
|
||||
|
||||
expect(updateResult).toEqual([]);
|
||||
expect(existingObject.transformCalls).toEqual([BASE_TRANSFORM]);
|
||||
expect(updateCase.scene.addedObjects).toEqual([]);
|
||||
|
||||
const staleCase = createHarness();
|
||||
staleCase.drawingManagerService.visibleReads = [true, false];
|
||||
expect(staleCase.service.renderFloatDom(floatDomParam(), staleCase.scene as unknown as Scene)).toEqual([]);
|
||||
expect(staleCase.scene.addedObjects).toEqual([]);
|
||||
});
|
||||
|
||||
it('renders drawings fetched from the drawing manager and ignores unsupported records', async () => {
|
||||
const imageCase = createHarness();
|
||||
const search = { unitId: 'book-1', subUnitId: 'sheet-1', drawingId: 'drawing-1' };
|
||||
imageCase.drawingManagerService.drawings.set(drawingKey(search), imageParam());
|
||||
|
||||
const imageResult = await imageCase.service.renderDrawing(search, imageCase.scene as unknown as Scene);
|
||||
|
||||
expect(imageResult).toEqual([imageCase.scene.objects.get(drawingKey(search))]);
|
||||
|
||||
const missingCase = createHarness();
|
||||
expect(missingCase.service.renderDrawing(search, missingCase.scene as unknown as Scene)).toBeUndefined();
|
||||
|
||||
const unsupportedCase = createHarness();
|
||||
unsupportedCase.drawingManagerService.drawings.set(drawingKey(search), { ...imageParam(), drawingType: DrawingTypeEnum.DRAWING_SHAPE });
|
||||
expect(unsupportedCase.service.renderDrawing(search, unsupportedCase.scene as unknown as Scene)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('opens preview images in the gallery and closes the gallery when the preview is dismissed', () => {
|
||||
const { service, galleryService } = createHarness();
|
||||
|
||||
service.previewImage('drawing-1', 'preview-src', 640, 480);
|
||||
galleryService.lastOpen?.onOpenChange?.(true);
|
||||
galleryService.lastOpen?.onOpenChange?.(false);
|
||||
|
||||
expect(galleryService.lastOpen?.images).toEqual(['preview-src']);
|
||||
expect(galleryService.closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('renders incomplete cached images through url loading so onload can repaint', async () => {
|
||||
const { imageIoService, scene, service } = createHarness();
|
||||
const incompleteImage = document.createElement('img');
|
||||
Object.defineProperty(incompleteImage, 'complete', { value: false });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), incompleteImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.addedObjects[0]?.object as Image;
|
||||
expect(image.getNative()).not.toBe(incompleteImage);
|
||||
expect(imageIoService.cacheWrites).toEqual([{ source: 'image-source', imageSourceType: ImageSourceType.BASE64, image: image.getNative() }]);
|
||||
});
|
||||
|
||||
it('uses completed cached images directly', async () => {
|
||||
const { imageIoService, scene, service } = createHarness();
|
||||
const completeImage = document.createElement('img');
|
||||
Object.defineProperty(completeImage, 'complete', { value: true });
|
||||
Object.defineProperty(completeImage, 'naturalWidth', { value: 1 });
|
||||
Object.defineProperty(completeImage, 'naturalHeight', { value: 1 });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), completeImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.addedObjects[0]?.object as Image;
|
||||
expect(image.getNative()).toBe(completeImage);
|
||||
expect(imageIoService.cacheWrites).toEqual([]);
|
||||
});
|
||||
|
||||
it('reloads completed cached images without natural dimensions', async () => {
|
||||
const { imageIoService, scene, service } = createHarness();
|
||||
const brokenCompleteImage = document.createElement('img');
|
||||
Object.defineProperty(brokenCompleteImage, 'complete', { value: true });
|
||||
Object.defineProperty(brokenCompleteImage, 'naturalWidth', { value: 0 });
|
||||
Object.defineProperty(brokenCompleteImage, 'naturalHeight', { value: 0 });
|
||||
imageIoService.cache.set(cacheKey('image-source', ImageSourceType.BASE64), brokenCompleteImage);
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
const image = scene.addedObjects[0]?.object as Image;
|
||||
expect(image.getNative()).not.toBe(brokenCompleteImage);
|
||||
expect(imageIoService.cacheWrites).toEqual([{ source: 'image-source', imageSourceType: ImageSourceType.BASE64, image: image.getNative() }]);
|
||||
});
|
||||
|
||||
it('renders docs behind-text images between page background and document text', async () => {
|
||||
const { scene, service } = createHarness();
|
||||
|
||||
await service.renderImages(imageParam({ behindText: true } as never), scene as unknown as Scene);
|
||||
|
||||
expect(scene.addedObjects[0]?.layerIndex).toBe(DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
|
||||
});
|
||||
|
||||
it('renders Word behindDoc wrap-none images between page background and document text', async () => {
|
||||
const { scene, service } = createHarness();
|
||||
|
||||
await service.renderImages(imageParam({
|
||||
behindDoc: BooleanNumber.TRUE,
|
||||
layoutType: PositionedObjectLayoutType.WRAP_NONE,
|
||||
} as never), scene as unknown as Scene);
|
||||
|
||||
expect(scene.addedObjects[0]?.layerIndex).toBe(DOC_DRAWING_BEHIND_TEXT_LAYER_INDEX);
|
||||
});
|
||||
|
||||
it('keeps normal images on the regular drawing layer', async () => {
|
||||
const { scene, service } = createHarness();
|
||||
|
||||
await service.renderImages(imageParam(), scene as unknown as Scene);
|
||||
|
||||
expect(scene.addedObjects[0]?.layerIndex).toBe(DRAWING_OBJECT_LAYER_INDEX);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
338
packages/drawing-ui/src/services/drawing-render.service.ts
Normal file
338
packages/drawing-ui/src/services/drawing-render.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
190
packages/drawing-ui/src/utils/__tests__/clipboard-image.spec.ts
Normal file
190
packages/drawing-ui/src/utils/__tests__/clipboard-image.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
368
packages/drawing-ui/src/utils/clipboard-image.ts
Normal file
368
packages/drawing-ui/src/utils/clipboard-image.ts
Normal 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;
|
||||
}
|
||||
21
packages/drawing-ui/src/utils/config.ts
Normal file
21
packages/drawing-ui/src/utils/config.ts
Normal 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];
|
||||
58
packages/drawing-ui/src/utils/get-update-params.ts
Normal file
58
packages/drawing-ui/src/utils/get-update-params.ts
Normal 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;
|
||||
}
|
||||
39
packages/drawing-ui/src/utils/image-crop-transform.ts
Normal file
39
packages/drawing-ui/src/utils/image-crop-transform.ts
Normal 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 };
|
||||
}
|
||||
34
packages/drawing-ui/src/utils/rotate-enabled.ts
Normal file
34
packages/drawing-ui/src/utils/rotate-enabled.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
60
packages/drawing-ui/src/views/crop/MobileCropActions.tsx
Normal file
60
packages/drawing-ui/src/views/crop/MobileCropActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
packages/drawing-ui/src/views/crop/image-cropper-object.ts
Normal file
264
packages/drawing-ui/src/views/crop/image-cropper-object.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
127
packages/drawing-ui/src/views/panel/DrawingAlign.tsx
Normal file
127
packages/drawing-ui/src/views/panel/DrawingAlign.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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>
|
||||
);
|
||||
};
|
||||
89
packages/drawing-ui/src/views/panel/DrawingArrange.tsx
Normal file
89
packages/drawing-ui/src/views/panel/DrawingArrange.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
134
packages/drawing-ui/src/views/panel/DrawingCommonPanel.tsx
Normal file
134
packages/drawing-ui/src/views/panel/DrawingCommonPanel.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
121
packages/drawing-ui/src/views/panel/DrawingGroup.tsx
Normal file
121
packages/drawing-ui/src/views/panel/DrawingGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
428
packages/drawing-ui/src/views/panel/DrawingTransform.tsx
Normal file
428
packages/drawing-ui/src/views/panel/DrawingTransform.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
packages/drawing-ui/src/views/panel/ImageCropper.tsx
Normal file
63
packages/drawing-ui/src/views/panel/ImageCropper.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
86
packages/drawing-ui/src/views/panel/MobileDrawingArrange.tsx
Normal file
86
packages/drawing-ui/src/views/panel/MobileDrawingArrange.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
474
packages/drawing-ui/src/views/panel/MobileDrawingControls.tsx
Normal file
474
packages/drawing-ui/src/views/panel/MobileDrawingControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
packages/drawing-ui/src/views/panel/MobileImageCropper.tsx
Normal file
83
packages/drawing-ui/src/views/panel/MobileImageCropper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
21
packages/drawing-ui/src/views/panel/component-name.ts
Normal file
21
packages/drawing-ui/src/views/panel/component-name.ts
Normal 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';
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
127
packages/drawing-ui/src/views/panel/use-image-cropper.ts
Normal file
127
packages/drawing-ui/src/views/panel/use-image-cropper.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 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 };
|
||||
}
|
||||
27
packages/drawing-ui/tailwind.config.ts
Normal file
27
packages/drawing-ui/tailwind.config.ts
Normal 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;
|
||||
8
packages/drawing-ui/tsconfig.json
Normal file
8
packages/drawing-ui/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/drawing-ui/tsconfig.node.json
Normal file
18
packages/drawing-ui/tsconfig.node.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
packages/drawing-ui/vitest.config.ts
Normal file
3
packages/drawing-ui/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user