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

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

View File

@@ -0,0 +1,41 @@
# @univerjs/slides-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/slides-ui?style=flat-square)](https://npmjs.com/package/@univerjs/slides-ui)
[![license](https://img.shields.io/npm/l/@univerjs/slides-ui?style=flat-square)](https://npmjs.com/package/@univerjs/slides-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/slides-ui?style=flat-square)](https://npmjs.com/package/@univerjs/slides-ui)
`@univerjs/slides-ui` adds the presentation editor UI layer for Univer Slides.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/slides-ui` | `UniverSlidesUi` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/slides-ui
# or
npm install @univerjs/slides-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/slides-ui/lib/index.css';
import EnUS from '@univerjs/slides-ui/locale/en-US';
import { UniverSlidesUIPlugin } from '@univerjs/slides-ui';
univer.registerPlugin(UniverSlidesUIPlugin);
// 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/slides-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,96 @@
{
"name": "@univerjs/slides-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Presentation editor UI layer for Univer Slides.",
"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",
"slides",
"presentation",
"editor",
"ui"
],
"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/docs": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/slides": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

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

View File

@@ -0,0 +1,482 @@
/**
* 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 { IPageElement, ISlideData, ISlidePage, SlideDataModel } from '@univerjs/slides';
import {
ICommandService,
IImageIoService,
ImageSourceType,
ImageUploadStatusType,
IUniverInstanceService,
LocaleService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { ObjectType } from '@univerjs/engine-render';
import { BasicShapes, PageElementType, PageType, UniverSlidesPlugin } from '@univerjs/slides';
import { DesktopSidebarService, ILocalFileService, ISidebarService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { CanvasView } from '../../../controllers/canvas-view';
import { ActivateSlidePageOperation } from '../activate.operation';
import { AppendSlideOperation } from '../append-slide.operation';
import { DeleteSlideElementOperation } from '../delete-element.operation';
import { InsertSlideFloatImageCommand } from '../insert-image.operation';
import {
InsertSlideShapeEllipseCommand,
InsertSlideShapeEllipseOperation,
InsertSlideShapeRectangleCommand,
InsertSlideShapeRectangleOperation,
ToggleSlideEditSidebarOperation,
} from '../insert-shape.operation';
import { SlideAddTextCommand, SlideAddTextOperation } from '../insert-text.operation';
import { SetSlidePageThumbOperation } from '../set-thumb.operation';
import { UpdateSlideElementOperation } from '../update-element.operation';
const unitId = 'slide-command-unit';
const pageId = 'page-1';
class TestCanvasView {
static thumbUnitIds: string[] = [];
static activatedPages: string[] = [];
static activeObjectIds: string[] = [];
static clearedControls = 0;
static activateCreatedObject = false;
constructor(@IUniverInstanceService private readonly _instanceService: IUniverInstanceService) {}
static reset() {
this.thumbUnitIds = [];
this.activatedPages = [];
this.activeObjectIds = [];
this.clearedControls = 0;
this.activateCreatedObject = false;
}
createObjectToPage() {
if (TestCanvasView.activateCreatedObject) {
return { oKey: 'created-object' } as never;
}
return null;
}
setObjectActiveByPage(object: { oKey?: string }) {
TestCanvasView.activeObjectIds.push(object.oKey ?? '');
}
removeObjectById() {
// render boundary is not part of the data-model behavior under test
}
appendPage(unitId: string) {
const slide = this._instanceService.getUnit<SlideDataModel>(unitId);
const page = slide?.getBlankPage();
if (page) {
slide?.appendPage(page);
}
}
activePage(pageId: string, unitId: string) {
const slide = this._instanceService.getUnit<SlideDataModel>(unitId);
slide?.setActivePage(slide.getPage(pageId) ?? null);
TestCanvasView.activatedPages.push(pageId);
}
getRenderUnitByPageId(pageId: string, unitId: string) {
const slide = this._instanceService.getUnit<SlideDataModel>(unitId);
if (!slide?.getPage(pageId)) {
return null;
}
return {
scene: {
getTransformer: () => ({
clearControls: () => {
TestCanvasView.clearedControls += 1;
},
}),
},
};
}
createThumbs(unitId: string) {
TestCanvasView.thumbUnitIds.push(unitId);
}
}
class TestLocalFileService implements ILocalFileService {
static files: File[] = [];
static reset() {
this.files = [];
}
openFile(): Promise<File[]> {
return Promise.resolve(TestLocalFileService.files);
}
downloadFile(): void {
// not used by slide image insertion
}
}
class TestImageIoService {
saveImage(): Promise<unknown> {
return Promise.resolve({
imageId: 'image-1',
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,ZmFrZQ==',
base64Cache: 'data:image/png;base64,ZmFrZQ==',
status: ImageUploadStatusType.SUCCUSS,
});
}
}
class TestImage {
width = 64;
height = 32;
onload: (() => void) | null = null;
onerror: ((error: unknown) => void) | null = null;
set src(_value: string) {
queueMicrotask(() => this.onload?.());
}
}
function createSlideSnapshot(): Partial<ISlideData> {
return {
id: unitId,
title: 'Command test deck',
pageSize: { width: 960, height: 540 },
body: {
pageOrder: [pageId, 'page-2'],
pages: {
[pageId]: {
id: pageId,
pageType: PageType.SLIDE,
zIndex: 1,
title: 'Overview',
description: '',
pageBackgroundFill: { rgb: '#ffffff' },
pageElements: {
'title-text': {
id: 'title-text',
zIndex: 1,
left: 40,
top: 40,
width: 400,
height: 60,
title: 'Title',
description: '',
type: PageElementType.TEXT,
richText: { text: 'Quarterly review' },
},
'old-shape': {
id: 'old-shape',
zIndex: 2,
left: 100,
top: 120,
width: 80,
height: 80,
title: 'Old shape',
description: '',
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Rect,
text: '',
shapeProperties: {
shapeBackgroundFill: { rgb: 'rgb(0,0,255)' },
},
},
},
},
},
'page-2': {
id: 'page-2',
pageType: PageType.SLIDE,
zIndex: 2,
title: 'Appendix',
description: '',
pageBackgroundFill: { rgb: '#ffffff' },
pageElements: {},
},
},
},
};
}
function getActivePage(slide: SlideDataModel): ISlidePage {
return slide.getActivePage()!;
}
function getElementIds(page: ISlidePage): string[] {
const ids: string[] = [];
for (const id in page.pageElements) {
ids.push(id);
}
return ids;
}
function findElementAddedAfter(page: ISlidePage, beforeIds: string[]): IPageElement {
for (const id in page.pageElements) {
if (!beforeIds.includes(id)) {
return page.pageElements[id];
}
}
throw new Error('No new slide element was added.');
}
describe('slide element operations', () => {
let univer: Univer;
let commandService: ICommandService;
let slide: SlideDataModel;
let originalImage: typeof Image;
beforeEach(() => {
univer = new Univer();
univer.registerPlugin(UniverSlidesPlugin);
TestCanvasView.reset();
TestLocalFileService.reset();
originalImage = globalThis.Image;
(globalThis as unknown as { Image: typeof Image }).Image = TestImage as unknown as typeof Image;
const injector = univer.__getInjector();
injector.add([CanvasView, { useClass: TestCanvasView as never }]);
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
injector.add([IImageIoService, { useClass: TestImageIoService as never }]);
slide = univer.createUnit<ISlideData, SlideDataModel>(UniverInstanceType.UNIVER_SLIDE, createSlideSnapshot());
injector.get(IUniverInstanceService).focusUnit(unitId);
commandService = injector.get(ICommandService);
commandService.registerCommand(SlideAddTextOperation);
commandService.registerCommand(SlideAddTextCommand);
commandService.registerCommand(InsertSlideShapeRectangleOperation);
commandService.registerCommand(InsertSlideShapeRectangleCommand);
commandService.registerCommand(InsertSlideShapeEllipseOperation);
commandService.registerCommand(InsertSlideShapeEllipseCommand);
commandService.registerCommand(UpdateSlideElementOperation);
commandService.registerCommand(DeleteSlideElementOperation);
commandService.registerCommand(AppendSlideOperation);
commandService.registerCommand(ActivateSlidePageOperation);
commandService.registerCommand(SetSlidePageThumbOperation);
commandService.registerCommand(InsertSlideFloatImageCommand);
commandService.registerCommand(ToggleSlideEditSidebarOperation);
injector.get(LocaleService).load({});
});
afterEach(() => {
(globalThis as unknown as { Image: typeof Image }).Image = originalImage;
univer.dispose();
});
it('adds a text box to the active slide with the entered content', async () => {
const page = getActivePage(slide);
const beforeIds = getElementIds(page);
const result = await commandService.executeCommand(SlideAddTextOperation.id, {
unitId,
text: 'Revenue is up',
});
expect(result).toBe(true);
const element = findElementAddedAfter(page, beforeIds);
expect(element).toMatchObject({
zIndex: 3,
type: PageElementType.TEXT,
richText: {
text: 'Revenue is up',
fs: 30,
},
});
});
it('adds rectangle and ellipse shapes to the active slide', async () => {
const page = getActivePage(slide);
const beforeRectangleIds = getElementIds(page);
await commandService.executeCommand(InsertSlideShapeRectangleCommand.id);
const rectangle = findElementAddedAfter(page, beforeRectangleIds);
const beforeEllipseIds = getElementIds(page);
await commandService.executeCommand(InsertSlideShapeEllipseCommand.id);
const ellipse = findElementAddedAfter(page, beforeEllipseIds);
expect(rectangle).toMatchObject({
zIndex: 3,
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Rect,
},
});
expect(ellipse).toMatchObject({
zIndex: 4,
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Ellipse,
},
});
});
it('uses the focused slide when adding text from the user command', async () => {
const page = getActivePage(slide);
const beforeIds = getElementIds(page);
const result = await commandService.executeCommand(SlideAddTextCommand.id);
expect(result).toBe(true);
const element = findElementAddedAfter(page, beforeIds);
expect(element).toMatchObject({
type: PageElementType.TEXT,
richText: { text: 'A New Text' },
});
});
it('updates an existing slide element without replacing the rest of its content', async () => {
const result = await commandService.executeCommand(UpdateSlideElementOperation.id, {
unitId,
oKey: 'title-text',
props: {
left: 120,
title: 'Updated title box',
},
});
expect(result).toBe(true);
expect(slide.getElement(pageId, 'title-text')).toMatchObject({
left: 120,
title: 'Updated title box',
type: PageElementType.TEXT,
richText: { text: 'Quarterly review' },
});
});
it('removes the selected element from the active slide', async () => {
const result = await commandService.executeCommand(DeleteSlideElementOperation.id, {
unitId,
id: 'old-shape',
});
expect(result).toBe(true);
expect(slide.getElement(pageId, 'old-shape')).toBeUndefined();
expect(slide.getElement(pageId, 'title-text')).toBeDefined();
});
it('appends a new page after the active slide', async () => {
expect(slide.getPageOrder()).toEqual([pageId, 'page-2']);
const result = await commandService.executeCommand(AppendSlideOperation.id, { unitId });
expect(result).toBe(true);
expect(slide.getPageOrder()).toHaveLength(3);
expect(slide.getPageOrder()?.[0]).toBe(pageId);
expect(slide.getPageOrder()?.[1]).not.toBe('page-2');
expect(slide.getPageOrder()?.[2]).toBe('page-2');
});
it('activates another page after clearing the current page controls', async () => {
const result = await commandService.executeCommand(ActivateSlidePageOperation.id, {
unitId,
id: 'page-2',
});
expect(result).toBe(true);
expect(slide.getActivePage()?.id).toBe('page-2');
expect(TestCanvasView.clearedControls).toBe(1);
expect(TestCanvasView.activatedPages).toEqual(['page-2']);
});
it('does not activate a missing page renderer', async () => {
const result = await commandService.executeCommand(ActivateSlidePageOperation.id, {
unitId: 'missing-slide',
id: 'page-2',
});
expect(result).toBe(false);
expect(slide.getActivePage()?.id).toBe(pageId);
});
it('creates thumbnails for the current deck', async () => {
const result = await commandService.executeCommand(SetSlidePageThumbOperation.id, { unitId });
expect(result).toBe(true);
expect(TestCanvasView.thumbUnitIds).toEqual([unitId]);
});
it('keeps the deck unchanged when the user cancels image selection', async () => {
const page = getActivePage(slide);
const beforeIds = getElementIds(page);
const result = await commandService.executeCommand(InsertSlideFloatImageCommand.id);
expect(result).toBe(false);
expect(getElementIds(page)).toEqual(beforeIds);
});
it('adds the selected image to the active slide and selects the created object', async () => {
const page = getActivePage(slide);
const beforeIds = getElementIds(page);
TestCanvasView.activateCreatedObject = true;
TestLocalFileService.files = [new File(['fake image'], 'chart.png', { type: 'image/png' })];
const result = await commandService.executeCommand(InsertSlideFloatImageCommand.id);
expect(result).toBe(true);
const element = findElementAddedAfter(page, beforeIds);
expect(element).toMatchObject({
id: 'image-1',
zIndex: 3,
width: 64,
height: 32,
type: PageElementType.IMAGE,
image: {
imageProperties: {
imageSourceType: ImageSourceType.BASE64,
source: 'data:image/png;base64,ZmFrZQ==',
base64Cache: 'data:image/png;base64,ZmFrZQ==',
},
},
});
expect(TestCanvasView.activeObjectIds).toEqual(['created-object']);
});
it('opens and closes the slide edit sidebar for the selected object type', async () => {
const sidebarService = univer.__getInjector().get(ISidebarService);
const objectTypes = [
{ type: ObjectType.RECT, title: 'slides-ui.sidebar.shape' },
{ type: ObjectType.IMAGE, title: 'slides-ui.sidebar.image' },
{ type: ObjectType.RICH_TEXT, title: 'slides-ui.sidebar.text' },
];
for (const objectType of objectTypes) {
await commandService.executeCommand(ToggleSlideEditSidebarOperation.id, {
visible: '1',
objectType: objectType.type,
});
expect(sidebarService.visible).toBe(true);
expect(sidebarService.options.header?.title).toBe(objectType.title);
}
await commandService.executeCommand(ToggleSlideEditSidebarOperation.id, {
visible: '',
objectType: ObjectType.RECT,
});
expect(sidebarService.visible).toBe(false);
});
});

View File

@@ -0,0 +1,50 @@
/**
* 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, IOperation } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import { CommandType, IUniverInstanceService } from '@univerjs/core';
import { CanvasView } from '../../controllers/canvas-view';
export interface IActiveSlidePageOperationParams {
unitId: string;
id: string;
}
export const ActivateSlidePageOperation: IOperation<IActiveSlidePageOperationParams> = {
id: 'slide.operation.activate-slide',
type: CommandType.OPERATION,
handler: (accessor: IAccessor, params: IActiveSlidePageOperationParams) => {
const unitId = params.unitId;
const canvasView = accessor.get(CanvasView);
const univerInstanceService = accessor.get(IUniverInstanceService);
// const model = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const model = univerInstanceService.getUnit<SlideDataModel>(unitId);
const pageId = model?.getActivePage()?.id;
if (!pageId) return false;
const page = canvasView.getRenderUnitByPageId(pageId, unitId);
if (!page) return false;
const transformer = page.scene?.getTransformer();
if (transformer) {
transformer.clearControls();
}
canvasView.activePage(params.id, unitId);
return true;
},
};

View File

@@ -0,0 +1,43 @@
/**
* 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 { IOperation } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import { CommandType, IUniverInstanceService } from '@univerjs/core';
import { CanvasView } from '../../controllers/canvas-view';
export interface IAppendSlideOperationParams {
unitId: string;
}
export const AppendSlideOperation: IOperation<IAppendSlideOperationParams> = {
id: 'slide.operation.append-slide',
type: CommandType.OPERATION,
handler: (accessor, params: IAppendSlideOperationParams) => {
const unitId = params.unitId;
const univerInstanceService = accessor.get(IUniverInstanceService);
// const slideData = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const canvasView = accessor.get(CanvasView);
canvasView.appendPage(unitId);
return true;
},
};

View File

@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import { CommandType, IUniverInstanceService } from '@univerjs/core';
import { CanvasView } from '../../controllers/canvas-view';
export interface IDeleteElementOperationParams {
unitId: string;
id: string;
};
export const DeleteSlideElementOperation: ICommand<IDeleteElementOperationParams> = {
id: 'slide.operation.delete-element',
type: CommandType.OPERATION,
handler: (accessor, params) => {
if (!params?.id) return false;
const unitId = params.unitId;
const univerInstanceService = accessor.get(IUniverInstanceService);
// const slideData = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const activePage = slideData.getActivePage()!;
delete activePage.pageElements[params.id];
slideData.updatePage(activePage.id, activePage);
const canvasview = accessor.get(CanvasView);
canvasview.removeObjectById(params.id, activePage.id, unitId);
return true;
},
};

View File

@@ -0,0 +1,84 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import { CommandType, IImageIoService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { DRAWING_IMAGE_ALLOW_IMAGE_LIST, getImageSize } from '@univerjs/drawing';
import { PageElementType } from '@univerjs/slides';
import { ILocalFileService } from '@univerjs/ui';
import { CanvasView } from '../../controllers/canvas-view';
export const InsertSlideFloatImageCommand: ICommand<{}> = {
id: 'slide.command.insert-float-image',
type: CommandType.COMMAND,
handler: async (accessor, params) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const unitId = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SLIDE)?.getUnitId();
if (!unitId) return false;
const fileOpenerService = accessor.get(ILocalFileService);
const files = await fileOpenerService.openFile({
multiple: true,
accept: DRAWING_IMAGE_ALLOW_IMAGE_LIST.map((image) => `.${image.replace('image/', '')}`).join(','),
});
if (files.length !== 1) return false;
const imageIoService = accessor.get(IImageIoService);
const imageParam = await imageIoService.saveImage(files[0]);
if (!imageParam) return false;
const { imageId, imageSourceType, source, base64Cache } = imageParam;
const { width, height, image } = await getImageSize(base64Cache || '');
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const activePage = slideData.getActivePage()!;
const elements = Object.values(activePage.pageElements);
const maxIndex = (elements?.length) ? Math.max(...elements.map((element) => element.zIndex)) : 20;
const data = {
id: imageId,
zIndex: maxIndex + 1,
left: 0,
top: 0,
width,
height,
title: '',
description: '',
type: PageElementType.IMAGE,
image: {
imageProperties: {
contentUrl: base64Cache,
imageSourceType,
source,
base64Cache,
image,
},
},
};
activePage.pageElements[imageId] = data;
slideData.updatePage(activePage.id, activePage);
const canvasView = accessor.get(CanvasView);
const sceneObject = canvasView.createObjectToPage(data, activePage.id, unitId);
if (sceneObject) {
canvasView.setObjectActiveByPage(sceneObject, activePage.id, unitId);
}
return true;
},
};

View File

@@ -0,0 +1,194 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, ICommand } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import { CommandType, generateRandomId, ICommandService, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { ObjectType } from '@univerjs/engine-render';
import { BasicShapes, PageElementType } from '@univerjs/slides';
import { ISidebarService } from '@univerjs/ui';
import { CanvasView } from '../../controllers/canvas-view';
import { COMPONENT_SLIDE_SIDEBAR } from '../../views/sidebar/Sidebar';
export interface IInsertShapeOperationParams {
unitId: string;
};
export const InsertSlideShapeRectangleCommand: ICommand = {
id: 'slide.command.insert-float-shape.rectangle',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const instanceService = accessor.get(IUniverInstanceService);
const unitId = instanceService.getFocusedUnit()?.getUnitId();
return commandService.executeCommand(InsertSlideShapeRectangleOperation.id, { unitId });
},
};
export const InsertSlideShapeRectangleOperation: ICommand<IInsertShapeOperationParams> = {
id: 'slide.operation.insert-float-shape.rectangle',
type: CommandType.OPERATION,
handler: async (accessor, params: IInsertShapeOperationParams) => {
const id = generateRandomId(6);
const univerInstanceService = accessor.get(IUniverInstanceService);
// const slideData = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const unitId = params.unitId;
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const activePage = slideData.getActivePage()!;
const elements = Object.values(activePage.pageElements);
const maxIndex = (elements?.length) ? Math.max(...elements.map((element) => element.zIndex)) : 20;
const data = {
id,
zIndex: maxIndex + 1,
left: 378,
top: 142,
width: 250,
height: 250,
title: id,
description: '',
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Rect,
text: '',
shapeProperties: {
shapeBackgroundFill: {
rgb: 'rgb(0,0,255)',
},
},
},
};
activePage.pageElements[id] = data;
slideData.updatePage(activePage.id, activePage);
const canvasview = accessor.get(CanvasView);
const sceneObject = canvasview.createObjectToPage(data, activePage.id, unitId);
if (sceneObject) {
canvasview.setObjectActiveByPage(sceneObject, activePage.id, unitId);
}
return true;
},
};
export interface IToggleSlideEditSidebarOperation {
visible: string;
objectType: ObjectType;
}
export const ToggleSlideEditSidebarOperation: ICommand = {
id: 'sidebar.operation.slide-shape',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IToggleSlideEditSidebarOperation) => {
const { visible, objectType } = params;
const sidebarService = accessor.get(ISidebarService);
const localeService = accessor.get(LocaleService);
let title = '';
let children = '';
if (objectType === ObjectType.RECT) {
title = 'slides-ui.sidebar.shape';
children = COMPONENT_SLIDE_SIDEBAR;
} else if (objectType === ObjectType.IMAGE) {
title = 'slides-ui.sidebar.image';
children = COMPONENT_SLIDE_SIDEBAR;
} else if (objectType === ObjectType.RICH_TEXT) {
title = 'slides-ui.sidebar.text';
children = COMPONENT_SLIDE_SIDEBAR;
}
if (visible) {
sidebarService.open({
header: { title: localeService.t(title) },
children: { label: children },
onClose: () => {
// drawingManagerService.focusDrawing(null);
},
width: 360,
});
} else {
sidebarService.close();
}
return true;
},
};
export const InsertSlideShapeEllipseCommand: ICommand = {
id: 'slide.command.insert-float-shape.ellipse',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const commandService = accessor.get(ICommandService);
const instanceService = accessor.get(IUniverInstanceService);
const unitId = instanceService.getFocusedUnit()?.getUnitId();
return commandService.executeCommand(InsertSlideShapeEllipseOperation.id, { unitId });
},
};
export const InsertSlideShapeEllipseOperation: ICommand<IInsertShapeOperationParams> = {
id: 'slide.operation.insert-float-shape.ellipse',
type: CommandType.OPERATION,
handler: async (accessor, params: IInsertShapeOperationParams) => {
const id = generateRandomId(6);
const univerInstanceService = accessor.get(IUniverInstanceService);
// const slideData = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const unitId = params.unitId;
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const activePage = slideData.getActivePage()!;
const elements = Object.values(activePage.pageElements);
const maxIndex = (elements?.length) ? Math.max(...elements.map((element) => element.zIndex)) : 20;
const data = {
id,
zIndex: maxIndex + 1,
left: 378,
top: 142,
width: 250,
height: 250,
title: id,
description: '',
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Ellipse,
text: '',
shapeProperties: {
radius: 100,
shapeBackgroundFill: {
rgb: 'rgb(0,0,255)',
},
},
},
};
activePage.pageElements[id] = data;
slideData.updatePage(activePage.id, activePage);
const canvasview = accessor.get(CanvasView);
const sceneObject = canvasview.createObjectToPage(data, activePage.id, unitId);
if (sceneObject) {
canvasview.setObjectActiveByPage(sceneObject, activePage.id, unitId);
}
return true;
},
};

View File

@@ -0,0 +1,95 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { IPageElement, SlideDataModel } from '@univerjs/slides';
import { CommandType, generateRandomId, ICommandService, IUniverInstanceService } from '@univerjs/core';
import { PageElementType } from '@univerjs/slides';
import { CanvasView } from '../../controllers/canvas-view';
export interface ISlideAddTextParam {
text: string;
unitId: string;
};
export const SlideAddTextCommand: ICommand = {
id: 'slide.command.add-text',
type: CommandType.COMMAND,
handler: async (accessor) => {
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const unitId = univerInstanceService.getFocusedUnit()?.getUnitId();
return await commandService.executeCommand(SlideAddTextOperation.id, { unitId });
},
};
export const SlideAddTextOperation: ICommand<ISlideAddTextParam> = {
id: 'slide.operation.add-text',
type: CommandType.OPERATION,
handler: async (accessor, params: ISlideAddTextParam) => {
const unitId = params.unitId;
const elementId = generateRandomId(6);
const defaultWidth = 220;
const defaultheight = 40;
const left = 230;
const top = 142;
const textContent = params?.text || 'A New Text';
const univerInstanceService = accessor.get(IUniverInstanceService);
// const slideData = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const activePage = slideData.getActivePage()!;
const elements = Object.values(activePage.pageElements);
const maxIndex = (elements?.length) ? Math.max(...elements.map((element) => element.zIndex)) : 21;
const elementData: IPageElement = {
id: elementId,
zIndex: maxIndex + 1,
left,
top,
width: defaultWidth,
height: defaultheight,
title: 'text',
description: '',
type: PageElementType.TEXT,
richText: {
text: textContent,
fs: 30,
cl: {
rgb: 'rgb(51, 51, 51)',
},
bl: 1,
},
};
activePage.pageElements[elementId] = elementData;
slideData.updatePage(activePage.id, activePage);
const canvasview = accessor.get(CanvasView);
const sceneObject = canvasview.createObjectToPage(elementData, activePage.id, unitId);
// make object active: a control rect wrap the object.
if (sceneObject) {
canvasview.setObjectActiveByPage(sceneObject, activePage.id, unitId);
}
return true;
},
};

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor, IOperation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
import { CanvasView } from '../../controllers/canvas-view';
export interface ISlideSetThumbParam {
unitId: string;
}
export const SetSlidePageThumbOperation: IOperation = {
id: 'slide.operation.set-slide-page-thumb',
type: CommandType.OPERATION,
handler: (accessor: IAccessor, params: ISlideSetThumbParam) => {
const canvasView = accessor.get(CanvasView);
canvasView.createThumbs(params.unitId);
return true;
},
};

View File

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

View File

@@ -0,0 +1,45 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import { CommandType, IUniverInstanceService, merge } from '@univerjs/core';
export interface IUpdateElementOperationParams {
unitId: string;
oKey: string;
props: Record<string, any>;
};
export const UpdateSlideElementOperation: ICommand<IUpdateElementOperationParams> = {
id: 'slide.operation.update-element',
type: CommandType.OPERATION,
handler: (accessor, params: IUpdateElementOperationParams) => {
const { oKey, props } = params!;
const univerInstanceService = accessor.get(IUniverInstanceService);
// const slideData = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const unitId = params?.unitId;
const slideData = univerInstanceService.getUnit<SlideDataModel>(unitId);
if (!slideData) return false;
const activePage = slideData.getActivePage()!;
activePage.pageElements[oKey] = merge(activePage.pageElements[oKey], props);
slideData.updatePage(activePage.id, activePage);
return true;
},
};

View File

@@ -0,0 +1,29 @@
/**
* 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 { DependencyOverride } from '@univerjs/core';
import type { MenuConfig } from '@univerjs/ui';
export const SLIDES_UI_PLUGIN_CONFIG_KEY = 'slides-ui.config';
export const configSymbol = Symbol(SLIDES_UI_PLUGIN_CONFIG_KEY);
export interface IUniverSlidesUIConfig {
override?: DependencyOverride;
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverSlidesUIConfig = {};

View File

@@ -0,0 +1,19 @@
/**
* 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 { createInternalEditorID } from '@univerjs/core';
export const SLIDE_EDITOR_ID = createInternalEditorID('SLIDE_EDITOR');

View File

@@ -0,0 +1,139 @@
/**
* 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, Injector } from '@univerjs/core';
import type { IRenderContext, IRichTextProps } from '@univerjs/engine-render';
import type { ISlideData } from '@univerjs/slides';
import {
ICommandService,
IUniverInstanceService,
LocaleService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
DocSelectionManagerService,
DocStateEmitService,
RichTextEditingMutation,
} from '@univerjs/docs';
import { EditorService, IEditorService, ReplaceSnapshotCommand } from '@univerjs/docs-ui';
import { IRenderManagerService, RichText } from '@univerjs/engine-render';
import { SlideDataModel } from '@univerjs/slides';
import { DesktopLayoutService, ILayoutService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SLIDE_EDITOR_ID } from '../../const';
import { ISlideEditorBridgeService, SlideEditorBridgeService } from '../../services/slide-editor-bridge.service';
import { ISlideEditorManagerService, SlideEditorManagerService } from '../../services/slide-editor-manager.service';
import { SlideEditingRenderController } from '../slide-editing.render-controller';
const unitId = 'slide-unit';
function createSlideSnapshot(): Partial<ISlideData> {
return {
id: unitId,
title: 'Editor test deck',
pageSize: { width: 960, height: 540 },
body: {
pageOrder: [],
pages: {},
},
};
}
function createRichText(injector: Injector, id: string, text: string, top: number): RichText {
const props: IRichTextProps = {
text,
zIndex: 1,
left: 10,
top,
width: 200,
height: 40,
};
return injector.createInstance(RichText, injector.get(LocaleService), id, props);
}
describe('SlideEditingRenderController', () => {
let univer: Univer;
let controller: SlideEditingRenderController;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
const renderManagerService = {
getRenderUnitById: () => null,
} as unknown as IRenderManagerService;
injector.add([IRenderManagerService, { useValue: renderManagerService }]);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([IEditorService, { useClass: EditorService }]);
injector.add([ILayoutService, { useClass: DesktopLayoutService }]);
injector.add([ISlideEditorBridgeService, { useClass: SlideEditorBridgeService }]);
injector.add([ISlideEditorManagerService, { useClass: SlideEditorManagerService }]);
const instanceService = injector.get(IUniverInstanceService);
instanceService.registerCtorForType(UniverInstanceType.UNIVER_SLIDE, SlideDataModel);
const commandService = injector.get(ICommandService);
commandService.registerCommand(RichTextEditingMutation);
commandService.registerCommand(ReplaceSnapshotCommand);
const slide = univer.createUnit<ISlideData, SlideDataModel>(UniverInstanceType.UNIVER_SLIDE, createSlideSnapshot());
controller = injector.createInstance(SlideEditingRenderController, {
unitId,
unit: slide,
} as IRenderContext<SlideDataModel>);
});
afterEach(() => {
controller?.dispose();
univer.dispose();
});
it('keeps the registered editor unit while replacing its snapshot', () => {
const injector = univer.__getInjector();
const instanceService = injector.get(IUniverInstanceService);
const editorBridgeService = injector.get(ISlideEditorBridgeService);
const firstText = createRichText(injector, 'first', 'First text', 20);
const secondText = createRichText(injector, 'second', 'Second text', 80);
editorBridgeService.setEditorRect({
scene: null as never,
engine: null as never,
unitId,
pageId: 'page-1',
richTextObj: firstText,
});
const editorDocument = instanceService.getUnit<DocumentDataModel>(SLIDE_EDITOR_ID, UniverInstanceType.UNIVER_DOC);
expect(editorDocument?.getBody()?.dataStream).toBe('First text\r\n');
editorBridgeService.setEditorRect({
scene: null as never,
engine: null as never,
unitId,
pageId: 'page-1',
richTextObj: secondText,
});
expect(instanceService.getUnit(SLIDE_EDITOR_ID, UniverInstanceType.UNIVER_DOC)).toBe(editorDocument);
expect(editorDocument?.getBody()?.dataStream).toBe('Second text\r\n');
expect(instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)).toBe(editorDocument);
firstText.dispose();
secondText.dispose();
});
});

View File

@@ -0,0 +1,79 @@
/**
* 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 { Nullable } from '@univerjs/core';
import type { BaseObject, IRenderModule } from '@univerjs/engine-render';
import type { IPageElement } from '@univerjs/slides';
import type { PageID } from '../type';
import { RxDisposable } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SlideRenderController } from './slide.render-controller';
export class CanvasView extends RxDisposable implements IRenderModule {
constructor(
// this controller needs by commands. root injector. T
// That means this controller is not init by renderUnit ---> no renderContext.
// private readonly _renderContext: IRenderContext<UnitModel>,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
}
private _getSlideRenderControllerFromRenderUnit(unitId: string) {
const renderUnit = this._renderManagerService
.getRenderUnitById(unitId)!;
const slideRC = renderUnit.with(SlideRenderController);
return slideRC;
}
createThumbs(unitId: string) {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
slideRC.createThumbs();
}
activePage(pageId: string, unitId: string) {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
slideRC.activePage(pageId);
}
getRenderUnitByPageId(pageId: PageID, unitId: string) {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
return slideRC.getPageRenderUnit(pageId);
}
createObjectToPage(element: IPageElement, pageID: PageID, unitId: string): Nullable<BaseObject> {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
return slideRC.createObjectToPage(element, pageID);
}
setObjectActiveByPage(obj: BaseObject, pageID: PageID, unitId: string) {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
return slideRC.setObjectActiveByPage(obj, pageID);
}
removeObjectById(id: string, pageID: PageID, unitId: string) {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
slideRC.removeObjectById(id, pageID);
}
/**
* append blank page
*/
appendPage(unitId: string) {
const slideRC = this._getSlideRenderControllerFromRenderUnit(unitId);
slideRC.appendPage();
}
}

View File

@@ -0,0 +1,48 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { AddImageIcon, GraphIcon, TextIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { COMPONENT_SLIDE_IMAGE_POPUP_MENU } from '../views/image-popup-menu/component-name';
import { SlideImagePopupMenu } from '../views/image-popup-menu/ImagePopupMenu';
import Sidebar, { COMPONENT_SLIDE_SIDEBAR } from '../views/sidebar/Sidebar';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerParts();
this._registerIcons();
}
private _registerParts(): void {
const componentManager = this._componentManager;
this.disposeWithMe(componentManager.register(COMPONENT_SLIDE_IMAGE_POPUP_MENU, SlideImagePopupMenu));
this.disposeWithMe(componentManager.register(COMPONENT_SLIDE_SIDEBAR, Sidebar));
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
AddImageIcon,
TextIcon,
GraphIcon,
}));
}
}

View File

@@ -0,0 +1,146 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IShortcutItem } from '@univerjs/ui';
import { DeleteLeftCommand } from '@univerjs/docs-ui';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { KeyCode, MetaKeys } from '@univerjs/ui';
import { SetTextEditArrowOperation } from '../../commands/operations/text-edit.operation';
import {
whenEditorActivated,
whenFormulaEditorFocused,
} from './utils';
export const ARROW_SELECTION_KEYCODE_LIST = [
KeyCode.ARROW_DOWN,
KeyCode.ARROW_UP,
KeyCode.ARROW_LEFT,
KeyCode.ARROW_RIGHT,
];
export const MOVE_SELECTION_KEYCODE_LIST = [KeyCode.ENTER, KeyCode.TAB, ...ARROW_SELECTION_KEYCODE_LIST];
export function generateArrowSelectionShortCutItem() {
const shortcutList: IShortcutItem[] = [];
for (const keycode of ARROW_SELECTION_KEYCODE_LIST) {
shortcutList.push({
id: SetTextEditArrowOperation.id,
binding: keycode,
preconditions: (contextService) => whenEditorActivated(contextService),
staticParameters: {
visible: false,
eventType: DeviceInputEventType.Keyboard,
keycode,
isShift: false,
},
});
shortcutList.push({
id: SetTextEditArrowOperation.id,
binding: keycode | MetaKeys.SHIFT,
preconditions: (contextService) => whenEditorActivated(contextService),
staticParameters: {
visible: false,
eventType: DeviceInputEventType.Keyboard,
keycode,
isShift: true,
},
});
}
return shortcutList;
}
// export const StartEditWithF2Shortcut: IShortcutItem = {
// id: SetCellEditVisibleWithF2Operation.id,
// binding: KeyCode.F2,
// description: 'shortcut.sheet.start-editing',
// group: '4_sheet-edit',
// preconditions: whenSheetEditorFocused,
// staticParameters: {
// visible: true,
// eventType: DeviceInputEventType.Keyboard,
// keycode: KeyCode.F2,
// },
// };
// export const EditorCursorEnterShortcut: IShortcutItem = {
// id: SetCellEditVisibleOperation.id,
// binding: KeyCode.ENTER,
// description: 'shortcut.sheet.toggle-editing',
// group: '4_sheet-edit',
// preconditions: (contextService) => whenEditorDidNotInputFormulaActivated(contextService),
// staticParameters: {
// visible: false,
// eventType: DeviceInputEventType.Keyboard,
// keycode: KeyCode.ENTER,
// },
// };
// export const EditorCursorTabShortcut: IShortcutItem = {
// id: SetCellEditVisibleOperation.id,
// binding: KeyCode.TAB,
// preconditions: (contextService) => whenEditorDidNotInputFormulaActivated(contextService),
// staticParameters: {
// visible: false,
// eventType: DeviceInputEventType.Keyboard,
// keycode: KeyCode.TAB,
// },
// };
// export const EditorCursorEscShortcut: IShortcutItem = {
// id: SetCellEditVisibleOperation.id,
// binding: KeyCode.ESC,
// description: 'shortcut.sheet.abort-editing',
// group: '4_sheet-edit',
// preconditions: (contextService) => whenEditorDidNotInputFormulaActivated(contextService),
// staticParameters: {
// visible: false,
// eventType: DeviceInputEventType.Keyboard,
// keycode: KeyCode.ESC,
// },
// };
// export const EditorBreakLineShortcut: IShortcutItem = {
// id: BreakLineCommand.id,
// description: 'shortcut.sheet.break-line',
// group: '4_sheet-edit',
// preconditions: (contextService) => whenSheetEditorActivated(contextService),
// binding: KeyCode.ENTER | MetaKeys.ALT,
// };
export const EditorDeleteLeftShortcut: IShortcutItem = {
id: DeleteLeftCommand.id,
preconditions: (contextService) => {
return whenEditorActivated(contextService) || whenFormulaEditorFocused(contextService);
},
binding: KeyCode.BACKSPACE,
};
// export const EditorDeleteLeftShortcutInActive: IShortcutItem = {
// id: SetCellEditVisibleOperation.id,
// description: 'shortcut.sheet.delete-and-start-editing',
// group: '4_sheet-edit',
// preconditions: (contextService) =>
// whenSheetEditorFocused(contextService) && !whenFormulaEditorFocused(contextService),
// binding: KeyCode.BACKSPACE,
// staticParameters: {
// visible: true,
// eventType: DeviceInputEventType.Keyboard,
// keycode: KeyCode.BACKSPACE,
// },
// };

View File

@@ -0,0 +1,114 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IContextService } from '@univerjs/core';
import {
EDITOR_ACTIVATED,
FOCUSING_COMMON_DRAWINGS,
FOCUSING_EDITOR_INPUT_FORMULA,
FOCUSING_EDITOR_STANDALONE,
FOCUSING_SLIDE,
FOCUSING_UNIVER_EDITOR,
FORMULA_EDITOR_ACTIVATED,
} from '@univerjs/core';
export function whenSheetFocused(contextService: IContextService) {
return contextService.getContextValue(FOCUSING_SLIDE);
}
// FIXME: why coupled with sheet editors?
/**
* Requires the currently focused unit to be Workbook and the sheet editor is focused but not activated.
* @param contextService
* @returns If the sheet editor is focused but not activated.
*/
export function whenSheetEditorFocused(contextService: IContextService): boolean {
return (
contextService.getContextValue(FOCUSING_SLIDE) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
!contextService.getContextValue(EDITOR_ACTIVATED) &&
!contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)
);
}
export function whenSheetEditorFocusedAndFxNotFocused(contextService: IContextService): boolean {
return (
contextService.getContextValue(FOCUSING_SLIDE) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
!contextService.getContextValue(EDITOR_ACTIVATED) &&
!contextService.getContextValue(FORMULA_EDITOR_ACTIVATED) &&
!contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)
);
}
/**
* Requires the currently focused unit to be Workbook and the sheet editor is activated.
* @param contextService
* @returns If the sheet editor is activated.
*/
export function whenSheetEditorActivated(contextService: IContextService) {
return (
contextService.getContextValue(FOCUSING_SLIDE) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
contextService.getContextValue(EDITOR_ACTIVATED)
);
}
export function whenEditorActivated(contextService: IContextService) {
return contextService.getContextValue(FOCUSING_UNIVER_EDITOR) && contextService.getContextValue(EDITOR_ACTIVATED);
}
/**
* Requires the currently focused editor is a formula editor.
* @param contextService
* @returns If the formula editor is focused.
*/
export function whenFormulaEditorFocused(contextService: IContextService) {
return (
contextService.getContextValue(FORMULA_EDITOR_ACTIVATED) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR)
);
}
/**
* Requires the currently focused editor is a formula editor, and it is activated.
* @param contextService
* @returns If the formula editor is activated.
*/
export function whenFormulaEditorActivated(contextService: IContextService) {
return (
contextService.getContextValue(FOCUSING_SLIDE) &&
contextService.getContextValue(EDITOR_ACTIVATED) &&
contextService.getContextValue(FOCUSING_EDITOR_INPUT_FORMULA) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR)
);
}
/**
* Requires the currently focused editor is not a formula editor, and it is activated.
* @param contextService
* @returns If the editor is activated and the editor is not the formula editor.
*/
export function whenEditorDidNotInputFormulaActivated(contextService: IContextService) {
return (
contextService.getContextValue(FOCUSING_SLIDE) &&
contextService.getContextValue(FOCUSING_UNIVER_EDITOR) &&
contextService.getContextValue(EDITOR_ACTIVATED) &&
!contextService.getContextValue(FOCUSING_EDITOR_INPUT_FORMULA) &&
!contextService.getContextValue(FOCUSING_EDITOR_STANDALONE)
);
}

View File

@@ -0,0 +1,902 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
ICommandInfo,
IDisposable,
IDocumentBody,
IPosition,
Nullable,
UnitModel,
} from '@univerjs/core';
import type { IDocObjectParam, IEditorInputConfig } from '@univerjs/docs-ui';
import type {
DocBackground,
Documents,
DocumentSkeleton,
IDocumentLayoutObject,
IRenderContext,
IRenderModule,
Scene,
} from '@univerjs/engine-render';
import type { SlideDataModel } from '@univerjs/slides';
import type { IEditorBridgeServiceVisibleParam } from '../services/slide-editor-bridge.service';
import {
createParagraphId,
DEFAULT_EMPTY_DOCUMENT_VALUE,
Direction,
Disposable,
DisposableCollection,
EDITOR_ACTIVATED,
FOCUSING_EDITOR_STANDALONE,
FOCUSING_UNIVER_EDITOR_STANDALONE_SINGLE_MODE,
HorizontalAlign,
ICommandService,
IContextService,
Inject,
IUndoRedoService,
IUniverInstanceService,
LocaleService,
toDisposable,
UniverInstanceType,
VerticalAlign,
WrapStrategy,
} from '@univerjs/core';
import {
DocSelectionManagerService,
DocSkeletonManagerService,
RichTextEditingMutation,
} from '@univerjs/docs';
import {
DOCS_COMPONENT_MAIN_LAYER_INDEX,
DOCS_VIEW_KEY,
DocSelectionRenderService,
IEditorService,
MoveCursorOperation,
MoveSelectionOperation,
ReplaceSnapshotCommand,
VIEWPORT_KEY,
} from '@univerjs/docs-ui';
import {
convertTextRotation,
DeviceInputEventType,
FIX_ONE_PIXEL_BLUR_OFFSET,
fixLineWidthByScale,
getCurrentTypeOfRenderer,
IRenderManagerService,
Rect,
ScrollBar,
} from '@univerjs/engine-render';
import { ILayoutService, KeyCode } from '@univerjs/ui';
import { filter } from 'rxjs';
import { SetTextEditArrowOperation } from '../commands/operations/text-edit.operation';
import { SLIDE_EDITOR_ID } from '../const';
import { ISlideEditorBridgeService } from '../services/slide-editor-bridge.service';
import { ISlideEditorManagerService } from '../services/slide-editor-manager.service';
import { CursorChange } from '../type';
const HIDDEN_EDITOR_POSITION = -1000;
const EDITOR_INPUT_SELF_EXTEND_GAP = 5;
const EDITOR_BORDER_SIZE = 2;
interface ICanvasOffset {
left: number;
top: number;
}
export class SlideEditingRenderController extends Disposable implements IRenderModule {
/**
* It is used to distinguish whether the user has actively moved the cursor in the editor, mainly through mouse clicks.
*/
private _cursorChange: CursorChange = CursorChange.InitialState;
/** If the corresponding unit is active and prepared for editing. */
private _isUnitEditing = false;
private _d: Nullable<IDisposable>;
constructor(
private readonly _renderContext: IRenderContext<UnitModel>,
@ILayoutService private readonly _layoutService: ILayoutService,
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,
@IContextService private readonly _contextService: IContextService,
@IUniverInstanceService private readonly _instanceSrv: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@ISlideEditorBridgeService private readonly _editorBridgeService: ISlideEditorBridgeService,
@ISlideEditorManagerService private readonly _cellEditorManagerService: ISlideEditorManagerService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(LocaleService) protected readonly _localService: LocaleService,
@IEditorService private readonly _editorService: IEditorService
) {
super();
// EditingRenderController is per unit. It should only handle keyboard events when the unit is
// the current of its type.
this.disposeWithMe(this._instanceSrv.getCurrentTypeOfUnit$<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE).subscribe((slideDataModel) => {
if (slideDataModel && slideDataModel.getUnitId() === this._renderContext.unitId) {
this._d = this._init();
} else {
this._disposeCurrent();
// Force ending editor when he switch to another workbook.
if (this._isUnitEditing) {
this._handleEditorInvisible({
visible: false,
eventType: DeviceInputEventType.Keyboard,
keycode: KeyCode.ESC,
unitId: this._renderContext.unitId,
});
this._isUnitEditing = false;
}
}
}));
this._initEditorVisibilityListener();
}
override dispose(): void {
super.dispose();
this._disposeCurrent();
}
private _disposeCurrent(): void {
this._d?.dispose();
this._d = null;
}
private _init(): IDisposable {
const d = new DisposableCollection();
this._subscribeToCurrentCell(d);
this._initialKeyboardListener(d);
this._initialCursorSync(d);
this._listenEditorFocus(d);
this._commandExecutedListener(d);
// FIXME: we should not use React component to create editor render, it would make the control
// flow not controllable.
setTimeout(() => {
this._cursorStateListener(d);
}, 1000);
return d;
}
private _initEditorVisibilityListener(): void {
this.disposeWithMe(this._editorBridgeService.visible$
// .pipe(distinctUntilChanged((prev, curr) => prev.visible === curr.visible))
.subscribe((param) => {
if (param.visible) {
this._isUnitEditing = true;
this._handleEditorVisible(param);
} else if (this._isUnitEditing) {
this._handleEditorInvisible(param);
this._isUnitEditing = false;
}
}));
}
private _listenEditorFocus(d: DisposableCollection) {
const renderConfig = this._getEditorObject();
if (!renderConfig) return;
d.add(renderConfig.document.onPointerDown$.subscribeEvent(() => {
// ...
}));
}
private _getEditorSkeleton(editorId: string) {
return this._renderManagerService.getRenderUnitById(editorId)?.with(DocSkeletonManagerService).getSkeleton();
}
private _getEditorViewModel(editorId: string) {
return this._renderManagerService.getRenderUnitById(editorId)?.with(DocSkeletonManagerService).getViewModel();
}
private _initialCursorSync(d: DisposableCollection) {
d.add(this._cellEditorManagerService.focus$.pipe(filter((f) => !!f)).subscribe(() => {
getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_DOC, this._instanceSrv, this._renderManagerService)
?.with(DocSelectionRenderService)
.sync();
}));
}
/**
* Set editorUnitId to curr doc.
* @param d DisposableCollection
*/
private _subscribeToCurrentCell(d: DisposableCollection) {
// first part of editing.
// startEditing --> _updateEditor --> slide-editor-bridge.service.ts@setEditorRect---> currentEditRectState$.next(editCellState)
// 2nd part is startEditing --> changeVisible
d.add(this._editorBridgeService.currentEditRectState$.subscribe((editCellState) => {
if (editCellState == null) {
return;
}
if (
this._contextService.getContextValue(FOCUSING_EDITOR_STANDALONE) ||
this._contextService.getContextValue(FOCUSING_UNIVER_EDITOR_STANDALONE_SINGLE_MODE)
) {
return;
}
const {
position: { startX, endX },
documentLayoutObject: { textRotation, wrapStrategy, documentModel },
scaleX,
editorUnitId,
} = editCellState;
const { vertexAngle: angle } = convertTextRotation(textRotation);
documentModel!.updateDocumentId(editorUnitId);
if (wrapStrategy === WrapStrategy.WRAP && angle === 0) {
documentModel!.updateDocumentDataPageSize((endX - startX) / scaleX);
}
const snapshot = documentModel!.getSnapshot();
const editorDocument = this._instanceSrv.getUnit(editorUnitId, UniverInstanceType.UNIVER_DOC);
if (editorDocument == null) {
this._instanceSrv.createUnit(UniverInstanceType.UNIVER_DOC, snapshot);
} else {
this._commandService.syncExecuteCommand(ReplaceSnapshotCommand.id, {
unitId: editorUnitId,
snapshot,
});
}
this._instanceSrv.setCurrentUnitForType(editorUnitId);
this._textSelectionManagerService.replaceDocRanges([{
startOffset: 0,
endOffset: 0,
}]);
// the valid pos of activate:
// EditorContainer.tsx cellEditorManagerService.setFocus(true) -->
// ---> _focus$.next --> editingRenderController
// _textSelectionRenderManager.sync() --> _updateInputPosition --> activate(left, top)
getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_DOC, this._instanceSrv, this._renderManagerService)
?.with(DocSelectionRenderService)
.activate(HIDDEN_EDITOR_POSITION, HIDDEN_EDITOR_POSITION);
}));
}
/**
* Set size and pos of editing area.
* @param positionFromEditRectState
* @param canvasOffset
* @param documentSkeleton
* @param documentLayoutObject
* @param scaleX
* @param scaleY
*/
private _fitTextSize(
positionFromEditRectState: IPosition,
canvasOffset: ICanvasOffset,
documentSkeleton: DocumentSkeleton,
documentLayoutObject: IDocumentLayoutObject,
scaleX: number = 1,
scaleY: number = 1
) {
//size & pos derives from slide-editor-bridge.service.ts@getEditRectState.position
const { startX, startY, endX, endY } = positionFromEditRectState;
const documentDataModel = documentLayoutObject.documentModel;
if (documentDataModel == null) {
return;
}
const { actualWidth, actualHeight } = this._predictingSize(
positionFromEditRectState,
canvasOffset,
documentSkeleton,
documentLayoutObject,
scaleX,
scaleY
);
const { verticalAlign, paddingData, fill } = documentLayoutObject;
let editorWidth = endX - startX;
let editorHeight = endY - startY;
if (editorWidth < actualWidth) {
editorWidth = actualWidth;
}
if (editorHeight < actualHeight) {
editorHeight = actualHeight;
// To restore the page margins for the skeleton.
documentDataModel.updateDocumentDataMargin(paddingData);
} else {
// Set the top margin under vertical alignment.
let offsetTop = 0;
if (verticalAlign === VerticalAlign.MIDDLE) {
offsetTop = (editorHeight - actualHeight) / 2 / scaleY;
} else if (verticalAlign === VerticalAlign.TOP) {
offsetTop = paddingData.t || 0;
} else { // VerticalAlign.UNSPECIFIED follow the same rule as HorizontalAlign.BOTTOM.
offsetTop = (editorHeight - actualHeight) / scaleY - (paddingData.b || 0);
}
// offsetTop /= scaleY;
offsetTop = offsetTop < (paddingData.t || 0) ? paddingData.t || 0 : offsetTop;
documentDataModel.updateDocumentDataMargin({
t: offsetTop,
});
}
// re-calculate skeleton(viewModel for component)
documentSkeleton.calculate();
this._editAreaProcessing(editorWidth, editorHeight, positionFromEditRectState, canvasOffset, fill, scaleX, scaleY);
}
/**
* Mainly used to pre-calculate the width of the editor,
* to determine whether it needs to be automatically widened.
*/
private _predictingSize(
actualRangeWithCoord: IPosition,
canvasOffset: ICanvasOffset,
documentSkeleton: DocumentSkeleton,
documentLayoutObject: IDocumentLayoutObject,
scaleX: number = 1,
scaleY: number = 1
) {
// startX and startY are the width and height after scaling.
const { startX, endX } = actualRangeWithCoord;
const { textRotation, wrapStrategy } = documentLayoutObject;
const documentDataModel = documentLayoutObject.documentModel;
const { vertexAngle: angle } = convertTextRotation(textRotation);
const clientWidth = document.body.clientWidth;
if (wrapStrategy === WrapStrategy.WRAP && angle === 0) {
const { actualWidth, actualHeight } = documentSkeleton.getActualSize();
// The skeleton obtains the original volume, which needs to be multiplied by the magnification factor.
return {
actualWidth: actualWidth * scaleX,
actualHeight: actualHeight * scaleY,
};
}
documentDataModel?.updateDocumentDataPageSize((clientWidth - startX - canvasOffset.left) / scaleX);
documentSkeleton.calculate();
const size = documentSkeleton.getActualSize();
let editorWidth = endX - startX;
if (editorWidth < size.actualWidth * scaleX + EDITOR_INPUT_SELF_EXTEND_GAP * scaleX) {
editorWidth = size.actualWidth * scaleX + EDITOR_INPUT_SELF_EXTEND_GAP * scaleX;
}
// Scaling is handled by the renderer, so the skeleton only accepts the original width and height, which need to be divided by the magnification factor.
documentDataModel?.updateDocumentDataPageSize(editorWidth / scaleX);
/**
* Do not rely on cell layout logic, depend on the document's internal alignment logic.
*/
documentDataModel?.updateDocumentRenderConfig({
horizontalAlign: HorizontalAlign.UNSPECIFIED,
cellValueType: undefined,
});
return {
actualWidth: editorWidth,
actualHeight: size.actualHeight * scaleY,
};
}
/**
* control the size of editing area
*/
// eslint-disable-next-line max-lines-per-function
private _editAreaProcessing(
editorWidth: number,
editorHeight: number,
positionFromEditRectState: IPosition,
canvasOffset: ICanvasOffset,
fill: Nullable<string>,
scaleX: number = 1,
scaleY: number = 1
) {
const editorObject = this._getEditorObject();
if (editorObject == null) {
return;
}
function pxToNum(width: string): number {
return Number.parseInt(width.replace('px', ''));
}
const engine = this._renderContext.engine;
const canvasElement = engine.getCanvasElement();
const canvasClientRect = canvasElement.getBoundingClientRect();
// We should take the scale into account when canvas is scaled by CSS.
const widthOfCanvas = pxToNum(canvasElement.style.width); // declared width
const { top, left, width } = canvasClientRect; // real width affected by scale
const scaleAdjust = width / widthOfCanvas;
let { startX, startY } = positionFromEditRectState;
startX += canvasOffset.left;
startY += canvasOffset.top;
const { document: documentComponent, scene: editorScene, engine: docEngine } = editorObject;
const viewportMain = editorScene.getViewport(VIEWPORT_KEY.VIEW_MAIN);
const clientHeight =
document.body.clientHeight -
startY -
canvasOffset.top -
EDITOR_BORDER_SIZE * 2;
const clientWidth = document.body.clientWidth - startX - canvasOffset.left;
let physicHeight = editorHeight;
let scrollBar = viewportMain?.getScrollBar() as Nullable<ScrollBar>;
if (physicHeight > clientHeight) {
physicHeight = clientHeight;
if (scrollBar == null) {
if (viewportMain) {
const sb = new ScrollBar(viewportMain, { enableHorizontal: false, barSize: 8 });
}
} else {
viewportMain?.resetCanvasSizeAndUpdateScroll();
}
} else {
scrollBar = null;
viewportMain?.getScrollBar()?.dispose();
}
editorWidth += scrollBar?.barSize || 0;
editorWidth = Math.min(editorWidth, clientWidth);
startX -= FIX_ONE_PIXEL_BLUR_OFFSET;
startY -= FIX_ONE_PIXEL_BLUR_OFFSET;
// physicHeight = editorHeight;
this._addBackground(editorScene, editorWidth / scaleX, editorHeight / scaleY, fill);
const { scaleX: precisionScaleX, scaleY: precisionScaleY } = editorScene.getPrecisionScale();
editorScene.transformByState({
width: editorWidth * scaleAdjust / scaleX,
height: editorHeight * scaleAdjust / scaleY,
scaleX: scaleX * scaleAdjust,
scaleY: scaleY * scaleAdjust,
});
documentComponent.resize(editorWidth / scaleX, editorHeight / scaleY);
/**
* sometimes requestIdleCallback is invalid, so use setTimeout to ensure the successful execution of the resizeBySize method.
* resize canvas
* When modifying the selection area for a formula, it is necessary to add a setTimeout to ensure successful updating.
*/
setTimeout(() => {
docEngine.resizeBySize(
fixLineWidthByScale(editorWidth, precisionScaleX),
fixLineWidthByScale(physicHeight, precisionScaleY)
);
}, 0);
const contentBoundingRect = this._layoutService.getContentElement().getBoundingClientRect();
const canvasBoundingRect = canvasElement.getBoundingClientRect();
startX = startX * scaleAdjust + (canvasBoundingRect.left - contentBoundingRect.left);
startY = startY * scaleAdjust + (canvasBoundingRect.top - contentBoundingRect.top);
// Update cell editor container position and size.
this._cellEditorManagerService.setState({
startX,
startY,
endX: editorWidth * scaleAdjust + startX,
endY: physicHeight * scaleAdjust + startY,
show: true,
});
}
/**
* Since the document does not support cell background color, an additional rect needs to be added.
*/
private _addBackground(scene: Scene, editorWidth: number, editorHeight: number, fill?: Nullable<string>) {
const fillRectKey = '_backgroundRectHelperColor_';
const rect = scene.getObject(fillRectKey) as Rect;
if (rect == null && fill == null) {
return;
}
if (rect == null) {
scene.addObjects(
[
new Rect(fillRectKey, {
width: editorWidth,
height: editorHeight,
fill,
evented: false,
}),
],
DOCS_COMPONENT_MAIN_LAYER_INDEX
);
} else if (fill == null) {
rect.dispose();
} else {
rect.setProps({
fill,
});
rect.transformByState({
width: editorWidth,
height: editorHeight,
});
}
}
/**
* Show input area, resize input area and then place input to right place.
* @TODO why do resize in show input area?
* @param param
*/
// handleVisible is the 2nd part of editing.
// first part: startEditing --> _updateEditor
// 2nd part: startEditing --> changeVisible --> slide-editor-bridge.render-controller.ts@changeVisible --> _editorBridgeService.changeVisible
private _handleEditorVisible(param: IEditorBridgeServiceVisibleParam) {
const { eventType } = param;
// Change `CursorChange` to changed status, when formula bar clicked.
this._cursorChange =
[DeviceInputEventType.PointerDown, DeviceInputEventType.Dblclick].includes(eventType)
? CursorChange.CursorChange
: CursorChange.StartEditor;
const editCellState = this._editorBridgeService.getEditRectState();
if (editCellState == null) {
return;
}
const {
position,
documentLayoutObject,
slideCardOffset: canvasOffset,
scaleX,
scaleY,
editorUnitId,
unitId,
} = editCellState;
const editorObject = this._getEditorObject();
if (editorObject == null) {
return;
}
const { scene } = editorObject;
this._contextService.setContextValue(EDITOR_ACTIVATED, true);
const { documentModel: documentDataModel } = documentLayoutObject;
const skeleton = this._getEditorSkeleton(editorUnitId);
if (!skeleton || !documentDataModel) {
return;
}
// core function! set editing area size and pos
this._fitTextSize(position, canvasOffset, skeleton, documentLayoutObject, scaleX, scaleY);
// TODO: @JOCS, Get the position close to the cursor after clicking on the cell.
const cursor = documentDataModel.getBody()!.dataStream.length - 2 || 0;
scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)?.scrollToViewportPos({
viewportScrollX: Number.POSITIVE_INFINITY,
});
this._textSelectionManagerService.replaceDocRanges([
{
startOffset: cursor,
endOffset: cursor,
},
]);
this._renderManagerService.getRenderUnitById(unitId)?.scene.resetCursor();
}
private _resetBodyStyle(body: IDocumentBody, removeStyle = false) {
body.dataStream = DEFAULT_EMPTY_DOCUMENT_VALUE;
if (body.textRuns != null) {
if (body.textRuns.length === 1 && !removeStyle) {
body.textRuns[0].st = 0;
body.textRuns[0].ed = 1;
} else {
body.textRuns = undefined;
}
}
if (body.paragraphs != null) {
if (body.paragraphs.length === 1) {
body.paragraphs[0].startIndex = 0;
} else {
body.paragraphs = [
{
startIndex: 0,
paragraphId: createParagraphId(new Set()),
},
];
}
}
if (body.sectionBreaks != null) {
body.sectionBreaks = undefined;
}
if (body.tables != null) {
body.tables = undefined;
}
if (body.customRanges != null) {
body.customRanges = undefined;
}
if (body.customBlocks != null) {
body.customBlocks = undefined;
}
}
/**
* Should activate the editor when the user inputs text.
* @param d DisposableCollection
*/
private _initialKeyboardListener(d: DisposableCollection) {
// d.add(this._textSelectionRenderManager.onInputBefore$.subscribe((config) => {
// // const isFocusFormulaEditor = this._contextService.getContextValue(FORMULA_EDITOR_ACTIVATED);
// // const isFocusSheets = this._contextService.getContextValue(FOCUSING_SHEET);
// // // TODO@Jocs: should get editor instead of current doc
// // const unitId = this._instanceSrv.getCurrentUniverDocInstance()?.getUnitId();
// // if (unitId && isFocusSheets && !isFocusFormulaEditor && this._editorService.isSheetEditor(unitId)) {
// // this._showEditorByKeyboard(config);
// // }
// }));
}
private _showEditorByKeyboard(config: Nullable<IEditorInputConfig>) {
if (config == null) {
//..
}
// const event = config.event as KeyboardEvent;
// this._commandService.executeCommand(SetCellEditVisibleOperation.id, {
// visible: true,
// eventType: DeviceInputEventType.Keyboard,
// keycode: event.which,
// unitId: this._context.unitId,
// });
}
private _commandExecutedListener(d: DisposableCollection) {
const moveCursorOP = [SetTextEditArrowOperation.id];
const editedMutations = [RichTextEditingMutation.id];
d.add(this._commandService.onCommandExecuted((command: ICommandInfo) => {
// Only should do something when it is the current editor.
// FIXME: listen to command execution is pretty expensive. We should
// have multi editor instances and only handle event from a single editor.
if (this._editorService.getFocusId() !== SLIDE_EDITOR_ID) {
// if (this._editorService.getFocusId() !== this._renderContext.unitId) {
return;
}
if (moveCursorOP.includes(command.id)) {
this._moveCursorCmdHandler(command);
}
if (editedMutations.includes(command.id)) {
if (this._editorBridgeService.isVisible()) {
this._editingChangedHandler();
}
}
}));
}
private _moveCursorCmdHandler(command: ICommandInfo) {
const params = command.params as IEditorBridgeServiceVisibleParam & { isShift: boolean };
const { keycode, isShift } = params;
/**
* After the user enters the editor and actively moves the editor selection area with the mouse,
* the up, down, left, and right keys can no longer switch editing cells,
* but move the cursor within the editor instead.
*/
if (keycode != null && this._cursorChange === CursorChange.CursorChange) {
this._moveInEditor(keycode, isShift);
} else {
// TODO @Jocs: After we merging editor related controllers, this seems verbose.
this._editorBridgeService.changeVisible(params);
}
}
private _editingChangedHandler() {
const editRect = this._editorBridgeService.getEditorRect();
if (!editRect) {
return;
}
const editingRichText = editRect.richTextObj;
editingRichText.refreshDocumentByDocData();
editingRichText.resizeToContentSize();
const { unitId } = this._renderContext;
this._handleEditorVisible({ visible: true, eventType: 3, unitId });
}
private _getEditorObject() {
return getEditorObject(this._editorBridgeService.getCurrentEditorId(), this._renderManagerService);
}
private async _handleEditorInvisible(param: IEditorBridgeServiceVisibleParam) {
const { keycode } = param;
this._cursorChange = CursorChange.InitialState;
this._exitInput(param);
const editCellState = this._editorBridgeService.getEditRectState();
if (editCellState == null) {
return;
}
// const { unitId, documentLayoutObject } = editCellState;
// If neither the formula bar editor nor the cell editor has been edited,
// it is considered that the content has not changed and returns directly.
const editorIsDirty = this._editorBridgeService.getEditorDirty();
if (editorIsDirty === false) {
this._moveCursor(keycode);
return;
}
// moveCursor need to put behind of SetRangeValuesCommand, fix https://github.com/dream-num/univer/issues/1155
this._moveCursor(keycode);
}
private _exitInput(param: IEditorBridgeServiceVisibleParam) {
this._contextService.setContextValue(EDITOR_ACTIVATED, false);
this._cellEditorManagerService.setState({
show: param.visible,
});
const editorUnitId = this._editorBridgeService.getCurrentEditorId();
if (editorUnitId == null) {
return;
}
this._undoRedoService.clearUndoRedo(editorUnitId);
}
private _moveCursor(keycode?: KeyCode) {
if (keycode == null) {
return;
}
let direction = Direction.LEFT;
switch (keycode) {
case KeyCode.ENTER:
direction = Direction.DOWN;
break;
case KeyCode.TAB:
direction = Direction.RIGHT;
break;
case KeyCode.ARROW_DOWN:
direction = Direction.DOWN;
break;
case KeyCode.ARROW_UP:
direction = Direction.UP;
break;
case KeyCode.ARROW_LEFT:
direction = Direction.LEFT;
break;
case KeyCode.ARROW_RIGHT:
direction = Direction.RIGHT;
break;
}
}
/**
* The user's operations follow the sequence of opening the editor and then moving the cursor.
* The logic here predicts the user's first cursor movement behavior based on this rule
*/
private _cursorStateListener(d: DisposableCollection) {
const editorObject = this._getEditorObject();
if (!editorObject) {
return;
}
const { document: documentComponent } = editorObject;
d.add(toDisposable(documentComponent.onPointerDown$.subscribeEvent(() => {
if (this._cursorChange === CursorChange.StartEditor) {
this._cursorChange = CursorChange.CursorChange;
}
})));
}
// TODO: @JOCS, is it necessary to move these commands MoveSelectionOperation\MoveCursorOperation to shortcut? and use multi-commands?
private _moveInEditor(keycode: KeyCode, isShift: boolean) {
let direction = Direction.LEFT;
if (keycode === KeyCode.ARROW_DOWN) {
direction = Direction.DOWN;
} else if (keycode === KeyCode.ARROW_UP) {
direction = Direction.UP;
} else if (keycode === KeyCode.ARROW_RIGHT) {
direction = Direction.RIGHT;
}
if (isShift) {
this._commandService.executeCommand(MoveSelectionOperation.id, {
direction,
});
} else {
this._commandService.executeCommand(MoveCursorOperation.id, {
direction,
});
}
}
}
export function getEditorObject(
unitId: Nullable<string>,
renderManagerService: IRenderManagerService
): Nullable<IDocObjectParam> {
if (unitId == null) {
return;
}
const currentRender = renderManagerService.getRenderUnitById(unitId);
if (currentRender == null) {
return;
}
const { mainComponent, scene, engine, components } = currentRender;
const document = mainComponent as Documents;
const docBackground = components.get(DOCS_VIEW_KEY.BACKGROUND) as DocBackground;
return {
document,
docBackground,
scene,
engine,
};
}

View File

@@ -0,0 +1,227 @@
/**
* 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, UnitModel } from '@univerjs/core';
import type { BaseObject, IRenderContext, IRenderModule, RichText, Scene, Slide } from '@univerjs/engine-render';
import type { SlideDataModel } from '@univerjs/slides';
import type { ISetEditorInfo } from '../services/slide-editor-bridge.service';
import type { ISlideRichTextProps } from '../type';
import { DisposableCollection, ICommandService, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { DeviceInputEventType, ObjectType } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { UpdateSlideElementOperation } from '../commands/operations/update-element.operation';
import { ISlideEditorBridgeService } from '../services/slide-editor-bridge.service';
// interface ICanvasOffset {
// left: number;
// top: number;
// }
// enum CursorChange {
// InitialState,
// StartEditor,
// CursorChange,
// }
export class SlideEditorBridgeRenderController extends RxDisposable implements IRenderModule {
/**
* It is used to distinguish whether the user has actively moved the cursor in the editor, mainly through mouse clicks.
*/
// private _cursorChange: CursorChange = CursorChange.InitialState;
/** If the corresponding unit is active and prepared for editing. */
// private _isUnitEditing = false;
setSlideTextEditor$: Subject<ISlideRichTextProps> = new Subject();
private _curRichText = null as RichText | null;
private _d: Nullable<IDisposable>;
constructor(
private readonly _renderContext: IRenderContext<UnitModel>,
@IUniverInstanceService private readonly _instanceSrv: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@ISlideEditorBridgeService private readonly _editorBridgeService: ISlideEditorBridgeService
) {
super();
this.disposeWithMe(this._instanceSrv.getCurrentTypeOfUnit$<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE).subscribe((slideDataModel) => {
if (slideDataModel && slideDataModel.getUnitId() === this._renderContext.unitId) {
this._d = this._init();
} else {
this._disposeCurrent();
}
}));
}
private _init(): IDisposable {
const d = new DisposableCollection();
this._initEventListener(d);
return d;
}
private _disposeCurrent(): void {
this._d?.dispose();
this._d = null;
}
private _setEditorRect(pageId: string, targetObject: RichText) {
this._curRichText = targetObject as RichText;
const { scene, engine } = this._renderContext;
const unitId = this._renderContext.unitId;
const setEditorRect: ISetEditorInfo = {
scene,
engine,
unitId,
pageId,
richTextObj: targetObject,
};
this._editorBridgeService.setEditorRect(setEditorRect);
}
private _initEventListener(d: DisposableCollection) {
const listenersForPageScene = (scene: Scene) => {
const transformer = scene.getTransformer();
if (!transformer) return;
d.add(transformer.clearControl$.subscribe(() => {
this.setEditorVisible(false);
this.pickOtherObjects();
}));
d.add(transformer.createControl$.subscribe(() => {
this.setEditorVisible(false);
}));
// d.add(transformer.changeStart$.subscribe((param: IChangeObserverConfig) => {
// const target = param.target;
// if (!target) return;
// if (target === this._curRichText) {
// // do nothing
// } else {
// this.pickOtherObjects();
// }
// }));
d.add(scene.onDblclick$.subscribeEvent(() => {
transformer.clearControls();
const selectedObjects = transformer.getSelectedObjectMap();
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) return;
if (object.objectType !== ObjectType.RICH_TEXT) {
this.pickOtherObjects();
} else {
this.startEditing(scene.sceneKey, object as RichText);
}
}));
d.add(this._instanceSrv.focused$.subscribe((fc: Nullable<string>) => {
this.endEditing();
}));
};
// const model = this._instanceSrv.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
// const pagesMap = model?.getPages() ?? {};
// const pages = Object.values(pagesMap);
const { mainComponent } = this._renderContext;
const slide = mainComponent as Slide;
// for new pages created by user
// TODO: better only one pageScene listener. but how?
// TODO: this controller is in render stage. but default page created in ready stage.
slide.subSceneChanged$.subscribeEvent((pageScene: Scene) => {
listenersForPageScene(pageScene);
});
// for default pages that already exist
const pageSceneList = Array.from((mainComponent as Slide).getSubScenes().values());
for (let i = 0; i < pageSceneList.length; i++) {
const pageScene = pageSceneList[i] as Scene;
listenersForPageScene(pageScene);
}
}
pickOtherObjects() {
this.endEditing();
}
/**
* invoked when picking other object.
*
* save editing state to curr richText.
*/
endEditing() {
if (!this._curRichText) return;
this.setEditorVisible(false);
const curRichText = this._curRichText;
const slideData = this._instanceSrv.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
if (!slideData) return false;
curRichText.refreshDocumentByDocData();
curRichText.resizeToContentSize();
const richText: Record<string, any> = {
bl: 1,
fs: curRichText.fs,
text: curRichText.text,
};
const textRuns = curRichText.documentData.body?.textRuns;
if (textRuns && textRuns.length) {
const textRun = textRuns[0];
const ts = textRun.ts;
richText.cl = ts?.cl;
}
this._commandService.executeCommand(UpdateSlideElementOperation.id, {
unitId: this._renderContext.unitId,
oKey: curRichText?.oKey,
props: {
richText,
},
});
this._curRichText = null;
}
/**
* TODO calling twice ????
* editingParam derives from RichText object.
*
* TODO @lumixraku need scale param
* @param target
*/
startEditing(pageId: string, target: RichText) {
// this.setSlideTextEditor$.next({ content, rect });
this._setEditorRect(pageId, target);
this.setEditorVisible(true);
}
setEditorVisible(visible: boolean) {
// if editor is visible, hide curr RichTerxtObject
if (visible) {
this._curRichText?.hide();
} else {
this._curRichText?.show();
}
const { unitId } = this._renderContext;
this._editorBridgeService.changeVisible({ visible, eventType: DeviceInputEventType.PointerDown, unitId });
}
}

View File

@@ -0,0 +1,471 @@
/**
* 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 { EventState, IColorStyle, Nullable, UnitModel } from '@univerjs/core';
import type { BaseObject, IRenderContext, IRenderModule, IWheelEvent } from '@univerjs/engine-render';
import type { IPageElement, ISlidePage, SlideDataModel } from '@univerjs/slides';
import type { PageID } from '../type';
import { debounce, getColorStyle, Inject, Injector, IUniverInstanceService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import {
getCurrentTypeOfRenderer,
IRenderManagerService,
Rect,
Scene,
ScrollBar,
Slide,
Viewport,
} from '@univerjs/engine-render';
import { ObjectProvider, SLIDE_KEY } from '@univerjs/slides';
export class SlideRenderController extends RxDisposable implements IRenderModule {
private _objectProvider: ObjectProvider | null = null;
constructor(
private readonly _renderContext: IRenderContext<UnitModel>,
@Inject(Injector) private readonly _injector: Injector,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
this._objectProvider = this._injector.createInstance(ObjectProvider);
this._addNewRender();
}
private _addNewRender() {
const { unitId, engine, scene } = this._renderContext;
const slideDataModel = this._getCurrUnitModel();
if (!slideDataModel) return;
// createRender moved to slideRenderService@this._instanceSrv.getAllUnitsForType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE).forEach((slideModel)
// this._renderManagerService.createRender(unitId);
//#region scene subscribe
// const { engine, scene } = currentRender;
const observer = engine.onTransformChange$.subscribeEvent(() => {
this._scrollToCenter();
// add once
observer?.unsubscribe();
});
engine.onTransformChange$.subscribeEvent(() => {
setTimeout(() => {
this.createThumbs();
}, 300);
});
const viewMain = new Viewport(SLIDE_KEY.VIEW, scene, {
left: 0,
top: 0,
bottom: 0,
right: 0,
explicitViewportWidthSet: false,
explicitViewportHeightSet: false,
isWheelPreventDefaultX: true,
});
scene.attachControl();
scene.onMouseWheel$.subscribeEvent((evt: unknown, state: EventState) => {
const e = evt as IWheelEvent;
if (e.ctrlKey) {
const deltaFactor = Math.abs(e.deltaX);
let scrollNum = deltaFactor < 40 ? 0.2 : deltaFactor < 80 ? 0.4 : 0.2;
scrollNum *= e.deltaY > 0 ? -1 : 1;
if (scene.scaleX < 1) {
scrollNum /= 2;
}
if (scene.scaleX + scrollNum > 4) {
scene.scale(4, 4);
} else if (scene.scaleX + scrollNum < 0.1) {
scene.scale(0.1, 0.1);
} else {
const value = e.deltaY > 0 ? 0.1 : -0.1;
// scene.scaleBy(scrollNum, scrollNum);
e.preventDefault();
}
} else {
viewMain.onMouseWheel(e, state);
}
});
scene.onFileLoaded$.subscribeEvent(() => {
this._refreshThumb();
});
//#endregion
ScrollBar.attachTo(viewMain);
// this._renderManagerService.setCurrent(unitId);
// #region create slide
const slide = this._createSlide(scene);
this._renderContext.mainComponent = slide;
this._createSlidePages(slideDataModel, slide);
this.createThumbs();
// #endregion
engine.runRenderLoop(() => {
scene.render();
});
}
private _scrollToCenter() {
const mainScene = this._currentRender()?.scene;
const viewMain = mainScene?.getViewport(SLIDE_KEY.VIEW);
const getCenterPositionViewPort = this._getCenterPositionViewPort(mainScene);
if (!viewMain || !getCenterPositionViewPort) return;
const { left: viewPortLeft, top: viewPortTop } = getCenterPositionViewPort;
const { x, y } = viewMain.transViewportScroll2ScrollValue(viewPortLeft, viewPortTop);
viewMain.scrollToBarPos({
x,
y,
});
}
private _currentRender() {
return getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SLIDE, this._univerInstanceService, this._renderManagerService);
}
private _refreshThumb = debounce(() => {
this.createThumbs();
}, 300);
/**
* @param mainScene
*/
private _createSlide(mainScene: Scene) {
const model = this._univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE)!;
const { width: sceneWidth, height: sceneHeight } = mainScene;
const pageSize = model.getPageSize();
const { width = 100, height = 100 } = pageSize;
const slideComponent = new Slide(SLIDE_KEY.COMPONENT, {
left: (sceneWidth - width) / 2,
top: (sceneHeight - height) / 2,
width,
height,
zIndex: 10,
});
// slideComponent.enableNav();
slideComponent.enableSelectedClipElement();
mainScene.addObject(slideComponent);
return slideComponent;
}
private _addBackgroundRect(scene: Scene, fill: IColorStyle) {
const model = this._univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE)!;
const pageSize = model.getPageSize();
const { width: pageWidth = 0, height: pageHeight = 0 } = pageSize;
const page = new Rect('canvas', {
left: 0,
top: 0,
width: pageWidth,
height: pageHeight,
strokeWidth: 1,
stroke: 'rgba(198,198,198,1)',
fill: getColorStyle(fill) || 'rgba(255,255,255,1)',
zIndex: 0,
evented: false,
});
scene.addObject(page, 0);
}
private _getCenterPositionViewPort(mainScene?: Scene) {
if (!mainScene) return { left: 0, top: 0 };
const { width, height } = mainScene;
const engine = mainScene.getEngine();
const canvasWidth = engine?.width || 0;
const canvasHeight = engine?.height || 0;
return {
left: (width - canvasWidth) / 2,
top: (height - canvasHeight) / 2,
};
}
private _thumbSceneRender(pageId: string, slide: Slide) {
const render = this._renderManagerService.getRenderUnitById(pageId);
if (render == null) {
return;
}
const { engine: thumbEngine } = render;
if (thumbEngine == null) {
return;
}
const { width, height } = slide;
const { width: pageWidth = width, height: pageHeight = height } = thumbEngine;
const thumbContext = thumbEngine.getCanvas().getContext();
slide.renderToThumb(thumbContext, pageId, pageWidth / width, pageHeight / height);
}
/**
* CreateScene by pages, and activate first one.
* @param slideDataModel
* @param slide
*/
private _createSlidePages(slideDataModel: SlideDataModel, slide: Slide) {
const pages = slideDataModel.getPages();
const pageOrder = slideDataModel.getPageOrder();
if (!pages || !pageOrder) {
return;
}
if (pageOrder.length === 0) {
return;
}
for (let i = 0, len = pageOrder.length; i < len; i++) {
const pageId = pageOrder[i];
this.createPageScene(pageId, pages[pageId]);
this._createThumb(pageId);
}
// setTimeout(() => {
// for (let i = 0, len = pageOrder.length; i < len; i++) {
// const pageId = pageOrder[i];
// this._thumbSceneRender(pageId, slide);
// }
// }, 0);
slide.activeFirstPage();
}
private _createThumb(pageId: string) {
this._renderManagerService.createRender(pageId);
}
/**
* SlideDataModel is UnitModel
*/
private _getCurrUnitModel() {
// return this._univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE)!;
return this._renderContext.unit as SlideDataModel;
}
activePage(_pageId?: string) {
let pageId = _pageId;
const model = this._getCurrUnitModel();
let page: Nullable<ISlidePage>;
if (pageId) {
page = model.getPage(pageId);
} else {
const pageElements = model.getPages();
const pageOrder = model.getPageOrder();
if (pageOrder == null || pageElements == null) {
return;
}
page = pageElements[pageOrder[0]];
pageId = page.id;
}
const render = this._currentRender();
if (page == null || render == null || render.mainComponent == null) {
return;
}
const { id } = page;
const slide = render.mainComponent as Slide;
model.setActivePage(page);
if (slide?.hasPage(id)) {
slide.changePage(id);
return;
}
this.createPageScene(id, page);
}
createThumbs() {
const slideDataModel = this._getCurrUnitModel();
const pageOrder = slideDataModel.getPageOrder();
const render = this._currentRender();
if (!pageOrder || !render) {
return;
}
if (pageOrder.length === 0) {
return;
}
for (let i = 0, len = pageOrder.length; i < len; i++) {
const pageId = pageOrder[i];
this._thumbSceneRender(pageId, render.mainComponent as Slide);
}
}
/**
* Create scene by page and set to _sceneMap.
* @param pageId
* @param page
*/
createPageScene(pageId: string, page: ISlidePage): Nullable<Scene> {
// const render = this._currentRender();
const render = this._renderContext;
if (!render || !this._objectProvider) {
return;
}
const { scene: mainScene, mainComponent } = render;
const slide = mainComponent as Slide;
const { width, height } = slide;
const pageScene = new Scene(pageId, slide, {
width,
height,
});
const viewMain = new Viewport(`PageViewer_${pageId}`, pageScene, {
left: 0,
top: 0,
bottom: 0,
right: 0,
explicitViewportWidthSet: false,
explicitViewportHeightSet: false,
});
viewMain.closeClip();
const { pageElements, pageBackgroundFill } = page;
// SceneViewers
const objects = this._objectProvider.convertToRenderObjects(pageElements, mainScene);
if (!objects || !slide) return;
this._addBackgroundRect(pageScene, pageBackgroundFill);
pageScene.addObjects(objects);
pageScene.initTransformer();
objects.forEach((object) => {
pageScene.attachTransformerTo(object);
});
const transformer = pageScene.getTransformer();
transformer?.changeEnd$.subscribe(() => {
this._thumbSceneRender(pageId, slide);
});
transformer?.clearControl$.subscribe(() => {
this._thumbSceneRender(pageId, slide);
});
// add SubScene
slide.addPageScene(pageScene);
return pageScene;
}
/**
* Get pageScene from Slide.
* @param pageId
* @returns {Scene, Engine, UnitModel} scene & engine & unit from renderContext
*/
getPageRenderUnit(pageId: PageID) {
//pageScene was added to the mainComponent(Slide) in createPageScene --> slide.addPageScene
const subsceneMap = (this._renderContext.mainComponent as Slide).getSubScenes();
const pageScene = subsceneMap.get(pageId) as unknown as Scene;
const { engine, unit } = this._renderContext;
return {
scene: pageScene,
engine,
unit,
};
}
createObjectToPage(element: IPageElement, pageID: PageID): Nullable<BaseObject> {
const { scene } = this.getPageRenderUnit(pageID);
if (!scene || !this._objectProvider) {
return;
}
const object = this._objectProvider.convertToRenderObject(element, scene);
if (object) {
scene.addObject(object);
scene.attachTransformerTo(object);
scene.getLayer().makeDirty();
return object;
}
}
setObjectActiveByPage(obj: BaseObject, pageID: PageID) {
const { scene } = this.getPageRenderUnit(pageID);
if (!scene) return;
const transformer = scene.getTransformer();
transformer?.activeAnObject(obj);
}
removeObjectById(id: string, pageID: PageID) {
const { scene } = this.getPageRenderUnit(pageID);
if (!scene) return;
scene.removeObject(id);
const transformer = scene.getTransformer();
transformer?.clearControls();
}
appendPage() {
const model = this._getCurrUnitModel();
const page = model.getBlankPage();
const render = this._currentRender();
if (page == null || render == null || render.mainComponent == null) {
return;
}
const { id: pageId } = page;
const slide = render.mainComponent as Slide;
const scene = this.createPageScene(pageId, page);
if (slide && scene) {
slide.addPageScene(scene);
}
model.appendPage(page);
model.setActivePage(page);
}
}

View File

@@ -0,0 +1,102 @@
/**
* 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, IShortcutService, IUIPartsService } from '@univerjs/ui';
import { ActivateSlidePageOperation } from '../commands/operations/activate.operation';
import { AppendSlideOperation } from '../commands/operations/append-slide.operation';
import { DeleteSlideElementOperation } from '../commands/operations/delete-element.operation';
import { InsertSlideFloatImageCommand } from '../commands/operations/insert-image.operation';
import {
InsertSlideShapeEllipseCommand,
InsertSlideShapeEllipseOperation,
InsertSlideShapeRectangleCommand,
InsertSlideShapeRectangleOperation,
ToggleSlideEditSidebarOperation,
} from '../commands/operations/insert-shape.operation';
import { SlideAddTextCommand, SlideAddTextOperation } from '../commands/operations/insert-text.operation';
import { SetSlidePageThumbOperation } from '../commands/operations/set-thumb.operation';
import { SetTextEditArrowOperation } from '../commands/operations/text-edit.operation';
import { UpdateSlideElementOperation } from '../commands/operations/update-element.operation';
import { menuSchema } from '../menu/schema';
import { SlideEditorContainer } from '../views/editor-container/EditorContainer';
import { SlideSideBar } from '../views/slide-bar/SlideBar';
import { EditorDeleteLeftShortcut, generateArrowSelectionShortCutItem } from './shortcuts/editor.shortcuts';
/**
* This controller registers UI parts of slide workbench to the base-ui workbench.
*/
export class SlidesUIController extends Disposable {
constructor(
@Inject(Injector) protected readonly _injector: Injector,
@IMenuManagerService protected readonly _menuManagerService: IMenuManagerService,
@IUIPartsService protected readonly _uiPartsService: IUIPartsService,
@ICommandService protected readonly _commandService: ICommandService,
@IShortcutService protected readonly _shortcutService: IShortcutService
) {
super();
this._initCommands();
this._initUIComponents();
this._initMenus();
this._initShortcuts();
}
private _initMenus(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initCommands(): void {
[
AppendSlideOperation,
ActivateSlidePageOperation,
SetSlidePageThumbOperation,
InsertSlideFloatImageCommand,
SlideAddTextOperation,
SlideAddTextCommand,
InsertSlideShapeEllipseCommand,
InsertSlideShapeEllipseOperation,
InsertSlideShapeRectangleOperation,
InsertSlideShapeRectangleCommand,
ToggleSlideEditSidebarOperation,
DeleteSlideElementOperation,
UpdateSlideElementOperation,
// commands for editor
SetTextEditArrowOperation,
].forEach((command) => this.disposeWithMe(this._commandService.registerCommand(command)));
}
protected _initUIComponents(): void {
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.LEFT_SIDEBAR, () => connectInjector(SlideSideBar, this._injector))
);
this.disposeWithMe(
this._uiPartsService.registerComponent(BuiltInUIPart.CONTENT, () => connectInjector(SlideEditorContainer, this._injector))
);
}
private _initShortcuts(): void {
[
EditorDeleteLeftShortcut,
...generateArrowSelectionShortCutItem(),
].forEach((item) => {
this.disposeWithMe(this._shortcutService.registerShortcut(item));
});
}
}

View File

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

View File

@@ -0,0 +1,41 @@
/**
* 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 { ActivateSlidePageOperation } from './commands/operations/activate.operation';
export { AppendSlideOperation } from './commands/operations/append-slide.operation';
export { DeleteSlideElementOperation } from './commands/operations/delete-element.operation';
export { InsertSlideFloatImageCommand } from './commands/operations/insert-image.operation';
export {
InsertSlideShapeEllipseCommand,
InsertSlideShapeEllipseOperation,
InsertSlideShapeRectangleCommand,
InsertSlideShapeRectangleOperation,
} from './commands/operations/insert-shape.operation';
export { SlideAddTextCommand, SlideAddTextOperation } from './commands/operations/insert-text.operation';
export { SetSlidePageThumbOperation } from './commands/operations/set-thumb.operation';
export { UpdateSlideElementOperation } from './commands/operations/update-element.operation';
export type { IUniverSlidesUIConfig } from './config/config';
export { SLIDE_EDITOR_ID } from './const';
export { CanvasView } from './controllers/canvas-view';
export { SlidesUIController } from './controllers/ui.controller';
export { SLIDES_IMAGE_MENU_ID } from './menu/image.menu';
export { menuSchema as SlidesUIMenuSchema } from './menu/schema';
export { SHAPE_MENU_ID } from './menu/shape.menu';
export { UniverSlidesUIPlugin } from './plugin';
export { ISlideEditorBridgeService } from './services/slide-editor-bridge.service';
export { SlideCanvasPopMangerService } from './services/slide-popup-manager.service';

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'إلحاق شريحة',
text: {
insert: {
title: 'إدراج نص',
},
},
shape: {
insert: {
title: 'إدراج شكل',
rectangle: 'إدراج مستطيل',
ellipse: 'إدراج قطع ناقص',
},
},
image: {
insert: {
title: 'إدراج صورة',
float: 'إدراج صورة عائمة',
},
},
popup: {
edit: 'تحرير',
delete: 'حذف',
},
sidebar: {
text: 'تحرير النص',
shape: 'تحرير الشكل',
image: 'تحرير الصورة',
},
'image-panel': {
arrange: {
title: 'ترتيب',
forward: 'إحضار إلى الأمام',
backward: 'إرسال إلى الخلف',
front: 'إحضار إلى المقدمة',
back: 'إرسال إلى المؤخرة',
},
transform: {
title: 'تحويل',
width: 'العرض (بكسل)',
height: 'الارتفاع (بكسل)',
x: 'X (بكسل)',
y: 'Y (بكسل)',
rotate: 'تدوير (°)',
},
},
panel: {
fill: {
title: 'لون التعبئة',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Afegeix diapositiva',
text: {
insert: {
title: 'Insereix text',
},
},
shape: {
insert: {
title: 'Insereix forma',
rectangle: 'Insereix rectangle',
ellipse: 'Insereix el·lipse',
},
},
image: {
insert: {
title: 'Insereix imatge',
float: 'Insereix imatge flotant',
},
},
popup: {
edit: 'Edita',
delete: 'Elimina',
},
sidebar: {
text: 'Edita text',
shape: 'Edita forma',
image: 'Edita imatge',
},
'image-panel': {
arrange: {
title: 'Organitza',
forward: 'Porta endavant',
backward: 'Envia enrere',
front: 'Porta al davant',
back: 'Envia al fons',
},
transform: {
title: 'Transforma',
width: 'Amplada (px)',
height: 'Alçada (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Gira (°)',
},
},
panel: {
fill: {
title: 'Color de farciment',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Folie anhängen',
text: {
insert: {
title: 'Text einfügen',
},
},
shape: {
insert: {
title: 'Form einfügen',
rectangle: 'Rechteck einfügen',
ellipse: 'Ellipse einfügen',
},
},
image: {
insert: {
title: 'Bild einfügen',
float: 'Schwebendes Bild einfügen',
},
},
popup: {
edit: 'Bearbeiten',
delete: 'Löschen',
},
sidebar: {
text: 'Text bearbeiten',
shape: 'Form bearbeiten',
image: 'Bild bearbeiten',
},
'image-panel': {
arrange: {
title: 'Anordnen',
forward: 'Nach vorne',
backward: 'Nach hinten',
front: 'In den Vordergrund',
back: 'In den Hintergrund',
},
transform: {
title: 'Transformieren',
width: 'Breite (px)',
height: 'Höhe (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Drehen (°)',
},
},
panel: {
fill: {
title: 'Füllfarbe',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,78 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const locale = {
'slides-ui': {
append: 'Append Slide',
text: {
insert: {
title: 'Insert Text',
},
},
shape: {
insert: {
title: 'Insert Shape',
rectangle: 'Insert Rectangle',
ellipse: 'Insert Ellipse',
},
},
image: {
insert: {
title: 'Insert Image',
float: 'Insert Float Image',
},
},
popup: {
edit: 'Edit',
delete: 'Delete',
},
sidebar: {
text: 'Edit Text',
shape: 'Edit Shape',
image: 'Edit Image',
},
'image-panel': {
arrange: {
title: 'Arrange',
forward: 'Bring Forward',
backward: 'Send Backward',
front: 'Bring to Front',
back: 'Send to Back',
},
transform: {
title: 'Transform',
width: 'Width (px)',
height: 'Height (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Rotate (°)',
},
},
panel: {
fill: {
title: 'Fill Color',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Añadir diapositiva',
text: {
insert: {
title: 'Insertar texto',
},
},
shape: {
insert: {
title: 'Insertar forma',
rectangle: 'Insertar rectángulo',
ellipse: 'Insertar elipse',
},
},
image: {
insert: {
title: 'Insertar imagen',
float: 'Insertar imagen flotante',
},
},
popup: {
edit: 'Editar',
delete: 'Eliminar',
},
sidebar: {
text: 'Editar texto',
shape: 'Editar forma',
image: 'Editar imagen',
},
'image-panel': {
arrange: {
title: 'Organizar',
forward: 'Traer adelante',
backward: 'Enviar atrás',
front: 'Traer al frente',
back: 'Enviar al fondo',
},
transform: {
title: 'Transformar',
width: 'Ancho (px)',
height: 'Alto (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Rotar (°)',
},
},
panel: {
fill: {
title: 'Color de relleno',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'اضافه کردن اسلاید',
text: {
insert: {
title: 'درج متن',
},
},
shape: {
insert: {
title: 'درج شکل',
rectangle: 'درج مستطیل',
ellipse: 'بیضی را درج کنید',
},
},
image: {
insert: {
title: 'درج تصویر',
float: 'درج تصویر شناور',
},
},
popup: {
edit: 'ویرایش',
delete: 'حذف',
},
sidebar: {
text: 'ویرایش متن',
shape: 'ویرایش شکل',
image: 'ویرایش تصویر',
},
'image-panel': {
arrange: {
title: 'ترتیب‌دهی',
forward: 'آوردن به جلو',
backward: 'فرستادن به عقب',
front: 'آوردن به جلوی همه',
back: 'فرستادن به پشت همه',
},
transform: {
title: 'تبدیل',
width: 'عرض (px)',
height: 'ارتفاع (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'چرخش (°)',
},
},
panel: {
fill: {
title: 'رنگ پر کردن',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Ajouter une diapositive',
text: {
insert: {
title: 'Insérer du texte',
},
},
shape: {
insert: {
title: 'Insérer une forme',
rectangle: 'Insérer un rectangle',
ellipse: 'Insérer une ellipse',
},
},
image: {
insert: {
title: 'Insérer une image',
float: 'Insérer une image flottante',
},
},
popup: {
edit: 'Éditer',
delete: 'Supprimer',
},
sidebar: {
text: 'Éditer le texte',
shape: 'Éditer la forme',
image: 'Éditer l\'image',
},
'image-panel': {
arrange: {
title: 'Arranger',
forward: 'Avancer',
backward: 'Reculer',
front: 'Mettre au premier plan',
back: 'Mettre à l\'arrière-plan',
},
transform: {
title: 'Transformer',
width: 'Largeur (px)',
height: 'Hauteur (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Pivoter (°)',
},
},
panel: {
fill: {
title: 'Couleur de remplissage',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Tambah Slide',
text: {
insert: {
title: 'Sisipkan Teks',
},
},
shape: {
insert: {
title: 'Sisipkan Bentuk',
rectangle: 'Sisipkan Persegi Panjang',
ellipse: 'Sisipkan Elips',
},
},
image: {
insert: {
title: 'Sisipkan Gambar',
float: 'Sisipkan Gambar Mengambang',
},
},
popup: {
edit: 'Edit',
delete: 'Hapus',
},
sidebar: {
text: 'Edit Teks',
shape: 'Edit Bentuk',
image: 'Edit Gambar',
},
'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',
width: 'Lebar (px)',
height: 'Tinggi (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Putar (°)',
},
},
panel: {
fill: {
title: 'Warna Isi',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Aggiungi Diapositiva',
text: {
insert: {
title: 'Inserisci Testo',
},
},
shape: {
insert: {
title: 'Inserisci Forma',
rectangle: 'Inserisci Rettangolo',
ellipse: 'Inserisci Ellisse',
},
},
image: {
insert: {
title: 'Inserisci Immagine',
float: 'Inserisci Immagine Fluttuante',
},
},
popup: {
edit: 'Modifica',
delete: 'Elimina',
},
sidebar: {
text: 'Modifica Testo',
shape: 'Modifica Forma',
image: 'Modifica Immagine',
},
'image-panel': {
arrange: {
title: 'Disposizione',
forward: 'Porta Avanti',
backward: 'Manda Indietro',
front: 'Porta in Primo Piano',
back: 'Manda in Secondo Piano',
},
transform: {
title: 'Trasforma',
width: 'Larghezza (px)',
height: 'Altezza (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Ruota (°)',
},
},
panel: {
fill: {
title: 'Colore Riempimento',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'スライドを追加',
text: {
insert: {
title: 'テキストの挿入',
},
},
shape: {
insert: {
title: '図形の挿入',
rectangle: '四角形を挿入',
ellipse: '楕円を挿入',
},
},
image: {
insert: {
title: '画像の挿入',
float: 'フローティング画像を挿入',
},
},
popup: {
edit: '編集',
delete: '削除',
},
sidebar: {
text: 'テキスト編集',
shape: '図形編集',
image: '画像編集',
},
'image-panel': {
arrange: {
title: '配置',
forward: '前面へ移動',
backward: '背面へ移動',
front: '最前面へ移動',
back: '最背面へ移動',
},
transform: {
title: '変形',
width: '幅 (px)',
height: '高さ (px)',
x: 'X座標 (px)',
y: 'Y座標 (px)',
rotate: '回転 (°)',
},
},
panel: {
fill: {
title: '塗りつぶしの色',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: '슬라이드 추가',
text: {
insert: {
title: '텍스트 삽입',
},
},
shape: {
insert: {
title: '도형 삽입',
rectangle: '사각형 삽입',
ellipse: '타원 삽입',
},
},
image: {
insert: {
title: '이미지 삽입',
float: '플로팅 이미지 삽입',
},
},
popup: {
edit: '편집',
delete: '삭제',
},
sidebar: {
text: '텍스트 편집',
shape: '도형 편집',
image: '이미지 편집',
},
'image-panel': {
arrange: {
title: '정렬',
forward: '앞으로 가져오기',
backward: '뒤로 보내기',
front: '맨 앞으로 가져오기',
back: '맨 뒤로 보내기',
},
transform: {
title: '변형',
width: '너비 (px)',
height: '높이 (px)',
x: 'X 좌표 (px)',
y: 'Y 좌표 (px)',
rotate: '회전 (°)',
},
},
panel: {
fill: {
title: '채우기 색상',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Dodaj slajd',
text: {
insert: {
title: 'Wstaw tekst',
},
},
shape: {
insert: {
title: 'Wstaw kształt',
rectangle: 'Wstaw prostokąt',
ellipse: 'Wstaw elipsę',
},
},
image: {
insert: {
title: 'Wstaw obraz',
float: 'Wstaw obraz swobodny',
},
},
popup: {
edit: 'Edytuj',
delete: 'Usuń',
},
sidebar: {
text: 'Edytuj tekst',
shape: 'Edytuj kształt',
image: 'Edytuj obraz',
},
'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łć',
width: 'Szerokość (px)',
height: 'Wysokość (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Obrót (°)',
},
},
panel: {
fill: {
title: 'Kolor wypełnienia',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Adicionar Slide',
text: {
insert: {
title: 'Inserir Texto',
},
},
shape: {
insert: {
title: 'Inserir Forma',
rectangle: 'Inserir Retângulo',
ellipse: 'Inserir Elipse',
},
},
image: {
insert: {
title: 'Inserir Imagem',
float: 'Inserir Imagem Flutuante',
},
},
popup: {
edit: 'Editar',
delete: 'Excluir',
},
sidebar: {
text: 'Editar Texto',
shape: 'Editar Forma',
image: 'Editar Imagem',
},
'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',
width: 'Largura (px)',
height: 'Altura (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Rotacionar (°)',
},
},
panel: {
fill: {
title: 'Cor de Preenchimento',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Добавить слайд',
text: {
insert: {
title: 'Вставить текст',
},
},
shape: {
insert: {
title: 'Вставить фигуру',
rectangle: 'Вставить прямоугольник',
ellipse: 'Вставить эллипс',
},
},
image: {
insert: {
title: 'Вставить изображение',
float: 'Вставить плавающее изображение',
},
},
popup: {
edit: 'Изменить',
delete: 'Удалить',
},
sidebar: {
text: 'Изменить текст',
shape: 'Изменить фигуру',
image: 'Изменить изображение',
},
'image-panel': {
arrange: {
title: 'Упорядочить',
forward: 'Переложить вперёд',
backward: 'Переложить назад',
front: 'На передний план',
back: 'На задний план',
},
transform: {
title: 'Преобразование',
width: 'Ширина (пкс)',
height: 'Высота (пкс)',
x: 'X (пкс)',
y: 'Y (пкс)',
rotate: 'Повернуть (°)',
},
},
panel: {
fill: {
title: 'Цвет заливки',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Pridať snímku',
text: {
insert: {
title: 'Vložiť text',
},
},
shape: {
insert: {
title: 'Vložiť tvar',
rectangle: 'Vložiť obdĺžnik',
ellipse: 'Vložiť elipsu',
},
},
image: {
insert: {
title: 'Vložiť obrázok',
float: 'Vložiť plávajúci obrázok',
},
},
popup: {
edit: 'Upraviť',
delete: 'Odstrániť',
},
sidebar: {
text: 'Upraviť text',
shape: 'Upraviť tvar',
image: 'Upraviť obrázok',
},
'image-panel': {
arrange: {
title: 'Usporiadať',
forward: 'Posunúť dopredu',
backward: 'Posunúť dozadu',
front: 'Presunúť do popredia',
back: 'Presunúť do pozadia',
},
transform: {
title: 'Transformovať',
width: 'Šírka (px)',
height: 'Výška (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Otočiť (°)',
},
},
panel: {
fill: {
title: 'Farba výplne',
},
},
},
};
export default locale;

View File

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

View File

@@ -0,0 +1,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 enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: 'Thêm trang chiếu',
text: {
insert: {
title: 'Chèn văn bản',
},
},
shape: {
insert: {
title: 'Chèn hình',
rectangle: 'Chèn hình chữ nhật',
ellipse: 'Chèn hình elip',
},
},
image: {
insert: {
title: 'Chèn hình ảnh',
float: 'Chèn hình ảnh nổi',
},
},
popup: {
edit: 'Chỉnh sửa',
delete: 'Xóa',
},
sidebar: {
text: 'Chỉnh sửa văn bản',
shape: 'Chỉnh sửa hình',
image: 'Chỉnh sửa hình ảnh',
},
'image-panel': {
arrange: {
title: 'Sắp xếp',
forward: 'Chuyển lên trên',
backward: 'Chuyển xuống dưới',
front: 'Đưa lên trên cùng',
back: 'Đưa xuống dưới cùng',
},
transform: {
title: 'Biến đổi',
width: 'Chiều rộng (px)',
height: 'Chiều cao (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: 'Xoay (°)',
},
},
panel: {
fill: {
title: 'Màu tô',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: '新增幻灯片',
text: {
insert: {
title: '插入文本',
},
},
shape: {
insert: {
title: '插入图形',
rectangle: '插入矩形',
ellipse: '插入椭圆',
},
},
image: {
insert: {
title: '插入图片',
float: '插入浮动图片',
},
},
popup: {
edit: '编辑',
delete: '删除',
},
sidebar: {
text: '编辑文本',
shape: '编辑图形',
image: '编辑图片',
},
'image-panel': {
arrange: {
title: '排列',
forward: '上移一层',
backward: '下移一层',
front: '置于顶层',
back: '置于底层',
},
transform: {
title: '变换',
width: '宽度 (px)',
height: '高度 (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: '旋转 (°)',
},
},
panel: {
fill: {
title: '填充',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: '新增投影片',
text: {
insert: {
title: '插入文字',
},
},
shape: {
insert: {
title: '插入圖形',
rectangle: '插入矩形',
ellipse: '插入橢圓',
},
},
image: {
insert: {
title: '插入圖片',
float: '插入浮動圖片',
},
},
popup: {
edit: '編輯',
delete: '刪除',
},
sidebar: {
text: '編輯文字',
shape: '編輯圖形',
image: '編輯圖片',
},
'image-panel': {
arrange: {
title: '排列',
forward: '上移一層',
backward: '下移一層',
front: '至頂',
back: '至底',
},
transform: {
title: '變換',
width: '寬度 (px)',
height: '高度 (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: '旋轉 (°)',
},
},
panel: {
fill: {
title: '填色',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'slides-ui': {
append: '新增投影片',
text: {
insert: {
title: '插入文字',
},
},
shape: {
insert: {
title: '插入圖形',
rectangle: '插入矩形',
ellipse: '插入橢圓',
},
},
image: {
insert: {
title: '插入圖片',
float: '插入浮動圖片',
},
},
popup: {
edit: '編輯',
delete: '刪除',
},
sidebar: {
text: '編輯文字',
shape: '編輯圖形',
image: '編輯圖片',
},
'image-panel': {
arrange: {
title: '排列',
forward: '上移一層',
backward: '下移一層',
front: '至頂',
back: '至底',
},
transform: {
title: '變換',
width: '寬度 (px)',
height: '高度 (px)',
x: 'X (px)',
y: 'Y (px)',
rotate: '旋轉 (°)',
},
},
panel: {
fill: {
title: '填色',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,43 @@
/**
* 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 { IMenuItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { InsertSlideFloatImageCommand } from '../commands/operations/insert-image.operation';
export const SLIDES_IMAGE_MENU_ID = 'slide.menu.image';
export function SlideImageMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: SLIDES_IMAGE_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'AddImageIcon',
tooltip: 'slides-ui.image.insert.title',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SLIDE),
};
}
export function UploadSlideFloatImageMenuFactory(_accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: InsertSlideFloatImageCommand.id,
title: 'slides-ui.image.insert.float',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_SLIDE),
};
}

View File

@@ -0,0 +1,201 @@
/**
* 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 { BaseObject, ObjectType, Scene } from '@univerjs/engine-render';
import type { SlideDataModel } from '@univerjs/slides';
import { FOCUSING_COMMON_DRAWINGS, ICommandService, IContextService, Inject, IUniverInstanceService, RxDisposable, toDisposable, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ISidebarService } from '@univerjs/ui';
import { DeleteSlideElementOperation } from '../commands/operations/delete-element.operation';
import { ToggleSlideEditSidebarOperation } from '../commands/operations/insert-shape.operation';
import { UpdateSlideElementOperation } from '../commands/operations/update-element.operation';
import { CanvasView } from '../controllers/canvas-view';
import { SlideCanvasPopMangerService } from '../services/slide-popup-manager.service';
import { COMPONENT_SLIDE_IMAGE_POPUP_MENU } from '../views/image-popup-menu/component-name';
export class SlidePopupMenuController extends RxDisposable {
private _initImagePopupMenu = new Set<string>();
constructor(
@Inject(SlideCanvasPopMangerService) private readonly _canvasPopManagerService: SlideCanvasPopMangerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IContextService private readonly _contextService: IContextService,
@Inject(CanvasView) private readonly _canvasView: CanvasView,
@ISidebarService private readonly _sidebarService: ISidebarService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._init();
}
private _init(): void {
this._univerInstanceService.getAllUnitsForType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE).forEach((slide) => this._create(slide));
}
private _create(slide: Nullable<SlideDataModel>) {
if (!slide) {
return;
}
const unitId = slide.getUnitId();
if (this._renderManagerService.has(unitId) && !this._initImagePopupMenu.has(unitId)) {
this._popupMenuListener(unitId);
this._initImagePopupMenu.add(unitId);
}
}
private _hasCropObject(scene: Scene) {
// const objects = scene.getAllObjects();
// for (const object of objects) {
// if (object instanceof ImageCropperObject) {
// return true;
// }
// }
// return false;
}
// eslint-disable-next-line max-lines-per-function
private _popupMenuListener(unitId: string) {
const model = this._univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const pages = model?.getPages() ?? {};
// eslint-disable-next-line max-lines-per-function
Object.keys(pages).forEach((pageId) => {
const page = this._canvasView.getRenderUnitByPageId(pageId, unitId);
const transformer = page.scene?.getTransformer();
if (!transformer) return;
let singletonPopupDisposer: IDisposable;
this.disposeWithMe(
toDisposable(
transformer.createControl$.subscribe(() => {
// this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, true);
// if (this._hasCropObject(scene)) {
// return;
// }
const selectedObjects = transformer.getSelectedObjectMap();
if (selectedObjects.size > 1) {
singletonPopupDisposer?.dispose();
return;
}
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) {
return;
}
const oKey = object.oKey;
// const drawingParam = this._drawingManagerService.getDrawingOKey(oKey);
// if (!drawingParam) {
// return;
// }
// const { unitId, subUnitId, drawingId } = drawingParam;
singletonPopupDisposer?.dispose();
singletonPopupDisposer = this.disposeWithMe(this._canvasPopManagerService.attachPopupToObject(object, {
componentKey: COMPONENT_SLIDE_IMAGE_POPUP_MENU,
direction: 'horizontal',
offset: [2, 0],
extraProps: {
menuItems: this._getMenuItemsByObjectType(object.objectType, oKey, unitId),
},
}));
if (this._sidebarService.visible) {
this._commandService.executeCommand(ToggleSlideEditSidebarOperation.id, {
visible: true,
objectType: object.objectType,
});
}
// this._drawingManagerService.focusDrawing([{
// unitId,
// subUnitId,
// drawingId,
// }]);
})
)
);
this.disposeWithMe(
transformer.clearControl$.subscribe(() => {
singletonPopupDisposer?.dispose();
this._contextService.setContextValue(FOCUSING_COMMON_DRAWINGS, false);
// this._drawingManagerService.focusDrawing(null);
})
);
this.disposeWithMe(
transformer.changing$.subscribe(() => {
singletonPopupDisposer?.dispose();
const selectedObjects = transformer.getSelectedObjectMap();
if (selectedObjects.size > 1) {
singletonPopupDisposer?.dispose();
return;
}
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) {
return;
}
this._commandService.executeCommand(UpdateSlideElementOperation.id, {
unitId,
oKey: object.oKey,
props: {
width: object.width,
height: object.height,
left: object.left,
top: object.top,
},
});
})
);
});
}
private _getMenuItemsByObjectType(objectType: ObjectType, oKey: string, unitId: string) {
const menuItems = [{
label: 'slides-ui.popup.edit',
index: 0,
commandId: ToggleSlideEditSidebarOperation.id,
commandParams: {
visible: true,
objectType,
},
disable: false,
}, {
label: 'slides-ui.popup.delete',
index: 5,
commandId: DeleteSlideElementOperation.id,
commandParams: {
id: oKey,
unitId,
},
disable: false,
}];
return menuItems;
}
}

View File

@@ -0,0 +1,56 @@
/**
* 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 { RibbonStartGroup } from '@univerjs/ui';
import { InsertSlideFloatImageCommand } from '../commands/operations/insert-image.operation';
import { InsertSlideShapeEllipseCommand, InsertSlideShapeRectangleCommand } from '../commands/operations/insert-shape.operation';
import { SlideAddTextCommand } from '../commands/operations/insert-text.operation';
import { SlideImageMenuFactory, SLIDES_IMAGE_MENU_ID, UploadSlideFloatImageMenuFactory } from './image.menu';
import { SHAPE_MENU_ID, SlideShapeMenuFactory, UploadSlideFloatEllipseShapeMenuFactory, UploadSlideFloatRectangleShapeMenuFactory } from './shape.menu';
import { SlideAddTextMenuItemFactory } from './text.menu';
export const menuSchema: MenuSchemaType = {
[RibbonStartGroup.FORMAT]: {
[SlideAddTextCommand.id]: {
order: 0,
gridLayout: { row: 1, column: 1, rowSpan: 2, showLabel: true },
menuItemFactory: SlideAddTextMenuItemFactory,
},
[SLIDES_IMAGE_MENU_ID]: {
order: 0,
gridLayout: { row: 1, column: 2, rowSpan: 2, showLabel: true },
menuItemFactory: SlideImageMenuFactory,
[InsertSlideFloatImageCommand.id]: {
order: 0,
menuItemFactory: UploadSlideFloatImageMenuFactory,
},
},
[SHAPE_MENU_ID]: {
order: 0,
gridLayout: { row: 1, column: 3, rowSpan: 2, showLabel: true },
menuItemFactory: SlideShapeMenuFactory,
[InsertSlideShapeRectangleCommand.id]: {
order: 0,
menuItemFactory: UploadSlideFloatRectangleShapeMenuFactory,
},
[InsertSlideShapeEllipseCommand.id]: {
order: 0,
menuItemFactory: UploadSlideFloatEllipseShapeMenuFactory,
},
},
},
};

View File

@@ -0,0 +1,55 @@
/**
* 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, IMenuItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import {
InsertSlideShapeEllipseCommand,
InsertSlideShapeRectangleCommand,
} from '../commands/operations/insert-shape.operation';
export const SHAPE_MENU_ID = 'slide.menu.shape';
export function SlideShapeMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: SHAPE_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'GraphIcon',
tooltip: 'slides-ui.shape.insert.title',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SLIDE),
};
}
export function UploadSlideFloatRectangleShapeMenuFactory(_accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: InsertSlideShapeRectangleCommand.id,
title: 'slides-ui.shape.insert.rectangle',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_SLIDE),
};
}
export function UploadSlideFloatEllipseShapeMenuFactory(_accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: InsertSlideShapeEllipseCommand.id,
title: 'slides-ui.shape.insert.ellipse',
type: MenuItemType.BUTTON,
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_SLIDE),
};
}

View File

@@ -0,0 +1,32 @@
/**
* 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 } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { SlideAddTextCommand } from '../commands/operations/insert-text.operation';
export function SlideAddTextMenuItemFactory(_accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: SlideAddTextCommand.id,
type: MenuItemType.BUTTON,
icon: 'TextIcon',
tooltip: 'slides-ui.text.insert.title',
hidden$: getMenuHiddenObservable(_accessor, UniverInstanceType.UNIVER_SLIDE),
};
}

View File

@@ -0,0 +1,29 @@
/**
* 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 class InfoBarModel {
constructor(private _name: string) {
// super
}
get name(): string {
return this._name;
}
setName(name: string) {
this._name = name;
}
}

View File

@@ -0,0 +1,154 @@
/**
* 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 { SlideDataModel } from '@univerjs/slides';
import type { IUniverSlidesUIConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
IUniverInstanceService,
merge,
mergeOverrideWithDependencies,
Plugin,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSlidesPlugin } from '@univerjs/slides';
import pkg from '../package.json';
import { defaultPluginConfig, SLIDES_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { CanvasView } from './controllers/canvas-view';
import { ComponentsController } from './controllers/components.controller';
import { SlideEditingRenderController } from './controllers/slide-editing.render-controller';
import { SlideEditorBridgeRenderController } from './controllers/slide-editor-bridge.render-controller';
import { SlideRenderController } from './controllers/slide.render-controller';
import { SlidesUIController } from './controllers/ui.controller';
import { SlidePopupMenuController } from './menu/popup-menu.controller';
import { ISlideEditorBridgeService, SlideEditorBridgeService } from './services/slide-editor-bridge.service';
import { ISlideEditorManagerService, SlideEditorManagerService } from './services/slide-editor-manager.service';
import { SlideCanvasPopMangerService } from './services/slide-popup-manager.service';
import { SlideRenderService } from './services/slide-render.service';
@DependentOn(
UniverDocsPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverSlidesPlugin,
UniverDocsUIPlugin
)
export class UniverSlidesUIPlugin extends Plugin {
static override pluginName = 'UNIVER_SLIDES_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SLIDE;
constructor(
private readonly _config: Partial<IUniverSlidesUIConfig> = defaultPluginConfig,
@Inject(Injector) override readonly _injector: Injector,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@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(SLIDES_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
mergeOverrideWithDependencies([
[SlideRenderService],
[ISlideEditorBridgeService, { useClass: SlideEditorBridgeService }],
// used by SlideUIController --> EditorContainer
[ISlideEditorManagerService, { useClass: SlideEditorManagerService }],
[SlideCanvasPopMangerService],
] as Dependency[], this._config.override)
.forEach((d) => this._injector.add(d));
}
override onReady(): void {
([
// SlideRenderService will be init in ready stage, and then calling RenderManagerService@createRender --> init all deps in this rendering register block.
[SlideRenderController],
] as Dependency[]).forEach((m) => {
this.disposeWithMe(this._renderManagerService.registerRenderModule(UniverInstanceType.UNIVER_SLIDE, m));
});
mergeOverrideWithDependencies([
[CanvasView],
// cannot register in _renderManagerService now.
// [ISlideEditorBridgeService, { useClass: SlideEditorBridgeService }],
// // used by SlideUIController --> EditorContainer
// [ISlideEditorManagerService, { useClass: SlideEditorManagerService }],
// SlidesUIController controller should be registered in Ready stage.
// SlidesUIController controller would add a new RenderUnit (__INTERNAL_EDITOR__DOCS_NORMAL)
[SlidesUIController], // editor service was create in renderManagerService
[SlideRenderController],
[SlidePopupMenuController],
] as Dependency[], this._config.override).forEach((m) => {
this._injector.add(m);
});
this._injector.get(CanvasView);
this._injector.get(SlideRenderService);
}
override onRendered(): void {
([
// need slideEditorBridgeService
// need TextSelectionRenderService which init by EditorContainer
[SlideEditorBridgeRenderController],
[SlideEditingRenderController],
] as Dependency[]).forEach((m) => {
// find all renderMap and register module to each item in renderMap
this.disposeWithMe(this._renderManagerService.registerRenderModule(UniverInstanceType.UNIVER_SLIDE, m));
});
this._markSlideAsFocused();
this._injector.get(SlidesUIController);
}
override onSteady(): void {
this._injector.get(SlidePopupMenuController);
}
private _markSlideAsFocused() {
const currentService = this._univerInstanceService;
try {
const slideDataModel = currentService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE)!;
currentService.focusUnit(slideDataModel.getUnitId());
} catch (e) {
}
}
}

View File

@@ -0,0 +1,132 @@
/**
* 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, IContextService, Injector } from '@univerjs/core';
import { IEditorService } from '@univerjs/docs-ui';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { beforeEach, describe, expect, it } from 'vitest';
import { SLIDE_EDITOR_ID } from '../../const';
import { ISlideEditorBridgeService, SlideEditorBridgeService } from '../slide-editor-bridge.service';
class TestEditorService {
readonly focusedEditors: string[] = [];
focusedEditor: unknown = {};
getFocusEditor() {
return this.focusedEditor;
}
focus(editorId: string) {
this.focusedEditors.push(editorId);
}
}
class TestRenderManagerService {
getRenderUnitById(unitId: string) {
if (unitId !== 'slide-1') {
return null;
}
return {
scene: {
getViewport: () => ({ viewportScrollX: 5, viewportScrollY: 10 }),
getObject: () => ({ left: 30, top: 45 }),
},
};
}
}
describe('SlideEditorBridgeService', () => {
let service: ISlideEditorBridgeService;
let editorService: TestEditorService;
beforeEach(() => {
const injector = new Injector();
injector.add([IEditorService, { useClass: TestEditorService as never }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([ISlideEditorBridgeService, { useClass: SlideEditorBridgeService }]);
editorService = injector.get(IEditorService) as unknown as TestEditorService;
service = injector.get(ISlideEditorBridgeService);
});
it('publishes editor visibility changes and resets dirty state when editing starts', () => {
const states: unknown[] = [];
service.visible$.subscribe((state) => states.push(state));
service.changeEditorDirty(true);
service.changeVisible({ visible: true, eventType: DeviceInputEventType.Dblclick, unitId: 'slide-1' });
expect(service.isVisible()).toBe(true);
expect(service.getEditorDirty()).toBe(false);
expect(states.at(-1)).toEqual({ visible: true, eventType: DeviceInputEventType.Dblclick, unitId: 'slide-1' });
});
it('calculates editor geometry from the selected rich text object and focuses the slide editor when needed', () => {
const states: unknown[] = [];
service.currentEditRectState$.subscribe((state) => states.push(state));
editorService.focusedEditor = null;
const editorInfo = {
unitId: 'slide-1',
pageId: 'page-1',
scene: {} as never,
engine: {} as never,
richTextObj: {
left: 100,
top: 80,
width: 240,
height: 60,
documentData: {
id: 'old-editor-id',
body: {
dataStream: 'Text\r\n',
},
documentStyle: {
pageSize: { width: 10, height: 10 },
},
},
},
};
service.setEditorRect(editorInfo as never);
expect(service.getEditorRect()).toBe(editorInfo);
expect(service.getCurrentEditorId()).toBe(SLIDE_EDITOR_ID);
expect(editorService.focusedEditors).toEqual([SLIDE_EDITOR_ID]);
expect(service.getEditRectState()).toMatchObject({
unitId: 'slide-1',
editorUnitId: SLIDE_EDITOR_ID,
position: {
startX: 100,
startY: 80,
endX: 340,
endY: 140,
},
slideCardOffset: {
left: 25,
top: 35,
},
documentLayoutObject: {
documentModel: expect.any(Object),
},
});
expect(states.at(-1)).toMatchObject({
unitId: 'slide-1',
editorUnitId: SLIDE_EDITOR_ID,
});
});
});

View File

@@ -0,0 +1,72 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { ISlideEditorManagerService, SlideEditorManagerService } from '../slide-editor-manager.service';
describe('SlideEditorManagerService', () => {
let service: ISlideEditorManagerService;
beforeEach(() => {
const injector = new Injector();
injector.add([ISlideEditorManagerService, { useClass: SlideEditorManagerService }]);
service = injector.get(ISlideEditorManagerService);
});
it('publishes slide text editor state, bounds and focus changes', () => {
const states: unknown[] = [];
const rects: unknown[] = [];
const focus: boolean[] = [];
service.state$.subscribe((state) => states.push(state));
service.rect$.subscribe((rect) => rects.push(rect));
service.focus$.subscribe((value) => focus.push(value));
service.setState({ show: true, startX: 12, startY: 24 });
service.setRect({ left: 12, top: 24, width: 120, height: 32 });
service.setFocus(true);
expect(states.at(-1)).toEqual({ show: true, startX: 12, startY: 24 });
expect(service.getState()).toEqual({ show: true, startX: 12, startY: 24 });
expect(rects.at(-1)).toEqual({ left: 12, top: 24, width: 120, height: 32 });
expect(service.getRect()).toEqual({ left: 12, top: 24, width: 120, height: 32 });
expect(focus).toEqual([false, true]);
});
it('clears editor state and rect when disposed', () => {
const states: unknown[] = [];
const rects: unknown[] = [];
let statesCompleted = false;
let rectsCompleted = false;
service.state$.subscribe({ next: (state) => states.push(state), complete: () => {
statesCompleted = true;
} });
service.rect$.subscribe({ next: (rect) => rects.push(rect), complete: () => {
rectsCompleted = true;
} });
service.setState({ show: true, startX: 1 });
service.setRect({ left: 1, top: 2, width: 3, height: 4 });
service.dispose();
expect(service.getState()).toBeNull();
expect(service.getRect()).toBeNull();
expect(states).toEqual([null, { show: true, startX: 1 }]);
expect(rects).toEqual([null, { left: 1, top: 2, width: 3, height: 4 }]);
expect(statesCompleted).toBe(true);
expect(rectsCompleted).toBe(true);
});
});

View File

@@ -0,0 +1,157 @@
/**
* 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 { ICommandService, Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ICanvasPopupService } from '@univerjs/ui';
import { beforeEach, describe, expect, it } from 'vitest';
import {
SlideCanvasPopMangerService,
transformBound2OffsetBound,
transformPosition2Offset,
} from '../slide-popup-manager.service';
class TestCanvasPopupService {
readonly addedPopups: unknown[] = [];
readonly removedPopupIds: string[] = [];
addPopup(popup: unknown) {
this.addedPopups.push(popup);
return 'popup-1';
}
removePopup(id: string) {
this.removedPopupIds.push(id);
}
}
class TestRenderManagerService {
static hasRender = false;
static reset() {
this.hasRender = false;
}
getRenderUnitById(unitId: string) {
if (!TestRenderManagerService.hasRender || unitId !== 'slide-1') {
return null;
}
const canvasElement = document.createElement('canvas');
canvasElement.style.width = '200px';
canvasElement.getBoundingClientRect = () => ({
left: 10,
top: 20,
width: 400,
height: 300,
right: 410,
bottom: 320,
x: 10,
y: 20,
toJSON: () => ({}),
});
return {
mainComponent: { width: 300, height: 200 },
engine: {
getCanvasElement: () => canvasElement,
},
scene: {
width: 500,
height: 400,
getAncestorScale: () => ({ scaleX: 2, scaleY: 3 }),
getViewport: () => ({ viewportScrollX: 5, viewportScrollY: 10 }),
},
};
}
}
class TestUniverInstanceService {
getCurrentUnitOfType(type: UniverInstanceType) {
return type === UniverInstanceType.UNIVER_SLIDE ? { getUnitId: () => 'slide-1' } : null;
}
}
class TestCommandService {}
describe('SlideCanvasPopMangerService', () => {
let service: SlideCanvasPopMangerService;
let popupService: TestCanvasPopupService;
beforeEach(() => {
TestRenderManagerService.reset();
const injector = new Injector();
injector.add([ICanvasPopupService, { useClass: TestCanvasPopupService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([SlideCanvasPopMangerService]);
popupService = injector.get(ICanvasPopupService) as unknown as TestCanvasPopupService;
service = injector.get(SlideCanvasPopMangerService);
});
it('does not create a canvas popup when the current slide has no renderer', () => {
const disposable = service.attachPopupToObject({ left: 0, top: 0, width: 10, height: 10 } as never, { componentKey: 'slide-comment' });
disposable.dispose();
expect(popupService.addedPopups).toEqual([]);
expect(popupService.removedPopupIds).toEqual([]);
});
it('attaches a popup to the rendered slide object and removes it on dispose', () => {
TestRenderManagerService.hasRender = true;
const disposable = service.attachPopupToObject({ left: 15, top: 20, width: 30, height: 10 } as never, {
componentKey: 'slide-comment',
direction: 'horizontal',
});
expect(popupService.addedPopups).toHaveLength(1);
expect(popupService.addedPopups[0]).toMatchObject({
unitId: 'slide-1',
subUnitId: 'default',
componentKey: 'slide-comment',
direction: 'horizontal',
anchorRect: {
left: 190,
right: 430,
top: 300,
bottom: 480,
},
canvasElement: expect.any(HTMLCanvasElement),
});
disposable.dispose();
expect(popupService.removedPopupIds).toEqual(['popup-1']);
});
it('converts slide coordinates to viewport offsets with scroll and scale applied', () => {
const scene = {
getAncestorScale: () => ({ scaleX: 2, scaleY: 3 }),
getViewport: () => ({ viewportScrollX: 5, viewportScrollY: 10 }),
};
expect(transformPosition2Offset(15, 20, scene as never)).toEqual({ x: 20, y: 30 });
expect(transformBound2OffsetBound({ left: 15, top: 20, right: 45, bottom: 30 }, scene as never)).toEqual({
left: 20,
top: 30,
right: 80,
bottom: 60,
});
});
});

View File

@@ -0,0 +1,92 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ContextService, IContextService, Injector, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { Subject } from 'rxjs';
import { beforeEach, describe, expect, it } from 'vitest';
import { SlideRenderService } from '../slide-render.service';
function createSlide(unitId: string) {
return {
getUnitId: () => unitId,
};
}
class TestUniverInstanceService {
readonly slideAdded$ = new Subject<{ unit: ReturnType<typeof createSlide> }>();
readonly slideDisposed$ = new Subject<ReturnType<typeof createSlide>>();
private readonly _initialSlides = [createSlide('existing-slide')];
getTypeOfUnitAdded$(type: UniverInstanceType) {
return type === UniverInstanceType.UNIVER_SLIDE ? this.slideAdded$.asObservable() : new Subject().asObservable();
}
getTypeOfUnitDisposed$(type: UniverInstanceType) {
return type === UniverInstanceType.UNIVER_SLIDE ? this.slideDisposed$.asObservable() : new Subject().asObservable();
}
getAllUnitsForType(type: UniverInstanceType) {
return type === UniverInstanceType.UNIVER_SLIDE ? this._initialSlides : [];
}
getUnit(unitId: string) {
return createSlide(unitId);
}
}
class TestRenderManagerService {
readonly createdRenderers: string[] = [];
readonly removedRenderers: string[] = [];
createRender(unitId: string) {
this.createdRenderers.push(unitId);
}
removeRender(unitId: string) {
this.removedRenderers.push(unitId);
}
}
describe('SlideRenderService', () => {
let instanceService: TestUniverInstanceService;
let renderManagerService: TestRenderManagerService;
beforeEach(() => {
const injector = new Injector();
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([SlideRenderService]);
instanceService = injector.get(IUniverInstanceService) as unknown as TestUniverInstanceService;
renderManagerService = injector.get(IRenderManagerService) as unknown as TestRenderManagerService;
injector.get(SlideRenderService);
});
it('creates renderers for existing and newly added slide units', async () => {
await Promise.resolve();
instanceService.slideAdded$.next({ unit: createSlide('new-slide') });
expect(renderManagerService.createdRenderers).toEqual(['existing-slide', 'new-slide']);
});
it('removes the renderer when a slide unit is disposed', async () => {
await Promise.resolve();
instanceService.slideDisposed$.next(createSlide('old-slide'));
expect(renderManagerService.removedRenderers).toEqual(['old-slide']);
});
});

View File

@@ -0,0 +1,279 @@
/**
* 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, IPosition, Nullable } from '@univerjs/core';
import type { Engine, IDocumentLayoutObject, RichText, Scene } from '@univerjs/engine-render';
import type { KeyCode } from '@univerjs/ui';
import type { Observable } from 'rxjs';
import {
createIdentifier,
Disposable,
DocumentDataModel,
EDITOR_ACTIVATED,
FOCUSING_EDITOR_STANDALONE,
FOCUSING_UNIVER_EDITOR_STANDALONE_SINGLE_MODE,
HorizontalAlign,
IContextService,
VerticalAlign,
} from '@univerjs/core';
import { IEditorService } from '@univerjs/docs-ui';
import { DeviceInputEventType, IRenderManagerService } from '@univerjs/engine-render';
import { SLIDE_KEY } from '@univerjs/slides';
import { BehaviorSubject } from 'rxjs';
import { SLIDE_EDITOR_ID } from '../const';
// TODO same as @univerjs/slides/views/render/adaptors/index.js
export enum SLIDE_VIEW_KEY {
MAIN = '__SLIDERender__',
SCENE_VIEWER = '__SLIDEViewer__',
SCENE = '__SLIDEScene__',
VIEWPORT = '__SLIDEViewPort_',
}
export const ISlideEditorBridgeService = createIdentifier<ISlideEditorBridgeService>('univer.slide-editor-bridge.service');
export interface IEditorBridgeServiceParam {
unitId: string;
/**
* pos and size of editing area
*/
position: IPosition;
slideCardOffset: { left: number; top: number };
documentLayoutObject: IDocumentLayoutObject;
scaleX: number;
scaleY: number;
editorUnitId: string;
}
export interface IEditorBridgeServiceVisibleParam {
visible: boolean;
eventType: DeviceInputEventType;
unitId: string;
keycode?: KeyCode;
}
export interface ISetEditorInfo {
scene: Scene;
engine: Engine;
unitId: string;
pageId: string;
richTextObj: RichText;
}
export interface ISlideEditorBridgeService {
currentEditRectState$: Observable<Nullable<IEditorBridgeServiceParam>>;
visible$: Observable<IEditorBridgeServiceVisibleParam>;
// interceptor: InterceptorManager<{
// BEFORE_CELL_EDIT: typeof BEFORE_CELL_EDIT;
// AFTER_CELL_EDIT: typeof AFTER_CELL_EDIT;
// VALIDATE_CELL: typeof VALIDATE_CELL;
// }>;
dispose(): void;
// refreshEditCellState(): void;
setEditorRect(param: ISetEditorInfo): void;
getEditorRect(): ISetEditorInfo;
getEditRectState(): Readonly<Nullable<IEditorBridgeServiceParam>>;
// // Gets the DocumentDataModel of the latest table cell based on the latest cell contents
// getLatestEditCellState(): Readonly<Nullable<IEditorBridgeServiceParam>>;
changeVisible(param: IEditorBridgeServiceVisibleParam): void;
changeEditorDirty(dirtyStatus: boolean): void;
getEditorDirty(): boolean;
isVisible(): boolean;
// enableForceKeepVisible(): void;
// disableForceKeepVisible(): void;
// isForceKeepVisible(): boolean;
getCurrentEditorId(): Nullable<string>;
}
export class SlideEditorBridgeService extends Disposable implements ISlideEditorBridgeService, IDisposable {
private _editorUnitId: string = SLIDE_EDITOR_ID;
private _isForceKeepVisible: boolean = false;
private _editorIsDirty: boolean = false;
private _currentEditRectState: Nullable<IEditorBridgeServiceParam> = null;
private readonly _currentEditRectState$ = new BehaviorSubject<Nullable<IEditorBridgeServiceParam>>(null);
readonly currentEditRectState$ = this._currentEditRectState$.asObservable();
private _visibleParam: IEditorBridgeServiceVisibleParam = {
visible: false,
eventType: DeviceInputEventType.Dblclick,
unitId: '',
};
private readonly _visible$ = new BehaviorSubject<IEditorBridgeServiceVisibleParam>(this._visibleParam);
readonly visible$ = this._visible$.asObservable();
private readonly _afterVisible$ = new BehaviorSubject<IEditorBridgeServiceVisibleParam>(this._visibleParam);
readonly afterVisible$ = this._afterVisible$.asObservable();
private _currentEditRectInfo: ISetEditorInfo;
constructor(
@IEditorService private readonly _editorService: IEditorService,
@IContextService private readonly _contextService: IContextService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
}
override dispose() {
super.dispose();
}
getEditorRect() {
return this._currentEditRectInfo;
}
/**
* 1st part of startEditing.
* @editorInfo editorInfo
*/
setEditorRect(editorInfo: ISetEditorInfo) {
this._currentEditRectInfo = editorInfo;
// If there is no editor currently focused, then default to selecting the sheet editor to prevent the editorService from using the previously selected editor object.
// todo: wzhudev: In univer mode, it is necessary to switch to the corresponding editorId based on the host's unitId.
if (!this._editorService.getFocusEditor()) {
this._editorService.focus(SLIDE_EDITOR_ID);
this._contextService.setContextValue(EDITOR_ACTIVATED, false);
this._contextService.setContextValue(FOCUSING_EDITOR_STANDALONE, false);
this._contextService.setContextValue(FOCUSING_UNIVER_EDITOR_STANDALONE_SINGLE_MODE, false);
}
const editRectState = this.getEditRectState();
this._currentEditRectState = editRectState;
// slide-editing.render-controller@_subscribeToCurrentCell(Set editorUnitId to curr doc.)
// --> activate(-1000, -1000)
this._currentEditRectState$.next(editRectState);
}
changeVisible(param: IEditorBridgeServiceVisibleParam) {
/**
* Non-sheetEditor and formula selection mode,
* double-clicking cannot activate the sheet editor.
*/
// const editor = this._editorService.getFocusEditor();
// if (this._refSelectionsService.getCurrentSelections().length > 0 && editor && !editor.isSheetEditor()) {
// return;
// }
this._visibleParam = param;
// Reset the dirty status when the editor is visible.
if (param.visible) {
this._editorIsDirty = false;
}
// subscriber: slide-editing.render-controller.ts@_handleEditorVisible
this._visible$.next(this._visibleParam);
this._afterVisible$.next(this._visibleParam);
}
/**
* get info from _currentEditRectInfo
*
* invoked by slide-editing.render-controller.ts@_handleEditorVisible
* && this@setEditorRect
*/
getEditRectState(): Readonly<Nullable<IEditorBridgeServiceParam>> {
const editorUnitId = SLIDE_EDITOR_ID;
//editorBridgeRenderController.slideTextEditor$ ---> editorBridgeRenderController@_updateEditor --> this._currentEditRectInfo = xxx
const editorRectInfo = this._currentEditRectInfo;
const unitId = editorRectInfo.unitId;
const docData = editorRectInfo.richTextObj.documentData;
docData.id = editorUnitId;
docData.documentStyle = {
...docData.documentStyle,
...{
pageSize: { width: editorRectInfo.richTextObj.width, height: Infinity },
},
};
const docDataModel = new DocumentDataModel(docData);
const documentLayoutObject: IDocumentLayoutObject = {
documentModel: docDataModel,
fontString: 'document',
textRotation: { a: 0, v: 0 },
wrapStrategy: 0,
verticalAlign: VerticalAlign.TOP,
horizontalAlign: HorizontalAlign.LEFT,
paddingData: { t: 0, b: 1, l: 2, r: 2 },
};
// see insert-text.operation
const editorWidth = editorRectInfo.richTextObj.width;
const editorHeight = editorRectInfo.richTextObj.height;
const left = editorRectInfo.richTextObj.left;
const top = editorRectInfo.richTextObj.top;
const canvasOffset = {
left: 0,
top: 0,
};
// canvasOffset will be used in slide-editing.render-controller.ts@_handleEditorVisible
// const mainScene = this._mainScene;
const renderUnit = this._renderManagerService.getRenderUnitById(unitId);
const mainScene = renderUnit?.scene;
const mainViewport = mainScene?.getViewport(SLIDE_KEY.VIEW);
const slideMainRect = mainScene?.getObject(SLIDE_KEY.COMPONENT);
const slidePos = {
x: slideMainRect?.left || 0,
y: slideMainRect?.top || 0,
};
const scrollX = mainViewport?.viewportScrollX || 0;
const scrollY = mainViewport?.viewportScrollY || 0;
canvasOffset.left = slidePos.x - scrollX;
canvasOffset.top = slidePos.y - scrollY;
return {
position: {
startX: left,
startY: top,
endX: left + editorWidth,
endY: top + editorHeight,
},
scaleX: 1,
scaleY: 1,
slideCardOffset: canvasOffset,
unitId,
editorUnitId,
documentLayoutObject,
} as IEditorBridgeServiceParam;
}
changeEditorDirty(dirtyStatus: boolean) {
this._editorIsDirty = dirtyStatus;
}
isVisible() {
return this._visibleParam.visible;
}
getEditorDirty() {
return this._editorIsDirty;
}
getCurrentEditorId() {
return this._editorUnitId;
}
}

View File

@@ -0,0 +1,98 @@
/**
* 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, IPosition, Nullable } from '@univerjs/core';
import type { Observable } from 'rxjs';
import { createIdentifier } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
export interface ICellEditorManagerParam extends Partial<IPosition> {
show: boolean;
}
export interface ICellEditorBoundingClientRect {
left: number;
top: number;
width: number;
height: number;
}
export interface ISlideEditorManagerService {
state$: Observable<Nullable<ICellEditorManagerParam>>;
rect$: Observable<Nullable<ICellEditorBoundingClientRect>>;
focus$: Observable<boolean>;
dispose(): void;
setState(param: ICellEditorManagerParam): void;
getState(): Readonly<Nullable<ICellEditorManagerParam>>;
setRect(param: ICellEditorBoundingClientRect): void;
getRect(): Readonly<Nullable<ICellEditorBoundingClientRect>>;
setFocus(param: boolean): void;
}
export class SlideEditorManagerService implements ISlideEditorManagerService, IDisposable {
private _state: Nullable<ICellEditorManagerParam> = null;
private _rect: Nullable<ICellEditorBoundingClientRect> = null;
private readonly _state$ = new BehaviorSubject<Nullable<ICellEditorManagerParam>>(null);
readonly state$ = this._state$.asObservable();
private readonly _rect$ = new BehaviorSubject<Nullable<ICellEditorBoundingClientRect>>(null);
readonly rect$ = this._rect$.asObservable();
private _focus: boolean = false;
private readonly _focus$ = new BehaviorSubject<boolean>(this._focus);
readonly focus$ = this._focus$.asObservable();
dispose(): void {
this._state$.complete();
this._state = null;
this._rect$.complete();
this._rect = null;
}
setState(param: ICellEditorManagerParam) {
this._state = param;
this._refresh(param);
}
getRect(): Readonly<Nullable<ICellEditorBoundingClientRect>> {
return this._rect;
}
setRect(param: ICellEditorBoundingClientRect) {
this._rect = param;
this._rect$.next(param);
}
getState(): Readonly<Nullable<ICellEditorManagerParam>> {
return this._state;
}
setFocus(param: boolean = false) {
this._focus = param;
this._focus$.next(param);
}
private _refresh(param: ICellEditorManagerParam): void {
this._state$.next(param);
}
}
export const ISlideEditorManagerService = createIdentifier<ISlideEditorManagerService>(
'univer.slide-editor-manager.service'
);

View File

@@ -0,0 +1,162 @@
/**
* 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 } from '@univerjs/core';
import type { BaseObject, IBoundRectNoAngle, IRender, Scene } from '@univerjs/engine-render';
import type { IPopup } from '@univerjs/ui';
import {
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService, pxToNum } from '@univerjs/engine-render';
import { SLIDE_KEY } from '@univerjs/slides';
import { ICanvasPopupService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
export interface ISlideCanvasPopup extends Pick<IPopup, 'direction' | 'excludeOutside' | 'componentKey' | 'offset' | 'onClickOutside' | 'hideOnInvisible'> {
mask?: boolean;
extraProps?: Record<string, any>;
}
export function transformBound2OffsetBound(originBound: IBoundRectNoAngle, scene: Scene): IBoundRectNoAngle {
const topLeft = transformPosition2Offset(originBound.left, originBound.top, scene);
const bottomRight = transformPosition2Offset(originBound.right, originBound.bottom, scene);
return {
left: topLeft.x,
top: topLeft.y,
right: bottomRight.x,
bottom: bottomRight.y,
};
}
export function transformPosition2Offset(x: number, y: number, scene: Scene) {
const { scaleX, scaleY } = scene.getAncestorScale();
const viewMain = scene.getViewport(SLIDE_KEY.VIEW);
if (!viewMain) {
return {
x,
y,
};
}
const { viewportScrollX: actualScrollX, viewportScrollY: actualScrollY } = viewMain;
const offsetX = (x - actualScrollX) * scaleX;
const offsetY = (y - actualScrollY) * scaleY;
return {
x: offsetX,
y: offsetY,
};
}
export class SlideCanvasPopMangerService extends Disposable {
constructor(
@Inject(ICanvasPopupService) private readonly _globalPopupManagerService: ICanvasPopupService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
}
private _createObjectPositionObserver(
targetObject: BaseObject,
currentRender: IRender
) {
const calc = () => {
const { scene, engine } = currentRender;
const { left, top, width, height } = targetObject;
const horizontalOffset = (scene.width - (currentRender.mainComponent?.width ?? 0)) / 2;
const verticalOffset = (scene.height - (currentRender.mainComponent?.height ?? 0)) / 2;
const bound: IBoundRectNoAngle = {
left,
right: left + width,
top,
bottom: top + height,
};
const canvasElement = engine.getCanvasElement();
const canvasClientRect = canvasElement.getBoundingClientRect();
const widthOfCanvas = pxToNum(canvasElement.style.width); // declared width
const { scaleX, scaleY } = scene.getAncestorScale();
const offsetBound = transformBound2OffsetBound(bound, scene);
const { top: topOffset, left: leftOffset, width: domWidth } = canvasClientRect;
const scaleAdjust = domWidth / widthOfCanvas;
const position = {
left: (offsetBound.left * scaleAdjust * scaleX) + leftOffset + horizontalOffset,
right: (offsetBound.right * scaleAdjust * scaleX) + leftOffset + horizontalOffset,
top: (offsetBound.top * scaleAdjust * scaleY) + topOffset + verticalOffset,
bottom: (offsetBound.bottom * scaleAdjust * scaleY) + topOffset + verticalOffset,
};
return position;
};
const position = calc();
const position$ = new BehaviorSubject(position);
const disposable = new DisposableCollection();
return {
position,
position$,
disposable,
};
}
attachPopupToObject(targetObject: BaseObject, popup: ISlideCanvasPopup): IDisposable {
const workbook = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SLIDE)!;
const unitId = workbook.getUnitId();
// const subUnitId =
const currentRender = this._renderManagerService.getRenderUnitById(unitId);
if (!currentRender) {
return {
dispose: () => {
// empty
},
};
}
const { position, position$, disposable } = this._createObjectPositionObserver(targetObject, currentRender);
const id = this._globalPopupManagerService.addPopup({
...popup,
unitId,
subUnitId: 'default',
anchorRect: position,
anchorRect$: position$,
canvasElement: currentRender.engine.getCanvasElement(),
});
return {
dispose: () => {
this._globalPopupManagerService.removePopup(id);
position$.complete();
disposable.dispose();
},
};
}
}

View File

@@ -0,0 +1,97 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Workbook } from '@univerjs/core';
import type { SlideDataModel } from '@univerjs/slides';
import {
IContextService,
IUniverInstanceService,
RxDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { takeUntil } from 'rxjs';
/**
* This controller is responsible for managing units of a specific kind to be rendered on the canvas.
*/
export class SlideRenderService extends RxDisposable {
// private _skeletonChangeMutations = new Set<string>();
constructor(
@IContextService private readonly _contextService: IContextService,
@IUniverInstanceService private readonly _instanceSrv: IUniverInstanceService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService
) {
super();
// It should be initialized after all other plugins are ready.
Promise.resolve().then(() => this._init());
}
private _init() {
this._initSlideDataListener();
this._initContextListener();
}
private _initSlideDataListener(): void {
this._instanceSrv.getTypeOfUnitAdded$<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE)
.pipe(takeUntil(this.dispose$))
.subscribe((event) => {
// TODO when does this function get called?
this._createRenderer(event.unit.getUnitId());
});
this._instanceSrv.getAllUnitsForType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE).forEach((slideModel) => {
this._createRenderer(slideModel.getUnitId());
});
this._instanceSrv.getTypeOfUnitDisposed$<Workbook>(UniverInstanceType.UNIVER_SLIDE)
.pipe(takeUntil(this.dispose$))
.subscribe((workbook) => this._disposeRenderer(workbook));
}
private _createRenderer(unitId: string): void {
if (unitId == null) {
return;
}
const model = this._instanceSrv.getUnit(unitId, UniverInstanceType.UNIVER_SLIDE);
if (model == null) {
return;
}
this._renderManagerService.createRender(unitId);
}
private _disposeRenderer(workbook: Workbook): void {
const unitId = workbook.getUnitId();
this._renderManagerService.removeRender(unitId);
}
private _initContextListener(): void {
// If we toggle the raw formula, we need to refresh all spreadsheets.
// this._contextService.subscribeContextValue$(RENDER_RAW_FORMULA_KEY)
// .pipe(distinctUntilChanged(), takeUntil(this.dispose$))
// .subscribe(() => {
// this._renderManagerService.getRenderAll().forEach((renderer) => {
// if (renderer.mainComponent instanceof Spreadsheet) {
// (renderer.mainComponent as Spreadsheet).makeForceDirty(true);
// }
// });
// });
}
}

View File

@@ -0,0 +1,41 @@
/**
* 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 interface ISlideRichTextProps {
/**
* top offset value of PPT card area(px).
*/
top: number;
/**
* left offset value of PPT card area(px).
*/
left: number;
width: number;
height: number;
scaleX: number;
scaleY: number;
text: string;
fs: number;
//... align margin...
};
export enum CursorChange {
InitialState,
StartEditor,
CursorChange,
}
export type PageID = string;

View File

@@ -0,0 +1,137 @@
/**
* 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 { IDocumentData } from '@univerjs/core';
import { createParagraphId, DEFAULT_EMPTY_DOCUMENT_VALUE, DocumentFlavor, IContextService } from '@univerjs/core';
import { borderClassName, clsx } from '@univerjs/design';
import { IEditorService } from '@univerjs/docs-ui';
import { FIX_ONE_PIXEL_BLUR_OFFSET } from '@univerjs/engine-render';
import { DISABLE_AUTO_FOCUS_KEY, useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import { SLIDE_EDITOR_ID } from '../../const';
import { ISlideEditorManagerService } from '../../services/slide-editor-manager.service';
const HIDDEN_EDITOR_POSITION = -1000;
const EDITOR_DEFAULT_POSITION = {
width: 0,
height: 0,
top: HIDDEN_EDITOR_POSITION,
left: HIDDEN_EDITOR_POSITION,
};
/**
* Floating editor's container.
*/
export function SlideEditorContainer() {
const [state, setState] = useState({
...EDITOR_DEFAULT_POSITION,
});
const slideEditorManagerService = useDependency(ISlideEditorManagerService);
const editorService = useDependency(IEditorService);
const contextService = useDependency(IContextService);
const disableAutoFocus = useObservable(
() => contextService.subscribeContextValue$(DISABLE_AUTO_FOCUS_KEY),
false,
undefined,
[contextService, DISABLE_AUTO_FOCUS_KEY]
);
const snapshot: IDocumentData = {
id: SLIDE_EDITOR_ID,
body: {
dataStream: `${DEFAULT_EMPTY_DOCUMENT_VALUE}`,
textRuns: [],
paragraphs: [
{
startIndex: 0,
paragraphId: createParagraphId(new Set()),
},
],
},
documentStyle: {
documentFlavor: DocumentFlavor.UNSPECIFIED,
},
};
useEffect(() => {
const subscription = slideEditorManagerService.state$.subscribe((param) => {
if (param == null) {
return;
}
const {
startX = HIDDEN_EDITOR_POSITION,
startY = HIDDEN_EDITOR_POSITION,
endX = 0,
endY = 0,
show = false,
} = param;
if (!show) {
setState({
...EDITOR_DEFAULT_POSITION,
});
} else {
setState({
width: endX - startX - FIX_ONE_PIXEL_BLUR_OFFSET + 2,
height: endY - startY - FIX_ONE_PIXEL_BLUR_OFFSET + 2,
left: startX + FIX_ONE_PIXEL_BLUR_OFFSET,
top: startY + FIX_ONE_PIXEL_BLUR_OFFSET,
});
const editor = editorService.getEditor(SLIDE_EDITOR_ID);
if (editor == null) {
return;
}
const { left, top, width, height } = editor.getBoundingClientRect();
slideEditorManagerService.setRect({ left, top, width, height });
}
});
return () => subscription.unsubscribe();
}, [editorService, slideEditorManagerService]);
useEffect(() => {
if (!disableAutoFocus) {
slideEditorManagerService.setFocus(true);
}
}, [disableAutoFocus, state]);
return (
<div
className={clsx('univer-absolute univer-z-10 univer-box-border univer-flex', borderClassName)}
style={{
left: state.left,
top: state.top,
width: state.width,
height: state.height,
}}
>
{/* <RichTextEditor
editorId={SLIDE_EDITOR_ID}
className={styles.editorInput}
initialValue={snapshot}
isSingle={false}
/> */}
</div>
);
};

View File

@@ -0,0 +1,129 @@
/**
* 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 { ICommandService, LocaleService } from '@univerjs/core';
import { borderClassName, clsx, Dropdown } from '@univerjs/design';
import { AutofillDoubleIcon, MoreDownIcon } from '@univerjs/icons';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
export interface IImagePopupMenuItem {
label: string;
index: number;
commandId: string;
commandParams?: IDrawingSearch[];
disable: boolean;
}
export interface IImagePopupMenuExtraProps {
menuItems: IImagePopupMenuItem[];
}
export interface IImagePopupMenuProps {
popup: {
extraProps?: IImagePopupMenuExtraProps;
};
}
export function SlideImagePopupMenu(props: IImagePopupMenuProps) {
const menuItems = props.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;
}
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;
const availableMenu = menuItems.filter((item) => !item.disable);
return (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Dropdown
align="start"
overlay={(
<ul
className={clsx(`
univer-m-0 univer-box-border univer-grid univer-list-none univer-items-center univer-gap-1
univer-rounded-lg univer-bg-gray-0 univer-p-1.5 univer-text-sm univer-shadow-lg
`, borderClassName)}
>
{availableMenu.map((item) => (
<li
key={item.index}
className={`
univer-relative univer-box-border univer-flex univer-h-8 univer-cursor-pointer
univer-items-center univer-rounded univer-text-sm univer-transition-colors
hover:univer-bg-gray-100
`}
onClick={() => handleClick(item)}
>
<span className="univer-px-2 univer-py-1.5 univer-align-middle">
{localeService.t(item.label)}
</span>
</li>
))}
</ul>
)}
open={visible}
onOpenChange={onVisibleChange}
>
<div
className={clsx(`
univer-flex univer-items-center univer-gap-2 univer-rounded univer-p-1
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>
</Dropdown>
</div>
);
};

