From 7c0fa4e107e3cb9fecb90fad4183fbc94b15a4e9 Mon Sep 17 00:00:00 2001 From: ZhangWei Date: Tue, 8 Apr 2025 15:07:50 +0800 Subject: [PATCH] feat(docs-ui): support paragraph menu (#4943) --- .cursor/rules/git-commit.mdc | 8 + .cursor/rules/implement-figma-component.mdc | 15 + .cursor/rules/tailwindcss.mdc | 53 +++ packages/core/src/index.ts | 2 +- packages/core/src/shared/rxjs.ts | 10 +- packages/core/src/types/const/const.ts | 2 +- packages/design/src/components/menu/Menu.tsx | 61 ++- .../src/components/menu/index.module.less | 66 ++- packages/design/src/components/menu/index.ts | 2 +- .../src/components/menu/menu.stories.tsx | 145 +++++++ .../src/components/popup/index.module.less | 2 +- packages/design/src/index.ts | 2 +- packages/design/src/themes/base.less | 4 +- .../drawing-popup-menu.controller.ts | 5 +- .../commands/commands/break-line.command.ts | 9 +- .../commands/commands/clipboard.command.ts | 45 +- .../commands/clipboard.inner.command.ts | 4 +- .../commands/commands/doc-delete.command.ts | 55 +++ .../commands/doc-horizontal-line.command.ts | 31 +- .../insert-bullet-below.command.ts | 98 +++++ .../src/commands/commands/list.command.ts | 102 ++++- .../commands/commands/set-heading.command.ts | 93 +++- .../docs-ui/src/commands/commands/util.ts | 32 ++ .../src/components/paragraph-menu/index.tsx | 148 +++++++ .../src/controllers/doc-ui.controller.ts | 7 +- .../docs-ui/src/controllers/menu.schema.ts | 84 +++- packages/docs-ui/src/controllers/menu/menu.ts | 4 +- .../src/controllers/menu/paragraph-menu.ts | 177 ++++++++ packages/docs-ui/src/docs-ui-plugin.ts | 29 +- packages/docs-ui/src/locale/en-US.ts | 6 + packages/docs-ui/src/locale/fa-IR.ts | 6 + packages/docs-ui/src/locale/fr-FR.ts | 6 + packages/docs-ui/src/locale/ru-RU.ts | 6 + packages/docs-ui/src/locale/vi-VN.ts | 6 + packages/docs-ui/src/locale/zh-CN.ts | 6 + packages/docs-ui/src/locale/zh-TW.ts | 6 + .../services/clipboard/clipboard.service.ts | 44 +- .../src/services/doc-event-manager.service.ts | 407 ++++++++++++++---- .../services/doc-paragraph-menu.service.ts | 158 +++++++ .../src/services/doc-popup-manager.service.ts | 67 ++- .../docs-ui/src/views/side-menu/index.tsx | 83 ++-- .../ui/src/components/menu/desktop/Menu.tsx | 66 +-- .../menu/desktop/tiny-menu-group.tsx | 80 ++++ packages/ui/src/index.ts | 2 +- .../src/services/menu/menu-manager.service.ts | 42 +- packages/ui/src/services/menu/types.ts | 8 + .../services/popup/canvas-popup.service.ts | 16 +- .../views/components/popup/CanvasPopup.tsx | 10 +- .../src/views/components/popup/RectPopup.tsx | 91 ++-- 49 files changed, 2119 insertions(+), 292 deletions(-) create mode 100644 .cursor/rules/git-commit.mdc create mode 100644 .cursor/rules/implement-figma-component.mdc create mode 100644 .cursor/rules/tailwindcss.mdc create mode 100644 packages/design/src/components/menu/menu.stories.tsx create mode 100644 packages/docs-ui/src/commands/commands/insert-below/insert-bullet-below.command.ts create mode 100644 packages/docs-ui/src/commands/commands/util.ts create mode 100644 packages/docs-ui/src/components/paragraph-menu/index.tsx create mode 100644 packages/docs-ui/src/controllers/menu/paragraph-menu.ts create mode 100644 packages/docs-ui/src/services/doc-paragraph-menu.service.ts create mode 100644 packages/ui/src/components/menu/desktop/tiny-menu-group.tsx diff --git a/.cursor/rules/git-commit.mdc b/.cursor/rules/git-commit.mdc new file mode 100644 index 0000000000..5d0e803ece --- /dev/null +++ b/.cursor/rules/git-commit.mdc @@ -0,0 +1,8 @@ +--- +description: when generate git commit message +globs: +alwaysApply: false +--- + +# Your rule content +- git commit message must not be longer than 100 characters diff --git a/.cursor/rules/implement-figma-component.mdc b/.cursor/rules/implement-figma-component.mdc new file mode 100644 index 0000000000..16014539ab --- /dev/null +++ b/.cursor/rules/implement-figma-component.mdc @@ -0,0 +1,15 @@ +--- +description: When user only give you an figma link. +globs: +alwaysApply: false +--- + +# Your rule content + +When I give you a figma link, it means you need to get the design from figma. And help me to implements it as a react component. +Please only use tailwindcss and html element (don't use button) only to write this component. +Please write a storybook demo for this component, please put storbook demo in the same directory with the component, storybook demo like packages/design/src/components/date-picker/DatePicker.stories.tsx. +Please note all interface should start with `I`. +Please make all svg file to react-component. make sure all files in one dir. +If the component include list items. you should using dataset and map to make sure this is configurable. + diff --git a/.cursor/rules/tailwindcss.mdc b/.cursor/rules/tailwindcss.mdc new file mode 100644 index 0000000000..e398131177 --- /dev/null +++ b/.cursor/rules/tailwindcss.mdc @@ -0,0 +1,53 @@ +--- +description: +globs: +alwaysApply: true +--- + +# Your rule content + +- The config file of tailwindcss is on [tailwind.config.ts](mdc:common/shared/tailwind/tailwind.config.ts). +- The prefix is `univer-`, eg. `univer-cursor-pointer` + +## Colors + +We use a consistent color system across the application. Here are the main color variables: + +### Primary Colors +- Primary: `primary-{50,100,200,300,400,500,600,700,800,900}` + - Example: `univer-text-primary-500` for primary text color + +### Gray Scale +- Gray: `gray-{50,100,200,300,400,500,600,700,800,900}` + - Example: `univer-bg-gray-100` for light gray background + +### Status Colors +- Blue: `blue-{50,100,200,300,400,500,600,700,800,900}` +- Red: `red-{50,100,200,300,400,500,600,700,800,900}` +- Orange: `orange-{50,100,200,300,400,500,600,700,800,900}` +- Yellow: `yellow-{50,100,200,300,400,500,600,700,800,900}` +- Green: `green-{50,100,200,300,400,500,600,700,800,900}` + +### Additional Colors +- Jiqing: `jiqing-{50,100,200,300,400,500,600,700,800,900}` +- Indigo: `indigo-{50,100,200,300,400,500,600,700,800,900}` +- Purple: `purple-{50,100,200,300,400,500,600,700,800,900}` +- Pink: `pink-{50,100,200,300,400,500,600,700,800,900}` + +## Common Color Usage Examples + +### Text Colors +- Primary text: `univer-text-gray-800` (#181C2A) +- Secondary text: `univer-text-gray-400` (#979DAC) +- Disabled text: `univer-text-gray-300` (#CDD0D8) + +### Background Colors +- Primary background: `univer-bg-gray-100` (#EEEFF1) +- Secondary background: `univer-bg-gray-50` (#F9FAFB) +- Active/Selected: `univer-bg-primary-50` (#F3F5FF) + +### Border Colors +- Default border: `univer-border-gray-200` (#E3E5EA) +- Active border: `univer-border-primary-500` (#466AF7) + +Remember to always prefix class names with `univer-` when using these colors. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c07e02b51..0617d66aa8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -146,7 +146,7 @@ export { isBlackColor, isWhiteColor } from './shared/color/color-kit'; export { cellToRange } from './shared/common'; export { getIntersectRange } from './shared/range'; export { nameCharacterCheck } from './shared/name'; -export { afterTime, bufferDebounceTime, fromCallback, takeAfter } from './shared/rxjs'; +export { afterTime, bufferDebounceTime, convertObservableToBehaviorSubject, fromCallback, takeAfter } from './shared/rxjs'; export { awaitTime, delayAnimationFrame } from './shared/timer'; export { Range } from './sheets/range'; export { diff --git a/packages/core/src/shared/rxjs.ts b/packages/core/src/shared/rxjs.ts index 6b8e53e87e..0f279bf591 100644 --- a/packages/core/src/shared/rxjs.ts +++ b/packages/core/src/shared/rxjs.ts @@ -16,7 +16,7 @@ import type { OperatorFunction } from 'rxjs'; import type { IDisposable } from '../common/di'; -import { debounceTime, map, Observable, ReplaySubject, take, tap } from 'rxjs'; +import { BehaviorSubject, debounceTime, map, Observable, ReplaySubject, take, tap } from 'rxjs'; type CallbackFn = (cb: (...args: T) => void) => IDisposable; @@ -76,3 +76,11 @@ export function afterTime(ms: number): Observable { setTimeout(() => subject.next(), ms); return subject.pipe(take(1)); } + +export function convertObservableToBehaviorSubject(observable: Observable, initValue: T): BehaviorSubject { + const subject = new BehaviorSubject(initValue); + + observable.subscribe(subject); + + return subject; +} diff --git a/packages/core/src/types/const/const.ts b/packages/core/src/types/const/const.ts index 4fd5789b31..9466fc550b 100644 --- a/packages/core/src/types/const/const.ts +++ b/packages/core/src/types/const/const.ts @@ -185,7 +185,7 @@ export const NAMED_STYLE_MAP: Record> = { [NamedStyleType.HEADING_3]: { fs: 16, bl: 1 }, // Heading 3: 16pt, bold [NamedStyleType.HEADING_4]: { fs: 14, bl: 1 }, // Heading 4: 14pt, bold [NamedStyleType.HEADING_5]: { fs: 12, bl: 1 }, // Heading 5: 12pt, bold - [NamedStyleType.NORMAL_TEXT]: { fs: 11 }, // Normal text: 11pt + [NamedStyleType.NORMAL_TEXT]: null, // Normal text: using global font size [NamedStyleType.TITLE]: { fs: 26, bl: 1 }, // Title: 26pt, bold [NamedStyleType.SUBTITLE]: { fs: 15, cl: { rgb: '#999999' } }, // Subtitle: 15pt [NamedStyleType.NAMED_STYLE_TYPE_UNSPECIFIED]: null, diff --git a/packages/design/src/components/menu/Menu.tsx b/packages/design/src/components/menu/Menu.tsx index 93d7886d2e..820e6e56b9 100644 --- a/packages/design/src/components/menu/Menu.tsx +++ b/packages/design/src/components/menu/Menu.tsx @@ -19,23 +19,72 @@ import RcMenu, { MenuItem as RcMenuItem, MenuItemGroup as RcMenuItemGroup, SubMe import React, { useContext } from 'react'; import { clsx } from '../../helper/clsx'; import { ConfigContext } from '../config-provider/ConfigProvider'; +import { Tooltip } from '../tooltip'; import styles from './index.module.less'; export const Menu = React.forwardRef((props, ref) => { const { mountContainer } = useContext(ConfigContext); - return mountContainer && React.cloneElement( mountContainer} />, { - ...props, - }); + return mountContainer && mountContainer} {...props} />; }); export function MenuItem(props: MenuItemProps) { - return React.cloneElement(, { ...props }); + return ; } export function SubMenu(props: SubMenuProps) { - return React.cloneElement(, { ...props }); + return ; } export function MenuItemGroup(props: MenuItemGroupProps) { - return React.cloneElement(, { ...props }); + return ; +} + +export interface ITinyMenuItem { + onClick: () => void; + className: string; + Icon: React.ComponentType<{ className?: string }>; + key: string; + active?: boolean; + tooltip?: string; +} + +export interface ITinyMenuGroupProps { + items: ITinyMenuItem[]; +} + +export function TinyMenuGroup({ items }: ITinyMenuGroupProps) { + return ( +
+ {items.map((item) => { + const ele = ( +
item.onClick()} + className={` + univer-flex univer-h-6 univer-w-6 univer-cursor-pointer univer-items-center + univer-justify-center univer-rounded-md + hover:univer-bg-[#EEEFF1] + ${item.active ? 'univer-bg-[#EEEFF1]' : ''} + ${item.className} + `} + > + +
+ ); + return item.tooltip + ? ( + + {ele} + + ) + : ( + ele + ); + })} +
+ ); } diff --git a/packages/design/src/components/menu/index.module.less b/packages/design/src/components/menu/index.module.less index 2bc16254e1..219bfd6e0d 100644 --- a/packages/design/src/components/menu/index.module.less +++ b/packages/design/src/components/menu/index.module.less @@ -1,19 +1,20 @@ .menu { box-sizing: border-box; margin: 0; - padding: var(--padding-base); + padding: var(--padding-xs) var(--padding-sm); font-size: var(--font-size-xs); list-style: none; background-color: rgb(var(--bg-color-secondary)); - border-radius: var(--border-radius-lg); + border-radius: var(--border-radius-xl); outline: none; - box-shadow: var(--box-shadow-base); + box-shadow: var(--box-shadow-lg); + border: 1px solid rgb(var(--border-color)); + width: 212px; &-vertical { display: grid; - gap: var(--margin-xs); } &-hidden, @@ -23,8 +24,9 @@ &-item { &-group { + padding: var(--padding-xs) 0; + &:not(:last-child) { - padding-bottom: var(--padding-sm); border-bottom: 1px solid rgb(var(--border-color)); } } @@ -38,22 +40,35 @@ } &-item-group-title { - color: rgb(var(--text-color)); + padding: 0 var(--padding-sm); + color: rgb(var(--grey-500)); + font-size: var(--font-size-xxs); + font-weight: 600; + line-height: 1.5em; + padding: 4px; + padding-top: 6px; + } + + &-item-group-title:empty { + display: none; } &-item-active, &-submenu-active { - background-color: rgb(var(--bg-color-hover)); + background-color: rgb(var(--bg-color-hover-light)); + border-radius: var(--border-radius-lg); } &-item-selected { // fix chrome render bug transform: translateZ(0); - background-color: rgb(var(--bg-color-hover)); + background-color: rgb(var(--bg-color-hover-light)); + border-radius: var(--border-radius-lg); } &-submenu-selected { - background-color: rgb(var(--bg-color-hover)); + background-color: rgb(var(--bg-color-hover-light)); + border-radius: var(--border-radius-lg); } & > li&-submenu { @@ -70,29 +85,37 @@ &-item { display: flex; - gap: var(--margin-xs); + gap: var(--margin-sm); align-items: center; + padding: 0 var(--padding-sm); + min-height: 32px; + color: rgb(var(--text-color)); + font-size: var(--font-size-sm); + line-height: 1.5em; + border-radius: var(--border-radius-lg); + + &:hover { + background-color: rgb(var(--bg-color-hover-light)); + } + + .icon { + color: rgb(var(--text-color-secondary)); + } } &-item, &-submenu { cursor: pointer; - position: relative; - box-sizing: border-box; - min-height: 28px; margin: 0; - padding: var(--padding-xs); - white-space: nowrap; list-style: none; - border-radius: var(--border-radius-base); // Disabled state sets text to gray and nukes hover/tab effects &.menu-item-disabled, &.menu-submenu-disabled { - color: rgb(var(--grey-200)) !important; + color: rgb(var(--text-color-tertiary)) !important; cursor: not-allowed; } } @@ -103,7 +126,7 @@ z-index: 1070; .submenu-title-wrapper { - padding-right: 20px; + padding-right: var(--padding-xl); } } @@ -112,6 +135,11 @@ align-items: center; justify-content: space-between; height: 100%; + padding: 0 var(--padding-sm); + min-height: 32px; + color: rgb(var(--text-color)); + font-size: var(--font-size-sm); + line-height: 1.5em; } } @@ -120,7 +148,7 @@ &-more-icon { margin-left: var(--margin-xs); font-size: var(--font-size-sm); - color: rgb(var(--text-color)); + color: rgb(var(--text-color-secondary)); } } diff --git a/packages/design/src/components/menu/index.ts b/packages/design/src/components/menu/index.ts index b1c9062bb1..fa27493651 100644 --- a/packages/design/src/components/menu/index.ts +++ b/packages/design/src/components/menu/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { Menu, MenuItem, MenuItemGroup, SubMenu } from './Menu'; +export { Menu, MenuItem, MenuItemGroup, SubMenu, TinyMenuGroup } from './Menu'; export type { MenuRef } from 'rc-menu'; diff --git a/packages/design/src/components/menu/menu.stories.tsx b/packages/design/src/components/menu/menu.stories.tsx new file mode 100644 index 0000000000..17a21b6b3b --- /dev/null +++ b/packages/design/src/components/menu/menu.stories.tsx @@ -0,0 +1,145 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { CopySingle, CutSingle, DeleteSingle } from '@univerjs/icons'; +import { Menu, MenuItem, MenuItemGroup, SubMenu, TinyMenuGroup } from './Menu'; + +export default { + title: 'Components / Menu', + component: Menu, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export const Default = () => { + return ( + + {}, + className: '', + Icon: CopySingle, + key: 'copy', + }, + { + onClick: () => {}, + className: '', + Icon: CutSingle, + key: 'cut', + }, + { + onClick: () => {}, + className: '', + Icon: DeleteSingle, + key: 'delete', + }, + { + onClick: () => {}, + className: '', + Icon: CopySingle, + key: 'copy1', + }, + { + onClick: () => {}, + className: '', + Icon: CutSingle, + key: 'cut1', + }, + { + onClick: () => {}, + className: '', + Icon: DeleteSingle, + key: 'delete1', + }, + { + onClick: () => {}, + className: '', + Icon: CopySingle, + key: 'copy2', + }, + { + onClick: () => {}, + className: '', + Icon: CutSingle, + key: 'cut2', + }, + { + onClick: () => {}, + className: '', + Icon: DeleteSingle, + key: 'delete2', + }, + ]} + /> + + + + Item 1 + + + + Item 2 + + + + Item 3 + + + + + + Item 4 + + + + Item 5 + + + + + + SubMenu + + )} + > + + + + Item 6 + + + + + + + + Item 7 + + + + Item 8 + + + + + + ); +}; diff --git a/packages/design/src/components/popup/index.module.less b/packages/design/src/components/popup/index.module.less index aa495d1780..110368cd06 100644 --- a/packages/design/src/components/popup/index.module.less +++ b/packages/design/src/components/popup/index.module.less @@ -3,7 +3,7 @@ z-index: 1070; top: -9999px; left: -9999px; - background-color: rgb(var(--bg-color-secondary)); + // background-color: rgb(var(--bg-color-secondary)); border-radius: 6px; overflow: hidden; box-shadow: var(--box-shadow-base); diff --git a/packages/design/src/index.ts b/packages/design/src/index.ts index f611096a09..e382b28652 100644 --- a/packages/design/src/index.ts +++ b/packages/design/src/index.ts @@ -31,7 +31,7 @@ export { Dropdown } from './components/dropdown'; export { FormDualColumnLayout, FormLayout, type IFormDualColumnLayoutProps, type IFormLayoutProps } from './components/form-layout'; export { type IInputProps, type IInputWithSlotProps, Input, InputWithSlot } from './components/input'; export { type IInputNumberProps, InputNumber } from './components/input-number'; -export { Menu, MenuItem, MenuItemGroup, type MenuRef, SubMenu } from './components/menu'; +export { Menu, MenuItem, MenuItemGroup, type MenuRef, SubMenu, TinyMenuGroup } from './components/menu'; export { type IMessageProps, message, Messager, MessageType, removeMessage } from './components/message'; export { type IPagerProps, Pager } from './components/pager'; export { type IPopupProps, Popup } from './components/popup'; diff --git a/packages/design/src/themes/base.less b/packages/design/src/themes/base.less index 37c8d3589f..7b858e2efc 100644 --- a/packages/design/src/themes/base.less +++ b/packages/design/src/themes/base.less @@ -323,7 +323,7 @@ * ===================================== */ @grey-50: #f5f5f5; -@grey-100: #ececec; +@grey-100: #eeeff1; @grey-200: #e5e5e5; @grey-300: #d8d8d8; @grey-400: #bcbcbc; @@ -363,6 +363,7 @@ // Background @bg-color: @grey-100; @bg-color-hover: @grey-300; +@bg-color-hover-light: @grey-100; @bg-color-secondary: @color-white; @bg-color-overlay: @color-white; @@ -418,6 +419,7 @@ // Background --bg-color: @bg-color; --bg-color-hover: @bg-color-hover; + --bg-color-hover-light: @bg-color-hover-light; --bg-color-secondary: @bg-color-secondary; --bg-color-overlay: @bg-color-overlay; diff --git a/packages/docs-drawing-ui/src/controllers/drawing-popup-menu.controller.ts b/packages/docs-drawing-ui/src/controllers/drawing-popup-menu.controller.ts index d1efd7b71c..7d0cda6e91 100644 --- a/packages/docs-drawing-ui/src/controllers/drawing-popup-menu.controller.ts +++ b/packages/docs-drawing-ui/src/controllers/drawing-popup-menu.controller.ts @@ -16,7 +16,7 @@ import type { DocumentDataModel, IDisposable, Nullable } from '@univerjs/core'; import type { BaseObject, Scene } from '@univerjs/engine-render'; -import { DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS, IContextService, Inject, IUniverInstanceService, RxDisposable, SHEET_EDITOR_UNITS, toDisposable, UniverInstanceType } from '@univerjs/core'; +import { DrawingTypeEnum, FOCUSING_COMMON_DRAWINGS, IContextService, Inject, IUniverInstanceService, RxDisposable, toDisposable, UniverInstanceType } from '@univerjs/core'; import { DocCanvasPopManagerService } from '@univerjs/docs-ui'; import { IDrawingManagerService } from '@univerjs/drawing'; import { COMPONENT_IMAGE_POPUP_MENU, ImageCropperObject, ImageResetSizeOperation, OpenImageCropOperation } from '@univerjs/drawing-ui'; @@ -183,7 +183,8 @@ export class DocDrawingPopupMenuController extends RxDisposable { index: 0, commandId: EditDocDrawingOperation.id, commandParams: { unitId, subUnitId, drawingId }, - disable: !!SHEET_EDITOR_UNITS.includes(unitId) || drawingType === DrawingTypeEnum.DRAWING_DOM, + // disable: !!SHEET_EDITOR_UNITS.includes(unitId) || drawingType === DrawingTypeEnum.DRAWING_DOM, + disable: true, }, { label: 'image-popup.delete', diff --git a/packages/docs-ui/src/commands/commands/break-line.command.ts b/packages/docs-ui/src/commands/commands/break-line.command.ts index c96deb723f..ddcc196fb5 100644 --- a/packages/docs-ui/src/commands/commands/break-line.command.ts +++ b/packages/docs-ui/src/commands/commands/break-line.command.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, IParagraph, IParagraphBorder } from '@univerjs/core'; +import type { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, IParagraph, IParagraphBorder, ITextRangeParam } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import { BuildTextUtils, CommandType, DataStreamTreeTokenType, ICommandService, IUniverInstanceService, JSONX, PresetListType, TextX, TextXActionType, Tools, UniverInstanceType, UpdateDocsAttributeType } from '@univerjs/core'; import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs'; @@ -68,6 +68,7 @@ export function generateParagraphs( interface IBreakLineCommandParams { horizontalLine?: IParagraphBorder; + textRange?: ITextRangeParam; } export const BreakLineCommand: ICommand = { @@ -75,13 +76,13 @@ export const BreakLineCommand: ICommand = { type: CommandType.COMMAND, - // eslint-disable-next-line max-lines-per-function - handler: async (accessor, params: IBreakLineCommandParams) => { + // eslint-disable-next-line max-lines-per-function, complexity + handler: (accessor, params: IBreakLineCommandParams) => { const docSelectionManagerService = accessor.get(DocSelectionManagerService); const univerInstanceService = accessor.get(IUniverInstanceService); const commandService = accessor.get(ICommandService); const docMenuStyleService = accessor.get(DocMenuStyleService); - const activeTextRange = docSelectionManagerService.getActiveTextRange(); + const activeTextRange = params?.textRange ?? docSelectionManagerService.getActiveTextRange(); const rectRanges = docSelectionManagerService.getRectRanges(); if (activeTextRange == null) { return false; diff --git a/packages/docs-ui/src/commands/commands/clipboard.command.ts b/packages/docs-ui/src/commands/commands/clipboard.command.ts index 1f586cd408..70e4387652 100644 --- a/packages/docs-ui/src/commands/commands/clipboard.command.ts +++ b/packages/docs-ui/src/commands/commands/clipboard.command.ts @@ -15,9 +15,10 @@ */ import type { IAccessor, IContextService, IMultiCommand } from '@univerjs/core'; -import { CommandType, EDITOR_ACTIVATED, FOCUSING_DOC } from '@univerjs/core'; +import { CommandType, DOC_RANGE_TYPE, EDITOR_ACTIVATED, FOCUSING_DOC, SliceBodyType } from '@univerjs/core'; import { CopyCommand, CutCommand, IClipboardInterfaceService, PasteCommand } from '@univerjs/ui'; import { IDocClipboardService } from '../../services/clipboard/clipboard.service'; +import { getCurrentParagraph } from './util'; export function whenDocOrEditor(contextService: IContextService): boolean { return contextService.getContextValue(FOCUSING_DOC) || contextService.getContextValue(EDITOR_ACTIVATED); @@ -45,6 +46,27 @@ export const DocCopyCommand: IMultiCommand = { }, }; +export const DocCopyCurrentParagraphCommand = { + id: 'doc.command.copy-current-paragraph', + type: CommandType.COMMAND, + handler: async (accessor: IAccessor) => { + const docClipboardService = accessor.get(IDocClipboardService); + const paragraph = getCurrentParagraph(accessor); + if (!paragraph) { + return false; + } + + return docClipboardService.copy( + SliceBodyType.copy, + [{ + startOffset: paragraph.paragraphStart, + endOffset: paragraph.paragraphEnd + 1, + collapsed: false, + }] + ); + }, +}; + export const DocCutCommand: IMultiCommand = { id: CutCommand.id, name: 'doc.command.cut', @@ -58,6 +80,27 @@ export const DocCutCommand: IMultiCommand = { }, }; +export const DocCutCurrentParagraphCommand = { + id: 'doc.command.cut-current-paragraph', + type: CommandType.COMMAND, + handler: async (accessor: IAccessor) => { + const docClipboardService = accessor.get(IDocClipboardService); + const paragraph = getCurrentParagraph(accessor); + if (!paragraph) { + return false; + } + + return docClipboardService.cut( + [{ + startOffset: paragraph.paragraphStart, + endOffset: paragraph.paragraphEnd + 1, + collapsed: false, + rangeType: DOC_RANGE_TYPE.TEXT, + }] + ); + }, +}; + export const DocPasteCommand: IMultiCommand = { id: PasteCommand.id, name: 'doc.command.paste', diff --git a/packages/docs-ui/src/commands/commands/clipboard.inner.command.ts b/packages/docs-ui/src/commands/commands/clipboard.inner.command.ts index 2751daa850..0aa2bc84e3 100644 --- a/packages/docs-ui/src/commands/commands/clipboard.inner.command.ts +++ b/packages/docs-ui/src/commands/commands/clipboard.inner.command.ts @@ -474,6 +474,7 @@ export interface IInnerCutCommandParams { segmentId: string; textRanges: ITextRangeWithStyle[]; selections?: ITextRange[]; + rectRanges?: IRectRangeWithStyle[]; } export const CutContentCommand: ICommand = { @@ -486,8 +487,7 @@ export const CutContentCommand: ICommand = { const commandService = accessor.get(ICommandService); const univerInstanceService = accessor.get(IUniverInstanceService); - const { segmentId, textRanges, selections = docSelectionManagerService.getTextRanges() } = params; - const rectRanges = docSelectionManagerService.getRectRanges(); + const { segmentId, textRanges, selections = docSelectionManagerService.getTextRanges(), rectRanges = docSelectionManagerService.getRectRanges() } = params; if ( (!Array.isArray(selections) || selections.length === 0) diff --git a/packages/docs-ui/src/commands/commands/doc-delete.command.ts b/packages/docs-ui/src/commands/commands/doc-delete.command.ts index 12fda5d0ec..9285ef2905 100644 --- a/packages/docs-ui/src/commands/commands/doc-delete.command.ts +++ b/packages/docs-ui/src/commands/commands/doc-delete.command.ts @@ -19,6 +19,7 @@ import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render'; import { BlockType, + BuildTextUtils, CommandType, DataStreamTreeTokenType, ICommandService, @@ -799,3 +800,57 @@ function getTextRangesWhenDelete(activeRange: ITextRangeWithStyle, ranges: reado return textRanges; } + +export const DeleteCurrentParagraphCommand: ICommand = { + id: 'doc.command.delete-current-paragraph', + type: CommandType.COMMAND, + handler: async (accessor) => { + const docSelectionManagerService = accessor.get(DocSelectionManagerService); + const univerInstanceService = accessor.get(IUniverInstanceService); + const commandService = accessor.get(ICommandService); + const docDataModel = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + if (!docDataModel) { + return false; + } + + const activeRange = docSelectionManagerService.getActiveTextRange(); + if (!activeRange) { + return false; + } + const range = docSelectionManagerService.getActiveTextRange(); + if (!range || !range.collapsed || range.segmentId) { + return false; + } + + const paragraph = BuildTextUtils.range.getParagraphsInRange(range, docDataModel.getBody()?.paragraphs ?? [])[0]; + if (!paragraph) { + return false; + } + + const actions = BuildTextUtils.selection.delete( + [{ + startOffset: paragraph.paragraphStart, + endOffset: paragraph.paragraphEnd + 1, + collapsed: false, + }], + docDataModel.getBody()!, + 0, + undefined, + true + ); + + const path = getRichTextEditPath(docDataModel); + + const params: IRichTextEditingMutationParams = { + unitId: docDataModel.getUnitId(), + actions: JSONX.getInstance().editOp(actions, path), + textRanges: [{ + startOffset: paragraph.paragraphStart, + endOffset: paragraph.paragraphStart, + collapsed: true, + }], + }; + + return commandService.syncExecuteCommand(RichTextEditingMutation.id, params); + }, +}; diff --git a/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts b/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts index ee1f357a20..1b5bf65364 100644 --- a/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts +++ b/packages/docs-ui/src/commands/commands/doc-horizontal-line.command.ts @@ -14,21 +14,24 @@ * limitations under the License. */ -import type { ICommand } from '@univerjs/core'; +import type { ICommand, ITextRangeParam } from '@univerjs/core'; import { CommandType, DashStyleType, ICommandService } from '@univerjs/core'; import { BreakLineCommand } from './break-line.command'; +import { getCurrentParagraph } from './util'; -interface IHorizontalCommandParams {} +interface IHorizontalCommandParams { + insertRange?: ITextRangeParam; +} export const HorizontalLineCommand: ICommand = { id: 'doc.command.horizontal-line', type: CommandType.COMMAND, - handler: (accessor, _params: IHorizontalCommandParams) => { + handler: (accessor, params: IHorizontalCommandParams) => { const commandService = accessor.get(ICommandService); - return commandService.executeCommand(BreakLineCommand.id, { + return commandService.syncExecuteCommand(BreakLineCommand.id, { horizontalLine: { padding: 5, color: { @@ -37,6 +40,26 @@ export const HorizontalLineCommand: ICommand = { width: 1, dashStyle: DashStyleType.SOLID, }, + textRange: params?.insertRange, + }); + }, +}; + +export const InsertHorizontalLineBellowCommand: ICommand = { + id: 'doc.command.insert-horizontal-line-bellow', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + const paragraph = getCurrentParagraph(accessor); + if (!paragraph) { + return false; + } + + return commandService.syncExecuteCommand(HorizontalLineCommand.id, { + insertRange: { + startOffset: paragraph.startIndex + 1, + endOffset: paragraph.startIndex + 1, + }, }); }, }; diff --git a/packages/docs-ui/src/commands/commands/insert-below/insert-bullet-below.command.ts b/packages/docs-ui/src/commands/commands/insert-below/insert-bullet-below.command.ts new file mode 100644 index 0000000000..457a4398de --- /dev/null +++ b/packages/docs-ui/src/commands/commands/insert-below/insert-bullet-below.command.ts @@ -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 { DocumentDataModel, ICommand, IDocumentBody, IMutationInfo, PresetListType } from '@univerjs/core'; +import type { IRichTextEditingMutationParams } from '@univerjs/docs'; +import { BuildTextUtils, CommandType, ICommandService, IUniverInstanceService, JSONX, UniverInstanceType } from '@univerjs/core'; +import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs'; +import { getRichTextEditPath } from '../../util'; + +interface IInsertBulletBelowCommandParams { + listType: PresetListType; +} + +export const InsertBulletBelowCommand: ICommand = { + type: CommandType.COMMAND, + id: 'doc.command.insert-bullet-below', + handler: (accessor, params) => { + if (!params) { + return false; + } + + const docSelectionManagerService = accessor.get(DocSelectionManagerService); + const univerInstanceService = accessor.get(IUniverInstanceService); + const commandService = accessor.get(ICommandService); + const docDataModel = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + if (!docDataModel) { + return false; + } + const listType: string = params.listType; + const textRanges = docSelectionManagerService.getTextRanges(); + + if (!(textRanges?.length === 1 && textRanges[0].startOffset === textRanges[0].endOffset)) { + return false; + } + const paragraphs = docDataModel.getSelfOrHeaderFooterModel(textRanges[0].segmentId).getBody()?.paragraphs ?? []; + const currentParagraph = BuildTextUtils.range.getParagraphsInRange(textRanges[0], paragraphs)[0]; + if (!currentParagraph) { + return false; + } + + const insertBody: IDocumentBody = { + dataStream: '\r', + paragraphs: [{ + startIndex: 0, + bullet: { + listType, + listId: listType === currentParagraph.bullet?.listType ? currentParagraph.bullet.listId : '', + nestingLevel: currentParagraph.bullet ? currentParagraph.bullet.nestingLevel : 0, + }, + }], + }; + + const textX = BuildTextUtils.selection.replace({ + selection: { + collapsed: true, + startOffset: currentParagraph.startIndex + 1, + endOffset: currentParagraph.startIndex + 1, + }, + body: insertBody, + doc: docDataModel, + }); + + if (!textX) { + return false; + } + + const jsonX = JSONX.getInstance(); + const doMutation: IMutationInfo = { + id: RichTextEditingMutation.id, + params: { + unitId: docDataModel.getUnitId(), + actions: jsonX.editOp(textX.serialize(), getRichTextEditPath(docDataModel, textRanges[0].segmentId)), + textRanges: [{ + startOffset: currentParagraph.startIndex + 1, + endOffset: currentParagraph.startIndex + 1, + collapsed: true, + segmentId: textRanges[0].segmentId, + }], + }, + }; + + const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params); + return result; + }, +}; diff --git a/packages/docs-ui/src/commands/commands/list.command.ts b/packages/docs-ui/src/commands/commands/list.command.ts index a1b0b2726d..19d49b6fa3 100644 --- a/packages/docs-ui/src/commands/commands/list.command.ts +++ b/packages/docs-ui/src/commands/commands/list.command.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { DocumentDataModel, ICommand, IMutationInfo, IParagraph, IParagraphRange, ISectionBreak } from '@univerjs/core'; +import type { DocumentDataModel, IAccessor, ICommand, IMutationInfo, IParagraph, IParagraphRange, ISectionBreak } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { ITextRangeWithStyle } from '@univerjs/engine-render'; import { @@ -36,9 +36,11 @@ import { import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs'; import { getCharSpaceApply, getNumberUnitValue } from '@univerjs/engine-render'; import { getRichTextEditPath } from '../util'; +import { getCurrentParagraph } from './util'; interface IListOperationCommandParams { listType: PresetListType; + docRange?: ITextRangeWithStyle[]; } export const ListOperationCommand: ICommand = { @@ -53,7 +55,7 @@ export const ListOperationCommand: ICommand = { const listType: string = params.listType; const docDataModel = univerInstanceService.getCurrentUnitForType(UniverInstanceType.UNIVER_DOC); - const docRanges = docSelectionManagerService.getDocRanges() ?? []; + const docRanges = params.docRange ?? docSelectionManagerService.getDocRanges() ?? []; if (docDataModel == null || docRanges.length === 0) { return false; @@ -104,6 +106,7 @@ export const ListOperationCommand: ICommand = { interface IChangeListTypeCommandParams { listType: PresetListType; + docRange?: ITextRangeWithStyle[]; } export const ChangeListTypeCommand: ICommand = { @@ -116,20 +119,19 @@ export const ChangeListTypeCommand: ICommand = { const commandService = accessor.get(ICommandService); const { listType } = params; const docDataModel = univerInstanceService.getCurrentUnitForType(UniverInstanceType.UNIVER_DOC); - const activeRanges = docSelectionManagerService.getDocRanges(); + const activeRanges = params.docRange ?? docSelectionManagerService.getDocRanges(); if (docDataModel == null || activeRanges == null || !activeRanges.length) { return false; } const { segmentId } = activeRanges[0]; - const selections = docSelectionManagerService.getDocRanges() ?? []; const paragraphs = docDataModel.getSelfOrHeaderFooterModel(segmentId).getBody()?.paragraphs; if (paragraphs == null) { return false; } - const currentParagraphs = BuildTextUtils.range.getParagraphsInRanges(selections, paragraphs); + const currentParagraphs = BuildTextUtils.range.getParagraphsInRanges(activeRanges, paragraphs); const unitId = docDataModel.getUnitId(); const textX = BuildTextUtils.paragraph.bullet.set({ @@ -149,7 +151,7 @@ export const ChangeListTypeCommand: ICommand = { params: { unitId, actions: [], - textRanges: selections, + textRanges: activeRanges, isEditing: false, }, }; @@ -231,6 +233,7 @@ export const ChangeListNestingLevelCommand: ICommand = { @@ -244,11 +247,13 @@ export const BulletListCommand: ICommand = { if (params?.value) { return commandService.syncExecuteCommand(ChangeListTypeCommand.id, { listType: params.value, + docRange: params.docRange, }); } return commandService.syncExecuteCommand(ListOperationCommand.id, { listType: PresetListType.BULLET_LIST, + docRange: params?.docRange, }); }, }; @@ -262,11 +267,13 @@ export const CheckListCommand: ICommand = { if (params?.value) { return commandService.syncExecuteCommand(ChangeListTypeCommand.id, { listType: params.value, + docRange: params.docRange, }); } return commandService.syncExecuteCommand(ListOperationCommand.id, { listType: PresetListType.CHECK_LIST, + docRange: params?.docRange, }); }, }; @@ -479,6 +486,89 @@ export const QuickListCommand: ICommand = { }, }; +function insertList(accessor: IAccessor, listType: PresetListType) { + const commandService = accessor.get(ICommandService); + const paragraph = getCurrentParagraph(accessor); + if (!paragraph) { + return false; + } + const docDataModel = accessor.get(IUniverInstanceService).getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + if (!docDataModel) { + return false; + } + const textX = BuildTextUtils.selection.replace({ + doc: docDataModel, + selection: { + startOffset: paragraph.startIndex + 1, + endOffset: paragraph.startIndex + 1, + collapsed: true, + }, + body: { + dataStream: '\r', + paragraphs: [ + { + startIndex: 0, + paragraphStyle: { + ...paragraph.paragraphStyle, + }, + bullet: { + listType, + listId: paragraph.bullet?.listType === listType ? paragraph.bullet.listId : Tools.generateRandomId(6), + nestingLevel: paragraph.bullet?.listType === listType ? paragraph.bullet.nestingLevel : 0, + }, + }, + ], + }, + }); + + if (!textX) { + return false; + } + const doMutation: IMutationInfo = { + id: RichTextEditingMutation.id, + params: { + unitId: docDataModel.getUnitId(), + actions: [], + textRanges: [{ + startOffset: paragraph.startIndex + 1, + endOffset: paragraph.startIndex + 1, + collapsed: true, + }], + isEditing: false, + }, + }; + const jsonX = JSONX.getInstance(); + const path = getRichTextEditPath(docDataModel); + doMutation.params.actions = jsonX.editOp(textX.serialize(), path); + const result = commandService.syncExecuteCommand(doMutation.id, doMutation.params); + + return Boolean(result); +} + +export const InsertBulletListBellowCommand: ICommand = { + id: 'doc.command.insert-bullet-list-bellow', + type: CommandType.COMMAND, + handler: (accessor) => { + return insertList(accessor, PresetListType.BULLET_LIST); + }, +}; + +export const InsertOrderListBellowCommand: ICommand = { + id: 'doc.command.insert-order-list-bellow', + type: CommandType.COMMAND, + handler: (accessor) => { + return insertList(accessor, PresetListType.ORDER_LIST); + }, +}; + +export const InsertCheckListBellowCommand: ICommand = { + id: 'doc.command.insert-check-list-bellow', + type: CommandType.COMMAND, + handler: (accessor) => { + return insertList(accessor, PresetListType.CHECK_LIST); + }, +}; + export function getParagraphsRelative(ranges: ITextRangeWithStyle[], paragraphs: IParagraph[]) { const selectionParagraphs: IParagraph[] = BuildTextUtils.range.getParagraphsInRanges(ranges, paragraphs); const startIndex = paragraphs.indexOf(selectionParagraphs[0]); diff --git a/packages/docs-ui/src/commands/commands/set-heading.command.ts b/packages/docs-ui/src/commands/commands/set-heading.command.ts index b2c4358081..d609e23fa7 100644 --- a/packages/docs-ui/src/commands/commands/set-heading.command.ts +++ b/packages/docs-ui/src/commands/commands/set-heading.command.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { DocumentDataModel, ICommand, IMutationInfo } from '@univerjs/core'; +import type { DocumentDataModel, ICommand, IMutationInfo, ITextRangeParam } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import { BuildTextUtils, CommandType, generateRandomId, ICommandService, IUniverInstanceService, JSONX, NamedStyleType, TextX, TextXActionType, UniverInstanceType } from '@univerjs/core'; import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs'; @@ -22,6 +22,7 @@ import { getRichTextEditPath } from '../util'; export interface ISetParagraphNamedStyleCommandParams { value: NamedStyleType; + textRanges?: ITextRangeParam[]; } export const SetParagraphNamedStyleCommand: ICommand = { @@ -39,7 +40,7 @@ export const SetParagraphNamedStyleCommand: ICommand !i.segmentId); + const selections = params.textRanges ?? selectionService.getTextRanges({ unitId, subUnitId: unitId })?.filter((i) => !i.segmentId); if (!selections?.length) { return false; } @@ -150,3 +151,91 @@ export const QUICK_HEADING_MAP = { '####': NamedStyleType.HEADING_4, '#####': NamedStyleType.HEADING_5, }; + +export const H1HeadingCommand: ICommand = { + id: 'doc.command.h1-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.HEADING_1, + }); + }, +}; + +export const H2HeadingCommand: ICommand = { + id: 'doc.command.h2-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.HEADING_2, + }); + }, +}; + +export const H3HeadingCommand: ICommand = { + id: 'doc.command.h3-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.HEADING_3, + }); + }, +}; + +export const H4HeadingCommand: ICommand = { + id: 'doc.command.h4-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.HEADING_4, + }); + }, +}; + +export const H5HeadingCommand: ICommand = { + id: 'doc.command.h5-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.HEADING_5, + }); + }, +}; + +export const NormalTextHeadingCommand: ICommand = { + id: 'doc.command.normal-text-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.NORMAL_TEXT, + }); + }, +}; + +export const TitleHeadingCommand: ICommand = { + id: 'doc.command.title', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.TITLE, + }); + }, +}; + +export const SubtitleHeadingCommand: ICommand = { + id: 'doc.command.subtitle-heading', + type: CommandType.COMMAND, + handler: (accessor) => { + const commandService = accessor.get(ICommandService); + return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, { + value: NamedStyleType.SUBTITLE, + }); + }, +}; diff --git a/packages/docs-ui/src/commands/commands/util.ts b/packages/docs-ui/src/commands/commands/util.ts new file mode 100644 index 0000000000..a75f846380 --- /dev/null +++ b/packages/docs-ui/src/commands/commands/util.ts @@ -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 { DocumentDataModel, IAccessor } from '@univerjs/core'; +import { BuildTextUtils, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { DocSelectionManagerService } from '@univerjs/docs'; + +export function getCurrentParagraph(accessor: IAccessor) { + const instanceService = accessor.get(IUniverInstanceService); + const docSelectionManagerService = accessor.get(DocSelectionManagerService); + const range = docSelectionManagerService.getActiveTextRange(); + const doc = instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC); + if (!range || !range.collapsed || !doc || range.segmentId) { + return false; + } + + const paragraph = BuildTextUtils.range.getParagraphsInRange(range, doc.getBody()?.paragraphs ?? [])[0]; + return paragraph; +} diff --git a/packages/docs-ui/src/components/paragraph-menu/index.tsx b/packages/docs-ui/src/components/paragraph-menu/index.tsx new file mode 100644 index 0000000000..08c6b3f223 --- /dev/null +++ b/packages/docs-ui/src/components/paragraph-menu/index.tsx @@ -0,0 +1,148 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { IPopup } from '@univerjs/ui'; +import { ICommandService, Injector, NamedStyleType } from '@univerjs/core'; +import { SetTextSelectionsOperation } from '@univerjs/docs'; +import { IRenderManagerService } from '@univerjs/engine-render'; +import { DownSingle } from '@univerjs/icons'; +import { ContextMenuPosition, DesktopMenu, ILayoutService, RectPopup, useDependency, useObservable } from '@univerjs/ui'; +import { useMemo, useRef, useState } from 'react'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { SetInlineFormatFontSizeCommand } from '../../commands/commands/inline-format.command'; +import { getParagraphStyleAtCursor } from '../../controllers/menu/menu'; +import { HEADING_ICON_MAP } from '../../controllers/menu/paragraph-menu'; +import { DocParagraphMenuService } from '../../services/doc-paragraph-menu.service'; + +export const ParagraphMenu = ({ popup }: { popup: IPopup }) => { + const [visible, setVisible] = useState(false); + const contentRef = useRef(null); + const commandService = useDependency(ICommandService); + const layoutService = useDependency(ILayoutService); + const anchorRef = useRef(null); + const isMouseOver = useRef(false); + const renderManagerService = useDependency(IRenderManagerService); + const renderUnit = renderManagerService.getRenderById(popup.unitId); + const docParagraphMenuService = renderUnit?.with(DocParagraphMenuService); + + const accessor = useDependency(Injector); + const textType = useObservable(useMemo(() => new Observable((subscriber) => { + const DEFAULT_TYPE = NamedStyleType.NORMAL_TEXT; + const calc = () => { + const paragraph = getParagraphStyleAtCursor(accessor); + if (paragraph == null) { + subscriber.next(DEFAULT_TYPE); + return; + } + + const namedStyleType = paragraph.paragraphStyle?.namedStyleType ?? DEFAULT_TYPE; + subscriber.next(namedStyleType); + }; + + const disposable = commandService.onCommandExecuted((c) => { + const id = c.id; + + if (id === SetTextSelectionsOperation.id || id === SetInlineFormatFontSizeCommand.id) { + calc(); + } + }); + + calc(); + + return () => disposable.dispose(); + }), [commandService])); + + const icon = HEADING_ICON_MAP[textType ?? NamedStyleType.NORMAL_TEXT]; + const anchorRect$ = useMemo(() => new BehaviorSubject({ + left: 0, + right: 0, + top: 0, + bottom: 0, + }), []); + + const handleHideMenu = () => { + setVisible(false); + docParagraphMenuService?.hideParagraphMenu(true); + }; + + return ( + <> +
{ + popup.onPointerEnter?.(e); + isMouseOver.current = true; + const boundingRect = anchorRef.current?.getBoundingClientRect(); + anchorRect$.next({ + left: (boundingRect?.left ?? 0) - 4, + right: boundingRect?.right ?? 0, + top: boundingRect?.top ?? 0, + bottom: boundingRect?.bottom ?? 0, + }); + }} + onMouseLeave={() => { + isMouseOver.current = false; + }} + onClick={() => { + setVisible(true); + docParagraphMenuService?.setParagraphMenuActive(true); + }} + > + + +
+ {visible && ( + +
{ + popup.onPointerEnter?.(e); + isMouseOver.current = true; + }} + onMouseLeave={() => { + isMouseOver.current = false; + }} + > + { + const { label: id, commandId, value } = params; + + if (commandService) { + commandService.executeCommand(commandId ?? id as string, { value }); + } + + layoutService.focus(); + handleHideMenu(); + }} + /> +
+
+ )} + + ); +}; diff --git a/packages/docs-ui/src/controllers/doc-ui.controller.ts b/packages/docs-ui/src/controllers/doc-ui.controller.ts index f1c9da5c4e..6e479f47cb 100644 --- a/packages/docs-ui/src/controllers/doc-ui.controller.ts +++ b/packages/docs-ui/src/controllers/doc-ui.controller.ts @@ -26,7 +26,7 @@ import { } from '@univerjs/core'; import { IRenderManagerService } from '@univerjs/engine-render'; -import { TodoList } from '@univerjs/icons'; +import { CutSingle, DeleteSingle, TodoList } from '@univerjs/icons'; import { BuiltInUIPart, ComponentManager, connectInjector, ILayoutService, IMenuManagerService, IShortcutService, IUIPartsService } from '@univerjs/ui'; import { CoreHeaderFooterCommand, OpenHeaderFooterPanelCommand } from '../commands/commands/doc-header-footer.command'; import { SidebarDocHeaderFooterPanelOperation } from '../commands/operations/doc-header-footer-panel.operation'; @@ -39,6 +39,7 @@ import { } from '../components/font-family'; import { FONT_SIZE_COMPONENT, FontSize } from '../components/font-size'; import { BULLET_LIST_TYPE_COMPONENT, BulletListTypePicker, ORDER_LIST_TYPE_COMPONENT, OrderListTypePicker } from '../components/list-type-picker'; +import { ParagraphMenu } from '../components/paragraph-menu'; import { DocSelectionRenderService } from '../services/selection/doc-selection-render.service'; import { TabShortCut } from '../shortcuts/format.shortcut'; import { @@ -85,8 +86,10 @@ export class DocUIController extends Disposable { this.disposeWithMe(componentManager.register(FONT_SIZE_COMPONENT, FontSize)); this.disposeWithMe(componentManager.register(BULLET_LIST_TYPE_COMPONENT, BulletListTypePicker)); this.disposeWithMe(componentManager.register(ORDER_LIST_TYPE_COMPONENT, OrderListTypePicker)); - this.disposeWithMe(componentManager.register('TodoList', TodoList)); + this.disposeWithMe(componentManager.register('doc.paragraph.menu', ParagraphMenu)); + this.disposeWithMe(componentManager.register('CutSingle', CutSingle)); + this.disposeWithMe(componentManager.register('DeleteSingle', DeleteSingle)); } private _initUiParts() { diff --git a/packages/docs-ui/src/controllers/menu.schema.ts b/packages/docs-ui/src/controllers/menu.schema.ts index 1cf881e35a..fd0850f2fe 100644 --- a/packages/docs-ui/src/controllers/menu.schema.ts +++ b/packages/docs-ui/src/controllers/menu.schema.ts @@ -16,15 +16,15 @@ import type { MenuSchemaType } from '@univerjs/ui'; import { ContextMenuGroup, ContextMenuPosition, RibbonStartGroup } from '@univerjs/ui'; -import { DocCopyCommand, DocCutCommand, DocPasteCommand } from '../commands/commands/clipboard.command'; -import { DeleteLeftCommand } from '../commands/commands/doc-delete.command'; +import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from '../commands/commands/clipboard.command'; +import { DeleteCurrentParagraphCommand, DeleteLeftCommand } from '../commands/commands/doc-delete.command'; import { OpenHeaderFooterPanelCommand } from '../commands/commands/doc-header-footer.command'; -import { HorizontalLineCommand } from '../commands/commands/doc-horizontal-line.command'; +import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from '../commands/commands/doc-horizontal-line.command'; import { ResetInlineFormatTextBackgroundColorCommand, SetInlineFormatBoldCommand, SetInlineFormatFontFamilyCommand, SetInlineFormatFontSizeCommand, SetInlineFormatItalicCommand, SetInlineFormatStrikethroughCommand, SetInlineFormatSubscriptCommand, SetInlineFormatSuperscriptCommand, SetInlineFormatTextBackgroundColorCommand, SetInlineFormatTextColorCommand, SetInlineFormatUnderlineCommand } from '../commands/commands/inline-format.command'; -import { BulletListCommand, CheckListCommand, OrderListCommand } from '../commands/commands/list.command'; +import { BulletListCommand, CheckListCommand, InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand, OrderListCommand } from '../commands/commands/list.command'; import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignRightCommand } from '../commands/commands/paragraph-align.command'; -import { SetParagraphNamedStyleCommand } from '../commands/commands/set-heading.command'; +import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SetParagraphNamedStyleCommand } from '../commands/commands/set-heading.command'; import { SwitchDocModeCommand } from '../commands/commands/switch-doc-mode.command'; import { DocTableDeleteColumnsCommand, DocTableDeleteRowsCommand, DocTableDeleteTableCommand } from '../commands/commands/table/doc-table-delete.command'; import { DocTableInsertColumnLeftCommand, DocTableInsertColumnRightCommand, DocTableInsertRowAboveCommand, DocTableInsertRowBellowCommand } from '../commands/commands/table/doc-table-insert.command'; @@ -75,6 +75,7 @@ import { TextColorSelectorMenuItemFactory, UnderlineMenuItemFactory, } from './menu/menu'; +import { CopyCurrentParagraphMenuItemFactory, CutCurrentParagraphMenuItemFactory, DeleteCurrentParagraphMenuItemFactory, H1HeadingMenuItemFactory, H2HeadingMenuItemFactory, H3HeadingMenuItemFactory, H4HeadingMenuItemFactory, H5HeadingMenuItemFactory, InsertBulletListBellowMenuItemFactory, InsertCheckListBellowMenuItemFactory, InsertHorizontalLineBellowMenuItemFactory, InsertOrderListBellowMenuItemFactory, NormalTextHeadingMenuItemFactory } from './menu/paragraph-menu'; export const menuSchema: MenuSchemaType = { [RibbonStartGroup.FORMAT]: { @@ -239,4 +240,77 @@ export const menuSchema: MenuSchemaType = { }, }, }, + [ContextMenuPosition.PARAGRAPH]: { + [ContextMenuGroup.QUICK]: { + [H1HeadingCommand.id]: { + order: 0, + menuItemFactory: H1HeadingMenuItemFactory, + }, + [H2HeadingCommand.id]: { + order: 1, + menuItemFactory: H2HeadingMenuItemFactory, + }, + [H3HeadingCommand.id]: { + order: 2, + menuItemFactory: H3HeadingMenuItemFactory, + }, + [H4HeadingCommand.id]: { + order: 3, + menuItemFactory: H4HeadingMenuItemFactory, + }, + [H5HeadingCommand.id]: { + order: 4, + menuItemFactory: H5HeadingMenuItemFactory, + }, + [NormalTextHeadingCommand.id]: { + order: 5, + menuItemFactory: NormalTextHeadingMenuItemFactory, + }, + [OrderListCommand.id]: { + order: 6, + menuItemFactory: OrderListMenuItemFactory, + }, + [BulletListCommand.id]: { + order: 7, + menuItemFactory: BulletListMenuItemFactory, + }, + [CheckListCommand.id]: { + order: 8, + menuItemFactory: CheckListMenuItemFactory, + }, + }, + [ContextMenuGroup.FORMAT]: { + [DocCopyCurrentParagraphCommand.id]: { + order: 0, + menuItemFactory: CopyCurrentParagraphMenuItemFactory, + }, + [DocCutCurrentParagraphCommand.id]: { + order: 1, + menuItemFactory: CutCurrentParagraphMenuItemFactory, + }, + [DeleteCurrentParagraphCommand.id]: { + order: 2, + menuItemFactory: DeleteCurrentParagraphMenuItemFactory, + }, + }, + [ContextMenuGroup.LAYOUT]: { + title: 'rightClick.insertBellow', + [InsertBulletListBellowCommand.id]: { + order: 0, + menuItemFactory: InsertBulletListBellowMenuItemFactory, + }, + [InsertOrderListBellowCommand.id]: { + order: 1, + menuItemFactory: InsertOrderListBellowMenuItemFactory, + }, + [InsertCheckListBellowCommand.id]: { + order: 2, + menuItemFactory: InsertCheckListBellowMenuItemFactory, + }, + [InsertHorizontalLineBellowCommand.id]: { + order: 3, + menuItemFactory: InsertHorizontalLineBellowMenuItemFactory, + }, + }, + }, }; diff --git a/packages/docs-ui/src/controllers/menu/menu.ts b/packages/docs-ui/src/controllers/menu/menu.ts index 713c311cf8..6ce4830697 100644 --- a/packages/docs-ui/src/controllers/menu/menu.ts +++ b/packages/docs-ui/src/controllers/menu/menu.ts @@ -214,7 +214,7 @@ function getTableDisabledObservable(accessor: IAccessor): Observable { }); } -function disableMenuWhenNoDocRange(accessor: IAccessor): Observable { +export function disableMenuWhenNoDocRange(accessor: IAccessor): Observable { const docSelectionManagerService = accessor.get(DocSelectionManagerService); return new Observable((subscriber) => { @@ -1061,7 +1061,7 @@ function getFontStyleAtCursor(accessor: IAccessor) { }; } -function getParagraphStyleAtCursor(accessor: IAccessor) { +export function getParagraphStyleAtCursor(accessor: IAccessor) { const univerInstanceService = accessor.get(IUniverInstanceService); const textSelectionService = accessor.get(DocSelectionManagerService); diff --git a/packages/docs-ui/src/controllers/menu/paragraph-menu.ts b/packages/docs-ui/src/controllers/menu/paragraph-menu.ts new file mode 100644 index 0000000000..2c55606c90 --- /dev/null +++ b/packages/docs-ui/src/controllers/menu/paragraph-menu.ts @@ -0,0 +1,177 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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 { IMenuItem } from '@univerjs/ui'; +import { ICommandService, NamedStyleType, UniverInstanceType } from '@univerjs/core'; +import { SetTextSelectionsOperation } from '@univerjs/docs'; +import { H1Single, H2Single, H3Single, H4Single, H5Single, TextTypeSingle } from '@univerjs/icons'; +import { ComponentManager, getMenuHiddenObservable, MenuItemType } from '@univerjs/ui'; +import { Observable } from 'rxjs'; +import { DocCopyCurrentParagraphCommand, DocCutCurrentParagraphCommand } from '../../commands/commands/clipboard.command'; +import { DeleteCurrentParagraphCommand } from '../../commands/commands/doc-delete.command'; +import { InsertHorizontalLineBellowCommand } from '../../commands/commands/doc-horizontal-line.command'; +import { SetInlineFormatFontSizeCommand } from '../../commands/commands/inline-format.command'; +import { InsertBulletListBellowCommand, InsertCheckListBellowCommand, InsertOrderListBellowCommand } from '../../commands/commands/list.command'; +import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, SubtitleHeadingCommand, TitleHeadingCommand } from '../../commands/commands/set-heading.command'; +import { disableMenuWhenNoDocRange, getParagraphStyleAtCursor } from './menu'; + +const HEADING_MAP: Record = { + [NamedStyleType.HEADING_1]: H1HeadingCommand, + [NamedStyleType.HEADING_2]: H2HeadingCommand, + [NamedStyleType.HEADING_3]: H3HeadingCommand, + [NamedStyleType.HEADING_4]: H4HeadingCommand, + [NamedStyleType.HEADING_5]: H5HeadingCommand, + [NamedStyleType.NORMAL_TEXT]: NormalTextHeadingCommand, + [NamedStyleType.TITLE]: TitleHeadingCommand, + [NamedStyleType.SUBTITLE]: SubtitleHeadingCommand, + [NamedStyleType.NAMED_STYLE_TYPE_UNSPECIFIED]: NormalTextHeadingCommand, +}; + +export const HEADING_ICON_MAP: Record }> = { + [NamedStyleType.HEADING_1]: { key: 'H1Single', component: H1Single }, + [NamedStyleType.HEADING_2]: { key: 'H2Single', component: H2Single }, + [NamedStyleType.HEADING_3]: { key: 'H3Single', component: H3Single }, + [NamedStyleType.HEADING_4]: { key: 'H4Single', component: H4Single }, + [NamedStyleType.HEADING_5]: { key: 'H5Single', component: H5Single }, + [NamedStyleType.NORMAL_TEXT]: { key: 'TextTypeSingle', component: TextTypeSingle }, + [NamedStyleType.TITLE]: { key: 'TextTypeSingle', component: TextTypeSingle }, + [NamedStyleType.SUBTITLE]: { key: 'TextTypeSingle', component: TextTypeSingle }, + [NamedStyleType.NAMED_STYLE_TYPE_UNSPECIFIED]: { key: 'TextTypeSingle', component: TextTypeSingle }, +}; + +const createHeadingSelectorMenuItemFactory = (headingType: NamedStyleType) => (accessor: IAccessor): IMenuItem => { + const commandService = accessor.get(ICommandService); + const componentManager = accessor.get(ComponentManager); + const icon = HEADING_ICON_MAP[headingType]; + if (!componentManager.get(icon.key)) { + componentManager.register(icon.key, icon.component); + } + + return { + id: HEADING_MAP[headingType]!.id, + type: MenuItemType.BUTTON, + icon: icon.key, + tooltip: 'toolbar.heading.tooltip', + disabled$: disableMenuWhenNoDocRange(accessor), + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), + activated$: new Observable((subscriber) => { + const DEFAULT_TYPE = NamedStyleType.NORMAL_TEXT; + const calc = () => { + const paragraph = getParagraphStyleAtCursor(accessor); + if (paragraph == null) { + subscriber.next(DEFAULT_TYPE === headingType); + return; + } + + const namedStyleType = paragraph.paragraphStyle?.namedStyleType ?? DEFAULT_TYPE; + subscriber.next(namedStyleType === headingType); + }; + + const disposable = commandService.onCommandExecuted((c) => { + const id = c.id; + + if (id === SetTextSelectionsOperation.id || id === SetInlineFormatFontSizeCommand.id) { + calc(); + } + }); + + calc(); + return disposable.dispose; + }), + }; +}; + +export const H1HeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.HEADING_1); +export const H2HeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.HEADING_2); +export const H3HeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.HEADING_3); +export const H4HeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.HEADING_4); +export const H5HeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.HEADING_5); +export const NormalTextHeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.NORMAL_TEXT); +export const TitleHeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.TITLE); +export const SubtitleHeadingMenuItemFactory = createHeadingSelectorMenuItemFactory(NamedStyleType.SUBTITLE); + +export const CopyCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: DocCopyCurrentParagraphCommand.id, + type: MenuItemType.BUTTON, + icon: 'Copy', + title: 'rightClick.copy', + }; +}; + +export const CutCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: DocCutCurrentParagraphCommand.id, + type: MenuItemType.BUTTON, + icon: 'CutSingle', + title: 'rightClick.cut', + }; +}; + +export const DeleteCurrentParagraphMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: DeleteCurrentParagraphCommand.id, + type: MenuItemType.BUTTON, + icon: 'DeleteSingle', + title: 'rightClick.delete', + }; +}; + +export const InsertBulletListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: InsertBulletListBellowCommand.id, + type: MenuItemType.BUTTON, + icon: 'UnorderSingle', + title: 'rightClick.bulletList', + }; +}; + +export const InsertOrderListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: InsertOrderListBellowCommand.id, + type: MenuItemType.BUTTON, + icon: 'OrderSingle', + title: 'rightClick.orderList', + }; +}; + +export const InsertCheckListBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: InsertCheckListBellowCommand.id, + type: MenuItemType.BUTTON, + icon: 'TodoList', + title: 'rightClick.checkList', + }; +}; + +// export const InsertTableBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { +// return { +// id: InsertTableBellowCommand.id, +// type: MenuItemType.BUTTON, +// icon: 'Table', +// title: 'rightClick.insertTableBellow', +// }; +// }; + +export const InsertHorizontalLineBellowMenuItemFactory = (accessor: IAccessor): IMenuItem => { + return { + id: InsertHorizontalLineBellowCommand.id, + type: MenuItemType.BUTTON, + icon: 'ReduceSingle', + title: 'toolbar.horizontalLine', + }; +}; diff --git a/packages/docs-ui/src/docs-ui-plugin.ts b/packages/docs-ui/src/docs-ui-plugin.ts index ed4b47cabb..3abad462cd 100644 --- a/packages/docs-ui/src/docs-ui-plugin.ts +++ b/packages/docs-ui/src/docs-ui-plugin.ts @@ -36,12 +36,12 @@ import { IShortcutService } from '@univerjs/ui'; import { DOC_UI_PLUGIN_NAME } from './basics/const/plugin-name'; import { AfterSpaceCommand, EnterCommand, TabCommand } from './commands/commands/auto-format.command'; import { BreakLineCommand } from './commands/commands/break-line.command'; -import { DocCopyCommand, DocCutCommand, DocPasteCommand } from './commands/commands/clipboard.command'; +import { DocCopyCommand, DocCopyCurrentParagraphCommand, DocCutCommand, DocCutCurrentParagraphCommand, DocPasteCommand } from './commands/commands/clipboard.command'; import { CutContentCommand, InnerPasteCommand } from './commands/commands/clipboard.inner.command'; import { DeleteCommand, InsertCommand, UpdateCommand } from './commands/commands/core-editing.command'; -import { DeleteCustomBlockCommand, DeleteLeftCommand, DeleteRightCommand, MergeTwoParagraphCommand, RemoveHorizontalLineCommand } from './commands/commands/doc-delete.command'; +import { DeleteCurrentParagraphCommand, DeleteCustomBlockCommand, DeleteLeftCommand, DeleteRightCommand, MergeTwoParagraphCommand, RemoveHorizontalLineCommand } from './commands/commands/doc-delete.command'; import { CloseHeaderFooterCommand } from './commands/commands/doc-header-footer.command'; -import { HorizontalLineCommand } from './commands/commands/doc-horizontal-line.command'; +import { HorizontalLineCommand, InsertHorizontalLineBellowCommand } from './commands/commands/doc-horizontal-line.command'; import { DocParagraphSettingCommand } from './commands/commands/doc-paragraph-setting.command'; import { DocSelectAllCommand } from './commands/commands/doc-select-all.command'; import { IMEInputCommand } from './commands/commands/ime-input.command'; @@ -65,6 +65,9 @@ import { ChangeListNestingLevelCommand, ChangeListTypeCommand, CheckListCommand, + InsertBulletListBellowCommand, + InsertCheckListBellowCommand, + InsertOrderListBellowCommand, ListOperationCommand, OrderListCommand, QuickListCommand, @@ -73,7 +76,7 @@ import { import { AlignCenterCommand, AlignJustifyCommand, AlignLeftCommand, AlignOperationCommand, AlignRightCommand } from './commands/commands/paragraph-align.command'; import { CoverContentCommand, ReplaceContentCommand, ReplaceSelectionCommand, ReplaceSnapshotCommand, ReplaceTextRunsCommand } from './commands/commands/replace-content.command'; import { SetDocZoomRatioCommand } from './commands/commands/set-doc-zoom-ratio.command'; -import { QuickHeadingCommand, SetParagraphNamedStyleCommand } from './commands/commands/set-heading.command'; +import { H1HeadingCommand, H2HeadingCommand, H3HeadingCommand, H4HeadingCommand, H5HeadingCommand, NormalTextHeadingCommand, QuickHeadingCommand, SetParagraphNamedStyleCommand, SubtitleHeadingCommand, TitleHeadingCommand } from './commands/commands/set-heading.command'; import { SwitchDocModeCommand } from './commands/commands/switch-doc-mode.command'; import { CreateDocTableCommand } from './commands/commands/table/doc-table-create.command'; import { DocTableDeleteColumnsCommand, DocTableDeleteRowsCommand, DocTableDeleteTableCommand } from './commands/commands/table/doc-table-delete.command'; @@ -115,6 +118,7 @@ import { DocEventManagerService } from './services/doc-event-manager.service'; import { DocIMEInputManagerService } from './services/doc-ime-input-manager.service'; import { DocMenuStyleService } from './services/doc-menu-style.service'; import { DocPageLayoutService } from './services/doc-page-layout.service'; +import { DocParagraphMenuService } from './services/doc-paragraph-menu.service'; import { DocCanvasPopManagerService } from './services/doc-popup-manager.service'; import { DocStateChangeManagerService } from './services/doc-state-change-manager.service'; import { DocsRenderService } from './services/docs-render.service'; @@ -194,6 +198,7 @@ export class UniverDocsUIPlugin extends Plugin { ]); } + // eslint-disable-next-line max-lines-per-function private _initCommand() { [ DeleteLeftCommand, @@ -265,6 +270,21 @@ export class UniverDocsUIPlugin extends Plugin { InsertCustomRangeCommand, SetParagraphNamedStyleCommand, QuickHeadingCommand, + DeleteCurrentParagraphCommand, + DocCopyCurrentParagraphCommand, + DocCutCurrentParagraphCommand, + H1HeadingCommand, + H2HeadingCommand, + H3HeadingCommand, + H4HeadingCommand, + H5HeadingCommand, + NormalTextHeadingCommand, + TitleHeadingCommand, + SubtitleHeadingCommand, + InsertBulletListBellowCommand, + InsertOrderListBellowCommand, + InsertCheckListBellowCommand, + InsertHorizontalLineBellowCommand, ].forEach((e) => { this.disposeWithMe(this._commandService.registerCommand(e)); }); @@ -352,6 +372,7 @@ export class UniverDocsUIPlugin extends Plugin { private _initRenderModules(): void { ([ [DocEventManagerService], + [DocParagraphMenuService], [DocBackScrollRenderController], [DocSelectionRenderController], [DocHeaderFooterController], diff --git a/packages/docs-ui/src/locale/en-US.ts b/packages/docs-ui/src/locale/en-US.ts index 04f1c72127..ec1e9200bf 100644 --- a/packages/docs-ui/src/locale/en-US.ts +++ b/packages/docs-ui/src/locale/en-US.ts @@ -98,6 +98,12 @@ const locale: typeof zhCN = { fixedValue: 'Fixed Value(px)', }, }, + rightClick: { + bulletList: 'Bullet list', + orderList: 'Ordered list', + checkList: 'Task list', + insertBellow: 'Insert below', + }, }; export default locale; diff --git a/packages/docs-ui/src/locale/fa-IR.ts b/packages/docs-ui/src/locale/fa-IR.ts index 908b0fbb1c..6192dea312 100644 --- a/packages/docs-ui/src/locale/fa-IR.ts +++ b/packages/docs-ui/src/locale/fa-IR.ts @@ -98,6 +98,12 @@ const locale: typeof zhCN = { fixedValue: 'مقدار ثابت(px)', }, }, + rightClick: { + bulletList: 'لیست بدون شماره', + orderList: 'لیست شماره‌دار', + checkList: 'لیست وظیفه', + insertBellow: 'درج در پایین', + }, }; export default locale; diff --git a/packages/docs-ui/src/locale/fr-FR.ts b/packages/docs-ui/src/locale/fr-FR.ts index 37d286e466..2dcd84bb40 100644 --- a/packages/docs-ui/src/locale/fr-FR.ts +++ b/packages/docs-ui/src/locale/fr-FR.ts @@ -98,6 +98,12 @@ const locale: typeof enUS = { fixedValue: 'Valeur fixe(px)', }, }, + rightClick: { + bulletList: 'Liste non ordonnée', + orderList: 'Liste ordonnée', + checkList: 'Liste de tâches', + insertBellow: 'Insérer dans le bas', + }, }; export default locale; diff --git a/packages/docs-ui/src/locale/ru-RU.ts b/packages/docs-ui/src/locale/ru-RU.ts index 97382cd724..1e14e87f02 100644 --- a/packages/docs-ui/src/locale/ru-RU.ts +++ b/packages/docs-ui/src/locale/ru-RU.ts @@ -98,6 +98,12 @@ const locale: typeof zhCN = { fixedValue: 'Fixed Value(px)', }, }, + rightClick: { + bulletList: 'Неупорядоченный список', + orderList: 'Упорядоченный список', + checkList: 'Список задач', + insertBellow: 'Вставить ниже', + }, }; export default locale; diff --git a/packages/docs-ui/src/locale/vi-VN.ts b/packages/docs-ui/src/locale/vi-VN.ts index cf6804c5fe..7404e03601 100644 --- a/packages/docs-ui/src/locale/vi-VN.ts +++ b/packages/docs-ui/src/locale/vi-VN.ts @@ -98,6 +98,12 @@ const locale: typeof zhCN = { fixedValue: 'Fixed Value(px)', }, }, + rightClick: { + bulletList: 'Danh sách không thứ tự', + orderList: 'Danh sách có thứ tự', + checkList: 'Danh sách công việc', + insertBellow: 'Chèn dưới', + }, }; export default locale; diff --git a/packages/docs-ui/src/locale/zh-CN.ts b/packages/docs-ui/src/locale/zh-CN.ts index e10f6b25aa..359afdbc81 100644 --- a/packages/docs-ui/src/locale/zh-CN.ts +++ b/packages/docs-ui/src/locale/zh-CN.ts @@ -96,6 +96,12 @@ const locale = { fixedValue: '固定值(px)', }, }, + rightClick: { + bulletList: '无序列表', + orderList: '有序列表', + checkList: '任务列表', + insertBellow: '在下方插入', + }, }; export default locale; diff --git a/packages/docs-ui/src/locale/zh-TW.ts b/packages/docs-ui/src/locale/zh-TW.ts index 6e565817b7..7a5773f726 100644 --- a/packages/docs-ui/src/locale/zh-TW.ts +++ b/packages/docs-ui/src/locale/zh-TW.ts @@ -98,6 +98,12 @@ const locale: typeof zhCN = { fixedValue: 'Fixed Value(px)', }, }, + rightClick: { + bulletList: '無序列表', + orderList: '有序列表', + checkList: '任務列表', + insertBellow: '在下方插入', + }, }; export default locale; diff --git a/packages/docs-ui/src/services/clipboard/clipboard.service.ts b/packages/docs-ui/src/services/clipboard/clipboard.service.ts index 8f08416f18..3aa21706f4 100644 --- a/packages/docs-ui/src/services/clipboard/clipboard.service.ts +++ b/packages/docs-ui/src/services/clipboard/clipboard.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { IDisposable, IDocumentBody, IDocumentData, Nullable } from '@univerjs/core'; +import type { IDisposable, IDocumentBody, IDocumentData } from '@univerjs/core'; import type { IDocImage } from '@univerjs/docs-drawing'; import type { IRectRangeWithStyle, ITextRangeWithStyle } from '@univerjs/engine-render'; @@ -53,8 +53,8 @@ export interface IDocClipboardHook { } export interface IDocClipboardService { - copy(sliceType?: SliceBodyType): Promise; - cut(): Promise; + copy(sliceType?: SliceBodyType, ranges?: ITextRangeWithStyle[]): Promise; + cut(ranges?: ITextRangeWithStyle[]): Promise; paste(items: ClipboardItem[]): Promise; legacyPaste(options: { html?: string; text?: string; files: File[] }): Promise; addClipboardHook(hook: IDocClipboardHook): IDisposable; @@ -110,16 +110,15 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ super(); } - async copy(sliceType: SliceBodyType = SliceBodyType.copy): Promise { - const { newSnapshotList = [], needCache = false, snapshot } = this._getDocumentBodyInRanges(sliceType) ?? {}; + async copy(sliceType: SliceBodyType = SliceBodyType.copy, ranges?: ITextRangeWithStyle[]): Promise { + const { newSnapshotList = [], needCache = false, snapshot, ranges: allRanges } = this._getDocumentBodyInRanges(sliceType, ranges) ?? {}; if (newSnapshotList.length === 0 || snapshot == null) { return false; } try { - const activeRange = this._docSelectionManagerService.getActiveTextRange(); - const isCopyInHeaderFooter = !!activeRange?.segmentId; + const isCopyInHeaderFooter = !!allRanges?.[0]?.segmentId; this._setClipboardData(newSnapshotList, !isCopyInHeaderFooter && needCache); } catch (e) { @@ -130,8 +129,8 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ return true; } - async cut(): Promise { - return this._cut(); + async cut(ranges?: ITextRangeWithStyle[]): Promise { + return this._cut(ranges); } async paste(items: ClipboardItem[]): Promise { @@ -164,15 +163,14 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ return this._paste(partDocData); } - private async _cut(): Promise { + private async _cut(ranges?: ITextRangeWithStyle[]): Promise { + const textRanges = ranges?.filter((range) => range.rangeType === DOC_RANGE_TYPE.TEXT) ?? this._docSelectionManagerService.getTextRanges() ?? []; + const rectRanges = ranges?.filter((range) => range.rangeType === DOC_RANGE_TYPE.RECT) as IRectRangeWithStyle[] ?? this._docSelectionManagerService.getRectRanges() ?? []; const { segmentId, endOffset: activeEndOffset, style, - } = this._docSelectionManagerService.getActiveTextRange() ?? {}; - const textRanges = this._docSelectionManagerService.getTextRanges() ?? []; - const rectRanges = this._docSelectionManagerService.getRectRanges() ?? []; - + } = textRanges[0] ?? {}; if (segmentId == null) { this._logService.error('[DocClipboardController] segmentId is not existed'); } @@ -182,7 +180,7 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ } // Set content to clipboard. - this.copy(SliceBodyType.cut); + this.copy(SliceBodyType.cut, ranges); try { let cursor = 0; @@ -212,7 +210,12 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ }, ]; - return this._commandService.executeCommand(CutContentCommand.id, { segmentId, textRanges: newTextRanges }); + return this._commandService.executeCommand(CutContentCommand.id, { + segmentId, + textRanges: newTextRanges, + rectRanges, + selections: textRanges, + }); // eslint-disable-next-line unused-imports/no-unused-vars } catch (_e) { this._logService.error('[DocClipboardController] cut content failed'); @@ -353,13 +356,9 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ }); } - private _getDocumentBodyInRanges(sliceType: SliceBodyType): Nullable<{ - newSnapshotList: IDocumentData[]; - needCache: boolean; - snapshot: IDocumentData; - }> { + private _getDocumentBodyInRanges(sliceType: SliceBodyType, ranges?: ITextRangeWithStyle[]) { const docDataModel = this._univerInstanceService.getCurrentUniverDocInstance(); - const allRanges = this._docSelectionManagerService.getDocRanges(); + const allRanges = ranges ?? this._docSelectionManagerService.getDocRanges(); const results: IDocumentData['body'][] = []; let needCache = true; @@ -415,6 +414,7 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ newSnapshotList: results.map((e) => ({ ...snapshot, body: e })), needCache, snapshot, + ranges: allRanges, }; } diff --git a/packages/docs-ui/src/services/doc-event-manager.service.ts b/packages/docs-ui/src/services/doc-event-manager.service.ts index 06bbd25438..8143fd7ae2 100644 --- a/packages/docs-ui/src/services/doc-event-manager.service.ts +++ b/packages/docs-ui/src/services/doc-event-manager.service.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import type { DocumentDataModel, ICustomRange, IParagraph, IParagraphRange, ITextRangeParam, Nullable } from '@univerjs/core'; -import type { Documents, DocumentSkeleton, IBoundRectNoAngle, IDocumentSkeletonGlyph, IDocumentSkeletonPage, IMouseEvent, IPointerEvent, IRenderContext, IRenderModule } from '@univerjs/engine-render'; -import { BuildTextUtils, Disposable, fromEventSubject, Inject } from '@univerjs/core'; +import type { DocumentDataModel, ICustomRange, IParagraph, ITextRangeParam, Nullable } from '@univerjs/core'; +import type { Documents, DocumentSkeleton, IBoundRectNoAngle, IDocumentSkeletonGlyph, IDocumentSkeletonPage, IDocumentSkeletonSection, IRenderContext, IRenderModule } from '@univerjs/engine-render'; +import { Disposable, fromEventSubject, Inject } from '@univerjs/core'; import { DocSkeletonManagerService } from '@univerjs/docs'; import { CURSOR_TYPE, TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render'; import { BehaviorSubject, distinctUntilChanged, filter, map, Subject, switchMap, take, throttleTime } from 'rxjs'; @@ -39,11 +39,6 @@ export interface IBulletBound { paragraph: IParagraph; } -export interface IParagraphBound { - rect: IBoundRectNoAngle; - paragraph: IParagraphRange; -} - const calcDocRangePositions = (range: ITextRangeParam, documents: Documents, skeleton: DocumentSkeleton, pageIndex: number): IBoundRectNoAngle[] | undefined => { const startPosition = skeleton.findNodePositionByCharIndex(range.startOffset, true, range.segmentId, pageIndex); const skeletonData = skeleton.getSkeletonData(); @@ -72,52 +67,61 @@ const calcDocRangePositions = (range: ITextRangeParam, documents: Documents, ske })); }; -const calcDocParagraphPositions = (startIndex: number, endIndex: number, documents: Documents, skeleton: DocumentSkeleton): Nullable => { - const startPosition = skeleton.findNodePositionByCharIndex(startIndex, true, undefined, -1); - const endPosition = skeleton.findNodePositionByCharIndex(endIndex, true, undefined, -1); - if (!endPosition || !startPosition) { - return null; - } +interface ICustomRangeBoundBase { + paragraphStart: number; + paragraphEnd: number; + startIndex: number; + rect: IBoundRectNoAngle; + fisrtLine: IBoundRectNoAngle; +} - try { - const documentOffsetConfig = documents.getOffsetConfig(); - const convertor = new NodePositionConvertToCursor(documentOffsetConfig, skeleton); - const { borderBoxPointGroup } = convertor.getRangePointData(startPosition, endPosition); - const bounds = getLineBounding(borderBoxPointGroup); - if (!bounds.length) { - return null; - } - const rects = bounds.map((rect) => ({ - top: rect.top + documentOffsetConfig.docsTop - DOC_VERTICAL_PADDING, - bottom: rect.bottom + documentOffsetConfig.docsTop + DOC_VERTICAL_PADDING, - left: rect.left + documentOffsetConfig.docsLeft, - right: rect.right + documentOffsetConfig.docsLeft, - })); +const calcDocParagraphPositions = (sections: IDocumentSkeletonSection[], top: number, left: number, pageWidth: number): ICustomRangeBoundBase[] => { + const paragraphBounds: ICustomRangeBoundBase[] = []; + for (const section of sections) { + const sectionTop = section.top; + for (const column of section.columns) { + const columnLeft = column.left; + const width = section.colCount === 1 ? pageWidth : column.width; + let currentParagraph: ICustomRangeBoundBase | null = null; + for (const line of column.lines) { + const startIndex = line.paragraphIndex; + if (line.paragraphStart) { + if (currentParagraph) { + paragraphBounds.push(currentParagraph); + currentParagraph = null; + } + const lineRect = { + top: top + sectionTop + line.top, + left: left + columnLeft, + right: left + columnLeft + width, + bottom: top + sectionTop + line.top + line.lineHeight, + }; + currentParagraph = { + paragraphStart: line.st, + paragraphEnd: startIndex, + startIndex, + rect: lineRect, + fisrtLine: { + top: top + sectionTop + line.top + line.marginTop + line.paddingTop, + left: left + columnLeft, + right: left + columnLeft + width, + bottom: top + sectionTop + line.top + line.marginTop + line.paddingTop + line.contentHeight, + }, + }; + } else { + if (currentParagraph && currentParagraph.startIndex === startIndex) { + currentParagraph.rect.bottom = top + sectionTop + line.top + line.lineHeight; + } + } + } - let left; - let right; - let top; - let bottom; - - for (const rect of rects) { - if (left === undefined || rect.left < left) { - left = rect.left; - } - if (right === undefined || rect.right > right) { - right = rect.right; - } - if (top === undefined || rect.top < top) { - top = rect.top; - } - if (bottom === undefined || rect.bottom > bottom) { - bottom = rect.bottom; + if (currentParagraph) { + paragraphBounds.push(currentParagraph); } } - - return { left: left!, right: right!, top: top!, bottom: bottom! }; - } catch (e) { - return null; } + + return paragraphBounds; }; export const calcDocGlyphPosition = (glyph: IDocumentSkeletonGlyph, documents: Documents, skeleton: DocumentSkeleton, pageIndex = -1): IBoundRectNoAngle | undefined => { @@ -155,6 +159,52 @@ interface IBulletActive { rect: IBoundRectNoAngle; } +export interface IMutiPageParagraphBound { + rect: IBoundRectNoAngle; + paragraphStart: number; + paragraphEnd: number; + startIndex: number; + rects: IBoundRectNoAngle[]; + pageIndex: number; + segmentId?: string; + firstLine: IBoundRectNoAngle; +} + +export interface ITableParagraphBound { + rect: IBoundRectNoAngle; + paragraphStart: number; + paragraphEnd: number; + startIndex: number; + pageIndex: number; + segmentId?: string; + rowIndex: number; + colIndex: number; + firstLine: IBoundRectNoAngle; + tableId: string; +} + +export interface ITableBound { + rect: IBoundRectNoAngle; + pageIndex: number; + tableId: string; +} + +export interface ITableCellBound { + rect: IBoundRectNoAngle; + pageIndex: number; + rowIndex: number; + colIndex: number; + tableId: string; +} + +function isPointInRect(x: number, y: number, rect: IBoundRectNoAngle) { + const { left, right, top, bottom } = rect; + if (x >= left && x <= right && y >= top && y <= bottom) { + return true; + } + return false; +} + export class DocEventManagerService extends Disposable implements IRenderModule { private readonly _hoverCustomRanges$ = new BehaviorSubject([]); readonly hoverCustomRanges$ = this._hoverCustomRanges$.pipe(distinctUntilChanged((pre, aft) => pre.length === aft.length && pre.every((item, i) => aft[i].range.rangeId === item.range.rangeId && aft[i].segmentId === item.segmentId && aft[i].segmentPageIndex === item.segmentPageIndex && aft[i].range.startIndex === item.range.startIndex))); @@ -168,6 +218,22 @@ export class DocEventManagerService extends Disposable implements IRenderModule private readonly _clickBullet$ = new Subject(); readonly clickBullets$ = this._clickBullet$.asObservable(); + private readonly _hoverParagraph$ = new Subject>(); + readonly hoverParagraph$ = this._hoverParagraph$.pipe(distinctUntilChanged((pre, aft) => pre?.startIndex === aft?.startIndex && pre?.segmentId === aft?.segmentId && pre?.pageIndex === aft?.pageIndex)); + readonly hoverParagraphRealTime$ = this._hoverParagraph$.asObservable(); + + private readonly _hoverParagraphLeft$ = new Subject>(); + readonly hoverParagraphLeft$ = this._hoverParagraphLeft$.pipe(distinctUntilChanged((pre, aft) => pre?.startIndex === aft?.startIndex && pre?.segmentId === aft?.segmentId && pre?.pageIndex === aft?.pageIndex)); + readonly hoverParagraphLeftRealTime$ = this._hoverParagraphLeft$.asObservable(); + + private readonly _hoverTableCell$ = new Subject>(); + readonly hoverTableCell$ = this._hoverTableCell$.pipe(distinctUntilChanged((pre, aft) => pre?.rowIndex === aft?.rowIndex && pre?.colIndex === aft?.colIndex && pre?.tableId === aft?.tableId && pre?.pageIndex === aft?.pageIndex)); + readonly hoverTableCellRealTime$ = this._hoverTableCell$.asObservable(); + + private readonly _hoverTable$ = new Subject>(); + readonly hoverTable$ = this._hoverTable$.pipe(distinctUntilChanged((pre, aft) => pre?.tableId === aft?.tableId && pre?.pageIndex === aft?.pageIndex)); + readonly hoverTableRealTime$ = this._hoverTable$.asObservable(); + private _customRangeDirty = true; private _bulletDirty = true; private _paragraphDirty = true; @@ -186,7 +252,13 @@ export class DocEventManagerService extends Disposable implements IRenderModule * cache the bounding of paragraphs, * it will be updated when the doc-skeleton is recalculated */ - private _paragraphBounds: IParagraphBound[] = []; + private _paragraphBounds: Map = new Map(); + private _paragraphLeftBounds: IMutiPageParagraphBound[] = []; + private _tableParagraphBounds: Map = new Map(); + private _segmentParagraphBounds: Map> = new Map(); + + private _tableCellBounds: Map = new Map(); + private _tableBounds: Map = new Map(); private get _skeleton() { return this._docSkeletonManagerService.getSkeleton(); @@ -245,11 +317,18 @@ export class DocEventManagerService extends Disposable implements IRenderModule private _initEvents() { this.disposeWithMe(fromEventSubject(this._context.scene.onPointerMove$).pipe(throttleTime(30)).subscribe((evt) => { + const { x, y } = transformOffset2Bound(evt.offsetX, evt.offsetY, this._context.scene); this._hoverCustomRanges$.next( - this._calcActiveRanges(evt) + this._calcActiveRanges({ x, y }) + ); + this._hoverParagraph$.next( + this._calcActiveParagraph({ x, y }) + ); + this._hoverParagraphLeft$.next( + this._calcActiveParagraphLeft({ x, y }) ); this._hoverBullet$.next( - this._calcActiveBullet(evt) + this._calcActiveBullet({ x, y }) ); })); this.disposeWithMe(this._context.scene.onPointerEnter$.subscribeEvent(() => { @@ -262,7 +341,6 @@ export class DocEventManagerService extends Disposable implements IRenderModule this.disposeWithMe(onPointerDown$.pipe( switchMap((down) => onPointerUp$.pipe(take(1), map((up) => ({ down, up })))), filter(({ down, up }) => down.target === up.target && up.timeStamp - down.timeStamp < 300) - // filter(({ down, up }) => down.offsetX === up.offsetX && down.offsetY === up.offsetY) ).subscribe(({ down }) => { if (down.button === 2) { return; @@ -327,19 +405,12 @@ export class DocEventManagerService extends Disposable implements IRenderModule this._customRangeBounds = customRangeBounds; } - private _calcActiveRanges(evt: IPointerEvent | IMouseEvent) { + private _calcActiveRanges(evt: { x: number; y: number }) { this._buildCustomRangeBounds(); - const { offsetX, offsetY } = evt; - const { x, y } = transformOffset2Bound(offsetX, offsetY, this._context.scene); + const { x, y } = evt; const matchedRanges = this._customRangeBounds.filter((layout) => { - return layout.rects.some((rect) => { - const { left, right, top, bottom } = rect; - if (x >= left && x <= right && y >= top && y <= bottom) { - return true; - } - return false; - }); + return layout.rects.some((rect) => isPointInRect(x, y, rect)); }); return matchedRanges.map( (range) => ({ @@ -433,31 +504,137 @@ export class DocEventManagerService extends Disposable implements IRenderModule }); } - private _calcActiveBullet(evt: IPointerEvent | IMouseEvent) { + private _calcActiveBullet(evt: { x: number; y: number }) { this._buildBulletBounds(); - const { offsetX, offsetY } = evt; - const { x, y } = transformOffset2Bound(offsetX, offsetY, this._context.scene); - const bullet = this._bulletBounds.find((layout) => { - const { left, right, top, bottom } = layout.rect; - if (x >= left && x <= right && y >= top && y <= bottom) { - return true; - } - return false; - }); + const { x, y } = evt; + const bullet = this._bulletBounds.find((layout) => isPointInRect(x, y, layout.rect)); return bullet; } - private _buildParagraphBoundsBySegment(segmentId?: string, pageIndex = -1) { - const paragraphs = BuildTextUtils.paragraph.util.transform(this._context.unit.getBody()?.paragraphs ?? []); - return paragraphs.map((paragraph) => { - return { - paragraph, - segmentId, - pageIndex, - rect: calcDocParagraphPositions(paragraph.paragraphStart, paragraph.paragraphEnd, this._documents, this._skeleton), - } as IParagraphBound; - }).filter((layout) => layout.rect !== null); + // eslint-disable-next-line max-lines-per-function + private _buildParagraphBoundsBySegment(segmentId?: string) { + const skeletonData = this._skeleton.getSkeletonData(); + const documents = this._documents; + const documentOffsetConfig = documents.getOffsetConfig(); + if (!skeletonData) { + return null; + } + + // eslint-disable-next-line max-lines-per-function + const calc = (pages: IDocumentSkeletonPage[]) => { + const paragraphMap: Map = new Map(); + const handlePage = (page: IDocumentSkeletonPage, pageIndex: number, top: number, left: number) => { + const bounds = calcDocParagraphPositions(page.sections, top, left, page.pageWidth - page.marginLeft - page.marginRight); + + bounds.forEach((bound) => { + if (!paragraphMap.has(bound.startIndex)) { + paragraphMap.set(bound.startIndex, { + rect: bound.rect, + paragraphStart: bound.paragraphStart, + paragraphEnd: bound.paragraphEnd, + startIndex: bound.startIndex, + rects: [bound.rect], + pageIndex, + segmentId, + firstLine: bound.fisrtLine, + }); + } else { + const current = paragraphMap.get(bound.startIndex); + if (current) { + current.rect.bottom = bound.rect.bottom; + current.rects.push(bound.rect); + } + } + }); + }; + + for (let i = 0; i < pages.length; i++) { + const page = pages[i]; + const top = ((page.pageHeight === Infinity ? 0 : page.pageHeight) + documentOffsetConfig.pageMarginTop) * i + page.marginTop + documentOffsetConfig.docsTop; + const left = page.marginLeft + documentOffsetConfig.docsLeft; + if (page.skeTables) { + Array.from(page.skeTables.values()).forEach((table) => { + const tableLeft = table.left + left; + const tableTop = table.top + top; + const tableRight = tableLeft + table.width; + const tableBottom = tableTop + table.height; + const tableId = table.tableId; + this._tableBounds.set(tableId, { + rect: { + left: tableLeft, + top: tableTop, + right: tableRight, + bottom: tableBottom, + }, + pageIndex: i, + tableId, + }); + + table.rows.forEach((row, rowIndex) => { + row.cells.forEach((cell, colIndex) => { + const top = (((page.pageHeight === Infinity ? 0 : page.pageHeight) + documentOffsetConfig.pageMarginTop) * i) + table.top + documentOffsetConfig.docsTop + page.marginTop + row.top + cell.marginTop; + const left = table.left + documentOffsetConfig.docsLeft + page.marginLeft + cell.left + cell.marginLeft; + const bounds = calcDocParagraphPositions(cell.sections, top, left, cell.pageWidth - cell.marginLeft - cell.marginRight); + let arr = this._tableParagraphBounds.get(tableId); + if (!arr) { + arr = []; + this._tableParagraphBounds.set(tableId, arr); + } + + arr.push(...bounds.map((bound) => ({ + rect: bound.rect, + paragraphStart: bound.paragraphStart, + paragraphEnd: bound.paragraphEnd, + startIndex: bound.startIndex, + pageIndex: i, + segmentId, + rowIndex, + colIndex, + firstLine: bound.fisrtLine, + tableId, + })) + ); + + let cellBounds = this._tableCellBounds.get(tableId); + + if (!cellBounds) { + cellBounds = []; + this._tableCellBounds.set(tableId, cellBounds); + } + + cellBounds.push({ + rect: { + top, + left, + right: left + cell.pageWidth - cell.marginLeft - cell.marginRight, + bottom: top + cell.pageHeight - cell.marginBottom - cell.marginTop, + }, + pageIndex: i, + rowIndex, + colIndex, + tableId, + }); + }); + }); + }); + } + + handlePage(page, i, top, left); + } + + return paragraphMap; + }; + + if (segmentId) { + const page = skeletonData.skeFooters.get(segmentId)?.values() ?? skeletonData.skeHeaders.get(segmentId)?.values(); + if (!page) { + return null; + } + return calc(Array.from(page)); + } + + return calc(skeletonData.pages); } private _buildParagraphBounds() { @@ -465,21 +642,73 @@ export class DocEventManagerService extends Disposable implements IRenderModule return; } this._paragraphDirty = false; + this._tableParagraphBounds = new Map(); + this._tableCellBounds = new Map(); + this._tableBounds = new Map(); + this._paragraphBounds = this._buildParagraphBoundsBySegment() ?? new Map(); + this._paragraphLeftBounds = Array.from(this._paragraphBounds.values()).map((bound) => ({ + ...bound, + rect: { + left: bound.rect.left - 60, + right: bound.rect.left, + top: bound.rect.top, + bottom: bound.rect.bottom, + }, + })); + const handleSegment = (segmentId: string) => { + this._segmentParagraphBounds.set(segmentId, this._buildParagraphBoundsBySegment(segmentId) ?? new Map()); + }; - this._paragraphBounds = []; - this._paragraphBounds.push(...this._buildParagraphBoundsBySegment()); - - this._skeleton.getSkeletonData()?.pages.forEach((page, pageIndex) => { + this._skeleton.getSkeletonData()?.pages.forEach((page) => { if (page.headerId) { - this._paragraphBounds.push(...this._buildParagraphBoundsBySegment(page.headerId, pageIndex)); + handleSegment(page.headerId); } if (page.footerId) { - this._paragraphBounds.push(...this._buildParagraphBoundsBySegment(page.footerId, pageIndex)); + handleSegment(page.footerId); } }); } + private _calcActiveParagraph(evt: { x: number; y: number }): Nullable { + this._buildParagraphBounds(); + + const { x, y } = evt; + + const table = Array.from(this._tableBounds.values()).find((bound) => isPointInRect(x, y, bound.rect)); + this._hoverTable$.next(table); + + if (table) { + const tableCell = this._tableCellBounds.get(table.tableId)?.find((bound) => isPointInRect(x, y, bound.rect)); + this._hoverTableCell$.next(tableCell); + if (!tableCell) { + return null; + } + + const paragraphs = this._tableParagraphBounds.get(tableCell.tableId) + ?.filter((bound) => bound.colIndex === tableCell.colIndex && bound.rowIndex === tableCell.rowIndex); + const paragraph = paragraphs?.find((bound) => isPointInRect(x, y, bound.rect)); + return paragraph && { + ...paragraph, + rects: [paragraph.rect], + }; + } + + const paragraph = this._paragraphBounds.values().find((bounds) => { + return bounds.rects.some((rect) => isPointInRect(x, y, rect)); + }); + + return paragraph; + } + + private _calcActiveParagraphLeft(evt: { x: number; y: number }): Nullable { + this._buildParagraphBounds(); + const { x, y } = evt; + + const paragraph = this._paragraphLeftBounds.find((bound) => isPointInRect(x, y, bound.rect)); + return paragraph; + } + get paragraphBounds() { this._buildParagraphBounds(); return this._paragraphBounds; diff --git a/packages/docs-ui/src/services/doc-paragraph-menu.service.ts b/packages/docs-ui/src/services/doc-paragraph-menu.service.ts new file mode 100644 index 0000000000..cbdf38c012 --- /dev/null +++ b/packages/docs-ui/src/services/doc-paragraph-menu.service.ts @@ -0,0 +1,158 @@ +/** + * Copyright 2023-present DreamNum Co., Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES 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, INeedCheckDisposable, IParagraphRange, Nullable } from '@univerjs/core'; +import type { IRenderContext, IRenderModule } from '@univerjs/engine-render'; +import type { IMutiPageParagraphBound } from './doc-event-manager.service'; +import { Disposable, Inject, isInternalEditorID } from '@univerjs/core'; +import { DocSelectionManagerService } from '@univerjs/docs'; +import { ComponentManager } from '@univerjs/ui'; +import { combineLatest, first, throttleTime } from 'rxjs'; +import { VIEWPORT_KEY } from '../basics/docs-view-key'; +import { DocEventManagerService } from './doc-event-manager.service'; +import { DocCanvasPopManagerService } from './doc-popup-manager.service'; + +export class DocParagraphMenuService extends Disposable implements IRenderModule { + private _paragrahMenu: { + paragraph: IParagraphRange; + disposable: INeedCheckDisposable; + active: boolean; + } | null = null; + + get activeParagraph() { + return this._paragrahMenu?.paragraph; + } + + constructor( + private _context: IRenderContext, + @Inject(DocSelectionManagerService) private _docSelectionManagerService: DocSelectionManagerService, + @Inject(DocEventManagerService) private _docEventManagerService: DocEventManagerService, + @Inject(DocCanvasPopManagerService) private _docPopupManagerService: DocCanvasPopManagerService, + @Inject(ComponentManager) private _componentManager: ComponentManager + ) { + super(); + + if (isInternalEditorID(this._context.unitId)) { + return; + } + + this._init(); + } + + private _isCursorInActiveParagraph() { + if (!this._paragrahMenu) { + return false; + } + + const activeTextRange = this._docSelectionManagerService.getActiveTextRange(); + if (!activeTextRange?.collapsed) { + return false; + } + + if ( + !activeTextRange?.collapsed || + activeTextRange.startOffset < this._paragrahMenu.paragraph.paragraphStart || + activeTextRange.startOffset > this._paragrahMenu.paragraph.paragraphEnd + ) { + return false; + } + + return true; + } + + setParagraphMenuActive(active: boolean) { + if (this._paragrahMenu) { + this._paragrahMenu.active = active; + if (!this._isCursorInActiveParagraph()) { + this._docSelectionManagerService.replaceDocRanges([{ + startOffset: this._paragrahMenu.paragraph.paragraphStart, + endOffset: this._paragrahMenu.paragraph.paragraphStart, + }]); + } + } + } + + private _init() { + const handleHoverParagraph = (paragraph: Nullable) => { + if (this._paragrahMenu?.active) { + return; + } + + if (paragraph) { + this.showParagraphMenu(paragraph); + return; + } + + this.hideParagraphMenu(true); + }; + + this.disposeWithMe( + combineLatest([this._docEventManagerService.hoverParagraphRealTime$, this._docEventManagerService.hoverParagraphLeft$]) + .pipe(throttleTime(16)) + .subscribe(([p, left]) => { + const paragraph = p ?? left; + handleHoverParagraph(paragraph); + }) + ); + + let lastScrollY = 0; + this.disposeWithMe(this._context.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN)!.onScrollAfter$.subscribeEvent((e) => { + if (lastScrollY === e.scrollY) { + return; + } + + lastScrollY = e.scrollY; + this.hideParagraphMenu(true); + })); + } + + showParagraphMenu(paragraph: IMutiPageParagraphBound) { + if (this._paragrahMenu?.paragraph.startIndex === paragraph.startIndex) { + return; + } + + this.hideParagraphMenu(true); + + const disposable = this._docPopupManagerService.attachPopupToRect( + paragraph.firstLine, + { + componentKey: 'doc.paragraph.menu', + direction: 'left-center', + onClickOutside: () => { + this._docSelectionManagerService.textSelection$.pipe(first()).subscribe(() => { + if (!this._isCursorInActiveParagraph()) { + this.hideParagraphMenu(true); + } + }); + }, + }, + this._context.unitId + ); + + this._paragrahMenu = { + paragraph, + disposable, + active: false, + }; + } + + hideParagraphMenu(force = false) { + if (this._paragrahMenu && ((this._paragrahMenu.disposable.canDispose() || force))) { + this._paragrahMenu.disposable.dispose(); + this._paragrahMenu = null; + } + } +} diff --git a/packages/docs-ui/src/services/doc-popup-manager.service.ts b/packages/docs-ui/src/services/doc-popup-manager.service.ts index 1cefa2d077..89f9b93d44 100644 --- a/packages/docs-ui/src/services/doc-popup-manager.service.ts +++ b/packages/docs-ui/src/services/doc-popup-manager.service.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { IDisposable, INeedCheckDisposable, ITextRangeParam } from '@univerjs/core'; +import type { INeedCheckDisposable, ITextRangeParam } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; import type { BaseObject, Documents, IBoundRectNoAngle, IRender, Scene } from '@univerjs/engine-render'; import type { IPopup } from '@univerjs/ui'; @@ -84,7 +84,7 @@ export function transformOffset2Bound(offsetX: number, offsetY: number, scene: S }; } -export interface IDocCanvasPopup extends Pick { +export interface IDocCanvasPopup extends Pick { mask?: boolean; extraProps?: Record; multipleDirection?: IPopup['direction']; @@ -134,20 +134,10 @@ export class DocCanvasPopManagerService extends Disposable { super(); } - private _createObjectPositionObserver( - targetObject: BaseObject, - currentRender: IRender - ) { + private _createRectPositionObserver(rect: IBoundRectNoAngle | (() => IBoundRectNoAngle), currentRender: IRender) { const calc = () => { const { scene, engine } = currentRender; - const { left, top, width, height } = targetObject; - - const bound: IBoundRectNoAngle = { - left, - right: left + width, - top, - bottom: top + height, - }; + const bound: IBoundRectNoAngle = typeof rect === 'function' ? rect() : rect; const canvasElement = engine.getCanvasElement(); const canvasClientRect = canvasElement.getBoundingClientRect(); const widthOfCanvas = pxToNum(canvasElement.style.width); // declared width @@ -192,6 +182,24 @@ export class DocCanvasPopManagerService extends Disposable { }; } + private _createObjectPositionObserver( + targetObject: BaseObject, + currentRender: IRender + ) { + const getBound = () => { + const { left, top, width, height } = targetObject; + const bound: IBoundRectNoAngle = { + left, + right: left + width, + top, + bottom: top + height, + }; + return bound; + }; + + return this._createRectPositionObserver(getBound, currentRender); + } + private _createRangePositionObserver(range: ITextRangeParam, currentRender: IRender) { const positions = calcDocRangePositions(range, currentRender) ?? []; const positions$ = new BehaviorSubject(positions); @@ -226,6 +234,32 @@ export class DocCanvasPopManagerService extends Disposable { }; } + attachPopupToRect(rect: IBoundRectNoAngle, popup: IDocCanvasPopup, unitId: string): INeedCheckDisposable { + const currentRender = this._renderManagerService.getRenderById(unitId); + if (!currentRender) { + throw new Error(`Current render not found, unitId: ${unitId}`); + } + + const { position, position$, disposable } = this._createRectPositionObserver(rect, 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(); + }, + canDispose: () => this._globalPopupManagerService.activePopupId !== id, + }; + } + // #region attach to object /** * attach a popup to canvas object @@ -233,7 +267,7 @@ export class DocCanvasPopManagerService extends Disposable { * @param popup popup item * @returns disposable */ - attachPopupToObject(targetObject: BaseObject, popup: IDocCanvasPopup, unitId: string): IDisposable { + attachPopupToObject(targetObject: BaseObject, popup: IDocCanvasPopup, unitId: string): INeedCheckDisposable { const currentRender = this._renderManagerService.getRenderById(unitId); if (!currentRender) { throw new Error(`Current render not found, unitId: ${unitId}`); @@ -255,6 +289,7 @@ export class DocCanvasPopManagerService extends Disposable { position$.complete(); disposable.dispose(); }, + canDispose: () => this._globalPopupManagerService.activePopupId !== id, }; } @@ -293,7 +328,7 @@ export class DocCanvasPopManagerService extends Disposable { ? bounds.length > 1 ? multipleDirection ?? direction : direction - : 'bottom', + : direction, canvasElement: currentRender.engine.getCanvasElement(), }); diff --git a/packages/docs-ui/src/views/side-menu/index.tsx b/packages/docs-ui/src/views/side-menu/index.tsx index d4a3a4fd78..2ef4613957 100644 --- a/packages/docs-ui/src/views/side-menu/index.tsx +++ b/packages/docs-ui/src/views/side-menu/index.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ -import type { DocumentDataModel } from '@univerjs/core'; +import type { DocumentDataModel, IParagraph } from '@univerjs/core'; import type { ISideMenuItem } from '@univerjs/design'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; -import type { IParagraphBound } from '../../services/doc-event-manager.service'; +import type { IMutiPageParagraphBound } from '../../services/doc-event-manager.service'; import { debounce, fromEventSubject, getPlainText, ICommandService, isInternalEditorID, IUniverInstanceService, NamedStyleType, UniverInstanceType } from '@univerjs/core'; import { SideMenu } from '@univerjs/design'; import { RichTextEditingMutation } from '@univerjs/docs'; import { IRenderManagerService } from '@univerjs/engine-render'; -import { useDependency, useObservable } from '@univerjs/ui'; +import { useDependency, useEvent, useObservable } from '@univerjs/ui'; import { useEffect, useMemo, useState } from 'react'; import { of, throttleTime } from 'rxjs'; import { VIEWPORT_KEY } from '../../basics/docs-view-key'; @@ -47,21 +47,27 @@ const transformNamedStyleTypeToLevel = (type: NamedStyleType) => { } }; -function findActiveHeading(paragraphBounds: IParagraphBound[], scrollTop: number, bottom: number) { - const paragraphIndex = paragraphBounds.findIndex((p) => p.paragraph.paragraphStart !== p.paragraph.paragraphEnd && p.rect.top < bottom && p.rect.bottom > scrollTop); +function findActiveHeading(boundMap: Map | undefined, paragraphMap: Map, scrollTop: number, bottom: number) { + if (!boundMap) { + return undefined; + } + const paragraphBounds = Array.from(boundMap.values()); + const paragraphIndex = paragraphBounds.findIndex((p) => p.paragraphStart !== p.paragraphEnd && p.rect.top < bottom && p.rect.bottom > scrollTop); if (paragraphIndex === -1) return undefined; - const lastParagraphIndex = paragraphBounds?.findLastIndex((p) => p.paragraph.paragraphStart !== p.paragraph.paragraphEnd && p.rect.top < bottom && p.rect.bottom > scrollTop); + const lastParagraphIndex = paragraphBounds?.findLastIndex((p) => p.paragraphStart !== p.paragraphEnd && p.rect.top < bottom && p.rect.bottom > scrollTop); for (let i = paragraphIndex; i <= lastParagraphIndex; i++) { - const paragraph = paragraphBounds[i]; - if (paragraph.paragraph.paragraphStyle?.headingId) { - return paragraph.paragraph.paragraphStyle.headingId; + const bound = paragraphBounds[i]; + const paragraph = paragraphMap.get(bound.startIndex); + if (paragraph?.paragraphStyle?.headingId) { + return paragraph.paragraphStyle.headingId; } } for (let i = paragraphIndex; i >= 0; i--) { - const paragraph = paragraphBounds[i]; - if (paragraph.paragraph.paragraphStyle?.headingId) { - return paragraph.paragraph.paragraphStyle.headingId; + const bound = paragraphBounds[i]; + const paragraph = paragraphMap.get(bound.startIndex); + if (paragraph?.paragraphStyle?.headingId) { + return paragraph.paragraphStyle.headingId; } } @@ -87,20 +93,39 @@ export function DocSideMenu() { const canvasHeight = renderer?.engine.height ?? 0; const scaleY = renderer?.scene.scaleY ?? 1; const bottom = scrollTop + (canvasHeight / scaleY); + const paragraphs = currentDoc?.getBody()?.paragraphs ?? []; + const paragraphMap = useMemo(() => { + const map = new Map(); + paragraphs.forEach((p) => { + map.set(p.startIndex, p); + }); + return map; + }, [paragraphs]); useObservable(useMemo(() => (renderer?.engine.onTransformChange$ ? fromEventSubject(renderer?.engine.onTransformChange$).pipe(throttleTime(33)) : of(null)), [renderer?.engine.onTransformChange$])); const mode = left < 180 ? 'float' : 'side-bar'; let minLevel = Infinity; - const paragraphMenus = paragraphBounds?.filter(({ paragraph: p }) => p.paragraphStyle?.namedStyleType !== undefined && p.paragraphStyle!.namedStyleType !== NamedStyleType.SUBTITLE).map(({ paragraph: p }) => { - const level = transformNamedStyleTypeToLevel(p.paragraphStyle!.namedStyleType!); - minLevel = Math.min(minLevel, level); - return { - id: p.paragraphStyle!.headingId!, - text: getPlainText(fullDataStream.slice(p.paragraphStart, p.paragraphEnd)), - level, - isTitle: p.paragraphStyle?.namedStyleType === NamedStyleType.TITLE, - }; - }); + const paragraphMenus = paragraphs + ?.filter((p) => + p.paragraphStyle?.namedStyleType !== undefined && + p.paragraphStyle!.namedStyleType !== NamedStyleType.SUBTITLE && + p.paragraphStyle.namedStyleType !== NamedStyleType.NORMAL_TEXT + ) + .map((p) => { + const level = transformNamedStyleTypeToLevel(p.paragraphStyle!.namedStyleType!); + minLevel = Math.min(minLevel, level); + const bound = paragraphBounds?.get(p.startIndex); + if (!bound) return null; + const { paragraphStart, paragraphEnd } = bound; + + return { + id: p.paragraphStyle!.headingId!, + text: getPlainText(fullDataStream.slice(paragraphStart, paragraphEnd)), + level, + isTitle: p.paragraphStyle?.namedStyleType === NamedStyleType.TITLE, + }; + }) + .filter(Boolean) as ISideMenuItem[]; const menus = paragraphMenus?.find((p) => p.isTitle) ? paragraphMenus @@ -114,10 +139,10 @@ export function DocSideMenu() { }] : []), ...(paragraphMenus ?? []), - ].filter(Boolean); + ].filter(Boolean) as ISideMenuItem[]; const [open, setOpen] = useState(true); - const activeId = findActiveHeading(paragraphBounds ?? [], scrollTop, bottom); + const activeId = findActiveHeading(paragraphBounds, paragraphMap, scrollTop, bottom); useEffect(() => { const debounceUpdater = debounce(setUpdateKey, 100); @@ -148,7 +173,7 @@ export function DocSideMenu() { }; }, [renderer]); - const handleClick = (menu: ISideMenuItem) => { + const handleClick = useEvent((menu: ISideMenuItem) => { const viewport = renderer?.scene.getViewport(VIEWPORT_KEY.VIEW_MAIN); if (!viewport) { return; @@ -158,13 +183,17 @@ export function DocSideMenu() { viewport.scrollToViewportPos({ viewportScrollY: 0 }); return; } - const bound = paragraphBounds?.find((p) => p.paragraph.paragraphStyle?.headingId === menu.id); + const paragraph = paragraphs.find((p) => p.paragraphStyle?.headingId === menu.id); + if (!paragraph) { + return; + } + const bound = paragraphBounds?.get(paragraph.startIndex); if (!bound) { return; } viewport.scrollToViewportPos({ viewportScrollY: bound.rect.top }); - }; + }); if (!currentDoc || isInternalEditorID(unitId) || !menus?.length) { return null; diff --git a/packages/ui/src/components/menu/desktop/Menu.tsx b/packages/ui/src/components/menu/desktop/Menu.tsx index 873e107593..2515d2979a 100644 --- a/packages/ui/src/components/menu/desktop/Menu.tsx +++ b/packages/ui/src/components/menu/desktop/Menu.tsx @@ -23,7 +23,7 @@ import type { IValueOption, MenuItemDefaultValueType, } from '../../../services/menu/menu'; -import { isRealNum } from '@univerjs/core'; +import { isRealNum, LocaleService } from '@univerjs/core'; import { clsx, Menu as DesignMenu, @@ -37,10 +37,12 @@ import { combineLatest, isObservable, of } from 'rxjs'; import { ILayoutService } from '../../../services/layout/layout.service'; import { MenuItemType } from '../../../services/menu/menu'; import { IMenuManagerService } from '../../../services/menu/menu-manager.service'; +import { ContextMenuGroup } from '../../../services/menu/types'; import { useDependency, useObservable } from '../../../utils/di'; import { CustomLabel } from '../../custom-label/CustomLabel'; import { useScrollYOverContainer } from '../../hooks/layout'; import styles from './index.module.less'; +import { UITinyMenuGroup } from './tiny-menu-group'; // TODO: @jikkai disabled and hidden are not working @@ -62,13 +64,12 @@ export interface IBaseMenuProps { /** @deprecated */ function MenuWrapper(props: IBaseMenuProps) { const { menuType, onOptionSelect } = props; - + const localeService = useDependency(LocaleService); const menuManagerService = useDependency(IMenuManagerService); const menuItems = useMemo(() => menuType ? menuManagerService.getMenuByPositionKey(menuType) : [], [menuType, menuManagerService]); const [hiddenStates, setHiddenStates] = useState>({}); - const filteredMenuItems = useMemo(() => { return menuItems.filter((item) => { if (!item.children) return item; @@ -114,21 +115,23 @@ function MenuWrapper(props: IBaseMenuProps) { /> ) : item.children?.length - ? ( - - {item.children.map((child) => ( - child.item && ( - ) => { - onOptionSelect?.({ value: '', label: child.key, ...object }); - }} - /> - ) - ))} - - ) + ? item.key === ContextMenuGroup.QUICK + ? + : ( + + {item.children.map((child) => ( + child.item && ( + ) => { + onOptionSelect?.({ value: '', label: child.key, ...object }); + }} + /> + ) + ))} + + ) : null); } @@ -195,7 +198,6 @@ export const Menu = (props: IBaseMenuProps) => { setMenuEl(ref.list); } } - return ( } > - {selections.length > 0 && ( - { - onClick({ value: v.value, id: item.id, commandId: v.commandId }); - }} - /> - )} + + {selections.length > 0 && ( + { + onClick({ value: v.value, id: item.id, commandId: v.commandId }); + }} + /> + )} + ); } @@ -339,7 +343,9 @@ function MenuItem({ menuItem, onClick }: IMenuItemProps) { )} expandIcon={} > - {subMenuItems.length && } + + {subMenuItems.length && } + ); }; diff --git a/packages/ui/src/components/menu/desktop/tiny-menu-group.tsx b/packages/ui/src/components/menu/desktop/tiny-menu-group.tsx new file mode 100644 index 0000000000..23032e74d2 --- /dev/null +++ b/packages/ui/src/components/menu/desktop/tiny-menu-group.tsx @@ -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 { IValueOption } from '../../../services/menu/menu'; +import type { IMenuSchema } from '../../../services/menu/menu-manager.service'; +import { convertObservableToBehaviorSubject, LocaleService } from '@univerjs/core'; +import { + TinyMenuGroup as DesignTinyMenuGroup, +} from '@univerjs/design'; +import { useDependency } from '@wendellhu/redi/react-bindings'; +import { useEffect, useState } from 'react'; +import { combineLatest, of } from 'rxjs'; +import { ComponentManager } from '../../../common'; + +interface IUITinyMenuGroupProps { + item: IMenuSchema; + onOptionSelect?: (option: IValueOption) => void; +} + +export function UITinyMenuGroup(props: IUITinyMenuGroupProps) { + const { item, onOptionSelect } = props; + const [activeItems, setActiveItems] = useState([]); + const componentManager = useDependency(ComponentManager); + const localeService = useDependency(LocaleService); + + useEffect(() => { + if (!item.children) return; + const observables = item.children.map((child) => convertObservableToBehaviorSubject(child.item?.activated$ ?? of(false), false)); + const subscription = combineLatest(observables).subscribe((activedArr) => { + const actived = activedArr.map((actived, index) => ({ actived, item: item.children![index].item!.id })).filter((actived) => actived.actived); + if (actived.length === 0) { + setActiveItems([]); + } else { + setActiveItems(actived.map((actived) => actived.item)); + } + }); + + return () => { + subscription.unsubscribe(); + observables.forEach((observable) => { + observable.complete(); + }); + }; + }, [item]); + + if (!item.children) return null; + + return ( + ({ + key: child.key, + onClick: () => { + onOptionSelect?.({ + value: '', + label: child.key, + commandId: child.item?.commandId, + id: child.item?.id, + tooltip: child.item?.tooltip && localeService.t(child.item?.tooltip), + }); + }, + className: '', + Icon: componentManager.get(child.item!.icon as string)!, + active: activeItems.includes(child.item?.id ?? ''), + }))} + /> + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index aeac5c93f8..ba18a87a53 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -121,7 +121,7 @@ export { UNIVER_UI_PLUGIN_NAME } from './ui-plugin'; export * from './utils'; export { ComponentContainer, type IComponentContainerProps, useComponentsOfPart } from './views/components/ComponentContainer'; export { ZenZone } from './views/components/zen-zone/ZenZone'; - +export { Menu as DesktopMenu } from './components/menu/desktop/Menu'; // #region - workbench components export { type IConfirmPartMethodOptions } from './views/components/confirm-part/interface'; diff --git a/packages/ui/src/services/menu/menu-manager.service.ts b/packages/ui/src/services/menu/menu-manager.service.ts index f24b7d92ff..e6be3b6982 100644 --- a/packages/ui/src/services/menu/menu-manager.service.ts +++ b/packages/ui/src/services/menu/menu-manager.service.ts @@ -27,8 +27,10 @@ export const IMenuManagerService = createIdentifier('univer export interface IMenuSchema { key: string; order: number; + title?: string; item?: IMenuItem; children?: IMenuSchema[]; + tiny?: boolean; } export interface IMenuManagerService { @@ -46,6 +48,7 @@ export interface IMenuManagerService { export type MenuSchemaType = { order?: number; menuItemFactory?: (accessor: IAccessor) => IMenuItem; + title?: string; } | { [key: string]: MenuSchemaType; }; @@ -113,6 +116,27 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic [MenuManagerPosition.CONTEXT_MENU]: { [ContextMenuPosition.MAIN_AREA]: { order: 0, + [ContextMenuGroup.QUICK]: { + order: -1, + }, + [ContextMenuGroup.FORMAT]: { + order: 0, + }, + [ContextMenuGroup.LAYOUT]: { + order: 1, + }, + [ContextMenuGroup.DATA]: { + order: 2, + }, + [ContextMenuGroup.OTHERS]: { + order: 3, + }, + }, + [ContextMenuPosition.PARAGRAPH]: { + order: 0, + [ContextMenuGroup.QUICK]: { + order: -1, + }, [ContextMenuGroup.FORMAT]: { order: 0, }, @@ -205,7 +229,7 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic _target[_key] = merge({}, _target[_key], source[_key]); this.menuChanged$.next(); - } else { + } else if (typeof value === 'object') { this.mergeMenu(source, value); } } @@ -223,6 +247,7 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic const menuItem: Partial = { key, order: value.order, + title: value.title, }; if (value.menuItemFactory) { @@ -239,14 +264,15 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic } } } + if (typeof value === 'object') { + const children = this._buildMenuSchema(value); + if (children.length > 0) { + menuItem.children = children.sort((a, b) => a.order - b.order); + } - const children = this._buildMenuSchema(value); - if (children.length > 0) { - menuItem.children = children.sort((a, b) => a.order - b.order); - } - - if (menuItem.item || menuItem.children) { - result.push(menuItem as IMenuSchema); // 使用类型断言补充缺失字段 + if (menuItem.item || menuItem.children) { + result.push(menuItem as IMenuSchema); // 使用类型断言补充缺失字段 + } } } diff --git a/packages/ui/src/services/menu/types.ts b/packages/ui/src/services/menu/types.ts index a23050c439..f510587a09 100644 --- a/packages/ui/src/services/menu/types.ts +++ b/packages/ui/src/services/menu/types.ts @@ -64,9 +64,17 @@ export enum ContextMenuPosition { ROW_HEADER = 'contextMenu.rowHeader', FOOTER_TABS = 'contextMenu.footerTabs', FOOTER_MENU = 'contextMenu.footerMenu', + /** + * paragraph context menu in doc + */ + PARAGRAPH = 'contextMenu.paragraph', } export enum ContextMenuGroup { + /** + * quick context menu, displayed as icon + */ + QUICK = 'contextMenu.quick', FORMAT = 'contextMenu.format', LAYOUT = 'contextMenu.layout', DATA = 'contextMenu.data', diff --git a/packages/ui/src/services/popup/canvas-popup.service.ts b/packages/ui/src/services/popup/canvas-popup.service.ts index 9e049ee5b7..b12fe2a5e9 100644 --- a/packages/ui/src/services/popup/canvas-popup.service.ts +++ b/packages/ui/src/services/popup/canvas-popup.service.ts @@ -36,7 +36,8 @@ export interface IPopup extends Omit; - + customActive?: boolean; + onActiveChange?: (active: boolean) => void; } export interface ICanvasPopupService { @@ -83,12 +84,13 @@ export class CanvasPopupService extends Disposable implements ICanvasPopupServic const id = Tools.generateRandomId(); this._popupMap.set(id, { ...item, - onPointerEnter: () => { - this._activePopupId = id; - }, - onPointerLeave: () => { - if (this._activePopupId === id) { - this._activePopupId = null; + onActiveChange: (active: boolean) => { + if (active) { + this._activePopupId = id; + } else { + if (this._activePopupId === id) { + this._activePopupId = null; + } } }, }); diff --git a/packages/ui/src/views/components/popup/CanvasPopup.tsx b/packages/ui/src/views/components/popup/CanvasPopup.tsx index 2a96d027e9..fce5edc63a 100644 --- a/packages/ui/src/views/components/popup/CanvasPopup.tsx +++ b/packages/ui/src/views/components/popup/CanvasPopup.tsx @@ -87,8 +87,14 @@ const SingleCanvasPopup = ({ popup, children }: ISingleCanvasPopupProps) => { onClickOutside={popup.onClickOutside} excludeOutside={popup.excludeOutside} excludeRects={excludeRectsRef} - onPointerEnter={popup.onPointerEnter} - onPointerLeave={popup.onPointerLeave} + { + ...popup.customActive + ? null + : { + onPointerEnter: () => popup.onActiveChange?.(true), + onPointerLeave: () => popup.onActiveChange?.(false), + } + } onClick={popup.onClick} onContextMenu={popup.onContextMenu} > diff --git a/packages/ui/src/views/components/popup/RectPopup.tsx b/packages/ui/src/views/components/popup/RectPopup.tsx index ad85f33c16..766d4faed6 100644 --- a/packages/ui/src/views/components/popup/RectPopup.tsx +++ b/packages/ui/src/views/components/popup/RectPopup.tsx @@ -42,7 +42,7 @@ export interface IRectPopupProps { */ anchorRect$: Observable; excludeRects?: RefObject>; - direction?: 'vertical' | 'horizontal' | 'top' | 'right' | 'left' | 'bottom' | 'bottom-center' | 'top-center'; + direction?: 'vertical' | 'horizontal' | 'top' | 'right' | 'left' | 'right-center' | 'left-center' | 'bottom' | 'bottom-center' | 'top-center'; hidden?: boolean; // #region closing behavior @@ -50,11 +50,16 @@ export interface IRectPopupProps { excludeOutside?: HTMLElement[]; onContextMenu?: () => void; - onPointerEnter?: (e: React.PointerEvent) => void; - onPointerLeave?: (e: React.PointerEvent) => void; + onPointerEnter?: (e: React.MouseEvent) => void; + onPointerLeave?: (e: React.MouseEvent) => void; onClick?: (e: React.MouseEvent) => void; // #endregion portal?: boolean; + + mask?: boolean; + zIndex?: number; + maskZIndex?: number; + onMaskClick?: () => void; } export interface IPopupLayoutInfo extends Pick { @@ -105,20 +110,49 @@ function calcPopupPosition(layout: IPopupLayoutInfo): { top: number; left: numbe // In x-axis const { left: startX, top: startY, right: endX, bottom: endY } = position; // const horizontalStyle = ((endX + width) > containerWidth || direction === 'left') - const horizontalStyle = direction === 'left' + const horizontalStyle = direction.includes('left') ? { left: Math.max(startX - width, PUSHING_MINIMUM_GAP) } // on left : { left: Math.min(endX, containerWidth - width - PUSHING_MINIMUM_GAP) }; // on right + let verticalStyle; + const minTop = PUSHING_MINIMUM_GAP; + const maxTop = containerHeight - height - PUSHING_MINIMUM_GAP; - // If the popup element exceed the visible area. We should "push" it back. - const verticalStyle = ((startY + height) > containerHeight) - ? { top: Math.max(endY - height, PUSHING_MINIMUM_GAP) } // on top - : { top: Math.min(startY, containerHeight - height - PUSHING_MINIMUM_GAP) }; // on bottom + if (direction.includes('center')) { + const rectHeight = endY - startY; + const offsetY = (rectHeight - height) / 2; + + verticalStyle = (Math.max(startY + offsetY, PUSHING_MINIMUM_GAP) + height) > containerHeight + ? { top: Math.max(Math.min(maxTop, endY - height - offsetY), minTop) } + : { top: Math.max(minTop, Math.min(startY + offsetY, maxTop)) }; + } else { + // If the popup element exceed the visible area. We should "push" it back. + verticalStyle = ((startY + height) > containerHeight) + ? { top: Math.max(endY - height, PUSHING_MINIMUM_GAP) } // on top + : { top: Math.min(startY, containerHeight - height - PUSHING_MINIMUM_GAP) }; // on bottom + } return { ...verticalStyle, ...horizontalStyle }; }; function RectPopup(props: IRectPopupProps) { - const { portal, children, anchorRect$, direction = 'vertical', onClickOutside, excludeOutside, excludeRects, onPointerEnter, onPointerLeave, onClick, hidden, onContextMenu } = props; + const { + mask, + portal, + children, + anchorRect$, + direction = 'vertical', + onClickOutside, + excludeOutside, + excludeRects, + onPointerEnter, + onPointerLeave, + onClick, + hidden, + onContextMenu, + zIndex = 1020, + maskZIndex = 100, + onMaskClick, + } = props; const nodeRef = useRef(null); const clickOtherFn = useEvent(onClickOutside ?? (() => { /* empty */ })); const contextMenuFn = useEvent(onContextMenu ?? (() => { /* empty */ })); @@ -227,21 +261,30 @@ function RectPopup(props: IRectPopupProps) { }, [contextMenuFn]); const ele = ( -
e.stopPropagation()} - onClick={onClick} - onPointerEnter={onPointerEnter} - onPointerLeave={onPointerLeave} - > - - {children} - -
+ <> + {mask && ( +
+ )} +
e.stopPropagation()} + onClick={onClick} + onPointerEnter={onPointerEnter} + onPointerLeave={onPointerLeave} + > + + {children} + +
+ ); return !portal ? ele : document.getElementById(popupRootId) ? createPortal(ele, document.getElementById(popupRootId)!) : null;