feat(docs-ui): support paragraph menu (#4943)

This commit is contained in:
ZhangWei
2025-04-08 15:07:50 +08:00
committed by GitHub
parent 092e1b5716
commit 7c0fa4e107
49 changed files with 2119 additions and 292 deletions
+8
View File
@@ -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
@@ -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.
+53
View File
@@ -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.
+1 -1
View File
@@ -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 {
+9 -1
View File
@@ -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<T extends readonly unknown[]> = (cb: (...args: T) => void) => IDisposable;
@@ -76,3 +76,11 @@ export function afterTime(ms: number): Observable<void> {
setTimeout(() => subject.next(), ms);
return subject.pipe(take(1));
}
export function convertObservableToBehaviorSubject<T>(observable: Observable<T>, initValue: T): BehaviorSubject<T> {
const subject = new BehaviorSubject(initValue);
observable.subscribe(subject);
return subject;
}
+1 -1
View File
@@ -185,7 +185,7 @@ export const NAMED_STYLE_MAP: Record<NamedStyleType, Nullable<ITextStyle>> = {
[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,
+55 -6
View File
@@ -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<MenuRef, MenuProps>((props, ref) => {
const { mountContainer } = useContext(ConfigContext);
return mountContainer && React.cloneElement(<RcMenu ref={ref} prefixCls={clsx(styles.menu, props.className)} getPopupContainer={() => mountContainer} />, {
...props,
});
return mountContainer && <RcMenu ref={ref} prefixCls={clsx(styles.menu, props.className)} getPopupContainer={() => mountContainer} {...props} />;
});
export function MenuItem(props: MenuItemProps) {
return React.cloneElement(<RcMenuItem />, { ...props });
return <RcMenuItem {...props} />;
}
export function SubMenu(props: SubMenuProps) {
return React.cloneElement(<RcSubMenu />, { ...props });
return <RcSubMenu {...props} />;
}
export function MenuItemGroup(props: MenuItemGroupProps) {
return React.cloneElement(<RcMenuItemGroup />, { ...props });
return <RcMenuItemGroup {...props} />;
}
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 (
<div
className={`
univer-flex univer-flex-wrap univer-gap-2.5 univer-menu-item-group univer-p-1 univer-pl-0 univer-pr-0
`}
>
{items.map((item) => {
const ele = (
<div
key={item.key}
onClick={() => 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}
`}
>
<item.Icon className="univer-h-4 univer-w-4 univer-text-[#181C2A]" />
</div>
);
return item.tooltip
? (
<Tooltip key={item.key} title={item.tooltip}>
{ele}
</Tooltip>
)
: (
ele
);
})}
</div>
);
}
@@ -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));
}
}
+1 -1
View File
@@ -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';
@@ -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 (
<Menu>
<TinyMenuGroup
items={[
{
onClick: () => {},
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',
},
]}
/>
<MenuItemGroup>
<MenuItem>
<CopySingle />
Item 1
</MenuItem>
<MenuItem>
<CutSingle />
Item 2
</MenuItem>
<MenuItem>
<DeleteSingle />
Item 3
</MenuItem>
</MenuItemGroup>
<MenuItemGroup title="Group 1">
<MenuItem>
<CopySingle />
Item 4
</MenuItem>
<MenuItem>
<CutSingle />
Item 5
</MenuItem>
</MenuItemGroup>
<MenuItemGroup>
<SubMenu
title={(
<div>
<CopySingle />
SubMenu
</div>
)}
>
<MenuItemGroup>
<MenuItem>
<CopySingle />
Item 6
</MenuItem>
</MenuItemGroup>
</SubMenu>
<SubMenu title="SubMenu2">
<MenuItemGroup>
<MenuItem>
<CopySingle />
Item 7
</MenuItem>
<MenuItem>
<CutSingle />
Item 8
</MenuItem>
</MenuItemGroup>
</SubMenu>
</MenuItemGroup>
</Menu>
);
};
@@ -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);
+1 -1
View File
@@ -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';
+3 -1
View File
@@ -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;
@@ -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',
@@ -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<IBreakLineCommandParams> = {
@@ -75,13 +76,13 @@ export const BreakLineCommand: ICommand<IBreakLineCommandParams> = {
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;
@@ -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',
@@ -474,6 +474,7 @@ export interface IInnerCutCommandParams {
segmentId: string;
textRanges: ITextRangeWithStyle[];
selections?: ITextRange[];
rectRanges?: IRectRangeWithStyle[];
}
export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
@@ -486,8 +487,7 @@ export const CutContentCommand: ICommand<IInnerCutCommandParams> = {
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)
@@ -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<DocumentDataModel>(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);
},
};
@@ -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<IHorizontalCommandParams> = {
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<IHorizontalCommandParams> = {
width: 1,
dashStyle: DashStyleType.SOLID,
},
textRange: params?.insertRange,
});
},
};
export const InsertHorizontalLineBellowCommand: ICommand<IHorizontalCommandParams> = {
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,
},
});
},
};
@@ -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<IInsertBulletBelowCommandParams> = {
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<DocumentDataModel>(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<IRichTextEditingMutationParams> = {
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;
},
};
@@ -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<IListOperationCommandParams> = {
@@ -53,7 +55,7 @@ export const ListOperationCommand: ICommand<IListOperationCommandParams> = {
const listType: string = params.listType;
const docDataModel = univerInstanceService.getCurrentUnitForType<DocumentDataModel>(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<IListOperationCommandParams> = {
interface IChangeListTypeCommandParams {
listType: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const ChangeListTypeCommand: ICommand<IChangeListTypeCommandParams> = {
@@ -116,20 +119,19 @@ export const ChangeListTypeCommand: ICommand<IChangeListTypeCommandParams> = {
const commandService = accessor.get(ICommandService);
const { listType } = params;
const docDataModel = univerInstanceService.getCurrentUnitForType<DocumentDataModel>(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<IChangeListTypeCommandParams> = {
params: {
unitId,
actions: [],
textRanges: selections,
textRanges: activeRanges,
isEditing: false,
},
};
@@ -231,6 +233,7 @@ export const ChangeListNestingLevelCommand: ICommand<IChangeListNestingLevelComm
interface IBulletListCommandParams {
value?: PresetListType;
docRange?: ITextRangeWithStyle[];
}
export const BulletListCommand: ICommand<IBulletListCommandParams> = {
@@ -244,11 +247,13 @@ export const BulletListCommand: ICommand<IBulletListCommandParams> = {
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<IBulletListCommandParams> = {
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<IQuickListCommandParams> = {
},
};
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<DocumentDataModel>(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<IRichTextEditingMutationParams> = {
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<IQuickListCommandParams> = {
id: 'doc.command.insert-bullet-list-bellow',
type: CommandType.COMMAND,
handler: (accessor) => {
return insertList(accessor, PresetListType.BULLET_LIST);
},
};
export const InsertOrderListBellowCommand: ICommand<IQuickListCommandParams> = {
id: 'doc.command.insert-order-list-bellow',
type: CommandType.COMMAND,
handler: (accessor) => {
return insertList(accessor, PresetListType.ORDER_LIST);
},
};
export const InsertCheckListBellowCommand: ICommand<IQuickListCommandParams> = {
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]);
@@ -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<ISetParagraphNamedStyleCommandParams> = {
@@ -39,7 +40,7 @@ export const SetParagraphNamedStyleCommand: ICommand<ISetParagraphNamedStyleComm
}
const unitId = documentDataModel.getUnitId();
const selectionService = accessor.get(DocSelectionManagerService);
const selections = selectionService.getTextRanges({ unitId, subUnitId: unitId })?.filter((i) => !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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
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<ISetParagraphNamedStyleCommandParams> = {
id: 'doc.command.subtitle-heading',
type: CommandType.COMMAND,
handler: (accessor) => {
const commandService = accessor.get(ICommandService);
return commandService.syncExecuteCommand(SetParagraphNamedStyleCommand.id, {
value: NamedStyleType.SUBTITLE,
});
},
};
@@ -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<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!range || !range.collapsed || !doc || range.segmentId) {
return false;
}
const paragraph = BuildTextUtils.range.getParagraphsInRange(range, doc.getBody()?.paragraphs ?? [])[0];
return paragraph;
}
@@ -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<HTMLDivElement>(null);
const commandService = useDependency(ICommandService);
const layoutService = useDependency(ILayoutService);
const anchorRef = useRef<HTMLDivElement>(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<NamedStyleType>((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 (
<>
<div
ref={anchorRef}
className={`
univer-mr-1 univer-inline-flex univer-h-7 univer-cursor-pointer univer-items-center univer-gap-1
univer-rounded-full univer-bg-[#EEEFF1] univer-px-2.5 univer-py-0
`}
onMouseEnter={(e) => {
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);
}}
>
<icon.component className="univer-h-4 univer-w-4 univer-text-[#181C2A]" />
<DownSingle className="univer-h-3 univer-w-3 univer-text-[#979DAC]" />
</div>
{visible && (
<RectPopup
portal
mask
maskZIndex={100}
anchorRect$={anchorRect$}
direction="left-center"
onMaskClick={handleHideMenu}
>
<section
ref={contentRef}
onMouseEnter={(e) => {
popup.onPointerEnter?.(e);
isMouseOver.current = true;
}}
onMouseLeave={() => {
isMouseOver.current = false;
}}
>
<DesktopMenu
menuType={ContextMenuPosition.PARAGRAPH}
onOptionSelect={(params) => {
const { label: id, commandId, value } = params;
if (commandService) {
commandService.executeCommand(commandId ?? id as string, { value });
}
layoutService.focus();
handleHideMenu();
}}
/>
</section>
</RectPopup>
)}
</>
);
};
@@ -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() {
@@ -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,
},
},
},
};
@@ -214,7 +214,7 @@ function getTableDisabledObservable(accessor: IAccessor): Observable<boolean> {
});
}
function disableMenuWhenNoDocRange(accessor: IAccessor): Observable<boolean> {
export function disableMenuWhenNoDocRange(accessor: IAccessor): Observable<boolean> {
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);
@@ -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, ICommand> = {
[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, { key: string; component: React.ComponentType<{ className: string }> }> = {
[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',
};
};
+25 -4
View File
@@ -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],
+6
View File
@@ -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;
+6
View File
@@ -98,6 +98,12 @@ const locale: typeof zhCN = {
fixedValue: 'مقدار ثابت(px)',
},
},
rightClick: {
bulletList: 'لیست بدون شماره',
orderList: 'لیست شماره‌دار',
checkList: 'لیست وظیفه',
insertBellow: 'درج در پایین',
},
};
export default locale;
+6
View File
@@ -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;
+6
View File
@@ -98,6 +98,12 @@ const locale: typeof zhCN = {
fixedValue: 'Fixed Value(px)',
},
},
rightClick: {
bulletList: 'Неупорядоченный список',
orderList: 'Упорядоченный список',
checkList: 'Список задач',
insertBellow: 'Вставить ниже',
},
};
export default locale;
+6
View File
@@ -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;
+6
View File
@@ -96,6 +96,12 @@ const locale = {
fixedValue: '固定值(px)',
},
},
rightClick: {
bulletList: '无序列表',
orderList: '有序列表',
checkList: '任务列表',
insertBellow: '在下方插入',
},
};
export default locale;
+6
View File
@@ -98,6 +98,12 @@ const locale: typeof zhCN = {
fixedValue: 'Fixed Value(px)',
},
},
rightClick: {
bulletList: '無序列表',
orderList: '有序列表',
checkList: '任務列表',
insertBellow: '在下方插入',
},
};
export default locale;
@@ -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<boolean>;
cut(): Promise<boolean>;
copy(sliceType?: SliceBodyType, ranges?: ITextRangeWithStyle[]): Promise<boolean>;
cut(ranges?: ITextRangeWithStyle[]): Promise<boolean>;
paste(items: ClipboardItem[]): Promise<boolean>;
legacyPaste(options: { html?: string; text?: string; files: File[] }): Promise<boolean>;
addClipboardHook(hook: IDocClipboardHook): IDisposable;
@@ -110,16 +110,15 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
super();
}
async copy(sliceType: SliceBodyType = SliceBodyType.copy): Promise<boolean> {
const { newSnapshotList = [], needCache = false, snapshot } = this._getDocumentBodyInRanges(sliceType) ?? {};
async copy(sliceType: SliceBodyType = SliceBodyType.copy, ranges?: ITextRangeWithStyle[]): Promise<boolean> {
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<boolean> {
return this._cut();
async cut(ranges?: ITextRangeWithStyle[]): Promise<boolean> {
return this._cut(ranges);
}
async paste(items: ClipboardItem[]): Promise<boolean> {
@@ -164,15 +163,14 @@ export class DocClipboardService extends Disposable implements IDocClipboardServ
return this._paste(partDocData);
}
private async _cut(): Promise<boolean> {
private async _cut(ranges?: ITextRangeWithStyle[]): Promise<boolean> {
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,
};
}
@@ -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<IBoundRectNoAngle> => {
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<ICustomRangeActive[]>([]);
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<IBulletActive>();
readonly clickBullets$ = this._clickBullet$.asObservable();
private readonly _hoverParagraph$ = new Subject<Nullable<IMutiPageParagraphBound>>();
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<Nullable<IMutiPageParagraphBound>>();
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<Nullable<ITableCellBound>>();
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<Nullable<ITableBound>>();
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<number, IMutiPageParagraphBound> = new Map();
private _paragraphLeftBounds: IMutiPageParagraphBound[] = [];
private _tableParagraphBounds: Map<string, ITableParagraphBound[]> = new Map();
private _segmentParagraphBounds: Map<string, Map<number, IMutiPageParagraphBound[]>> = new Map();
private _tableCellBounds: Map<string, ITableCellBound[]> = new Map();
private _tableBounds: Map<string, ITableBound> = 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<number, IMutiPageParagraphBound> = 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<IMutiPageParagraphBound> {
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<IMutiPageParagraphBound> {
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;
@@ -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<DocumentDataModel>,
@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<IMutiPageParagraphBound>) => {
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;
}
}
}
@@ -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<IPopup, 'direction' | 'excludeOutside' | 'componentKey' | 'offset' | 'onClickOutside' | 'hideOnInvisible'> {
export interface IDocCanvasPopup extends Pick<IPopup, 'direction' | 'excludeOutside' | 'componentKey' | 'offset' | 'onClickOutside' | 'hideOnInvisible' | 'customActive'> {
mask?: boolean;
extraProps?: Record<string, any>;
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(),
});
+56 -27
View File
@@ -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<number, IMutiPageParagraphBound> | undefined, paragraphMap: Map<number, IParagraph>, 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<number, IParagraph>();
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;
@@ -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<Record<string, boolean>>({});
const filteredMenuItems = useMemo(() => {
return menuItems.filter((item) => {
if (!item.children) return item;
@@ -114,21 +115,23 @@ function MenuWrapper(props: IBaseMenuProps) {
/>
)
: item.children?.length
? (
<DesignMenuItemGroup key={item.key} eventKey={item.key}>
{item.children.map((child) => (
child.item && (
<MenuItem
key={child.key}
menuItem={child.item}
onClick={(object: Partial<IValueOption>) => {
onOptionSelect?.({ value: '', label: child.key, ...object });
}}
/>
)
))}
</DesignMenuItemGroup>
)
? item.key === ContextMenuGroup.QUICK
? <UITinyMenuGroup key={item.key} item={item} onOptionSelect={onOptionSelect} />
: (
<DesignMenuItemGroup key={item.key} eventKey={item.key} title={item.title ? localeService.t(item.title) : undefined}>
{item.children.map((child) => (
child.item && (
<MenuItem
key={child.key}
menuItem={child.item}
onClick={(object: Partial<IValueOption>) => {
onOptionSelect?.({ value: '', label: child.key, ...object });
}}
/>
)
))}
</DesignMenuItemGroup>
)
: null);
}
@@ -195,7 +198,6 @@ export const Menu = (props: IBaseMenuProps) => {
setMenuEl(ref.list);
}
}
return (
<DesignMenu
ref={handleSetMenuEl}
@@ -292,16 +294,18 @@ function MenuItem({ menuItem, onClick }: IMenuItemProps) {
)}
expandIcon={<MoreSingle className={styles.menuItemMoreIcon} />}
>
{selections.length > 0 && (
<MenuOptionsWrapper
parentKey={item.id}
menuType={item.id}
options={selections}
onOptionSelect={(v) => {
onClick({ value: v.value, id: item.id, commandId: v.commandId });
}}
/>
)}
<DesignMenuItemGroup>
{selections.length > 0 && (
<MenuOptionsWrapper
parentKey={item.id}
menuType={item.id}
options={selections}
onOptionSelect={(v) => {
onClick({ value: v.value, id: item.id, commandId: v.commandId });
}}
/>
)}
</DesignMenuItemGroup>
</DesignSubMenu>
);
}
@@ -339,7 +343,9 @@ function MenuItem({ menuItem, onClick }: IMenuItemProps) {
)}
expandIcon={<MoreSingle className={styles.menuItemMoreIcon} />}
>
{subMenuItems.length && <MenuWrapper menuType={item.id} parentKey={item.id} onOptionSelect={onClick} />}
<DesignMenuItemGroup>
{subMenuItems.length && <MenuWrapper menuType={item.id} parentKey={item.id} onOptionSelect={onClick} />}
</DesignMenuItemGroup>
</DesignSubMenu>
);
};
@@ -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<string[]>([]);
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 (
<DesignTinyMenuGroup
items={item.children.map((child) => ({
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 ?? ''),
}))}
/>
);
}
+1 -1
View File
@@ -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';
@@ -27,8 +27,10 @@ export const IMenuManagerService = createIdentifier<IMenuManagerService>('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<IMenuSchema> = {
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); // 使用类型断言补充缺失字段
}
}
}
+8
View File
@@ -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',
@@ -36,7 +36,8 @@ export interface IPopup extends Omit<IRectPopupProps, 'children' | 'hidden' | 'e
hideOnInvisible?: boolean;
hiddenType?: 'hide' | 'destroy';
hiddenRects$?: Observable<IBoundRectNoAngle[]>;
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;
}
}
},
});
@@ -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}
>
@@ -42,7 +42,7 @@ export interface IRectPopupProps {
*/
anchorRect$: Observable<IAbsolutePosition>;
excludeRects?: RefObject<Nullable<IAbsolutePosition[]>>;
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<HTMLElement>) => void;
onPointerLeave?: (e: React.PointerEvent<HTMLElement>) => void;
onPointerEnter?: (e: React.MouseEvent<HTMLElement>) => void;
onPointerLeave?: (e: React.MouseEvent<HTMLElement>) => void;
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
// #endregion
portal?: boolean;
mask?: boolean;
zIndex?: number;
maskZIndex?: number;
onMaskClick?: () => void;
}
export interface IPopupLayoutInfo extends Pick<IRectPopupProps, 'direction'> {
@@ -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<HTMLElement>(null);
const clickOtherFn = useEvent(onClickOutside ?? (() => { /* empty */ }));
const contextMenuFn = useEvent(onContextMenu ?? (() => { /* empty */ }));
@@ -227,21 +261,30 @@ function RectPopup(props: IRectPopupProps) {
}, [contextMenuFn]);
const ele = (
<section
ref={nodeRef}
className={`
univer-pointer-events-auto univer-fixed univer-left-[-9999px] univer-top-[-9999px] univer-z-[1020]
`}
style={{ ...positionRef.current, ...hidden ? { display: 'none' } : null }}
onPointerDown={(e) => e.stopPropagation()}
onClick={onClick}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
<RectPopupContext.Provider value={anchorRectRef}>
{children}
</RectPopupContext.Provider>
</section>
<>
{mask && (
<div
className="univer-fixed univer-bottom-0 univer-left-0 univer-right-0 univer-top-0 univer-z-[100]"
style={{ zIndex: maskZIndex }}
onClick={onMaskClick}
/>
)}
<section
ref={nodeRef}
className={`
univer-pointer-events-auto univer-fixed univer-left-[-9999px] univer-top-[-9999px] univer-z-[1020]
`}
style={{ ...positionRef.current, ...hidden ? { display: 'none' } : null, zIndex }}
onPointerDown={(e) => e.stopPropagation()}
onClick={onClick}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
<RectPopupContext.Provider value={anchorRectRef}>
{children}
</RectPopupContext.Provider>
</section>
</>
);
return !portal ? ele : document.getElementById(popupRootId) ? createPortal(ele, document.getElementById(popupRootId)!) : null;