View File

@@ -0,0 +1,17 @@
/**
* 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_SLIDE_IMAGE_POPUP_MENU = 'COMPONENT_SLIDE_IMAGE_POPUP_MENU';

View File

@@ -0,0 +1,146 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { Image, Rect, RichText } from '@univerjs/engine-render';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { Button } from '@univerjs/design';
import { BottomIcon, MoveDownIcon, MoveUpIcon, TopmostIcon } from '@univerjs/icons';
import { useDependency } from '@univerjs/ui';
import { UpdateSlideElementOperation } from '../../commands/operations/update-element.operation';
import { CanvasView } from '../../controllers/canvas-view';
enum ArrangeTypeEnum {
forward,
backward,
front,
back,
}
interface IProps {
pageId: string;
unitId: string;
}
export default function ArrangePanel(props: IProps) {
const { pageId, unitId } = props;
const localeService = useDependency(LocaleService);
const canvasView = useDependency(CanvasView);
const commandService = useDependency(ICommandService);
const page = canvasView.getRenderUnitByPageId(pageId, unitId);
const scene = page?.scene;
if (!scene) return null;
const transformer = scene.getTransformer();
if (!transformer) return null;
const selectedObjects = transformer.getSelectedObjectMap();
const object = selectedObjects.values().next().value as Nullable<Rect | RichText | Image>;
if (!object) return null;
const onArrangeBtnClick = (arrangeType: ArrangeTypeEnum) => {
const allObjects = scene.getAllObjects();
const [minZIndex, maxZIndex] = allObjects.reduce(([min, max], obj) => {
const zIndex = obj.zIndex;
const minZIndex = zIndex < min ? zIndex : min;
const maxZIndex = zIndex > max ? zIndex : max;
return [minZIndex, maxZIndex];
}, [0, 0]);
let zIndex = object.zIndex;
if (arrangeType === ArrangeTypeEnum.back) {
zIndex = minZIndex - 1;
} else if (arrangeType === ArrangeTypeEnum.front) {
zIndex = maxZIndex + 1;
} else if (arrangeType === ArrangeTypeEnum.forward) {
zIndex = object.zIndex + 1;
} else if (arrangeType === ArrangeTypeEnum.backward) {
zIndex = object.zIndex - 1;
}
object.setProps({
zIndex,
});
commandService.executeCommand(UpdateSlideElementOperation.id, {
unitId,
oKey: object?.oKey,
props: {
zIndex,
},
});
};
return (
<div className="univer-relative univer-w-full">
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div
className={`
univer-w-full univer-text-left univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('slides-ui.image-panel.arrange.title')}</div>
</div>
</div>
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div className="univer-w-1/2">
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.forward); }}>
<span className="univer-flex univer-items-center univer-gap-1">
<MoveUpIcon />
{localeService.t<LocaleKey>('slides-ui.image-panel.arrange.forward')}
</span>
</Button>
</div>
<div className="univer-w-1/2">
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.backward); }}>
<span className="univer-flex univer-items-center univer-gap-1">
<MoveDownIcon />
{localeService.t<LocaleKey>('slides-ui.image-panel.arrange.backward')}
</span>
</Button>
</div>
</div>
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div className="univer-w-1/2">
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.front); }}>
<span className="univer-flex univer-items-center univer-gap-1">
<TopmostIcon />
{localeService.t<LocaleKey>('slides-ui.image-panel.arrange.front')}
</span>
</Button>
</div>
<div className="univer-w-1/2">
<Button onClick={() => { onArrangeBtnClick(ArrangeTypeEnum.back); }}>
<span className="univer-flex univer-items-center univer-gap-1">
<BottomIcon />
{localeService.t<LocaleKey>('slides-ui.image-panel.arrange.back')}
</span>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { Rect } from '@univerjs/engine-render';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { borderTopClassName, clsx, ColorPicker, Dropdown } from '@univerjs/design';
import { MoreDownIcon, PaintBucketDoubleIcon } from '@univerjs/icons';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
import { UpdateSlideElementOperation } from '../../commands/operations/update-element.operation';
import { CanvasView } from '../../controllers/canvas-view';
interface IProps {
pageId: string;
unitId: string;
}
/**
*
* @param props
*/
export default function ArrangePanel(props: IProps) {
const { pageId, unitId } = props;
const localeService = useDependency(LocaleService);
const canvasView = useDependency(CanvasView);
const commandService = useDependency(ICommandService);
const page = canvasView.getRenderUnitByPageId(pageId, unitId);
const scene = page?.scene;
const transformer = scene?.getTransformer();
const object = transformer?.getSelectedObjectMap().values().next().value as Nullable<Rect>;
const [color, setColor] = useState<string>(object?.fill?.toString() ?? '');
if (!scene || !transformer || !object) return null;
/**
*
* @param color
*/
function handleChangeColor(color: string) {
object?.setProps({
fill: color,
});
commandService.executeCommand(UpdateSlideElementOperation.id, {
unitId,
oKey: object?.oKey,
props: {
shape: {
shapeProperties: {
shapeBackgroundFill: {
rgb: color,
},
},
},
},
});
setColor(color);
}
return (
<div
className={clsx('univer-relative univer-bottom-0 univer-mt-5 univer-w-full', borderTopClassName)}
>
<div className="univer-relative univer-w-full">
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div
className={`
univer-w-full univer-text-left univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('slides-ui.panel.fill.title')}</div>
</div>
</div>
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div className="univer-w-1/2">
<Dropdown
overlay={(
<div className="univer-rounded-lg univer-p-4">
<ColorPicker
value="#fff"
onChange={handleChangeColor}
/>
</div>
)}
>
<a className="univer-flex univer-cursor-pointer univer-items-center univer-gap-1">
<PaintBucketDoubleIcon className="univer-fill-primary-600" />
<MoreDownIcon />
</a>
</Dropdown>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { BaseObject, IChangeObserverConfig, Image, Rect, RichText } from '@univerjs/engine-render';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, LocaleService } from '@univerjs/core';
import { borderTopClassName, clsx, InputNumber } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { useEffect, useState } from 'react';
import { UpdateSlideElementOperation } from '../../commands/operations/update-element.operation';
import { CanvasView } from '../../controllers/canvas-view';
interface IProps {
pageId: string;
unitId: string;
}
export default function TransformPanel(props: IProps) {
const { pageId, unitId } = props;
const localeService = useDependency(LocaleService);
const canvasView = useDependency(CanvasView);
const commandService = useDependency(ICommandService);
const page = canvasView.getRenderUnitByPageId(pageId, unitId);
const scene = page?.scene;
const transformer = scene?.getTransformer();
const object = transformer?.getSelectedObjectMap().values().next().value as Nullable<Rect | Image | RichText>;
const {
width: originWidth = 0,
height: originHeight = 0,
left: originX = 0,
top: originY = 0,
angle: originRotation = 0,
} = object ?? {};
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 handleLockRatioChange = (val: string | number | boolean) => {
// setLockRatio(val as boolean);
// transformer.keepRatio = val as boolean;
// };
const changeObs = (state: IChangeObserverConfig) => {
const { objects } = state;
const object = objects.values().next().value as BaseObject;
const {
width: originWidth = 0,
height: originHeight = 0,
left: originX = 0,
top: originY = 0,
angle: originRotation = 0,
} = object;
setWidth(originWidth);
setHeight(originHeight);
setXPosition(originX);
setYPosition(originY);
setRotation(originRotation);
};
useEffect(() => {
if (!transformer) return;
const changeStartSub = transformer.changeStart$.subscribe((state) => {
changeObs(state);
});
const changingSub = transformer.changing$.subscribe((state) => {
changeObs(state);
});
return () => {
changingSub.unsubscribe();
changeStartSub.unsubscribe();
// focusSub.unsubscribe();
};
}, [transformer]);
if (!scene || !transformer || !object) return null;
function handleWidthChange(val: number | null) {
if (!val || !object) return;
commandService.executeCommand(UpdateSlideElementOperation.id, {
pageId,
oKey: object.oKey,
props: {
width: val,
},
});
object?.resize(val, object.height);
setWidth(val);
transformer?.refreshControls();
}
function handleHeightChange(val: number | null) {
if (!val || !object) return;
commandService.executeCommand(UpdateSlideElementOperation.id, {
pageId,
oKey: object.oKey,
props: {
height: val,
},
});
object?.resize(object.width, val);
setHeight(val);
transformer?.refreshControls();
}
function handleXChange(val: number | null) {
if (!val || !object) return;
commandService.executeCommand(UpdateSlideElementOperation.id, {
pageId,
oKey: object.oKey,
props: {
left: val,
},
});
object?.translate(val, object.top);
setXPosition(val);
transformer?.refreshControls();
}
function handleYChange(val: number | null) {
if (!val || !object) return;
commandService.executeCommand(UpdateSlideElementOperation.id, {
pageId,
oKey: object.oKey,
props: {
right: val,
},
});
object?.translate(object.left, val);
setYPosition(val);
transformer?.refreshControls();
}
function handleChangeRotation(val: number | null) {
if (!val || !object) return;
commandService.executeCommand(UpdateSlideElementOperation.id, {
pageId,
oKey: object.oKey,
props: {
angle: val,
},
});
object?.transformByState({
angle: val,
});
setRotation(val);
transformer?.refreshControls();
}
return (
<div
className={clsx('univer-grid univer-gap-2 univer-py-2 univer-text-gray-400', borderTopClassName)}
>
<header
className={`
univer-text-gray-600
dark:!univer-text-gray-200
`}
>
<div>{localeService.t<LocaleKey>('slides-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>('slides-ui.image-panel.transform.width')}</span>
<InputNumber
min={1}
value={width}
onChange={(val) => { handleWidthChange(val); }}
/>
</div>
<div>
<span>{localeService.t<LocaleKey>('slides-ui.image-panel.transform.height')}</span>
<InputNumber
min={1}
value={height}
onChange={(val) => { handleHeightChange(val); }}
/>
</div>
</div>
<div className="univer-relative univer-mt-2.5 univer-flex univer-h-full">
<div>
<span>{localeService.t<LocaleKey>('slides-ui.image-panel.transform.x')}</span>
<InputNumber min={0} precision={1} value={xPosition} onChange={(val) => { handleXChange(val); }} />
</div>
<div>
<span>{localeService.t<LocaleKey>('slides-ui.image-panel.transform.y')}</span>
<InputNumber min={0} precision={1} value={yPosition} onChange={(val) => { handleYChange(val); }} />
</div>
<div>
<span>{localeService.t<LocaleKey>('slides-ui.image-panel.transform.rotate')}</span>
<InputNumber
precision={1}
value={rotation}
onChange={handleChangeRotation}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,255 @@
/**
* 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 { ISlideData, SlideDataModel } from '@univerjs/slides';
import type { Root } from 'react-dom/client';
import {
ICommandService,
IUniverInstanceService,
LocaleService,
LocaleType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { BasicShapes, PageElementType, PageType, UniverSlidesPlugin } from '@univerjs/slides';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { UpdateSlideElementOperation } from '../../../commands/operations/update-element.operation';
import { CanvasView } from '../../../controllers/canvas-view';
import locale from '../../../locale/en-US';
import ArrangePanel from '../ArrangePanel';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'arrange-panel-unit';
const PAGE_ID = 'page-1';
const SELECTED_ID = 'selected-shape';
class TestSlideObject {
constructor(
readonly oKey: string,
public zIndex: number
) {}
setProps(props: { zIndex?: number }) {
if (typeof props.zIndex === 'number') {
this.zIndex = props.zIndex;
}
}
}
class TestTransformer {
constructor(private readonly _object: TestSlideObject) {}
getSelectedObjectMap() {
return new Map([[this._object.oKey, this._object]]);
}
}
class TestScene {
private readonly _transformer: TestTransformer;
constructor(private readonly _objects: TestSlideObject[], selectedObject: TestSlideObject) {
this._transformer = new TestTransformer(selectedObject);
}
getTransformer() {
return this._transformer;
}
getAllObjects() {
return this._objects;
}
}
class TestCanvasView {
static scene: TestScene | null = null;
static reset() {
this.scene = null;
}
getRenderUnitByPageId() {
return TestCanvasView.scene
? { scene: TestCanvasView.scene }
: null;
}
}
function createSlideSnapshot(): Partial<ISlideData> {
return {
id: UNIT_ID,
title: 'Arrange panel deck',
pageSize: { width: 960, height: 540 },
body: {
pageOrder: [PAGE_ID],
pages: {
[PAGE_ID]: {
id: PAGE_ID,
pageType: PageType.SLIDE,
zIndex: 1,
title: 'Slide',
description: '',
pageBackgroundFill: { rgb: '#ffffff' },
pageElements: {
[SELECTED_ID]: {
id: SELECTED_ID,
zIndex: 2,
left: 100,
top: 100,
width: 120,
height: 80,
title: 'Selected shape',
description: '',
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Rect,
text: '',
shapeProperties: {
shapeBackgroundFill: { rgb: '#4472c4' },
},
},
},
},
},
},
},
};
}
function createPanelTestBed() {
const univer = new Univer();
univer.registerPlugin(UniverSlidesPlugin);
const injector = univer.__getInjector();
injector.add([CanvasView, { useClass: TestCanvasView as never }]);
const slide = univer.createUnit<ISlideData, SlideDataModel>(UniverInstanceType.UNIVER_SLIDE, createSlideSnapshot());
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
const commandService = injector.get(ICommandService);
commandService.registerCommand(UpdateSlideElementOperation);
injector.get(LocaleService).load({
[LocaleType.EN_US]: locale,
});
injector.get(LocaleService).setLocale(LocaleType.EN_US);
return { univer, injector, slide };
}
function createRenderScene(selectedZIndex: number, otherZIndexes: number[]) {
const selectedObject = new TestSlideObject(SELECTED_ID, selectedZIndex);
const objects = [
selectedObject,
...otherZIndexes.map((zIndex, index) => new TestSlideObject(`object-${index}`, zIndex)),
];
TestCanvasView.scene = new TestScene(objects, selectedObject);
return selectedObject;
}
function renderPanel(root: Root, testBed: ReturnType<typeof createPanelTestBed>) {
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<ArrangePanel pageId={PAGE_ID} unitId={UNIT_ID} />
</RediContext.Provider>
);
});
}
function clickButton(container: HTMLElement, text: string) {
const button = Array.from(container.querySelectorAll('button'))
.find((button) => button.textContent?.includes(text));
if (!button) {
throw new Error(`Button not found: ${text}`);
}
act(() => {
button.click();
});
}
describe('ArrangePanel', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPanelTestBed> | undefined;
beforeEach(() => {
TestCanvasView.reset();
});
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('moves the selected slide element above every rendered object', () => {
currentTestBed = createPanelTestBed();
const selectedObject = createRenderScene(2, [1, 4]);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
clickButton(container, 'Bring to Front');
expect(selectedObject.zIndex).toBe(5);
expect(currentTestBed.slide.getElement(PAGE_ID, SELECTED_ID)).toMatchObject({
zIndex: 5,
});
});
it('moves the selected slide element one layer forward', () => {
currentTestBed = createPanelTestBed();
const selectedObject = createRenderScene(2, [1, 4]);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
clickButton(container, 'Bring Forward');
expect(selectedObject.zIndex).toBe(3);
expect(currentTestBed.slide.getElement(PAGE_ID, SELECTED_ID)).toMatchObject({
zIndex: 3,
});
});
it('moves the selected slide element behind every rendered object', () => {
currentTestBed = createPanelTestBed();
const selectedObject = createRenderScene(2, [3, 5]);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderPanel(root, currentTestBed);
clickButton(container, 'Send to Back');
expect(selectedObject.zIndex).toBe(-1);
expect(currentTestBed.slide.getElement(PAGE_ID, SELECTED_ID)).toMatchObject({
zIndex: -1,
});
});
});

View File

@@ -0,0 +1,270 @@
/**
* 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 { ISlideData, SlideDataModel } from '@univerjs/slides';
import type { Root } from 'react-dom/client';
import {
ICommandService,
IUniverInstanceService,
LocaleService,
LocaleType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { BasicShapes, PageElementType, PageType, UniverSlidesPlugin } from '@univerjs/slides';
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 { UpdateSlideElementOperation } from '../../../commands/operations/update-element.operation';
import { CanvasView } from '../../../controllers/canvas-view';
import locale from '../../../locale/en-US';
import TransformPanel from '../TransformPanel';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'transform-panel-unit';
const PAGE_ID = 'page-1';
const SELECTED_ID = 'selected-shape';
class TestSlideObject {
public refreshCount = 0;
constructor(
readonly oKey: string,
public width: number,
public height: number,
public left: number,
public top: number,
public angle: number
) {}
resize(width: number, height: number) {
this.width = width;
this.height = height;
}
translate(left: number, top: number) {
this.left = left;
this.top = top;
}
transformByState(state: { angle?: number }) {
if (typeof state.angle === 'number') {
this.angle = state.angle;
}
}
}
class TestTransformer {
readonly changeStart$ = new Subject<{ objects: Map<string, TestSlideObject> }>();
readonly changing$ = new Subject<{ objects: Map<string, TestSlideObject> }>();
constructor(private readonly _object: TestSlideObject) {}
getSelectedObjectMap() {
return new Map([[this._object.oKey, this._object]]);
}
refreshControls() {
this._object.refreshCount += 1;
}
}
class TestScene {
private readonly _transformer: TestTransformer;
constructor(selectedObject: TestSlideObject) {
this._transformer = new TestTransformer(selectedObject);
}
getTransformer() {
return this._transformer;
}
}
class TestCanvasView {
static scene: TestScene | null = null;
static reset() {
this.scene = null;
}
getRenderUnitByPageId() {
return TestCanvasView.scene
? { scene: TestCanvasView.scene }
: null;
}
}
function createSlideSnapshot(): Partial<ISlideData> {
return {
id: UNIT_ID,
title: 'Transform panel deck',
pageSize: { width: 960, height: 540 },
body: {
pageOrder: [PAGE_ID],
pages: {
[PAGE_ID]: {
id: PAGE_ID,
pageType: PageType.SLIDE,
zIndex: 1,
title: 'Slide',
description: '',
pageBackgroundFill: { rgb: '#ffffff' },
pageElements: {
[SELECTED_ID]: {
id: SELECTED_ID,
zIndex: 2,
left: 100,
top: 120,
width: 160,
height: 90,
angle: 15,
title: 'Selected shape',
description: '',
type: PageElementType.SHAPE,
shape: {
shapeType: BasicShapes.Rect,
text: '',
shapeProperties: {
shapeBackgroundFill: { rgb: '#4472c4' },
},
},
},
},
},
},
},
};
}
function createPanelTestBed() {
const univer = new Univer();
univer.registerPlugin(UniverSlidesPlugin);
const injector = univer.__getInjector();
injector.add([CanvasView, { useClass: TestCanvasView as never }]);
const slide = univer.createUnit<ISlideData, SlideDataModel>(UniverInstanceType.UNIVER_SLIDE, createSlideSnapshot());
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
const commandService = injector.get(ICommandService);
commandService.registerCommand(UpdateSlideElementOperation);
injector.get(LocaleService).load({
[LocaleType.EN_US]: locale,
});
injector.get(LocaleService).setLocale(LocaleType.EN_US);
return { univer, injector, slide };
}
function createRenderScene() {
const selectedObject = new TestSlideObject(SELECTED_ID, 160, 90, 100, 120, 15);
TestCanvasView.scene = new TestScene(selectedObject);
return selectedObject;
}
function renderTransformPanel(root: Root, testBed: ReturnType<typeof createPanelTestBed>) {
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<TransformPanel pageId={PAGE_ID} unitId={UNIT_ID} />
</RediContext.Provider>
);
});
}
function setInputValue(input: HTMLInputElement, value: number) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
act(() => {
valueSetter?.call(input, String(value));
input.dispatchEvent(new Event('input', { bubbles: true }));
});
}
function getInputs(container: HTMLElement) {
const inputs = Array.from(container.querySelectorAll('input'));
if (inputs.length !== 5) {
throw new Error(`Expected five transform inputs, received ${inputs.length}`);
}
return inputs;
}
describe('TransformPanel', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createPanelTestBed> | undefined;
beforeEach(() => {
TestCanvasView.reset();
});
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('updates the selected slide element when dimension controls change', () => {
currentTestBed = createPanelTestBed();
const selectedObject = createRenderScene();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderTransformPanel(root, currentTestBed);
const [widthInput, heightInput] = getInputs(container);
setInputValue(widthInput, 240);
setInputValue(heightInput, 135);
expect(selectedObject).toMatchObject({
width: 240,
height: 135,
refreshCount: 2,
});
});
it('updates the selected slide element when position and rotation controls change', () => {
currentTestBed = createPanelTestBed();
const selectedObject = createRenderScene();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderTransformPanel(root, currentTestBed);
const [, , xInput, yInput, rotationInput] = getInputs(container);
setInputValue(xInput, 210);
setInputValue(yInput, 180);
setInputValue(rotationInput, 45);
expect(selectedObject).toMatchObject({
left: 210,
top: 180,
angle: 45,
refreshCount: 3,
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Nullable } from '@univerjs/core';
import type { BaseObject } from '@univerjs/engine-render';
import type { SlideDataModel } from '@univerjs/slides';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { ObjectType } from '@univerjs/engine-render';
import { useDependency } from '@univerjs/ui';
import { CanvasView } from '../../controllers/canvas-view';
import ArrangePanel from '../panels/ArrangePanel';
import FillPanel from '../panels/FillPanel';
import TransformPanel from '../panels/TransformPanel';
export const COMPONENT_SLIDE_SIDEBAR = 'COMPONENT_SLIDE_SIDEBAR';
export default function RectSidebar() {
const univerInstanceService = useDependency(IUniverInstanceService);
const canvasView = useDependency(CanvasView);
const currentSlide = univerInstanceService.getCurrentUnitOfType<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE);
const pageId = currentSlide?.getActivePage()?.id;
// see packages/sheets-ui/src/views/permission/permission-dialog/index.tsx@SheetPermissionDialog
// see packages/sheets-conditional-formatting-ui/src/views/panel/rule-edit/index.tsx@getUnitId
// const unitId = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SLIDE)!.getUnitId();
const unitId = univerInstanceService.getFocusedUnit()?.getUnitId() || '';
if (!pageId || !unitId) return null;
const page = canvasView.getRenderUnitByPageId(pageId, unitId);
const transformer = page.scene?.getTransformer();
if (!transformer) return null;
const selectedObjects = transformer.getSelectedObjectMap();
const object = selectedObjects.values().next().value as Nullable<BaseObject>;
if (!object) {
return null;
}
return (
<section className="univer-p-2 univer-text-center univer-text-sm">
<ArrangePanel pageId={pageId} unitId={unitId} />
<TransformPanel pageId={pageId} unitId={unitId} />
{object.objectType === ObjectType.RECT && <FillPanel pageId={pageId} unitId={unitId} />}
</section>
);
}

View File

@@ -0,0 +1,130 @@
/**
* 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 { SlideDataModel } from '@univerjs/slides';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core';
import { borderClassName, clsx, scrollbarClassName } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import { useDependency, useObservable } from '@univerjs/ui';
import { createRef, useCallback, useEffect, useMemo, useRef } from 'react';
import { scan } from 'rxjs';
import { ActivateSlidePageOperation } from '../../commands/operations/activate.operation';
import { AppendSlideOperation } from '../../commands/operations/append-slide.operation';
import { SetSlidePageThumbOperation } from '../../commands/operations/set-thumb.operation';
/**
* This components works as the root component of the left Sidebar of Slide.
*/
export function SlideSideBar() {
const univerInstanceService = useDependency(IUniverInstanceService);
const commandService = useDependency(ICommandService);
const renderManagerService = useDependency(IRenderManagerService);
const localeService = useDependency(LocaleService);
const slideBarRef = useRef<HTMLDivElement>(null);
const currentSlide = useObservable(
() => univerInstanceService.getCurrentTypeOfUnit$<SlideDataModel>(UniverInstanceType.UNIVER_SLIDE),
undefined,
false,
[univerInstanceService]
);
const pages = currentSlide?.getPages();
const pageOrder = currentSlide?.getPageOrder();
const slideList = useMemo(() => pages && pageOrder ? pageOrder.map((id) => pages[id]) : [], [pageOrder, pages]);
const initialActivePageId = currentSlide?.getActivePage()?.id ?? null;
const activatePageId = useObservable(
currentSlide
? () => currentSlide.activePage$.pipe(
scan((previousId, page) => page?.id ?? previousId, initialActivePageId)
)
: null,
initialActivePageId,
false,
[currentSlide, initialActivePageId]
);
const divRefs = useMemo(() => slideList.map(() => createRef<HTMLDivElement>()), [slideList]);
useEffect(() => {
divRefs.forEach((ref, index) => {
if (ref.current) {
const slide = slideList[index];
renderManagerService.getRenderUnitById(slide.id)?.engine.mount(ref.current);
}
});
if (divRefs.length > 0) {
commandService.syncExecuteCommand(SetSlidePageThumbOperation.id, { unitId: currentSlide?.getUnitId() });
}
}, [divRefs, slideList, renderManagerService, commandService, currentSlide]); // Depends on changes in divRefs array
const activatePage = useCallback((page: string) => {
commandService.syncExecuteCommand(ActivateSlidePageOperation.id, { id: page, unitId: currentSlide?.getUnitId() });
}, [commandService, currentSlide]);
const handleAppendSlide = useCallback(() => {
commandService.syncExecuteCommand(AppendSlideOperation.id, { unitId: currentSlide?.getUnitId() });
}, [commandService, currentSlide]);
if (!pages || !pageOrder) {
return null;
}
return (
<aside
ref={slideBarRef}
className={clsx(`
univer-flex univer-h-full univer-w-64 univer-flex-col univer-overflow-y-auto univer-overflow-x-hidden
`, scrollbarClassName)}
>
<div className="univer-px-4">
<header className="univer-flex univer-justify-center univer-pt-4">
<a
className={clsx(`
univer-box-border univer-block univer-h-8 univer-w-full univer-cursor-pointer
univer-rounded-md univer-bg-gray-0 univer-text-center univer-text-sm univer-transition-colors
`, borderClassName)}
onClick={handleAppendSlide}
>
{localeService.t<LocaleKey>('slides-ui.append')}
</a>
</header>
{slideList.map((item, index) => (
<div
key={item.id}
className={clsx('univer-my-4 univer-flex univer-gap-2', {
'[&>div]:univer-border-primary-600 [&>span]:univer-text-primary-600': item.id === activatePageId,
})}
onClick={() => activatePage(item.id)}
>
<span>{index + 1}</span>
<div
ref={divRefs[index]}
className={clsx(`
univer-relative univer-box-border univer-h-32 univer-w-52 univer-bg-gray-0
hover:univer-border-primary-600
`, borderClassName)}
/>
</div>
))}
</div>
</aside>
);
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

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

View File

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

View File

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