From 7ef01d3415f3bfc4349751ff1e43aeef73827690 Mon Sep 17 00:00:00 2001 From: Univer <68851825+DR-Univer@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:38:34 +0800 Subject: [PATCH] feat(docs): refine paragraph menus and table viewport (#7050) --- .../__tests__/insert-shape.command.spec.ts | 94 +++ .../commands/commands/insert-shape.command.ts | 121 ++++ .../src/controllers/doc-drawing.controller.ts | 3 + ...doc-drawing-transform-update.controller.ts | 9 +- packages/docs-drawing-ui/src/index.ts | 2 + .../src/menu/__tests__/schema.spec.ts | 16 + packages/docs-drawing-ui/src/menu/schema.ts | 35 + .../docs-drawing-ui/src/menu/shape.menu.ts | 79 ++ .../__tests__/ui.controller.spec.ts | 45 ++ .../src/controllers/ui.controller.ts | 2 + .../src/menu/__tests__/schema.spec.ts | 10 +- .../docs-hyper-link-ui/src/menu/schema.ts | 8 +- .../doc-thread-comment-ui.controller.spec.ts | 6 +- .../doc-thread-comment-ui.controller.ts | 2 + .../src/menu/__tests__/menu.spec.ts | 11 + .../docs-thread-comment-ui/src/menu/schema.ts | 7 + packages/docs-ui/src/__tests__/index.spec.ts | 30 + .../components/float-toolbar/FloatToolbar.tsx | 56 +- .../__tests__/FloatToolbar.spec.ts | 74 ++ .../__tests__/paragraph-menu.spec.ts | 254 ++++++- .../src/components/paragraph-menu/index.tsx | 676 ++++++++++++++++-- .../__tests__/doc-render-controller.spec.ts | 43 +- .../__tests__/zoom.render-controller.spec.ts | 12 +- .../doc.render-controller.ts | 4 +- .../zoom.render-controller.ts | 73 +- packages/docs-ui/src/index.ts | 16 +- packages/docs-ui/src/locale/ar-SA.ts | 12 + packages/docs-ui/src/locale/ca-ES.ts | 12 + packages/docs-ui/src/locale/de-DE.ts | 12 + packages/docs-ui/src/locale/en-US.ts | 12 + packages/docs-ui/src/locale/es-ES.ts | 12 + packages/docs-ui/src/locale/fa-IR.ts | 12 + packages/docs-ui/src/locale/fr-FR.ts | 12 + packages/docs-ui/src/locale/id-ID.ts | 12 + packages/docs-ui/src/locale/it-IT.ts | 12 + packages/docs-ui/src/locale/ja-JP.ts | 12 + packages/docs-ui/src/locale/ko-KR.ts | 12 + packages/docs-ui/src/locale/pl-PL.ts | 12 + packages/docs-ui/src/locale/pt-BR.ts | 12 + packages/docs-ui/src/locale/ru-RU.ts | 12 + packages/docs-ui/src/locale/sk-SK.ts | 12 + packages/docs-ui/src/locale/vi-VN.ts | 12 + packages/docs-ui/src/locale/zh-CN.ts | 12 + packages/docs-ui/src/locale/zh-HK.ts | 12 + packages/docs-ui/src/locale/zh-TW.ts | 12 + .../docs-ui/src/menu/__tests__/schema.spec.ts | 173 ++++- packages/docs-ui/src/menu/paragraph-menu.ts | 529 +++++++++++++- packages/docs-ui/src/menu/schema.ts | 434 ++++++++++- .../doc-paragraph-menu.service.spec.ts | 5 +- .../src/services/doc-event-manager.service.ts | 14 +- .../services/doc-paragraph-menu.service.ts | 5 +- .../services/selection/convert-rect-range.ts | 14 +- .../services/selection/convert-text-range.ts | 10 +- .../docs/__tests__/document.spec.ts | 3 +- .../src/components/docs/document.ts | 11 +- .../components/docs/layout/doc-skeleton.ts | 6 +- .../components/docs/table-render-viewport.ts | 10 + .../menu/desktop/DesignTinyMenuGroup.tsx | 61 +- .../components/menu/desktop/TinyMenuGroup.tsx | 26 +- .../__tests__/DesignTinyMenuGroup.spec.tsx | 49 ++ .../desktop/__tests__/TinyMenuGroup.spec.ts | 148 +++- .../src/services/menu/menu-manager.service.ts | 12 + .../context-menu/ContextMenuPanel.tsx | 500 ++++++++++--- .../__tests__/ContextMenuPanel.spec.tsx | 452 ++++++++++++ .../ribbon/TooltipButtonWrapper.tsx | 43 +- .../__tests__/TooltipButtonWrapper.spec.tsx | 73 ++ 66 files changed, 4204 insertions(+), 290 deletions(-) create mode 100644 packages/docs-drawing-ui/src/commands/commands/__tests__/insert-shape.command.spec.ts create mode 100644 packages/docs-drawing-ui/src/commands/commands/insert-shape.command.ts create mode 100644 packages/docs-drawing-ui/src/menu/shape.menu.ts create mode 100644 packages/docs-hyper-link-ui/src/controllers/__tests__/ui.controller.spec.ts create mode 100644 packages/docs-ui/src/__tests__/index.spec.ts create mode 100644 packages/docs-ui/src/components/float-toolbar/__tests__/FloatToolbar.spec.ts create mode 100644 packages/ui/src/components/menu/desktop/__tests__/DesignTinyMenuGroup.spec.tsx create mode 100644 packages/ui/src/views/components/context-menu/__tests__/ContextMenuPanel.spec.tsx create mode 100644 packages/ui/src/views/components/ribbon/__tests__/TooltipButtonWrapper.spec.tsx diff --git a/packages/docs-drawing-ui/src/commands/commands/__tests__/insert-shape.command.spec.ts b/packages/docs-drawing-ui/src/commands/commands/__tests__/insert-shape.command.spec.ts new file mode 100644 index 0000000000..9094de7449 --- /dev/null +++ b/packages/docs-drawing-ui/src/commands/commands/__tests__/insert-shape.command.spec.ts @@ -0,0 +1,94 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { describe, expect, it, vi } from 'vitest'; +import { InsertDocDrawingCommand } from '../insert-doc-drawing.command'; +import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../insert-shape.command'; + +function createAccessor() { + const executeCommand = vi.fn(async () => true); + const getCurrentUnitOfType = vi.fn((type: UniverInstanceType) => { + if (type !== UniverInstanceType.UNIVER_DOC) { + return null; + } + + return { + getUnitId: () => 'test-doc', + }; + }); + + return { + executeCommand, + accessor: { + get: (token: unknown) => { + if (token === ICommandService) { + return { + executeCommand, + }; + } + + if (token === IUniverInstanceService) { + return { + getCurrentUnitOfType, + }; + } + + throw new Error(`Unexpected token: ${String(token)}`); + }, + }, + }; +} + +describe('insert shape commands', () => { + it('inserts a rectangle shape through the drawing insertion command', async () => { + const { accessor, executeCommand } = createAccessor(); + + await InsertDocRectangleShapeCommand.handler(accessor as never); + + expect(executeCommand).toHaveBeenCalledTimes(1); + expect(executeCommand).toHaveBeenCalledWith( + InsertDocDrawingCommand.id, + expect.objectContaining({ + unitId: 'test-doc', + drawings: [ + expect.objectContaining({ + source: expect.stringContaining('data:image/svg+xml'), + }), + ], + }) + ); + }); + + it('inserts an ellipse shape through the drawing insertion command', async () => { + const { accessor, executeCommand } = createAccessor(); + + await InsertDocEllipseShapeCommand.handler(accessor as never); + + expect(executeCommand).toHaveBeenCalledTimes(1); + expect(executeCommand).toHaveBeenCalledWith( + InsertDocDrawingCommand.id, + expect.objectContaining({ + unitId: 'test-doc', + drawings: [ + expect.objectContaining({ + source: expect.stringContaining('data:image/svg+xml'), + }), + ], + }) + ); + }); +}); diff --git a/packages/docs-drawing-ui/src/commands/commands/insert-shape.command.ts b/packages/docs-drawing-ui/src/commands/commands/insert-shape.command.ts new file mode 100644 index 0000000000..7fb931bf8d --- /dev/null +++ b/packages/docs-drawing-ui/src/commands/commands/insert-shape.command.ts @@ -0,0 +1,121 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IAccessor, ICommand, IDocDrawingPosition } from '@univerjs/core'; +import type { IInsertDrawingCommandParams } from './interfaces'; +import { + BooleanNumber, + CommandType, + DrawingTypeEnum, + generateRandomId, + ICommandService, + IUniverInstanceService, + ObjectRelativeFromH, + ObjectRelativeFromV, + PositionedObjectLayoutType, + UniverInstanceType, + WrapTextType, +} from '@univerjs/core'; +import { docDrawingPositionToTransform } from '@univerjs/docs-ui'; +import { ImageSourceType } from '@univerjs/drawing'; +import { InsertDocDrawingCommand } from './insert-doc-drawing.command'; + +type DocShapeKind = 'rectangle' | 'ellipse'; + +const DOC_SHAPE_FILL = '#3A60F7'; +const DOC_SHAPE_STROKE = '#1D3EA6'; + +function createShapeSvgSource(shape: DocShapeKind, width: number, height: number) { + const content = shape === 'ellipse' + ? `` + : ``; + const svg = `${content}`; + + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +function createDefaultDocTransform(width: number, height: number): IDocDrawingPosition { + return { + size: { + width, + height, + }, + positionH: { + relativeFrom: ObjectRelativeFromH.PAGE, + posOffset: 0, + }, + positionV: { + relativeFrom: ObjectRelativeFromV.PARAGRAPH, + posOffset: 0, + }, + angle: 0, + }; +} + +function createShapeInsertCommand(shape: DocShapeKind, width: number, height: number, id: string): ICommand { + return { + id, + type: CommandType.COMMAND, + handler: async (accessor: IAccessor) => { + const commandService = accessor.get(ICommandService); + const univerInstanceService = accessor.get(IUniverInstanceService); + const unitId = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId(); + + if (!unitId) { + return false; + } + + const docTransform = createDefaultDocTransform(width, height); + + return commandService.executeCommand(InsertDocDrawingCommand.id, { + unitId, + drawings: [{ + unitId, + subUnitId: unitId, + drawingId: generateRandomId(6), + drawingType: DrawingTypeEnum.DRAWING_IMAGE, + imageSourceType: ImageSourceType.BASE64, + source: createShapeSvgSource(shape, width, height), + transform: docDrawingPositionToTransform(docTransform), + docTransform, + behindDoc: BooleanNumber.FALSE, + title: '', + description: '', + layoutType: PositionedObjectLayoutType.INLINE, + wrapText: WrapTextType.BOTH_SIDES, + distB: 0, + distL: 0, + distR: 0, + distT: 0, + }], + } as IInsertDrawingCommandParams); + }, + }; +} + +export const InsertDocRectangleShapeCommand = createShapeInsertCommand( + 'rectangle', + 144, + 96, + 'doc.command.insert-float-shape.rectangle' +); + +export const InsertDocEllipseShapeCommand = createShapeInsertCommand( + 'ellipse', + 112, + 112, + 'doc.command.insert-float-shape.ellipse' +); diff --git a/packages/docs-drawing-ui/src/controllers/doc-drawing.controller.ts b/packages/docs-drawing-ui/src/controllers/doc-drawing.controller.ts index 1629ecddd3..ffbd3737aa 100644 --- a/packages/docs-drawing-ui/src/controllers/doc-drawing.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/doc-drawing.controller.ts @@ -22,6 +22,7 @@ import { GroupDocDrawingCommand } from '../commands/commands/group-doc-drawing.c import { InsertDocDrawingCommand } from '../commands/commands/insert-doc-drawing.command'; import { InsertDocImageCommand } from '../commands/commands/insert-image.command'; +import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command'; import { MoveDocDrawingsCommand } from '../commands/commands/move-drawings.command'; import { RemoveDocDrawingCommand } from '../commands/commands/remove-doc-drawing.command'; import { SetDocDrawingArrangeCommand } from '../commands/commands/set-drawing-arrange.command'; @@ -59,6 +60,8 @@ export class DocDrawingUIController extends Disposable { private _initCommands() { [ InsertDocImageCommand, + InsertDocRectangleShapeCommand, + InsertDocEllipseShapeCommand, InsertDocDrawingCommand, UpdateDocDrawingWrappingStyleCommand, UpdateDocDrawingDistanceCommand, diff --git a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts index 5606928323..597ccd6877 100644 --- a/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/render-controllers/doc-drawing-transform-update.controller.ts @@ -16,7 +16,7 @@ import type { DocumentDataModel, ICommandInfo, IDrawingParam, ITransformState } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; -import type { Documents, DocumentSkeleton, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render'; +import type { Documents, DocumentSkeleton, IDocsTableRenderViewport, IDocumentSkeletonHeaderFooter, IDocumentSkeletonPage, IDocumentSkeletonRow, IDocumentSkeletonTable, Image, IRenderContext, IRenderModule } from '@univerjs/engine-render'; import { BooleanNumber, Disposable, @@ -55,7 +55,7 @@ export function getDocsTableCellDrawingOffset( ) { const sourceTableId = getTableIdAndSliceIndex(table.tableId).tableId; const viewport = getDocsTableRenderViewport(unitId, sourceTableId); - const hasHorizontalViewport = viewport && viewport.contentWidth > viewport.viewportWidth; + const hasHorizontalViewport = hasHorizontalTableViewport(viewport); const scrollLeft = hasHorizontalViewport ? viewport.scrollLeft : 0; return { @@ -64,6 +64,11 @@ export function getDocsTableCellDrawingOffset( }; } +function hasHorizontalTableViewport(viewport: IDocsTableRenderViewport | null | undefined): viewport is IDocsTableRenderViewport { + return viewport != null && + (viewport.leadingInsetLeft ?? 0) + viewport.contentWidth + (viewport.trailingInsetRight ?? 0) > viewport.viewportWidth; +} + export class DocDrawingTransformUpdateController extends Disposable implements IRenderModule { private _liquid = new Liquid(); diff --git a/packages/docs-drawing-ui/src/index.ts b/packages/docs-drawing-ui/src/index.ts index 489e6065b2..6fdd5d1fbd 100644 --- a/packages/docs-drawing-ui/src/index.ts +++ b/packages/docs-drawing-ui/src/index.ts @@ -20,6 +20,7 @@ export { DeleteDocDrawingsCommand } from './commands/commands/delete-doc-drawing export { GroupDocDrawingCommand } from './commands/commands/group-doc-drawing.command'; export { InsertDocDrawingCommand } from './commands/commands/insert-doc-drawing.command'; export { InsertDocImageCommand } from './commands/commands/insert-image.command'; +export { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from './commands/commands/insert-shape.command'; export { MoveDocDrawingsCommand } from './commands/commands/move-drawings.command'; export { RemoveDocDrawingCommand } from './commands/commands/remove-doc-drawing.command'; export { SetDocDrawingArrangeCommand } from './commands/commands/set-drawing-arrange.command'; @@ -30,6 +31,7 @@ export { SidebarDocDrawingOperation } from './commands/operations/open-drawing-p export { type IUniverDocsDrawingUIConfig } from './config/config'; export { DocFloatDomController } from './controllers/doc-float-dom.controller'; export { DOCS_IMAGE_MENU_ID } from './menu/image.menu'; +export { DOCS_SHAPE_BELOW_MENU_ID, DOCS_SHAPE_MENU_ID } from './menu/shape.menu'; export { UniverDocsDrawingUIPlugin } from './plugin'; export { DocDrawingPosition } from './views/doc-image-panel/DocDrawingPosition'; export { DocDrawingTextWrap } from './views/doc-image-panel/DocDrawingTextWrap'; diff --git a/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts b/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts index 906e5bf322..388de19fcb 100644 --- a/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts +++ b/packages/docs-drawing-ui/src/menu/__tests__/schema.spec.ts @@ -18,8 +18,13 @@ import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui'; import { describe, expect, it } from 'vitest'; import { InsertDocImageCommand } from '../../commands/commands/insert-image.command'; +import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../../commands/commands/insert-shape.command'; import { UploadFloatImageMenuFactory } from '../image.menu'; import { menuSchema } from '../schema'; +import { + DOCS_SHAPE_BELOW_MENU_ID, + DOCS_SHAPE_MENU_ID, +} from '../shape.menu'; describe('docs drawing menu schema', () => { it('adds image to paragraph insert menus', () => { @@ -35,4 +40,15 @@ describe('docs drawing menu schema', () => { expect(item.icon).toBe('AddImageIcon'); }); + + it('registers shape submenu options for paragraph insert menus', () => { + const paragraph = (menuSchema as any)[ContextMenuPosition.PARAGRAPH]; + const rootShapeMenu = paragraph[DOCS_SHAPE_MENU_ID].shapes; + const belowShapeMenu = paragraph[DOCS_SHAPE_BELOW_MENU_ID].shapes; + + expect(rootShapeMenu[InsertDocRectangleShapeCommand.id].menuItemFactory).toBeDefined(); + expect(rootShapeMenu[InsertDocEllipseShapeCommand.id].menuItemFactory).toBeDefined(); + expect(belowShapeMenu[`${InsertDocRectangleShapeCommand.id}.below`].menuItemFactory).toBeDefined(); + expect(belowShapeMenu[`${InsertDocEllipseShapeCommand.id}.below`].menuItemFactory).toBeDefined(); + }); }); diff --git a/packages/docs-drawing-ui/src/menu/schema.ts b/packages/docs-drawing-ui/src/menu/schema.ts index 9b2a96fee8..b9310155ff 100644 --- a/packages/docs-drawing-ui/src/menu/schema.ts +++ b/packages/docs-drawing-ui/src/menu/schema.ts @@ -17,12 +17,21 @@ import type { MenuSchemaType } from '@univerjs/ui'; import { DOC_CONTENT_INSERT_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui'; import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui'; +import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command'; import { DOCS_IMAGE_MENU_ID, IMAGE_MENU_UPLOAD_FLOAT_ID, ImageMenuFactory, UploadFloatImageMenuFactory, } from './image.menu'; +import { + DOCS_SHAPE_BELOW_MENU_ID, + DOCS_SHAPE_MENU_ID, + InsertEllipseShapeBelowMenuFactory, + InsertEllipseShapeMenuFactory, + InsertRectangleShapeBelowMenuFactory, + InsertRectangleShapeMenuFactory, +} from './shape.menu'; export const menuSchema: MenuSchemaType = { [RibbonInsertGroup.MEDIA]: { @@ -60,5 +69,31 @@ export const menuSchema: MenuSchemaType = { }, }, }, + [DOCS_SHAPE_MENU_ID]: { + shapes: { + order: 0, + [InsertDocRectangleShapeCommand.id]: { + order: 0, + menuItemFactory: InsertRectangleShapeMenuFactory, + }, + [InsertDocEllipseShapeCommand.id]: { + order: 1, + menuItemFactory: InsertEllipseShapeMenuFactory, + }, + }, + }, + [DOCS_SHAPE_BELOW_MENU_ID]: { + shapes: { + order: 0, + [`${InsertDocRectangleShapeCommand.id}.below`]: { + order: 0, + menuItemFactory: InsertRectangleShapeBelowMenuFactory, + }, + [`${InsertDocEllipseShapeCommand.id}.below`]: { + order: 1, + menuItemFactory: InsertEllipseShapeBelowMenuFactory, + }, + }, + }, }, }; diff --git a/packages/docs-drawing-ui/src/menu/shape.menu.ts b/packages/docs-drawing-ui/src/menu/shape.menu.ts new file mode 100644 index 0000000000..d64209ba64 --- /dev/null +++ b/packages/docs-drawing-ui/src/menu/shape.menu.ts @@ -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 { IAccessor } from '@univerjs/core'; +import type { IMenuButtonItem, IMenuItem } from '@univerjs/ui'; +import { UniverInstanceType } from '@univerjs/core'; +import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui'; +import { InsertDocEllipseShapeCommand, InsertDocRectangleShapeCommand } from '../commands/commands/insert-shape.command'; + +export const DOCS_SHAPE_MENU_ID = 'doc.command.menu-insert-shape'; +export const DOCS_SHAPE_BELOW_MENU_ID = 'doc.command.menu-insert-shape.below'; + +export function ShapeMenuFactory(accessor: IAccessor): IMenuItem { + return { + id: DOCS_SHAPE_MENU_ID, + type: MenuItemType.SUBITEMS, + icon: 'ShapeIcon', + title: 'Insert Shape', + tooltip: 'Insert Shape', + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), + }; +} + +export function InsertRectangleShapeMenuFactory(accessor: IAccessor): IMenuButtonItem { + return { + id: InsertDocRectangleShapeCommand.id, + title: 'Insert Rectangle', + type: MenuItemType.BUTTON, + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), + }; +} + +export function InsertEllipseShapeMenuFactory(accessor: IAccessor): IMenuButtonItem { + return { + id: InsertDocEllipseShapeCommand.id, + title: 'Insert Ellipse', + type: MenuItemType.BUTTON, + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), + }; +} + +export function InsertRectangleShapeBelowMenuFactory(accessor: IAccessor): IMenuButtonItem { + return { + id: `${InsertDocRectangleShapeCommand.id}.below`, + commandId: InsertDocRectangleShapeCommand.id, + title: 'Insert Rectangle', + type: MenuItemType.BUTTON, + params: { + paragraphMenuPlacement: 'below', + }, + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), + }; +} + +export function InsertEllipseShapeBelowMenuFactory(accessor: IAccessor): IMenuButtonItem { + return { + id: `${InsertDocEllipseShapeCommand.id}.below`, + commandId: InsertDocEllipseShapeCommand.id, + title: 'Insert Ellipse', + type: MenuItemType.BUTTON, + params: { + paragraphMenuPlacement: 'below', + }, + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), + }; +} diff --git a/packages/docs-hyper-link-ui/src/controllers/__tests__/ui.controller.spec.ts b/packages/docs-hyper-link-ui/src/controllers/__tests__/ui.controller.spec.ts new file mode 100644 index 0000000000..672723486b --- /dev/null +++ b/packages/docs-hyper-link-ui/src/controllers/__tests__/ui.controller.spec.ts @@ -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 { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; +import { describe, expect, it, vi } from 'vitest'; +import { DocHyperLinkEdit } from '../../views/hyper-link-edit'; +import { DocHyperLinkUIController } from '../ui.controller'; + +describe('DocHyperLinkUIController', () => { + it('creates the docs floating toolbar root before merging hyperlink menus', () => { + const registerComponent = vi.fn(() => ({ dispose: vi.fn() })); + const registerCommand = vi.fn(); + const appendRootMenu = vi.fn(); + const mergeMenu = vi.fn(); + const registerShortcut = vi.fn(); + + const controller = new DocHyperLinkUIController( + { register: registerComponent } as any, + { registerCommand } as any, + { appendRootMenu, mergeMenu } as any, + { registerShortcut } as any + ); + + expect(registerComponent).toHaveBeenCalledWith(DocHyperLinkEdit.componentKey, DocHyperLinkEdit); + expect(appendRootMenu).toHaveBeenCalledWith({ [FLOAT_TOOLBAR_MENU_POSITION]: {} }); + expect(mergeMenu).toHaveBeenCalled(); + expect(appendRootMenu.mock.invocationCallOrder[0]).toBeLessThan(mergeMenu.mock.invocationCallOrder[0]); + expect(registerShortcut).toHaveBeenCalled(); + + controller.dispose(); + }); +}); diff --git a/packages/docs-hyper-link-ui/src/controllers/ui.controller.ts b/packages/docs-hyper-link-ui/src/controllers/ui.controller.ts index aa7341915e..e1954aa929 100644 --- a/packages/docs-hyper-link-ui/src/controllers/ui.controller.ts +++ b/packages/docs-hyper-link-ui/src/controllers/ui.controller.ts @@ -16,6 +16,7 @@ import type { MenuConfig } from '@univerjs/ui'; import { Disposable, ICommandService, Inject } from '@univerjs/core'; +import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; import { LinkIcon } from '@univerjs/icons'; import { ComponentManager, IMenuManagerService, IShortcutService } from '@univerjs/ui'; import { AddDocHyperLinkCommand } from '../commands/commands/add-link.command'; @@ -78,6 +79,7 @@ export class DocHyperLinkUIController extends Disposable { } private _initMenus() { + this._menuManagerService.appendRootMenu({ [FLOAT_TOOLBAR_MENU_POSITION]: {} }); this._menuManagerService.mergeMenu(menuSchema); } } diff --git a/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts b/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts index c24c5b54fd..e9053abadb 100644 --- a/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts +++ b/packages/docs-hyper-link-ui/src/menu/__tests__/schema.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui'; +import { EMPTY_PARAGRAPH_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui'; import { ContextMenuGroup, ContextMenuPosition } from '@univerjs/ui'; import { describe, expect, it } from 'vitest'; import { ShowDocHyperLinkEditPopupOperation } from '../../commands/operations/popup.operation'; @@ -27,4 +27,12 @@ describe('docs hyperlink menu schema', () => { expect(paragraph[ContextMenuGroup.LAYOUT][INSERT_BELLOW_MENU_ID][ShowDocHyperLinkEditPopupOperation.id].menuItemFactory).toBeDefined(); expect(paragraph[EMPTY_PARAGRAPH_MENU_ID][ContextMenuGroup.LAYOUT][ShowDocHyperLinkEditPopupOperation.id].menuItemFactory).toBeDefined(); }); + + it('adds hyperlink to docs text floating toolbar', () => { + const floatToolbar = (menuSchema as any)[FLOAT_TOOLBAR_MENU_POSITION]; + const hyperlink = floatToolbar[ShowDocHyperLinkEditPopupOperation.id]; + + expect(hyperlink.order).toBe(20); + expect(hyperlink.menuItemFactory).toBeDefined(); + }); }); diff --git a/packages/docs-hyper-link-ui/src/menu/schema.ts b/packages/docs-hyper-link-ui/src/menu/schema.ts index dc3656ae3e..edcfe2a730 100644 --- a/packages/docs-hyper-link-ui/src/menu/schema.ts +++ b/packages/docs-hyper-link-ui/src/menu/schema.ts @@ -15,7 +15,7 @@ */ import type { MenuSchemaType } from '@univerjs/ui'; -import { EMPTY_PARAGRAPH_MENU_ID, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui'; +import { EMPTY_PARAGRAPH_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION, INSERT_BELLOW_MENU_ID } from '@univerjs/docs-ui'; import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui'; import { ShowDocHyperLinkEditPopupOperation } from '../commands/operations/popup.operation'; import { AddHyperLinkMenuItemFactory } from './menu'; @@ -27,6 +27,12 @@ export const menuSchema: MenuSchemaType = { menuItemFactory: AddHyperLinkMenuItemFactory, }, }, + [FLOAT_TOOLBAR_MENU_POSITION]: { + [ShowDocHyperLinkEditPopupOperation.id]: { + order: 20, + menuItemFactory: AddHyperLinkMenuItemFactory, + }, + }, [ContextMenuPosition.MAIN_AREA]: { [ContextMenuGroup.DATA]: { [ShowDocHyperLinkEditPopupOperation.id]: { diff --git a/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-ui.controller.spec.ts b/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-ui.controller.spec.ts index 975f150727..314d41604b 100644 --- a/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-ui.controller.spec.ts +++ b/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-ui.controller.spec.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; import { describe, expect, it, vi } from 'vitest'; import { DOCS_THREAD_COMMENT_PANEL } from '../../common/const'; import { DocThreadCommentPanel } from '../../views/doc-thread-comment-panel'; @@ -23,16 +24,19 @@ describe('DocThreadCommentUIController', () => { it('should register commands, menus and components', () => { const registerCommand = vi.fn(() => ({ dispose: vi.fn() })); const mergeMenu = vi.fn(); + const appendRootMenu = vi.fn(); const registerComponent = vi.fn(() => ({ dispose: vi.fn() })); const controller = new DocThreadCommentUIController( { registerCommand } as any, - { mergeMenu } as any, + { appendRootMenu, mergeMenu } as any, { register: registerComponent } as any ); expect(registerCommand).toHaveBeenCalled(); + expect(appendRootMenu).toHaveBeenCalledWith({ [FLOAT_TOOLBAR_MENU_POSITION]: {} }); expect(mergeMenu).toHaveBeenCalled(); + expect(appendRootMenu.mock.invocationCallOrder[0]).toBeLessThan(mergeMenu.mock.invocationCallOrder[0]); expect(registerComponent).toHaveBeenCalledWith(DOCS_THREAD_COMMENT_PANEL, DocThreadCommentPanel); controller.dispose(); diff --git a/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-ui.controller.ts b/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-ui.controller.ts index 508aa008eb..a21b4bf5b6 100644 --- a/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-ui.controller.ts +++ b/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-ui.controller.ts @@ -15,6 +15,7 @@ */ import { Disposable, ICommandService, Inject } from '@univerjs/core'; +import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; import { CommentIcon } from '@univerjs/icons'; import { ComponentManager, IMenuManagerService } from '@univerjs/ui'; import { AddDocCommentComment } from '../commands/commands/add-doc-comment.command'; @@ -49,6 +50,7 @@ export class DocThreadCommentUIController extends Disposable { } private _initMenus() { + this._menuManagerService.appendRootMenu({ [FLOAT_TOOLBAR_MENU_POSITION]: {} }); this._menuManagerService.mergeMenu(menuSchema); } diff --git a/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts b/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts index 94eef4790d..adb92e9018 100644 --- a/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts +++ b/packages/docs-thread-comment-ui/src/menu/__tests__/menu.spec.ts @@ -14,10 +14,13 @@ * limitations under the License. */ +import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; import { DocumentEditArea } from '@univerjs/engine-render'; import { describe, expect, it, vi } from 'vitest'; +import { StartAddCommentOperation } from '../../commands/operations/show-comment-panel.operation'; import { AddDocCommentMenuItemFactory, shouldDisableAddComment, ToolbarDocCommentMenuItemFactory } from '../menu'; +import { menuSchema } from '../schema'; vi.mock('@univerjs/engine-render', async () => { const actual = await vi.importActual('@univerjs/engine-render'); @@ -65,4 +68,12 @@ describe('docs-thread-comment-ui menu', () => { expect(addItem.id).toBe('docs.operation.start-add-comment'); expect(toolbarItem.id).toBe('docs.operation.toggle-comment-panel'); }); + + it('adds comment to docs text floating toolbar', () => { + const floatToolbar = (menuSchema as any)[FLOAT_TOOLBAR_MENU_POSITION]; + const comment = floatToolbar[StartAddCommentOperation.id]; + + expect(comment.order).toBe(21); + expect(comment.menuItemFactory).toBe(AddDocCommentMenuItemFactory); + }); }); diff --git a/packages/docs-thread-comment-ui/src/menu/schema.ts b/packages/docs-thread-comment-ui/src/menu/schema.ts index 5493514180..5b4ffdd0b6 100644 --- a/packages/docs-thread-comment-ui/src/menu/schema.ts +++ b/packages/docs-thread-comment-ui/src/menu/schema.ts @@ -15,6 +15,7 @@ */ import type { MenuSchemaType } from '@univerjs/ui'; +import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui'; import { StartAddCommentOperation, @@ -29,6 +30,12 @@ export const menuSchema: MenuSchemaType = { menuItemFactory: ToolbarDocCommentMenuItemFactory, }, }, + [FLOAT_TOOLBAR_MENU_POSITION]: { + [StartAddCommentOperation.id]: { + order: 21, + menuItemFactory: AddDocCommentMenuItemFactory, + }, + }, [ContextMenuPosition.MAIN_AREA]: { [ContextMenuGroup.DATA]: { [StartAddCommentOperation.id]: { diff --git a/packages/docs-ui/src/__tests__/index.spec.ts b/packages/docs-ui/src/__tests__/index.spec.ts new file mode 100644 index 0000000000..c112f8d218 --- /dev/null +++ b/packages/docs-ui/src/__tests__/index.spec.ts @@ -0,0 +1,30 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { + DOC_TABLE_BLOCK_MENU_ID, + INSERT_BELLOW_MENU_ID, + ParagraphMenuInsertBelowSubmenuItemFactory, +} from '../index'; + +describe('docs-ui public exports', () => { + it('re-exports table block paragraph menu ids and factories', () => { + expect(DOC_TABLE_BLOCK_MENU_ID).toBe('doc.menu.table-block'); + expect(INSERT_BELLOW_MENU_ID).toBe('doc.menu.insert-bellow'); + expect(ParagraphMenuInsertBelowSubmenuItemFactory).toBeTypeOf('function'); + }); +}); diff --git a/packages/docs-ui/src/components/float-toolbar/FloatToolbar.tsx b/packages/docs-ui/src/components/float-toolbar/FloatToolbar.tsx index 964f153b7f..1a77da8114 100644 --- a/packages/docs-ui/src/components/float-toolbar/FloatToolbar.tsx +++ b/packages/docs-ui/src/components/float-toolbar/FloatToolbar.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { IMenuSchema } from '@univerjs/ui'; +import type { IMenuManagerService as IMenuManagerServiceType, IMenuSchema } from '@univerjs/ui'; import { borderClassName, clsx } from '@univerjs/design'; import { IMenuManagerService, MenuManagerPosition, ToolbarItem, useDependency } from '@univerjs/ui'; import { useEffect, useState } from 'react'; @@ -48,29 +48,44 @@ const DEFAULT_AVALIABLE_MENUS: string[] = [ SetInlineFormatTextBackgroundColorCommand.id, ]; +export function resolveFloatToolbarMenus( + menuManagerService: IMenuManagerServiceType, + avaliableMenus: string[] +): { menus: IMenuSchema[]; extraMenus: IMenuSchema[] } { + const floatToolbarMenus = menuManagerService.getMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION); + const flatMenus = [ + ...menuManagerService.getFlatMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION), + ...menuManagerService.getFlatMenuByPositionKey(MenuManagerPosition.RIBBON), + ]; + + const menus: IMenuSchema[] = []; + for (const key of avaliableMenus) { + const item = flatMenus.find((item) => item.key === key); + if (item) { + menus.push(item); + } + } + + return { + menus, + extraMenus: floatToolbarMenus.filter((item) => item.item && !avaliableMenus.includes(item.key)), + }; +} + export function FloatToolbar(props: IFloatToolbarProps) { const { avaliableMenus = DEFAULT_AVALIABLE_MENUS } = props; const menuManagerService = useDependency(IMenuManagerService); const [menus, setMenus] = useState([]); + const [extraMenus, setExtraMenus] = useState([]); // subscribe to menu changes useEffect(() => { function getRibbon(): void { - const flatMenus = [ - ...menuManagerService.getFlatMenuByPositionKey(FLOAT_TOOLBAR_MENU_POSITION), - ...menuManagerService.getFlatMenuByPositionKey(MenuManagerPosition.RIBBON), - ]; - - const menus: IMenuSchema[] = []; - for (const key of avaliableMenus) { - const item = flatMenus.find((item) => item.key === key); - if (item) { - menus.push(item); - } - } + const { menus, extraMenus } = resolveFloatToolbarMenus(menuManagerService, avaliableMenus); setMenus(menus); + setExtraMenus(extraMenus); } getRibbon(); @@ -79,7 +94,7 @@ export function FloatToolbar(props: IFloatToolbarProps) { return () => { subscription.unsubscribe(); }; - }, [menuManagerService]); + }, [avaliableMenus, menuManagerService]); return (
))} + {extraMenus.length > 0 && ( +
+ )} + {extraMenus.map((groupItem) => groupItem.item && ( +
+ +
+ ))}
); } diff --git a/packages/docs-ui/src/components/float-toolbar/__tests__/FloatToolbar.spec.ts b/packages/docs-ui/src/components/float-toolbar/__tests__/FloatToolbar.spec.ts new file mode 100644 index 0000000000..23face11ce --- /dev/null +++ b/packages/docs-ui/src/components/float-toolbar/__tests__/FloatToolbar.spec.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { IMenuSchema } from '@univerjs/ui'; +import { MenuManagerPosition } from '@univerjs/ui'; +import { describe, expect, it, vi } from 'vitest'; +import { SetInlineFormatBoldCommand } from '../../../commands/commands/inline-format.command'; +import { FLOAT_TEXT_STYLE_MENU_ID, FLOAT_TOOLBAR_MENU_POSITION } from '../../../menu/menu'; +import { resolveFloatToolbarMenus } from '../FloatToolbar'; + +describe('resolveFloatToolbarMenus', () => { + it('keeps default toolbar menus ordered by whitelist and appends direct extension menus separately', () => { + const textStyleItem = createMenuItem(FLOAT_TEXT_STYLE_MENU_ID); + const boldItem = createMenuItem(SetInlineFormatBoldCommand.id); + const hyperlinkItem = createMenuItem('doc.operation.show-hyper-link-edit-popup'); + const commentItem = createMenuItem('docs.operation.start-add-comment'); + const menuManagerService = { + getMenuByPositionKey: vi.fn((position: string) => { + if (position === FLOAT_TOOLBAR_MENU_POSITION) { + return [textStyleItem, hyperlinkItem, commentItem]; + } + + return []; + }), + getFlatMenuByPositionKey: vi.fn((position: string) => { + if (position === FLOAT_TOOLBAR_MENU_POSITION) { + return [textStyleItem, hyperlinkItem, commentItem]; + } + if (position === MenuManagerPosition.RIBBON) { + return [boldItem]; + } + + return []; + }), + }; + + const { menus, extraMenus } = resolveFloatToolbarMenus(menuManagerService as never, [ + FLOAT_TEXT_STYLE_MENU_ID, + SetInlineFormatBoldCommand.id, + ]); + + expect(menus.map((item) => item.key)).toEqual([ + FLOAT_TEXT_STYLE_MENU_ID, + SetInlineFormatBoldCommand.id, + ]); + expect(extraMenus.map((item) => item.key)).toEqual([ + 'doc.operation.show-hyper-link-edit-popup', + 'docs.operation.start-add-comment', + ]); + }); +}); + +function createMenuItem(key: string): IMenuSchema { + return { + key, + order: 0, + item: { + id: key, + } as never, + }; +} diff --git a/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts b/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts index cd1c9eb99c..bd8525997e 100644 --- a/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts +++ b/packages/docs-ui/src/components/paragraph-menu/__tests__/paragraph-menu.spec.ts @@ -15,20 +15,37 @@ */ import { NamedStyleType } from '@univerjs/core'; +import { MenuItemType } from '@univerjs/ui'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createParagraphMenuHoverOpenScheduler, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..'; +import * as paragraphMenu from '..'; +import { createParagraphMenuHoverOpenScheduler, getParagraphFormattingRange, getParagraphMenuActiveHeadingCommandId, getParagraphMenuCommand, getParagraphMenuCommandTargetRange, getParagraphMenuHiddenHeadingCommandIds, getParagraphMenuHiddenItemIds, getParagraphMenuIconSizeClass, getParagraphMenuPopupDirection, getParagraphMenuTargetRange, isEmptyParagraphMenuTarget, PARAGRAPH_MENU_HOVER_OPEN_DELAY, shouldShowParagraphSettingMenu, shouldUseInsertBelowRange } from '..'; import { HorizontalLineCommand } from '../../../commands/commands/doc-horizontal-line.command'; +import { SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../../commands/commands/inline-format.command'; import { BulletListCommand, InsertBulletListBellowCommand, OrderListCommand } from '../../../commands/commands/list.command'; -import { H1HeadingCommand, H3HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../../commands/commands/set-heading.command'; +import { H1HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../../commands/commands/set-heading.command'; import { CreateDocTableCommand } from '../../../commands/commands/table/doc-table-create.command'; import { + DOC_PARAGRAPH_T_EDIT_MENU_ID, + DOC_PARAGRAPH_T_INSERT_MENU_ID, EmptyParagraphBulletListMenuItemFactory, EmptyParagraphH1MenuItemFactory, EmptyParagraphHorizontalLineMenuItemFactory, HEADING_ICON_MAP, INSERT_BELLOW_MENU_ID, - shouldShowParagraphHeadingOption, + InsertBulletListBellowMenuItemFactory, + InsertHorizontalLineBellowMenuItemFactory, + InsertOrderListBellowMenuItemFactory, + ParagraphMenuBackgroundColorHeaderActionMenuItemFactory, + ParagraphMenuBackgroundColorSwatchMenuItemFactories, + ParagraphMenuDefaultTextColorMenuItemFactory, + ParagraphMenuIndentDecreaseMenuItemFactory, + ParagraphMenuIndentIncreaseMenuItemFactory, + ParagraphMenuNoBackgroundMenuItemFactory, + ParagraphMenuTextColorHeaderActionMenuItemFactory, + ParagraphMenuTextColorSwatchMenuItemFactories, TableBlockCopyMenuItemFactory, TableBlockDeleteMenuItemFactory, TableBlockPasteMenuItemFactory, @@ -48,6 +65,10 @@ describe('ParagraphMenu', () => { expect(HEADING_ICON_MAP[NamedStyleType.SUBTITLE].key).toBe('SubtitleTypeIcon'); }); + it('uses the enlarged context menu size variant for the docs T menu', () => { + expect((paragraphMenu as any).getParagraphMenuContextMenuSizeVariant?.()).toBe('paragraph-t'); + }); + it('uses fully-qualified locale keys for paragraph context menu labels', () => { const accessor = { get: () => ({ @@ -64,11 +85,155 @@ describe('ParagraphMenu', () => { expect(TableBlockDeleteMenuItemFactory(accessor).title).toBe('docs-ui.rightClick.delete'); }); + it('adds matching tooltips to T-menu icon actions', () => { + const accessor = { + get: () => ({ + get: () => undefined, + register: () => undefined, + }), + } as never; + + expect(EmptyParagraphH1MenuItemFactory(accessor).tooltip).toBe('ui.toolbar.heading.1'); + expect(EmptyParagraphBulletListMenuItemFactory(accessor).tooltip).toBe('docs-ui.rightClick.bulletList'); + expect(EmptyParagraphHorizontalLineMenuItemFactory(accessor).tooltip).toBe('docs-ui.toolbar.horizontalLine'); + expect(InsertOrderListBellowMenuItemFactory(accessor).tooltip).toBe('docs-ui.rightClick.orderList'); + expect(InsertBulletListBellowMenuItemFactory(accessor).tooltip).toBe('docs-ui.rightClick.bulletList'); + expect(InsertHorizontalLineBellowMenuItemFactory(accessor).tooltip).toBe('docs-ui.toolbar.horizontalLine'); + expect(ParagraphMenuIndentIncreaseMenuItemFactory(accessor).tooltip).toBe('docs-ui.paragraphMenu.increaseIndent'); + expect(ParagraphMenuIndentDecreaseMenuItemFactory(accessor).tooltip).toBe('docs-ui.paragraphMenu.decreaseIndent'); + expect(ParagraphMenuIndentIncreaseMenuItemFactory(accessor).title).toBe('docs-ui.paragraphMenu.increase'); + expect(ParagraphMenuIndentDecreaseMenuItemFactory(accessor).title).toBe('docs-ui.paragraphMenu.decrease'); + }); + + it('renders paragraph text color swatches with an A glyph inside an outlined color chip', () => { + const registeredIcons = new Map>(); + const componentManager = { + get: (key: string) => registeredIcons.get(key), + register: (key: string, component: React.ComponentType<{ className?: string }>) => { + registeredIcons.set(key, component); + }, + }; + const accessor = { + get: () => componentManager, + } as never; + + const factory = ParagraphMenuTextColorSwatchMenuItemFactories['doc.menu.paragraph-t.text-color.0'].menuItemFactory; + const menuItem = factory(accessor); + const Icon = registeredIcons.get(menuItem.icon as string); + + expect(Icon).toBeDefined(); + expect(menuItem.tooltip).toBeUndefined(); + + const markup = renderToStaticMarkup(React.createElement(Icon!, { className: 'swatch-icon' })); + + expect(markup).toContain('>A<'); + expect(markup.match(/ { + const registeredIcons = new Map>(); + const componentManager = { + get: (key: string) => registeredIcons.get(key), + register: (key: string, component: React.ComponentType<{ className?: string }>) => { + registeredIcons.set(key, component); + }, + }; + const accessor = { + get: () => componentManager, + } as never; + + const factory = ParagraphMenuBackgroundColorSwatchMenuItemFactories['doc.menu.paragraph-t.background-color.0'].menuItemFactory; + const menuItem = factory(accessor); + const Icon = registeredIcons.get(menuItem.icon as string); + + expect(Icon).toBeDefined(); + expect(menuItem.tooltip).toBeUndefined(); + + const markup = renderToStaticMarkup(React.createElement(Icon!, { className: 'swatch-icon' })); + + expect(markup).not.toContain('>A<'); + expect(markup.match(/ { + const accessor = { + get: () => ({ + get: () => undefined, + register: () => undefined, + }), + } as never; + + expect(ParagraphMenuDefaultTextColorMenuItemFactory(accessor).tooltip).toBeUndefined(); + expect(ParagraphMenuNoBackgroundMenuItemFactory(accessor).tooltip).toBeUndefined(); + }); + + it('reuses the existing text color selector logic for the colors header action with an A icon', () => { + const accessor = { + get: () => ({ + get: () => undefined, + register: () => undefined, + }), + } as never; + + const menuItem = ParagraphMenuTextColorHeaderActionMenuItemFactory(accessor); + + expect(menuItem.type).toBe(MenuItemType.BUTTON_SELECTOR); + expect(menuItem.id).toBe(SetInlineFormatTextColorCommand.id); + expect(menuItem.icon).toBe('HeaderTextColorIcon'); + expect(menuItem.tooltip).toBeUndefined(); + expect(Array.isArray((menuItem as any).selections)).toBe(true); + }); + + it('reuses the existing background color selector logic for the colors header action with the bucket icon', () => { + const accessor = { + get: () => ({ + get: () => undefined, + register: () => undefined, + }), + } as never; + + const menuItem = ParagraphMenuBackgroundColorHeaderActionMenuItemFactory(accessor); + + expect(menuItem.type).toBe(MenuItemType.BUTTON_SELECTOR); + expect(menuItem.id).toBe(SetInlineFormatTextBackgroundColorCommand.id); + expect(menuItem.icon).toBe('PaintBucketDoubleIcon'); + expect(menuItem.tooltip).toBeUndefined(); + expect(Array.isArray((menuItem as any).selections)).toBe(true); + }); + it('opens the popup away from the drag handle when there is not enough left space', () => { expect(getParagraphMenuPopupDirection(170)).toBe('right'); expect(getParagraphMenuPopupDirection(260)).toBe('left'); }); + it('creates a hover bridge overlapping the trigger edge for left-side paragraph popups', () => { + expect(paragraphMenu.getParagraphMenuHoverBridgeStyle?.({ + left: 320, + right: 348, + top: 120, + bottom: 152, + }, 'left')).toEqual({ + left: 308, + top: 112, + width: 24, + height: 48, + }); + }); + + it('creates a hover bridge overlapping the trigger edge for right-side paragraph popups', () => { + expect(paragraphMenu.getParagraphMenuHoverBridgeStyle?.({ + left: 24, + right: 52, + top: 40, + bottom: 72, + }, 'right')).toEqual({ + left: 40, + top: 32, + width: 24, + height: 48, + }); + }); + it('delays opening the paragraph popup for hover and cancels before the delay elapses', () => { vi.useFakeTimers(); const openMenu = vi.fn(); @@ -106,20 +271,6 @@ describe('ParagraphMenu', () => { expect(openMenu).toHaveBeenCalledTimes(1); }); - it('shows title and subtitle heading shortcuts only when they are the current paragraph style', () => { - expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.NORMAL_TEXT)).toBe(true); - expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.NORMAL_TEXT)).toBe(false); - expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.NORMAL_TEXT)).toBe(false); - - expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.TITLE)).toBe(false); - expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.TITLE)).toBe(true); - expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.TITLE)).toBe(false); - - expect(shouldShowParagraphHeadingOption(NamedStyleType.HEADING_5, NamedStyleType.SUBTITLE)).toBe(false); - expect(shouldShowParagraphHeadingOption(NamedStyleType.TITLE, NamedStyleType.SUBTITLE)).toBe(false); - expect(shouldShowParagraphHeadingOption(NamedStyleType.SUBTITLE, NamedStyleType.SUBTITLE)).toBe(true); - }); - it('maps paragraph named styles to the active heading menu item', () => { expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.HEADING_1)).toBe(H1HeadingCommand.id); expect(getParagraphMenuActiveHeadingCommandId(NamedStyleType.HEADING_3)).toBe(H3HeadingCommand.id); @@ -131,14 +282,35 @@ describe('ParagraphMenu', () => { it('hides the alternate title shortcuts for the hovered paragraph style', () => { expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.TITLE)).toEqual([ + H4HeadingCommand.id, H5HeadingCommand.id, SubtitleHeadingCommand.id, ]); expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.SUBTITLE)).toEqual([ + H4HeadingCommand.id, H5HeadingCommand.id, TitleHeadingCommand.id, ]); expect(getParagraphMenuHiddenHeadingCommandIds(NamedStyleType.NORMAL_TEXT)).toEqual([ + H5HeadingCommand.id, + TitleHeadingCommand.id, + SubtitleHeadingCommand.id, + ]); + }); + + it('keeps all six insert-state header icons visible for empty paragraphs', () => { + expect(getParagraphMenuHiddenItemIds( + DOC_PARAGRAPH_T_INSERT_MENU_ID, + { kind: 'paragraph' } as never, + NamedStyleType.NORMAL_TEXT + )).toEqual([]); + + expect(getParagraphMenuHiddenItemIds( + DOC_PARAGRAPH_T_EDIT_MENU_ID, + { kind: 'paragraph' } as never, + NamedStyleType.NORMAL_TEXT + )).toEqual([ + H5HeadingCommand.id, TitleHeadingCommand.id, SubtitleHeadingCommand.id, ]); @@ -182,6 +354,54 @@ describe('ParagraphMenu', () => { }); }); + it('builds a full formatting range for whole-paragraph color actions', () => { + expect(getParagraphFormattingRange({ + kind: 'paragraph', + menuRange: { + startOffset: 3, + endOffset: 3, + collapsed: true, + }, + } as never, { + paragraphStart: 3, + paragraphEnd: 8, + segmentId: 'header-1', + } as never)).toEqual({ + collapsed: false, + endOffset: 8, + segmentId: 'header-1', + startOffset: 3, + }); + + expect(getParagraphFormattingRange({ + kind: 'blockRange', + blockRange: { + startIndex: 6, + endIndex: 14, + }, + menuRange: { + startOffset: 6, + endOffset: 15, + collapsed: false, + }, + } as never, { + segmentId: 'body', + } as never)).toEqual({ + collapsed: false, + endOffset: 15, + segmentId: 'body', + startOffset: 6, + }); + }); + + it('uses the formatting range for whole-paragraph selection commands', () => { + const targetRange = { startOffset: 3, endOffset: 3, collapsed: true }; + const formattingRange = { startOffset: 3, endOffset: 8, collapsed: false }; + + expect(getParagraphMenuCommandTargetRange(SetInlineFormatTextColorCommand.id, targetRange as never, formattingRange as never)).toEqual(formattingRange); + expect(getParagraphMenuCommandTargetRange(H1HeadingCommand.id, targetRange as never, formattingRange as never)).toEqual(targetRange); + }); + it('preserves context menu command params for paragraph menu actions', () => { expect(getParagraphMenuCommand({ commandId: CreateDocTableCommand.id, diff --git a/packages/docs-ui/src/components/paragraph-menu/index.tsx b/packages/docs-ui/src/components/paragraph-menu/index.tsx index c634a2e0d1..bde1341044 100644 --- a/packages/docs-ui/src/components/paragraph-menu/index.tsx +++ b/packages/docs-ui/src/components/paragraph-menu/index.tsx @@ -14,26 +14,47 @@ * limitations under the License. */ -import type { DocumentDataModel } from '@univerjs/core'; +import type { DocumentDataModel, IDocumentBlockRange, IDocumentBody, IParagraph, ITextRun } from '@univerjs/core'; import type { ITextRangeWithStyle } from '@univerjs/engine-render'; import type { IPopup, IValueOption } from '@univerjs/ui'; +import type { CSSProperties } from 'react'; import type { IMutiPageParagraphBound } from '../../services/doc-event-manager.service'; import type { IDocBlockMenuTarget } from '../../services/doc-paragraph-menu.service'; -import { ICommandService, IUniverInstanceService, NamedStyleType, SliceBodyType, UniverInstanceType } from '@univerjs/core'; +import { DataStreamTreeTokenType, ICommandService, IUniverInstanceService, JSONX, NamedStyleType, SliceBodyType, Tools, UniverInstanceType } from '@univerjs/core'; import { clsx } from '@univerjs/design'; -import { DocContentInsertService, DocSelectionManagerService } from '@univerjs/docs'; +import { DocContentInsertService, DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs'; import { IRenderManagerService } from '@univerjs/engine-render'; -import { ComponentManager, ContextMenuPanel, ContextMenuPosition, IClipboardInterfaceService, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui'; +import { ComponentManager, ContextMenuPanel, IClipboardInterfaceService, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui'; import { useEffect, useMemo, useRef, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; -import { DocCopyCommand, DocPasteCommand } from '../../commands/commands/clipboard.command'; +import { BreakLineCommand } from '../../commands/commands/break-line.command'; +import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from '../../commands/commands/clipboard.command'; import { MoveDocBlockCommand } from '../../commands/commands/doc-block-move.command'; +import { DeleteCurrentParagraphCommand } from '../../commands/commands/doc-delete.command'; import { HorizontalLineCommand } from '../../commands/commands/doc-horizontal-line.command'; +import { DocParagraphSettingCommand } from '../../commands/commands/doc-paragraph-setting.command'; +import { ResetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand } from '../../commands/commands/inline-format.command'; import { BulletListCommand, CheckListCommand, OrderListCommand } from '../../commands/commands/list.command'; +import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignRightCommand } from '../../commands/commands/paragraph-align.command'; import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command'; import { DocTableDeleteTableCommand } from '../../commands/commands/table/doc-table-delete.command'; -import { DocParagraphSettingPanelOperation } from '../../commands/operations/doc-paragraph-setting-panel.operation'; -import { DOC_TABLE_BLOCK_MENU_ID, EMPTY_PARAGRAPH_MENU_ID, HEADING_ICON_MAP, INSERT_BELLOW_MENU_ID } from '../../menu/paragraph-menu'; +import { + DOC_PARAGRAPH_T_DIVIDER_MENU_ID, + DOC_PARAGRAPH_T_EDIT_MENU_ID, + DOC_PARAGRAPH_T_INDENT_DECREASE_ID, + DOC_PARAGRAPH_T_INDENT_INCREASE_ID, + DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID, + DOC_PARAGRAPH_T_INSERT_MENU_ID, + DOC_PARAGRAPH_T_RESET_COLORS_ID, + DOC_TABLE_BLOCK_MENU_ID, + DOCS_CALLOUT_INSERT_COMMAND_ID, + DOCS_CODE_INSERT_COMMAND_ID, + DOCS_QUOTE_INSERT_COMMAND_ID, + HEADING_ICON_MAP, + INSERT_BELLOW_MENU_ID, + INSERT_DOC_IMAGE_COMMAND_ID, + INSERT_DOC_SHAPE_COMMAND_ID, +} from '../../menu/paragraph-menu'; import { IDocClipboardService } from '../../services/clipboard/clipboard.service'; import { DocEventManagerService } from '../../services/doc-event-manager.service'; import { DocParagraphMenuService } from '../../services/doc-paragraph-menu.service'; @@ -42,11 +63,18 @@ export function getParagraphMenuIconSizeClass(iconKey: string): string { return iconKey === 'TextTypeIcon' ? 'univer-size-3' : 'univer-size-4'; } +export function getParagraphMenuContextMenuSizeVariant(): 'paragraph-t' { + return 'paragraph-t'; +} + export function getParagraphMenuPopupDirection(anchorLeft: number, menuWidth = 212, viewportPadding = 8): 'left' | 'right' { return anchorLeft - menuWidth < viewportPadding ? 'right' : 'left'; } export const PARAGRAPH_MENU_HOVER_OPEN_DELAY = 800; +const PARAGRAPH_MENU_HOVER_HIDE_DELAY = 240; +const PARAGRAPH_MENU_HOVER_BRIDGE_EDGE_OVERLAP = 12; +const PARAGRAPH_MENU_HOVER_BRIDGE_VERTICAL_PADDING = 8; export function createParagraphMenuHoverOpenScheduler(openMenu: () => void, delay = PARAGRAPH_MENU_HOVER_OPEN_DELAY) { let openTimer: number | null = null; @@ -105,6 +133,24 @@ export function getParagraphMenuTargetRange(paragraph?: IMutiPageParagraphBound }; } +export function getParagraphMenuHoverBridgeStyle( + anchorRect: { left: number; right: number; top: number; bottom: number } | null | undefined, + direction: 'left' | 'right', + edgeOverlap = PARAGRAPH_MENU_HOVER_BRIDGE_EDGE_OVERLAP, + verticalPadding = PARAGRAPH_MENU_HOVER_BRIDGE_VERTICAL_PADDING +): CSSProperties | undefined { + if (!anchorRect) { + return undefined; + } + + return { + left: direction === 'left' ? anchorRect.left - edgeOverlap : anchorRect.right - edgeOverlap, + top: anchorRect.top - verticalPadding, + width: edgeOverlap * 2, + height: anchorRect.bottom - anchorRect.top + verticalPadding * 2, + }; +} + const HEADING_COMMAND_VALUES: Record = { [H1HeadingCommand.id]: NamedStyleType.HEADING_1, [H2HeadingCommand.id]: NamedStyleType.HEADING_2, @@ -133,16 +179,56 @@ export function getParagraphMenuActiveHeadingCommandId(namedStyleType?: NamedSty export function getParagraphMenuHiddenHeadingCommandIds(namedStyleType?: NamedStyleType): string[] { if (namedStyleType === NamedStyleType.TITLE) { - return [H5HeadingCommand.id, SubtitleHeadingCommand.id]; + return [H4HeadingCommand.id, H5HeadingCommand.id, SubtitleHeadingCommand.id]; } if (namedStyleType === NamedStyleType.SUBTITLE) { - return [H5HeadingCommand.id, TitleHeadingCommand.id]; + return [H4HeadingCommand.id, H5HeadingCommand.id, TitleHeadingCommand.id]; } - return [TitleHeadingCommand.id, SubtitleHeadingCommand.id]; + if (namedStyleType === NamedStyleType.HEADING_5) { + return [H4HeadingCommand.id, TitleHeadingCommand.id, SubtitleHeadingCommand.id]; + } + + return [H5HeadingCommand.id, TitleHeadingCommand.id, SubtitleHeadingCommand.id]; } +const LIST_ICON_TO_COMMAND_ID: Record = { + OrderIcon: OrderListCommand.id, + UnorderIcon: BulletListCommand.id, + TodoListDoubleIcon: CheckListCommand.id, +}; + +const BLOCK_TYPE_TO_COMMAND_ID: Record = { + code: DOCS_CODE_INSERT_COMMAND_ID, + quote: DOCS_QUOTE_INSERT_COMMAND_ID, + callout: DOCS_CALLOUT_INSERT_COMMAND_ID, +}; + +const PARAGRAPH_MENU_SELECTION_COMMAND_IDS = new Set([ + BulletListCommand.id, + OrderListCommand.id, + CheckListCommand.id, + HorizontalLineCommand.id, + SetInlineFormatTextColorCommand.id, + SetInlineFormatTextBackgroundColorCommand.id, + DOCS_CODE_INSERT_COMMAND_ID, + DOCS_QUOTE_INSERT_COMMAND_ID, + DOCS_CALLOUT_INSERT_COMMAND_ID, + AlignLeftCommand.id, + AlignCenterCommand.id, + AlignRightCommand.id, + AlignJustifyCommand.id, +]); + +const PARAGRAPH_MENU_SKIP_REPLACE_SELECTION_COMMAND_IDS = new Set([ + DocCopyCurrentParagraphCommand.id, + DocCutCurrentParagraphCommand.id, + DeleteCurrentParagraphCommand.id, + INSERT_DOC_IMAGE_COMMAND_ID, + INSERT_DOC_SHAPE_COMMAND_ID, +]); + export function getParagraphMenuCommand(params: IValueOption, targetRange?: ITextRangeWithStyle | null): { commandId?: string; params?: object } { const commandId = params.commandId ?? params.id ?? (typeof params.label === 'string' ? params.label : undefined); if (commandId && targetRange && commandId in HEADING_COMMAND_VALUES) { @@ -189,19 +275,289 @@ function getParagraphMenuType(target: IDocBlockMenuTarget | null | undefined, em return DOC_TABLE_BLOCK_MENU_ID; } - return emptyMode ? EMPTY_PARAGRAPH_MENU_ID : ContextMenuPosition.PARAGRAPH; + if (emptyMode) { + return DOC_PARAGRAPH_T_INSERT_MENU_ID; + } + + if (target?.icon === 'ReduceIcon') { + return DOC_PARAGRAPH_T_DIVIDER_MENU_ID; + } + + return DOC_PARAGRAPH_T_EDIT_MENU_ID; } export function shouldShowParagraphSettingMenu(target: IDocBlockMenuTarget | null | undefined): boolean { return !target || target.kind === 'paragraph'; } +function getParagraphMenuActiveItemIds(target: IDocBlockMenuTarget | null | undefined, namedStyleType?: NamedStyleType): string[] { + if (target?.kind === 'blockRange') { + const blockType = target.blockRange?.blockType; + return blockType && BLOCK_TYPE_TO_COMMAND_ID[blockType] + ? [BLOCK_TYPE_TO_COMMAND_ID[blockType]] + : []; + } + + const activeIds = [getParagraphMenuActiveHeadingCommandId(namedStyleType)]; + const listCommandId = target?.icon ? LIST_ICON_TO_COMMAND_ID[target.icon] : undefined; + if (listCommandId) { + activeIds.push(listCommandId); + } + + return activeIds; +} + +export function getParagraphMenuHiddenItemIds( + menuType: string, + target: IDocBlockMenuTarget | null | undefined, + namedStyleType?: NamedStyleType +): string[] { + if (menuType === DOC_PARAGRAPH_T_INSERT_MENU_ID) { + return []; + } + + const hiddenIds = [...getParagraphMenuHiddenHeadingCommandIds(namedStyleType)]; + const blockType = target?.kind === 'blockRange' ? target.blockRange?.blockType : undefined; + + if (blockType === 'callout') { + hiddenIds.push(DOCS_CODE_INSERT_COMMAND_ID, DOCS_QUOTE_INSERT_COMMAND_ID); + } else if (blockType === 'quote' || blockType === 'code') { + hiddenIds.push(DOCS_CALLOUT_INSERT_COMMAND_ID); + } + + return hiddenIds; +} + +function getEndTokenOffset(body: IDocumentBody, blockRange: Pick): number { + return body.dataStream[blockRange.endIndex] === DataStreamTreeTokenType.BLOCK_END + ? blockRange.endIndex + : body.dataStream[blockRange.endIndex + 1] === DataStreamTreeTokenType.BLOCK_END + ? blockRange.endIndex + 1 + : blockRange.endIndex; +} + +function shiftPointRangesAfterDelete(ranges: T[] | undefined, offset: number, length: number): T[] | undefined { + const endOffset = offset + length; + return ranges + ?.map((range) => { + if (range.startIndex >= offset && range.startIndex < endOffset) { + return null; + } + + return range.startIndex >= endOffset ? { ...range, startIndex: range.startIndex - length } : range; + }) + .filter((range): range is T => range != null); +} + +function shiftTextRunsAfterDelete(runs: ITextRun[] | undefined, offset: number, length: number): ITextRun[] | undefined { + const endOffset = offset + length; + return runs + ?.map((run) => { + if (run.st >= offset && run.ed < endOffset) { + return null; + } + + if (run.st >= endOffset) { + return { + ...run, + st: run.st - length, + ed: run.ed - length, + }; + } + + if (run.ed >= offset) { + return { + ...run, + st: run.st < offset ? run.st : offset, + ed: Math.max(offset, run.ed - length), + }; + } + + return run; + }) + .filter((run): run is ITextRun => run != null); +} + +function shiftIndexRangesAfterDelete( + ranges: T[] | undefined, + offset: number, + length: number +): T[] | undefined { + const endOffset = offset + length; + return ranges + ?.map((range) => { + if (range.startIndex >= offset && range.endIndex < endOffset) { + return null; + } + + if (range.startIndex >= endOffset) { + return { + ...range, + startIndex: range.startIndex - length, + endIndex: range.endIndex - length, + }; + } + + if (range.endIndex >= offset) { + return { + ...range, + startIndex: range.startIndex < offset ? range.startIndex : offset, + endIndex: Math.max(offset, range.endIndex - length), + }; + } + + return range; + }) + .filter((range): range is T => range != null); +} + +function deleteBodyText(body: IDocumentBody, startOffset: number, endOffset: number): void { + const length = endOffset - startOffset; + body.dataStream = `${body.dataStream.slice(0, startOffset)}${body.dataStream.slice(endOffset)}`; + body.paragraphs = shiftPointRangesAfterDelete(body.paragraphs, startOffset, length); + body.sectionBreaks = shiftPointRangesAfterDelete(body.sectionBreaks, startOffset, length); + body.customBlocks = shiftPointRangesAfterDelete(body.customBlocks, startOffset, length); + body.textRuns = shiftTextRunsAfterDelete(body.textRuns, startOffset, length); + body.tables = shiftIndexRangesAfterDelete(body.tables, startOffset, length); + body.customRanges = shiftIndexRangesAfterDelete(body.customRanges, startOffset, length); + body.customDecorations = shiftIndexRangesAfterDelete(body.customDecorations, startOffset, length); + body.blockRanges = shiftIndexRangesAfterDelete(body.blockRanges, startOffset, length); +} + +function stripBlockParagraphStyle(style: IParagraph['paragraphStyle'], blockType: string): IParagraph['paragraphStyle'] { + const nextStyle = { ...style }; + + if (blockType === 'quote') { + delete nextStyle.indentStart; + delete nextStyle.spaceAbove; + delete nextStyle.spaceBelow; + return nextStyle; + } + + if (blockType === 'code') { + delete nextStyle.indentStart; + delete nextStyle.indentEnd; + delete nextStyle.spaceAbove; + delete nextStyle.spaceBelow; + if (nextStyle.textStyle?.ff === 'monospace' && nextStyle.textStyle?.fs === 12) { + delete nextStyle.textStyle; + } + return nextStyle; + } + + if (blockType === 'callout') { + delete nextStyle.indentStart; + delete nextStyle.indentEnd; + delete nextStyle.spaceAbove; + delete nextStyle.spaceBelow; + return nextStyle; + } + + return nextStyle; +} + +function unwrapBlockRangeBody(documentBody: IDocumentBody, blockRange: IDocumentBlockRange) { + const body = Tools.deepClone(documentBody); + const endTokenOffset = getEndTokenOffset(body, blockRange); + + (body.paragraphs ?? []) + .filter((paragraph) => paragraph.startIndex > blockRange.startIndex && paragraph.startIndex < endTokenOffset) + .forEach((paragraph) => { + paragraph.paragraphStyle = stripBlockParagraphStyle(paragraph.paragraphStyle, blockRange.blockType); + }); + + deleteBodyText(body, endTokenOffset, endTokenOffset + 1); + deleteBodyText(body, blockRange.startIndex, blockRange.startIndex + 1); + body.blockRanges = body.blockRanges?.filter((range) => range.blockId !== blockRange.blockId); + + return { + body, + range: { + startOffset: blockRange.startIndex, + endOffset: Math.max(blockRange.startIndex, endTokenOffset - 1), + collapsed: false, + }, + }; +} + +function buildReplaceBodyActions(previousBody: IDocumentBody, nextBody: IDocumentBody) { + const jsonX = JSONX.getInstance(); + return [ + jsonX.replaceOp(['body', 'dataStream'], previousBody.dataStream, nextBody.dataStream), + jsonX.replaceOp(['body', 'paragraphs'], previousBody.paragraphs, nextBody.paragraphs), + jsonX.replaceOp(['body', 'sectionBreaks'], previousBody.sectionBreaks, nextBody.sectionBreaks), + jsonX.replaceOp(['body', 'textRuns'], previousBody.textRuns, nextBody.textRuns), + jsonX.replaceOp(['body', 'customRanges'], previousBody.customRanges, nextBody.customRanges), + jsonX.replaceOp(['body', 'customDecorations'], previousBody.customDecorations, nextBody.customDecorations), + jsonX.replaceOp(['body', 'customBlocks'], previousBody.customBlocks, nextBody.customBlocks), + jsonX.replaceOp(['body', 'tables'], previousBody.tables, nextBody.tables), + jsonX.replaceOp(['body', 'blockRanges'], previousBody.blockRanges, nextBody.blockRanges), + ].reduce((acc, action) => JSONX.compose(acc, action), null as ReturnType); +} + +function getTargetSelectionRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null { + if (!target) { + return getParagraphMenuTargetRange(paragraph); + } + + return { + ...target.menuRange, + segmentId: paragraph?.segmentId, + }; +} + +export function getParagraphFormattingRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null { + if (target?.kind === 'blockRange') { + return getBlockSelectionRange(target, paragraph); + } + + if (paragraph) { + return { + startOffset: paragraph.paragraphStart, + endOffset: paragraph.paragraphEnd, + collapsed: false, + segmentId: paragraph.segmentId, + }; + } + + return getTargetSelectionRange(target, paragraph); +} + +export function getParagraphMenuCommandTargetRange( + commandId: string | undefined, + targetRange?: ITextRangeWithStyle | null, + formattingRange?: ITextRangeWithStyle | null +): ITextRangeWithStyle | null | undefined { + if (commandId && PARAGRAPH_MENU_SELECTION_COMMAND_IDS.has(commandId)) { + return formattingRange ?? targetRange; + } + + return targetRange ?? formattingRange; +} + +function getBlockSelectionRange(target: IDocBlockMenuTarget | null | undefined, paragraph?: IMutiPageParagraphBound | null): ITextRangeWithStyle | null { + if (target?.kind !== 'blockRange') { + return getTargetSelectionRange(target, paragraph); + } + + const blockRange = target.blockRange; + if (!blockRange) { + return getTargetSelectionRange(target, paragraph); + } + + return { + startOffset: blockRange.startIndex, + endOffset: blockRange.endIndex + 1, + collapsed: false, + segmentId: paragraph?.segmentId, + }; +} + export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { const [visible, setVisible] = useState(false); - const [emptyMode, setEmptyMode] = useState(false); + const [anchorRect, setAnchorRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null); const [dropRect, setDropRect] = useState<{ left: number; right: number; top: number; bottom: number } | null>(null); const [menuDirection, setMenuDirection] = useState<'left' | 'right'>('left'); - const contentRef = useRef(null); const targetRangeRef = useRef(null); const dragTargetOffsetRef = useRef(null); const dragRangeRef = useRef<{ startOffset: number; endOffset: number } | null>(null); @@ -234,18 +590,23 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { const paragraphObj = useMemo(() => doc?.getBody()?.paragraphs?.find((p) => p.startIndex === startIndex), [doc, startIndex]); const isEmptyParagraph = currentActiveTarget?.emptyMode ?? isEmptyParagraphMenuTarget(dataStream, activeParagraphBound); const namedStyleType = paragraphObj?.paragraphStyle?.namedStyleType; - const activeHeadingCommandId = getParagraphMenuActiveHeadingCommandId(namedStyleType); - const hiddenHeadingCommandIds = useMemo(() => getParagraphMenuHiddenHeadingCommandIds(namedStyleType), [namedStyleType]); - const hiddenItemIds = useMemo(() => { - if (!shouldShowParagraphSettingMenu(currentActiveTarget)) { - return [...hiddenHeadingCommandIds, DocParagraphSettingPanelOperation.id]; - } - - return hiddenHeadingCommandIds; - }, [currentActiveTarget, hiddenHeadingCommandIds]); const icon = HEADING_ICON_MAP[namedStyleType ?? NamedStyleType.NORMAL_TEXT]; const targetIconKey = currentActiveTarget?.icon ?? icon.key; const TargetIcon = componentManager.get(targetIconKey) ?? icon.component; + const paragraphMenuType = useMemo( + () => getParagraphMenuType(currentActiveTarget, isEmptyParagraph), + [currentActiveTarget, isEmptyParagraph] + ); + const paragraphMenuActiveItemIds = useMemo( + () => getParagraphMenuActiveItemIds(currentActiveTarget, namedStyleType), + [currentActiveTarget, namedStyleType] + ); + const paragraphMenuHiddenItemIds = useMemo( + () => currentActiveTarget?.kind === 'table' + ? [] + : getParagraphMenuHiddenItemIds(paragraphMenuType, currentActiveTarget, namedStyleType), + [currentActiveTarget, namedStyleType, paragraphMenuType] + ); const anchorRect$ = useMemo(() => new BehaviorSubject({ left: 0, right: 0, @@ -257,12 +618,14 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { const boundingRect = anchorRef.current?.getBoundingClientRect(); const left = (boundingRect?.left ?? 0) - 4; setMenuDirection(getParagraphMenuPopupDirection(left)); - anchorRect$.next({ + const nextAnchorRect = { left, right: boundingRect?.right ?? 0, top: boundingRect?.top ?? 0, bottom: boundingRect?.bottom ?? 0, - }); + }; + setAnchorRect(nextAnchorRect); + anchorRect$.next(nextAnchorRect); }; const handleHideMenu = () => { @@ -283,7 +646,7 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { if (!isMouseOver.current && !isDraggingRef.current) { handleHideMenu(); } - }, 180); + }, PARAGRAPH_MENU_HOVER_HIDE_DELAY); }; const handleOpenMenu = () => { @@ -297,7 +660,6 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { : getParagraphMenuTargetRange(activeParagraphBound); targetRangeRef.current = targetRange; updateAnchorRect(); - setEmptyMode(isEmptyParagraph); setVisible(true); }; openMenuRef.current = handleOpenMenu; @@ -311,6 +673,199 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { hoverOpenSchedulerRef.current.cancel(); }; + const replaceSelection = (range: ITextRangeWithStyle | null | undefined) => { + if (!range) { + return; + } + + docSelectionManagerService.replaceTextRanges([range], false); + }; + + const executeResolvedCommand = (commandId: string, params?: Record, targetRange?: ITextRangeWithStyle | null) => { + const resolved = getParagraphMenuCommand({ + commandId, + id: commandId, + params, + }, targetRange); + + if (!resolved.commandId) { + return false; + } + + return commandService.executeCommand(resolved.commandId, resolved.params); + }; + + const unwrapActiveBlockRange = async () => { + const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget; + if (!latestTarget || latestTarget.kind !== 'blockRange' || !latestTarget.blockRange || !doc?.getBody()) { + return null; + } + + const previousBody = doc.getBody()!; + const { body, range } = unwrapBlockRangeBody(previousBody, latestTarget.blockRange); + const actions = buildReplaceBodyActions(previousBody, body); + const segmentId = activeParagraphBound?.segmentId ?? ''; + + const success = await commandService.executeCommand(RichTextEditingMutation.id, { + unitId: popup.unitId, + actions, + textRanges: [{ + ...range, + segmentId, + }], + }); + + if (!success) { + return null; + } + + const nextRange = { + ...range, + segmentId, + }; + replaceSelection(nextRange); + targetRangeRef.current = nextRange; + return nextRange; + }; + + const executeParagraphMenuOption = async (option: IValueOption) => { + const latestTarget = docParagraphMenuService?.activeTarget ?? activeTarget; + const targetRange = getTargetSelectionRange(latestTarget, activeParagraphBound ?? undefined); + const blockRange = getBlockSelectionRange(latestTarget, activeParagraphBound ?? undefined); + const formattingRange = getParagraphFormattingRange(latestTarget, activeParagraphBound ?? undefined); + const commandId = option.commandId ?? option.id ?? (typeof option.label === 'string' ? option.label : undefined); + const commandParams = typeof option.params === 'function' ? option.params() : option.params; + + if (!commandId) { + return; + } + + if (commandId === DOC_PARAGRAPH_T_RESET_COLORS_ID) { + if (formattingRange) { + replaceSelection(formattingRange); + } + await commandService.executeCommand(SetInlineFormatTextColorCommand.id, { value: '#000000' }); + await commandService.executeCommand(ResetInlineFormatTextBackgroundColorCommand.id); + layoutService.focus(); + handleHideMenu(); + return; + } + + if (commandId === DOC_PARAGRAPH_T_INDENT_INCREASE_ID || commandId === DOC_PARAGRAPH_T_INDENT_DECREASE_ID) { + if (formattingRange) { + replaceSelection(formattingRange); + } + const currentIndent = paragraphObj?.paragraphStyle?.indentFirstLine?.v ?? 0; + const delta = commandId === DOC_PARAGRAPH_T_INDENT_INCREASE_ID ? 20 : -20; + await commandService.executeCommand(DocParagraphSettingCommand.id, { + paragraph: { + indentFirstLine: { + v: Math.max(0, currentIndent + delta), + }, + }, + }); + layoutService.focus(); + handleHideMenu(); + return; + } + + if (commandId === DOC_PARAGRAPH_T_INSERT_BELOW_COMMAND_ID) { + if (!latestTarget?.moveRange) { + return; + } + + const insertRange = { + startOffset: latestTarget.moveRange.endOffset, + endOffset: latestTarget.moveRange.endOffset, + collapsed: true, + segmentId: activeParagraphBound?.segmentId, + }; + replaceSelection(insertRange); + await commandService.executeCommand(BreakLineCommand.id, { + textRange: insertRange, + }); + const wrappedCommandId = typeof commandParams?.commandId === 'string' ? commandParams.commandId : undefined; + if (wrappedCommandId) { + await commandService.executeCommand(wrappedCommandId); + } + layoutService.focus(); + handleHideMenu(); + return; + } + + if (latestTarget?.kind === 'blockRange') { + const currentBlockCommandId = BLOCK_TYPE_TO_COMMAND_ID[latestTarget.blockRange?.blockType ?? '']; + + if (commandId === currentBlockCommandId) { + await unwrapActiveBlockRange(); + layoutService.focus(); + handleHideMenu(); + return; + } + + if ( + commandId === NormalTextHeadingCommand.id || + commandId in HEADING_COMMAND_VALUES || + commandId === BulletListCommand.id || + commandId === OrderListCommand.id || + commandId === CheckListCommand.id + ) { + const nextRange = await unwrapActiveBlockRange(); + if (commandId !== NormalTextHeadingCommand.id) { + await executeResolvedCommand(commandId, commandParams as Record | undefined, nextRange); + } + layoutService.focus(); + handleHideMenu(); + return; + } + + if ( + commandId === DOCS_CODE_INSERT_COMMAND_ID || + commandId === DOCS_QUOTE_INSERT_COMMAND_ID || + commandId === DOCS_CALLOUT_INSERT_COMMAND_ID + ) { + if (blockRange) { + replaceSelection(blockRange); + } + await executeResolvedCommand(commandId, commandParams as Record | undefined, blockRange); + layoutService.focus(); + handleHideMenu(); + return; + } + } + + if (commandId === getParagraphMenuActiveHeadingCommandId(namedStyleType) && commandId !== NormalTextHeadingCommand.id) { + if (targetRange) { + replaceSelection(targetRange); + } + await executeResolvedCommand(NormalTextHeadingCommand.id, undefined, targetRange); + layoutService.focus(); + handleHideMenu(); + return; + } + + if (commandId && shouldUseInsertBelowRange(commandId, option) && latestTarget?.moveRange) { + docContentInsertService.setInsertRange({ + unitId: popup.unitId, + startOffset: latestTarget.moveRange.endOffset, + endOffset: latestTarget.moveRange.endOffset, + segmentId: targetRange?.segmentId ?? activeParagraphBound?.segmentId ?? '', + }); + } else if (PARAGRAPH_MENU_SELECTION_COMMAND_IDS.has(commandId) && !PARAGRAPH_MENU_SKIP_REPLACE_SELECTION_COMMAND_IDS.has(commandId)) { + replaceSelection(getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange)); + } + + await executeResolvedCommand( + commandId, + commandParams as Record | undefined, + getParagraphMenuCommandTargetRange(commandId, targetRange, formattingRange) + ); + layoutService.focus(); + handleHideMenu(); + }; + + const hoverBridgeStyle = visible ? getParagraphMenuHoverBridgeStyle(anchorRect, menuDirection) : undefined; + useEffect(() => () => { if (hideTimerRef.current != null) { window.clearTimeout(hideTimerRef.current); @@ -454,6 +1009,23 @@ export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { )} + {visible && hoverBridgeStyle && ( +